Fix BLE queue usage and tighten actor isolation

This commit is contained in:
jack
2025-10-11 19:49:40 +02:00
parent d371131ad5
commit e868e3bbfb
6 changed files with 295 additions and 277 deletions
+20 -19
View File
@@ -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])
}
}
+2
View File
@@ -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
View File
@@ -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)
}
}
}
+4 -3
View File
@@ -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
+4 -4
View File
@@ -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)
}
}
}
+71 -59
View File
@@ -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)