mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Fix BLE queue usage and tighten actor isolation
This commit is contained in:
+20
-19
@@ -49,9 +49,10 @@ struct BitchatApp: App {
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
let nickname = chatViewModel.nickname
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
@@ -237,29 +238,29 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
let identifier = notification.request.identifier
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else {
|
||||
completionHandler([.banner, .sound])
|
||||
return
|
||||
}
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||
// Check if this is a private message notification and chat already open
|
||||
if identifier.hasPrefix("private-"),
|
||||
let peerID = userInfo["peerID"] as? String,
|
||||
self.chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last,
|
||||
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
ch.geohash == gh {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
|
||||
// MARK: - Delegate Protocol
|
||||
|
||||
@MainActor
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
func didReceiveMessage(_ message: BitchatMessage)
|
||||
func didConnectToPeer(_ peerID: PeerID)
|
||||
@@ -180,6 +181,7 @@ protocol BitchatDelegate: AnyObject {
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@MainActor
|
||||
extension BitchatDelegate {
|
||||
func isFavorite(fingerprint: String) -> Bool {
|
||||
return false
|
||||
|
||||
+194
-192
@@ -406,23 +406,35 @@ final class BLEService: NSObject {
|
||||
// MARK: Lifecycle
|
||||
|
||||
func startServices() {
|
||||
// Start BLE services if not already running
|
||||
if centralManager?.state == .poweredOn {
|
||||
centralManager?.scanForPeripherals(
|
||||
withServices: [BLEService.serviceUUID],
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||||
)
|
||||
performOnBLEQueue { service in
|
||||
// Start BLE services if not already running
|
||||
if service.centralManager?.state == .poweredOn {
|
||||
service.centralManager?.scanForPeripherals(
|
||||
withServices: [BLEService.serviceUUID],
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial announce after services are ready
|
||||
// Use longer delay to avoid conflicts with other announces
|
||||
|
||||
// Send initial announce after services are ready.
|
||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
|
||||
func stopServices() {
|
||||
// Send leave message synchronously to ensure delivery
|
||||
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||
stopServicesOnBLEQueue()
|
||||
} else {
|
||||
bleQueue.sync {
|
||||
self.stopServicesOnBLEQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopServicesOnBLEQueue() {
|
||||
assertOnBLEQueue()
|
||||
// Send a final leave message to connected peers before shutting down.
|
||||
let leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
@@ -432,43 +444,45 @@ final class BLEService: NSObject {
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
// Send immediately to all connected peers
|
||||
|
||||
if let data = leavePacket.toBinaryData(padding: false) {
|
||||
// Send to peripherals we're connected to as central
|
||||
for state in peripherals.values where state.isConnected {
|
||||
if let characteristic = state.characteristic {
|
||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
// Send to centrals subscribed to us as peripheral
|
||||
if subscribedCentrals.count > 0 && characteristic != nil {
|
||||
peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil)
|
||||
if !subscribedCentrals.isEmpty, let characteristic = characteristic {
|
||||
_ = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Give leave message a moment to send
|
||||
Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds)
|
||||
|
||||
// Clear pending notifications
|
||||
|
||||
let delay = TransportConfig.bleThreadSleepWriteShortDelaySeconds
|
||||
if delay > 0 {
|
||||
Thread.sleep(forTimeInterval: delay)
|
||||
}
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingNotifications.removeAll()
|
||||
pendingPeripheralWrites.removeAll()
|
||||
pendingDirectedRelays.removeAll()
|
||||
}
|
||||
|
||||
// Stop timer
|
||||
|
||||
maintenanceTimer?.cancel()
|
||||
maintenanceTimer = nil
|
||||
scanDutyTimer?.cancel()
|
||||
scanDutyTimer = nil
|
||||
|
||||
|
||||
centralManager?.stopScan()
|
||||
peripheralManager?.stopAdvertising()
|
||||
|
||||
// Disconnect all peripherals
|
||||
|
||||
for state in peripherals.values {
|
||||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||
}
|
||||
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
|
||||
func emergencyDisconnectAll() {
|
||||
@@ -483,12 +497,6 @@ final class BLEService: NSObject {
|
||||
|
||||
// Clear processed messages
|
||||
messageDeduplicator.reset()
|
||||
|
||||
// Clear peripheral references
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
|
||||
// MARK: Connectivity and peers
|
||||
@@ -702,8 +710,9 @@ extension BLEService: GossipSyncManager.Delegate {
|
||||
extension BLEService: CBCentralManagerDelegate {
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
// Notify delegate about state change on main thread
|
||||
Task { @MainActor in
|
||||
self.delegate?.didUpdateBluetoothState(central.state)
|
||||
let state = central.state
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateBluetoothState(state)
|
||||
}
|
||||
|
||||
if central.state == .poweredOn {
|
||||
@@ -933,17 +942,13 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||
|
||||
// Notify delegate about disconnection on main thread (direct link dropped)
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
if let peerID {
|
||||
self.notifyPeerDisconnectedDebounced(peerID)
|
||||
service.notifyPeerDisconnectedDebounced(peerID)
|
||||
}
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
service.requestPeerDataPublish()
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,16 +1359,11 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
centralToPeerID.removeValue(forKey: centralUUID)
|
||||
|
||||
// Update UI immediately
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
self.notifyPeerDisconnectedDebounced(peerID)
|
||||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
service.notifyPeerDisconnectedDebounced(peerID)
|
||||
service.requestPeerDataPublish()
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1517,13 +1517,29 @@ extension BLEService {
|
||||
|
||||
extension BLEService {
|
||||
|
||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||
private func notifyUI(_ block: @escaping () -> Void) {
|
||||
// Always hop onto the MainActor so calls to @MainActor delegates are safe
|
||||
Task { @MainActor in
|
||||
block()
|
||||
/// Hop to the MainActor and hand `self` to the caller for UI delegate updates.
|
||||
private func notifyUI(_ block: @MainActor @escaping (BLEService) -> Void) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
block(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure work that touches CoreBluetooth objects executes on `bleQueue`
|
||||
private func performOnBLEQueue(_ work: @escaping (BLEService) -> Void) {
|
||||
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||
work(self)
|
||||
} else {
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
work(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func assertOnBLEQueue(file: StaticString = #file, line: UInt = #line) {
|
||||
assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "BLE queue required", file: file, line: line)
|
||||
}
|
||||
|
||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
@@ -1634,42 +1650,13 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||
// BLE operations run on bleQueue; keep queue affinity
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let uuid = peripheral.identifier.uuidString
|
||||
if peripheral.canSendWriteWithoutResponse {
|
||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
} else {
|
||||
self.collectionsQueue.async(flags: .barrier) {
|
||||
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
||||
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
|
||||
let newSize = data.count
|
||||
// If single chunk exceeds cap, drop it immediately
|
||||
if newSize > capBytes {
|
||||
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
|
||||
} else {
|
||||
// Append and trim from the front to respect cap
|
||||
var total = queue.reduce(0) { $0 + $1.count }
|
||||
queue.append(data)
|
||||
total += newSize
|
||||
if total > capBytes {
|
||||
var removedBytes = 0
|
||||
while total > capBytes && !queue.isEmpty {
|
||||
let removed = queue.removeFirst()
|
||||
removedBytes += removed.count
|
||||
total -= removed.count
|
||||
}
|
||||
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
|
||||
}
|
||||
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
|
||||
}
|
||||
}
|
||||
}
|
||||
performOnBLEQueue { service in
|
||||
service.writeOrEnqueueOnBLEQueue(data, to: peripheral, characteristic: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
||||
assertOnBLEQueue()
|
||||
let uuid = peripheral.identifier.uuidString
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -1701,6 +1688,38 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func writeOrEnqueueOnBLEQueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||
let uuid = peripheral.identifier.uuidString
|
||||
if peripheral.canSendWriteWithoutResponse {
|
||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
return
|
||||
}
|
||||
|
||||
collectionsQueue.async(flags: .barrier) {
|
||||
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
||||
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
|
||||
let newSize = data.count
|
||||
if newSize > capBytes {
|
||||
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
var total = queue.reduce(0) { $0 + $1.count }
|
||||
queue.append(data)
|
||||
total += newSize
|
||||
if total > capBytes {
|
||||
var removedBytes = 0
|
||||
while total > capBytes && !queue.isEmpty {
|
||||
let removed = queue.removeFirst()
|
||||
removedBytes += removed.count
|
||||
total -= removed.count
|
||||
}
|
||||
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
|
||||
}
|
||||
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Application State Handlers (iOS)
|
||||
|
||||
@@ -1784,8 +1803,8 @@ extension BLEService {
|
||||
}
|
||||
|
||||
// Notify delegate that message was sent
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to encrypt message: \(error)")
|
||||
@@ -1805,8 +1824,8 @@ extension BLEService {
|
||||
initiateNoiseHandshake(with: recipientID)
|
||||
|
||||
// Notify delegate that message is pending
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1882,8 +1901,8 @@ extension BLEService {
|
||||
broadcastPacket(packet)
|
||||
|
||||
// Notify delegate that message was sent
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
}
|
||||
|
||||
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
|
||||
@@ -1891,8 +1910,8 @@ extension BLEService {
|
||||
SecureLogger.error("Failed to send pending message after handshake: \(error)")
|
||||
|
||||
// Notify delegate of failure
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1907,11 +1926,13 @@ extension BLEService {
|
||||
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
||||
return
|
||||
}
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
sendEncrypted(packet, data: data, pad: padForBLE)
|
||||
return
|
||||
performOnBLEQueue { service in
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
service.sendEncrypted(packet, data: data, pad: padForBLE)
|
||||
} else {
|
||||
service.sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
||||
}
|
||||
}
|
||||
sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
||||
}
|
||||
|
||||
// MARK: Broadcast helpers (single responsibility)
|
||||
@@ -2550,21 +2571,15 @@ extension BLEService {
|
||||
}
|
||||
|
||||
// Notify UI on main thread
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Get current peer list (after addition)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
// Only notify of connection for new or reconnected peers when it is a direct announce
|
||||
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
||||
self.delegate?.didConnectToPeer(peerID)
|
||||
// Schedule initial unicast sync to this peer
|
||||
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||
let isDirectAnnounce = (packet.ttl == messageTTL) && (isNewPeer || isReconnectedPeer)
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
if isDirectAnnounce {
|
||||
service.delegate?.didConnectToPeer(peerID)
|
||||
service.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||
}
|
||||
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
service.requestPeerDataPublish()
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
|
||||
// Track for sync (include our own and others' announces)
|
||||
@@ -2636,17 +2651,26 @@ extension BLEService {
|
||||
if peerID == myPeerID {
|
||||
accepted = true
|
||||
senderNickname = myNickname
|
||||
}
|
||||
else if let info = peers[peerID], info.isVerifiedNickname {
|
||||
// Known verified peer path
|
||||
accepted = true
|
||||
senderNickname = info.nickname
|
||||
// Handle nickname collisions
|
||||
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
|
||||
if hasCollision {
|
||||
senderNickname += "#" + String(peerID.id.prefix(4))
|
||||
}
|
||||
} else {
|
||||
let selfNickname = myNickname
|
||||
let peerLookup = collectionsQueue.sync { () -> (PeerInfo?, Bool) in
|
||||
guard let info = peers[peerID] else { return (nil, false) }
|
||||
let collision = peers.values.contains {
|
||||
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
||||
}
|
||||
return (info, collision)
|
||||
}
|
||||
if let info = peerLookup.0, info.isVerifiedNickname {
|
||||
accepted = true
|
||||
senderNickname = info.nickname
|
||||
let hasCollision = peerLookup.1 || (selfNickname == info.nickname)
|
||||
if hasCollision {
|
||||
senderNickname += "#" + String(peerID.id.prefix(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !accepted {
|
||||
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
||||
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
||||
// Find candidate identities by peerID prefix (16 hex)
|
||||
@@ -2704,8 +2728,8 @@ extension BLEService {
|
||||
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2770,28 +2794,28 @@ extension BLEService {
|
||||
switch NoisePayloadType(rawValue: payloadType) {
|
||||
case .privateMessage:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .delivered:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .readReceipt:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .verifyChallenge:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .verifyResponse:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
default:
|
||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||
@@ -2816,14 +2840,10 @@ extension BLEService {
|
||||
// Remove any stored announcement for sync purposes
|
||||
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||
// Send on main thread
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
service.delegate?.didDisconnectFromPeer(peerID)
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2841,62 +2861,48 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func sendAnnounce(forceSend: Bool = false) {
|
||||
// Throttle announces to prevent flooding
|
||||
let now = Date()
|
||||
let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent)
|
||||
|
||||
// Even forced sends should respect a minimum interval to avoid overwhelming BLE
|
||||
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
|
||||
|
||||
if timeSinceLastAnnounce < minInterval {
|
||||
// Skipping announce (rate limited)
|
||||
return
|
||||
performOnBLEQueue { service in
|
||||
service.sendAnnounceOnBLEQueue(forceSend: forceSend)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAnnounceOnBLEQueue(forceSend: Bool) {
|
||||
let now = Date()
|
||||
let elapsed = now.timeIntervalSince(lastAnnounceSent)
|
||||
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
|
||||
guard elapsed >= minInterval else { return }
|
||||
lastAnnounceSent = now
|
||||
|
||||
// Reduced logging - only log errors, not every announce
|
||||
|
||||
// Create announce payload with both noise and signing public keys
|
||||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
||||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
||||
|
||||
|
||||
let noisePub = noiseService.getStaticPublicKeyData()
|
||||
let signingPub = noiseService.getSigningPublicKeyData()
|
||||
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: myNickname,
|
||||
noisePublicKey: noisePub,
|
||||
signingPublicKey: signingPub
|
||||
)
|
||||
|
||||
|
||||
guard let payload = announcement.encode() else {
|
||||
SecureLogger.error("❌ Failed to encode announce packet", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Create packet with signature using the noise private key
|
||||
let packet = BitchatPacket(
|
||||
|
||||
let unsigned = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil, // Will be set by signPacket below
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
// Sign the packet using the noise private key
|
||||
guard let signedPacket = noiseService.signPacket(packet) else {
|
||||
|
||||
guard let signedPacket = noiseService.signPacket(unsigned) else {
|
||||
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
// Call directly if on messageQueue, otherwise dispatch
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
broadcastPacket(signedPacket)
|
||||
} else {
|
||||
messageQueue.async { [weak self] in
|
||||
self?.broadcastPacket(signedPacket)
|
||||
}
|
||||
}
|
||||
// Ensure our own announce is included in sync state
|
||||
|
||||
broadcastPacket(signedPacket)
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
}
|
||||
|
||||
@@ -2938,6 +2944,7 @@ extension BLEService {
|
||||
}
|
||||
|
||||
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
||||
@MainActor
|
||||
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
||||
let now = Date()
|
||||
let last = recentDisconnectNotifies[peerID]
|
||||
@@ -3082,18 +3089,13 @@ extension BLEService {
|
||||
|
||||
// Update UI if there were direct disconnections or offline removals
|
||||
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
||||
notifyUI { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
for peerID in disconnectedPeers {
|
||||
self.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
|
||||
service.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
|
||||
}
|
||||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
service.requestPeerDataPublish()
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,11 @@ final class NostrTransport: Transport {
|
||||
|
||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||
private static var cachedNoiseService: NoiseEncryptionService?
|
||||
private static let noiseServiceLock = NSLock()
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
if let noiseService = Self.cachedNoiseService {
|
||||
return noiseService
|
||||
}
|
||||
Self.noiseServiceLock.lock()
|
||||
defer { Self.noiseServiceLock.unlock() }
|
||||
if let noiseService = Self.cachedNoiseService { return noiseService }
|
||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
Self.cachedNoiseService = noiseService
|
||||
return noiseService
|
||||
|
||||
@@ -91,6 +91,7 @@ import UIKit
|
||||
/// Manages the application state and business logic for BitChat.
|
||||
/// Acts as the primary coordinator between UI components and backend services,
|
||||
/// implementing the BitchatDelegate protocol to handle network events.
|
||||
@MainActor
|
||||
final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Precompiled regexes and detectors reused across formatting
|
||||
private enum Regexes {
|
||||
@@ -229,14 +230,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||
@Published var nickname: String = "" {
|
||||
didSet {
|
||||
// Trim whitespace whenever nickname is set
|
||||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed != nickname {
|
||||
nickname = trimmed
|
||||
return
|
||||
}
|
||||
// Update mesh service nickname if it's initialized
|
||||
if meshService.myPeerID != "" {
|
||||
meshService.setNickname(nickname)
|
||||
if meshService.myPeerID != "", meshService.myNickname != trimmed {
|
||||
meshService.setNickname(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ import UIKit
|
||||
|
||||
// MARK: - Main Content View
|
||||
|
||||
@MainActor
|
||||
struct ContentView: View {
|
||||
private struct ScrollRequest: Equatable {
|
||||
let id = UUID()
|
||||
let target: String
|
||||
}
|
||||
// MARK: - Properties
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@@ -46,7 +51,8 @@ struct ContentView: View {
|
||||
@State private var isAtBottomPublic: Bool = true
|
||||
@State private var isAtBottomPrivate: Bool = true
|
||||
@State private var lastScrollTime: Date = .distantPast
|
||||
@State private var scrollThrottleTimer: Timer?
|
||||
@State private var scrollThrottleTask: Task<Void, Never>?
|
||||
@State private var pendingScrollRequest: ScrollRequest?
|
||||
@State private var autocompleteDebounceTimer: Timer?
|
||||
@State private var showLocationChannelsSheet = false
|
||||
@State private var showVerifySheet = false
|
||||
@@ -79,6 +85,30 @@ struct ContentView: View {
|
||||
private var headerLineLimit: Int? {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
private func enqueueScroll(target: String) {
|
||||
pendingScrollRequest = ScrollRequest(target: target)
|
||||
}
|
||||
|
||||
private func scheduleScroll(target: String, delay: TimeInterval) {
|
||||
scrollThrottleTask?.cancel()
|
||||
scrollThrottleTask = nil
|
||||
if delay <= 0 {
|
||||
lastScrollTime = Date()
|
||||
enqueueScroll(target: target)
|
||||
scrollThrottleTask = nil
|
||||
return
|
||||
}
|
||||
let nanoseconds = UInt64(max(delay, 0) * 1_000_000_000)
|
||||
scrollThrottleTask = Task { [target] in
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
await MainActor.run {
|
||||
lastScrollTime = Date()
|
||||
enqueueScroll(target: target)
|
||||
scrollThrottleTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
@@ -296,8 +326,8 @@ struct ContentView: View {
|
||||
Text(viewModel.bluetoothAlertMessage)
|
||||
}
|
||||
.onDisappear {
|
||||
// Clean up timers
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTask?.cancel()
|
||||
scrollThrottleTask = nil
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
@@ -422,6 +452,11 @@ struct ContentView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.onChange(of: pendingScrollRequest) { request in
|
||||
guard let request else { return }
|
||||
proxy.scrollTo(request.target, anchor: .bottom)
|
||||
pendingScrollRequest = nil
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard url.scheme == "bitchat", url.host == "user" else { return }
|
||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
@@ -543,44 +578,27 @@ struct ContentView: View {
|
||||
let lastMsg = viewModel.messages.last!
|
||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||
if !isFromSelf {
|
||||
// Only autoscroll when user is at/near bottom
|
||||
guard isAtBottom.wrappedValue else { return }
|
||||
} else {
|
||||
// Ensure we consider ourselves at bottom for subsequent messages
|
||||
isAtBottom.wrappedValue = true
|
||||
}
|
||||
// Throttle scroll animations to prevent excessive UI updates
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
// Immediate scroll if enough time has passed
|
||||
lastScrollTime = now
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
let count = windowCountPublic
|
||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||
DispatchQueue.main.async {
|
||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
} else {
|
||||
// Schedule a delayed scroll
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
lastScrollTime = Date()
|
||||
let contextKey: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
let count = windowCountPublic
|
||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||
DispatchQueue.main.async {
|
||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||
}
|
||||
}()
|
||||
let count = windowCountPublic
|
||||
if let target = viewModel.messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||
let elapsed = now.timeIntervalSince(lastScrollTime)
|
||||
if elapsed > TransportConfig.uiScrollThrottleSeconds {
|
||||
scrollThrottleTask?.cancel()
|
||||
lastScrollTime = now
|
||||
enqueueScroll(target: target)
|
||||
} else {
|
||||
let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed
|
||||
scheduleScroll(target: target, delay: remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,26 +616,18 @@ struct ContentView: View {
|
||||
} else {
|
||||
isAtBottom.wrappedValue = true
|
||||
}
|
||||
// Same throttling for private chats
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
lastScrollTime = now
|
||||
let contextKey = "dm:\(peerID)"
|
||||
let count = windowCountPrivate[peerID] ?? 300
|
||||
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||
DispatchQueue.main.async {
|
||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||
}
|
||||
} else {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
lastScrollTime = Date()
|
||||
let contextKey = "dm:\(peerID)"
|
||||
let count = windowCountPrivate[peerID] ?? 300
|
||||
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||
DispatchQueue.main.async {
|
||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||
}
|
||||
let contextKey = "dm:\(peerID)"
|
||||
let count = windowCountPrivate[peerID] ?? 300
|
||||
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||
let elapsed = now.timeIntervalSince(lastScrollTime)
|
||||
if elapsed > TransportConfig.uiScrollThrottleSeconds {
|
||||
scrollThrottleTask?.cancel()
|
||||
lastScrollTime = now
|
||||
enqueueScroll(target: target)
|
||||
} else {
|
||||
let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed
|
||||
scheduleScroll(target: target, delay: remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,9 +705,9 @@ struct ContentView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||
@@ -791,9 +801,11 @@ struct ContentView: View {
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
Task { @MainActor in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
|
||||
Reference in New Issue
Block a user