diff --git a/.periphery.baseline.json b/.periphery.baseline.json index 0b826869..9400fdf1 100644 --- a/.periphery.baseline.json +++ b/.periphery.baseline.json @@ -1 +1 @@ -{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file +{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file diff --git a/Justfile b/Justfile index 9b1d3a31..a3d6f6b6 100644 --- a/Justfile +++ b/Justfile @@ -26,7 +26,7 @@ check-clean-safety: check: check-clean-safety @echo "Checking prerequisites..." @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1) - @developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac + @developer_dir="$(xcode-select -p 2>/dev/null)"; case "$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac @xcodebuild -version @echo "✅ Development environment ready (a signing identity is not required for just build)" @@ -35,7 +35,7 @@ build: check @xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build run: build - @app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app" + @app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$app" || (echo "❌ Built app not found at $app" && exit 1); open "$app" # Backward-compatible alias for the old quick-run recipe. dev-run: run diff --git a/README.md b/README.md index fc9313c1..36f50be2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) +📲 [Play Store](https://play.google.com/store/apps/details?id=com.bitchat.droid) + ### Getting a copy you can trust Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified — see [Verifying bitchat](docs/VERIFYING-A-BUILD.md) for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get. @@ -49,7 +51,7 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor - **Global Reach**: Connect with users worldwide via internet relays - **Location Channels**: Geographic chat rooms using geohash coordinates -- **290+ Relay Network**: Distributed across the globe for reliability +- **440+ Relay Network**: Distributed across the globe for reliability - **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays - **Ephemeral Keys**: Fresh cryptographic identity per geohash area diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index e0738afc..d9239d66 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -94,7 +94,6 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, - bitchatShareExtension.entitlements, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; }; @@ -379,6 +378,11 @@ E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, ); }; + 7E9B64F63F93443FB7BA12DF /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; C5E027A42ECCDFD700BD6012 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( @@ -395,13 +399,6 @@ E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */, ); }; - 7E9B64F63F93443FB7BA12DF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 227536ca..70a6c28c 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -85,7 +85,8 @@ final class AppChromeModel: ObservableObject { /// neighbor claim but never announced to us) fall back to a short ID. func meshTopologyDisplayModel() -> MeshTopologyDisplayModel { let mesh = chatViewModel.meshService - guard let snapshot = mesh.currentMeshTopology() else { return .empty } + guard let diagnostics = mesh as? MeshDiagnosing, + let snapshot = diagnostics.currentMeshTopology() else { return .empty } let nicknames = mesh.getPeerNicknames() let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index b7c20511..0158ea0a 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -152,18 +152,23 @@ final class AppRuntime: ObservableObject { NetworkActivationService.shared.start() GeohashPresenceService.shared.start() checkForSharedContent() - expireAgedMedia() + performMediaMaintenance() record(.launched) record(.startupCompleted) } - /// Drops media that has outlived the retention window. Off the main thread - /// and best-effort: the sweep walks the media tree, and nothing at launch - /// depends on its result. - private func expireAgedMedia() { - Task(priority: .utility) { - BLEIncomingFileStore().expireAgedMedia() + /// Drops media that has outlived the retention window, then applies the + /// explicit protection class to files that older builds wrote without + /// one. Expiry runs first so the migration never touches files the + /// sweep is about to delete. Detached because `AppRuntime` is + /// main-actor and both passes go file by file through the media tree; + /// best-effort, nothing at launch depends on their results. + private func performMediaMaintenance() { + Task.detached(priority: .utility) { + let store = BLEIncomingFileStore() + store.expireAgedMedia() + store.migrateFileProtectionIfNeeded() } } diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index eb174120..76e71813 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -230,8 +230,7 @@ final class Conversation: ObservableObject, Identifiable { // MARK: Internals - static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool { - guard let current else { return false } + static func shouldSkipStatusUpdate(current: DeliveryStatus, new: DeliveryStatus) -> Bool { if current == new { return true } // Never downgrade to a weaker delivery state. Ordering of certainty: @@ -254,6 +253,10 @@ final class Conversation: ObservableObject, Identifiable { return true case (.sent, .sending): return true + case (_, .notSentYet): + // .notSentYet is the pre-transport initial state; once a message + // has any real status, resetting to it is always a downgrade. + return true default: return false } diff --git a/bitchat/App/LocationPresenceStore.swift b/bitchat/App/LocationPresenceStore.swift index b4f6aaef..b67731ba 100644 --- a/bitchat/App/LocationPresenceStore.swift +++ b/bitchat/App/LocationPresenceStore.swift @@ -36,6 +36,7 @@ final class LocationPresenceStore: ObservableObject { return } + let nickname = nickname.normalizedNickname let key = pubkeyHex.lowercased() if geoNicknames[key] != nil { geoNicknames[key] = nickname @@ -64,7 +65,7 @@ final class LocationPresenceStore: ObservableObject { let lower = key.lowercased() guard seen.insert(lower).inserted else { continue } ordered.append(lower) - normalized[lower] = value + normalized[lower] = value.normalizedNickname } if ordered.count > geoNicknameCapacity { let kept = Array(ordered.suffix(geoNicknameCapacity)) diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift index b49f92ca..a6eb25d1 100644 --- a/bitchat/Features/media/ImageUtils.swift +++ b/bitchat/Features/media/ImageUtils.swift @@ -206,7 +206,7 @@ enum ImageUtils { } else { directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) return directory.appendingPathComponent(fileName) } diff --git a/bitchat/Features/voice/VoiceCaptureSession.swift b/bitchat/Features/voice/VoiceCaptureSession.swift index b49c3beb..75451552 100644 --- a/bitchat/Features/voice/VoiceCaptureSession.swift +++ b/bitchat/Features/voice/VoiceCaptureSession.swift @@ -244,7 +244,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { let directory = base .appendingPathComponent("files", isDirectory: true) .appendingPathComponent("voicenotes/outgoing", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a") } } diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift index 80412856..909879cd 100644 --- a/bitchat/Features/voice/VoiceRecorder.swift +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -300,7 +300,7 @@ actor VoiceRecorder { let baseDirectory = try outputDirectory ?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true) - try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) return baseDirectory.appendingPathComponent(fileName) } diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 966f210a..4204e940 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -663,7 +663,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func removeEphemeralSession(peerID: PeerID) { queue.sync(flags: .barrier) { - self.ephemeralSessions.removeValue(forKey: peerID) + _ = self.ephemeralSessions.removeValue(forKey: peerID) } } diff --git a/bitchat/Models/BitchatMessage+Media.swift b/bitchat/Models/BitchatMessage+Media.swift index a718e484..16e35a79 100644 --- a/bitchat/Models/BitchatMessage+Media.swift +++ b/bitchat/Models/BitchatMessage+Media.swift @@ -24,7 +24,7 @@ extension BitchatMessage { do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) - try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes) self.filesDir = filesDir } catch { filesDir = nil diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index e8609aed..6e2b3c23 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -1028,6 +1028,27 @@ final class NoiseSessionManager { .cancel() } + #if DEBUG + /// Fires a pending suppressed-initiation recovery immediately instead of + /// waiting out the completion-grace timer, so tests can inject a grace + /// period too large to lose against a starved runner and still exercise + /// the recovery path deterministically. + func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) { + managerQueue.sync(flags: .barrier) { + guard let pending = suppressedInitiationRecoveryTimeouts + .removeValue(forKey: peerID) else { + return + } + pending.cancel() + guard let current = sessions[peerID], + current.isEstablished() else { + return + } + requestHandshakeRecovery(for: peerID) + } + } + #endif + private func requestHandshakeRecovery( for peerID: PeerID, after delay: TimeInterval = 0 diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index fca24465..6601b863 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -153,14 +153,18 @@ final class NostrRelayManager: ObservableObject { // Built-in relays carry private-message envelopes, so avoid relays known to // reject the kinds they use. - private static let builtInRelays = [ + nonisolated private static let builtInRelays = [ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", "wss://offchain.pub" // For local testing, you can add: "ws://localhost:8080" ] - private static let builtInRelaySet = Set(builtInRelays.compactMap { NostrRelayURL.normalized($0) }) + /// Exposed so the relay settings UI can reject re-adding a built-in. + /// `nonisolated` because it is an immutable constant with no actor state. + nonisolated static let builtInRelayURLs = Set( + builtInRelays.compactMap { NostrRelayURL.normalized($0) } + ) /// The relays private messages target: the built-in set plus any added by /// hand. Four hardcoded hostnames are four names for a censor to block, so @@ -182,10 +186,6 @@ final class NostrRelayManager: ObservableObject { defaultRelaySet = Set(defaultRelays) } - /// Exposed so the relay settings UI can reject re-adding a built-in. - /// `nonisolated` because it is an immutable constant with no actor state. - nonisolated static var builtInRelayURLs: Set { builtInRelaySet } - @Published private(set) var relays: [Relay] = [] @Published private(set) var isConnected = false /// Whether a relay that carries private messages is connected. DMs diff --git a/bitchat/Services/AutocompleteService.swift b/bitchat/Services/AutocompleteService.swift index 9ae47df9..6c55f34e 100644 --- a/bitchat/Services/AutocompleteService.swift +++ b/bitchat/Services/AutocompleteService.swift @@ -55,10 +55,10 @@ final class AutocompleteService { let fullRange = match.range(at: 0) let captureRange = match.range(at: 1) - let prefix = nsText.substring(with: captureRange).lowercased() - + let prefix = nsText.substring(with: captureRange).normalizedNickname.lowercased() + let suggestions = peers - .filter { $0.lowercased().hasPrefix(prefix) } + .filter { $0.normalizedNickname.lowercased().hasPrefix(prefix) } .sorted() .prefix(5) .map { "@\($0)" } diff --git a/bitchat/Services/BLE/BLEAnnounceThrottle.swift b/bitchat/Services/BLE/BLEAnnounceThrottle.swift index d6bf5490..4b064136 100644 --- a/bitchat/Services/BLE/BLEAnnounceThrottle.swift +++ b/bitchat/Services/BLE/BLEAnnounceThrottle.swift @@ -37,4 +37,13 @@ final class BLEAnnounceThrottle: @unchecked Sendable { return true } } + + /// Forgets the last-sent timestamp. A panic rotation calls this so the + /// new identity's first announce cannot be swallowed by the old + /// identity's throttle debt — otherwise a panic within the forced + /// minimum interval of the last announce leaves the rotated identity + /// invisible until the next maintenance cycle. + func reset() { + lock.withLock { lastSent = .distantPast } + } } diff --git a/bitchat/Services/BLE/BLEEngineScheduler.swift b/bitchat/Services/BLE/BLEEngineScheduler.swift new file mode 100644 index 00000000..ab9ebbe8 --- /dev/null +++ b/bitchat/Services/BLE/BLEEngineScheduler.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Schedules deferred engine work: relay jitter, announce delays, protocol +/// deadlines (ping, capability proof), notification retry backoff, and +/// fragment pacing. +/// +/// This is the transport's only source of engine-side delay. Production +/// wraps the engine queue's `asyncAfter`; tests inject a manually advanced +/// clock so timer-driven behavior is asserted deterministically instead of +/// racing the wall clock — product constants used as deadlines are exactly +/// the hidden-elapsed-deadline flake class the test-timing hygiene rules +/// exist to contain. +protocol BLEEngineScheduling: AnyObject { + /// Called once by the transport with its engine queue. Scheduled work + /// always executes there: deferred bodies touch engine-confined state. + func activate(engineQueue: DispatchQueue) + /// Runs `work` on the engine queue after `delay`, honoring + /// `DispatchWorkItem` cancellation. + func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) +} + +extension BLEEngineScheduling { + func schedule(after delay: TimeInterval, _ body: @escaping () -> Void) { + schedule(after: delay, execute: DispatchWorkItem(block: body)) + } +} + +/// Production scheduler: a thin veneer over the engine queue. +final class BLEEngineDispatchScheduler: BLEEngineScheduling { + private var queue: DispatchQueue? + + func activate(engineQueue: DispatchQueue) { + queue = engineQueue + } + + func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) { + queue?.asyncAfter(deadline: .now() + delay, execute: work) + } +} diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index 826a391c..08ee3649 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -140,6 +140,20 @@ struct BLEIncomingFileStore: @unchecked Sendable { /// orphans a previous session left behind. static let liveCapturePrefix = "voice_live_" + /// Media payloads follow the same at-rest posture as the app's other + /// persistence layers (courier, outbox, receipt index): protected until + /// first unlock, so the launch-time retention sweep can still run after + /// a reboot. Applied to the media directories so recordings that save + /// as they go (live captures, `AVAudioRecorder`) inherit it, and stated + /// explicitly at the payload write site like every other store. + static var mediaProtectionAttributes: [FileAttributeKey: Any]? { + #if os(iOS) + return [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + #else + return nil + #endif + } + /// Exposed so callers that write progressively into the store's /// directories (live voice captures) share the same file manager. let fileManager: FileManager @@ -223,7 +237,7 @@ struct BLEIncomingFileStore: @unchecked Sendable { isDirectory: true ), withIntermediateDirectories: true, - attributes: nil + attributes: Self.mediaProtectionAttributes ) } } catch { @@ -268,7 +282,7 @@ struct BLEIncomingFileStore: @unchecked Sendable { /// write progressively instead of via `save` (live voice captures). func incomingDirectory(subdirectory: String) throws -> URL { let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true) - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes) return directory } @@ -284,7 +298,7 @@ struct BLEIncomingFileStore: @unchecked Sendable { do { let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true) - try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil) + try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes) let sanitized = sanitizedFileName( preferredName, defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))", @@ -306,7 +320,11 @@ struct BLEIncomingFileStore: @unchecked Sendable { ), forceRandomizedName: reservedPaths == nil ) - try data.write(to: destination, options: .atomic) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + try data.write(to: destination, options: options) payloadCoordination.pendingDeliveryPaths.insert( destination.standardizedFileURL.path ) @@ -650,9 +668,90 @@ struct BLEIncomingFileStore: @unchecked Sendable { return removed } + /// Stamps the media directories and any resident payloads with the + /// explicit protection class, covering files written by builds that + /// relied on the container default. Runs every launch: re-stamping an + /// equal class is a metadata no-op, and anything carrying a stronger + /// class is left alone, so repetition is cheap and can never downgrade. + /// In-flight live captures are skipped for symmetry with the retention + /// sweep; they receive the class at creation and need no repair. + /// Best-effort like the sweep it runs alongside; a file that cannot be + /// stamped is logged, not fatal, and the migration moves on to the next + /// item. Returns the number of items stamped so the launch path and + /// tests can observe coverage. + @discardableResult + func migrateFileProtectionIfNeeded() -> Int { + #if os(iOS) + guard let attributes = Self.mediaProtectionAttributes else { return 0 } + var stamped = 0 + guard let base = try? filesDirectory() else { return 0 } + for subdirectory in Self.mediaSubdirectories { + let dir = base.appendingPathComponent(subdirectory, isDirectory: true) + guard fileManager.fileExists(atPath: dir.path) else { continue } + let files = (try? fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey], + options: [.skipsHiddenFiles] + )) ?? [] + stamped += stampProtectionIfWeaker(dir, requireRegularFile: false, attributes: attributes) + for fileURL in files { + guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } + stamped += stampProtectionIfWeaker(fileURL, requireRegularFile: true, attributes: attributes) + } + } + return stamped + #else + return 0 + #endif + } + + #if os(iOS) + /// Applies the class to one item, but only when the item currently sits + /// at the container default or weaker. The list names the classes that + /// are safe to replace; anything else, including classes added in later + /// iOS versions, is left alone. Only regular files are stamped when + /// `requireRegularFile` is set (and only real directories otherwise), + /// matching the caution the legacy-file removal path applies; symlinks + /// and other non-regular files are left untouched. + private func stampProtectionIfWeaker( + _ itemURL: URL, + requireRegularFile: Bool, + attributes: [FileAttributeKey: Any] + ) -> Int { + let values = try? itemURL.resourceValues( + forKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey] + ) + if requireRegularFile { + guard values?.isRegularFile == true else { return 0 } + } else { + guard values?.isDirectory == true else { return 0 } + } + if let current = values?.fileProtection, + current != .none, + current != .completeUntilFirstUserAuthentication { + return 0 + } + do { + try fileManager.setAttributes(attributes, ofItemAtPath: itemURL.path) + return 1 + } catch let error as CocoaError where error.code == .fileNoSuchFile { + // Quota eviction or a deletion commit on another store instance + // can delete an item out from under this migration; that is not + // a failure. + return 0 + } catch { + SecureLogger.warning( + "⚠️ Failed to migrate media file protection: \(error)", + category: .security + ) + return 0 + } + } + #endif + private func filesDirectory() throws -> URL { let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true) - try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) + try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes) return filesDir } diff --git a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift index 970921b1..77a00f56 100644 --- a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift +++ b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift @@ -124,3 +124,45 @@ struct BLEIngressLinkRegistry { packet.isRSR && packet.ttl == 0 } } + +/// Lock-backed shared ownership of the ingress-link registry. Ingress is +/// recorded on bleQueue the moment a frame decodes (the link identity is +/// only known there, and the duplicate-ingress gate must answer before +/// the packet is handed to the engine), while relay and routing decisions +/// read it from the engine. Every registry mutation is a single +/// whole-transition method, so readers never observe a torn state. +final class BLEIngressLinkStore: @unchecked Sendable { + private let lock = NSLock() + private var registry = BLEIngressLinkRegistry() + + var isEmpty: Bool { + lock.withLock { registry.isEmpty } + } + + func removeAll() { + lock.withLock { registry.removeAll() } + } + + func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? { + lock.withLock { registry.record(for: packet) } + } + + func link(for packet: BitchatPacket) -> BLEIngressLinkID? { + lock.withLock { registry.link(for: packet) } + } + + func recordIfNew( + _ packet: BitchatPacket, + link: BLEIngressLinkID, + peerID: PeerID, + lifetime: TimeInterval + ) -> Bool { + lock.withLock { + registry.recordIfNew(packet, link: link, peerID: peerID, lifetime: lifetime) + } + } + + func prune(before cutoff: Date) { + lock.withLock { registry.prune(before: cutoff) } + } +} diff --git a/bitchat/Services/BLE/BLELinkAuthState.swift b/bitchat/Services/BLE/BLELinkAuthState.swift new file mode 100644 index 00000000..3989c3c4 --- /dev/null +++ b/bitchat/Services/BLE/BLELinkAuthState.swift @@ -0,0 +1,115 @@ +import BitFoundation +import Foundation + +/// Per-link Noise authentication and rebind-containment state. +/// +/// A peer ID can retain an established Noise session after its physical +/// link disappears, and link bindings heal on announces whose directness +/// is forgeable (TTL is unsigned). This state pins the stronger facts the +/// containment rules need: which exact ingress link a Noise handshake +/// completed on, each link's revalidation epoch, and the cooldowns that +/// stop a replayed announce from flip-flopping bindings or survivor +/// selection. +/// +/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md), +/// alongside the link bindings it qualifies: BLEService debug-traps any +/// access off the engine queue. +struct BLELinkAuthState { + private var authenticatedOwners: [BLEIngressLinkID: PeerID] = [:] + private var reconnectPolicy = BLENoiseReconnectPolicy() + // Entries older than the cooldown are pruned on each check. + private var lastRebindAt: [String: Date] = [:] + private var lastRedundantRetirementAt: [PeerID: Date] = [:] + + // MARK: - Authentication ownership + + /// Whether `peerID`'s Noise session was established on this exact link. + func isAuthenticated(_ link: BLEIngressLinkID, for peerID: PeerID) -> Bool { + authenticatedOwners[link] == peerID + } + + func links(ownedBy peerID: PeerID) -> [BLEIngressLinkID] { + authenticatedOwners.compactMap { link, owner in + owner == peerID ? link : nil + } + } + + mutating func markAuthenticated(_ link: BLEIngressLinkID, owner peerID: PeerID) { + authenticatedOwners[link] = peerID + } + + /// Retires a link's proof and closes its revalidation epoch — the pair + /// every teardown path (disconnect, unsubscribe, timeout, rebind, + /// redundant retirement) must apply together. + mutating func retireLink(_ link: BLEIngressLinkID) { + authenticatedOwners.removeValue(forKey: link) + reconnectPolicy.endLinkEpoch(link) + } + + /// Retires every link the departing peer's proofs still own; returns + /// the retired links. + mutating func retireLinks(ownedBy peerID: PeerID) -> [BLEIngressLinkID] { + let departed = links(ownedBy: peerID) + for link in departed { + retireLink(link) + } + return departed + } + + /// Drops every link proof and revalidation epoch. The containment + /// cooldowns deliberately SURVIVE this: panic and emergency resets can + /// restart services well inside `bleLinkRebindCooldownSeconds`, and a + /// stable CoreBluetooth UUID must not get a fresh rebind/retirement + /// allowance just because the session state around it was wiped. The + /// maps stay time-pruned on each permit check. + mutating func removeAll() { + authenticatedOwners.removeAll() + reconnectPolicy.removeAll() + } + + // MARK: - Session revalidation + + /// Whether a fresh direct link warrants revalidating a cached + /// peer-level session with a new XX exchange. + mutating func shouldRevalidate( + on link: BLEIngressLinkID, + for peerID: PeerID, + hasEstablishedSession: Bool, + hasAuthenticatedPeerLink: Bool, + now: Date + ) -> Bool { + reconnectPolicy.shouldRevalidate( + on: link, + hasEstablishedSession: hasEstablishedSession, + isNoiseAuthenticatedLink: isAuthenticated(link, for: peerID), + hasAuthenticatedPeerLink: hasAuthenticatedPeerLink, + now: now + ) + } + + // MARK: - Rebind containment cooldowns + + /// At most one rotation rebind per link per cooldown window, so two + /// identities can't fight over a link in a replay flip-flop. Prunes, + /// checks, and records in one transition; true = permitted (recorded). + mutating func permitRebind(linkUUID: String, now: Date, cooldown: TimeInterval) -> Bool { + lastRebindAt = lastRebindAt.filter { + now.timeIntervalSince($0.value) < cooldown + } + guard lastRebindAt[linkUUID] == nil else { return false } + lastRebindAt[linkUUID] = now + return true + } + + /// At most one redundant-link retirement per peer per cooldown window, + /// bounding how often a replayed announce could flip which duplicate + /// link survives. True = permitted (recorded). + mutating func permitRedundantRetirement(peerID: PeerID, now: Date, cooldown: TimeInterval) -> Bool { + lastRedundantRetirementAt = lastRedundantRetirementAt.filter { + now.timeIntervalSince($0.value) < cooldown + } + guard lastRedundantRetirementAt[peerID] == nil else { return false } + lastRedundantRetirementAt[peerID] = now + return true + } +} diff --git a/bitchat/Services/BLE/BLELinkBindings.swift b/bitchat/Services/BLE/BLELinkBindings.swift new file mode 100644 index 00000000..840acdfa --- /dev/null +++ b/bitchat/Services/BLE/BLELinkBindings.swift @@ -0,0 +1,152 @@ +import BitFoundation +import Foundation + +/// Identity↔link bindings: which peer each physical link currently +/// belongs to, in both roles, plus each peer's preferred peripheral link +/// for directed sends and fanout collapse. +/// +/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md), +/// alongside `BLELinkAuthState`: *who owns a link* lives on the engine, +/// *what links exist* stays on bleQueue in the physical store. BLEService +/// debug-traps any access off the engine queue. +/// +/// Lifecycle contract: bindings are only created for live physical links +/// (callers check liveness through `readLinkState`) and are retired +/// through `peripheralRemoved`/`centralRemoved`/`clear*` on an engine hop +/// queued by the physical teardown. A binding can therefore briefly +/// outlive its departed link; queries that need liveness join against the +/// physical store, and everything converges once the queued retirement +/// runs. +struct BLELinkBindings { + private var peripheralPeers: [String: PeerID] = [:] + private var centralPeers: [String: PeerID] = [:] + /// The peer's most recently bound peripheral link, kept so duplicate- + /// link fanout collapse stays deterministic (see BLEFanoutSelector). + private var preferredPeripheral: [PeerID: String] = [:] + + // MARK: - Queries + + func peer(forPeripheralID peripheralID: String) -> PeerID? { + peripheralPeers[peripheralID] + } + + func peer(forCentralUUID centralUUID: String) -> PeerID? { + centralPeers[centralUUID] + } + + func boundPeer(for link: BLEIngressLinkID) -> PeerID? { + switch link { + case .peripheral(let peripheralUUID): + return peripheralPeers[peripheralUUID] + case .central(let centralUUID): + return centralPeers[centralUUID] + } + } + + /// Every link bound to the peer, both roles. After a state restoration + /// the same device can hold several live peripheral links bound to one + /// peer (it reappears under a fresh UUID while the restored connection + /// lives on), so this scans all bindings rather than the 1:1 preferred + /// map. + func links(to peerID: PeerID?) -> Set { + guard let peerID else { return [] } + var links: Set = [] + for (peripheralUUID, boundPeer) in peripheralPeers where boundPeer == peerID { + links.insert(.peripheral(peripheralUUID)) + } + for (centralUUID, boundPeer) in centralPeers where boundPeer == peerID { + links.insert(.central(centralUUID)) + } + return links + } + + func hasCentral(boundTo peerID: PeerID) -> Bool { + centralPeers.values.contains(peerID) + } + + func preferredPeripheralUUID(for peerID: PeerID) -> String? { + preferredPeripheral[peerID] + } + + /// The full preferred-peripheral map, for fanout collapse. + var preferredPeripheralBindings: [PeerID: String] { + preferredPeripheral + } + + /// The full central binding map, for the subscribed-central snapshot. + var centralPeersByUUID: [String: PeerID] { + centralPeers + } + + // MARK: - Binding transitions + + mutating func bindCentral(_ centralUUID: String, to peerID: PeerID) { + centralPeers[centralUUID] = peerID + } + + mutating func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { + let previousPeerID = peripheralPeers[peripheralUUID] + peripheralPeers[peripheralUUID] = peerID + // Rebinding (peer-ID rotation): drop the retired ID's reverse + // mapping so the old peer no longer claims this link. + if let previousPeerID, previousPeerID != peerID, + preferredPeripheral[previousPeerID] == peripheralUUID { + preferredPeripheral.removeValue(forKey: previousPeerID) + } + preferredPeripheral[peerID] = peripheralUUID + } + + /// Retires a peripheral link's binding. When the removed link was the + /// peer's preferred one, the reverse map is repaired onto a surviving + /// duplicate chosen by the caller from the peer's remaining bound links + /// (the caller knows physical liveness; prefer a writable survivor — + /// repairing onto a link mid-service-rediscovery would strand directed + /// sends until its characteristic comes back). + mutating func peripheralRemoved( + _ peripheralUUID: String, + chooseSurvivor: (_ remainingBoundUUIDs: [String]) -> String? + ) -> PeerID? { + guard let peerID = peripheralPeers.removeValue(forKey: peripheralUUID) else { + return nil + } + // Only clear (or repair) the reverse map when it points at the + // removed link: with duplicate links to one peer, removing a stale + // duplicate must not strand the peer's surviving bound link. + if preferredPeripheral[peerID] == peripheralUUID { + let remaining = peripheralPeers.compactMap { uuid, boundPeer in + boundPeer == peerID ? uuid : nil + } + if let survivorUUID = chooseSurvivor(remaining) { + preferredPeripheral[peerID] = survivorUUID + } else { + preferredPeripheral.removeValue(forKey: peerID) + } + } + return peerID + } + + mutating func centralRemoved(_ centralUUID: String) -> PeerID? { + centralPeers.removeValue(forKey: centralUUID) + } + + /// Drops every peripheral binding; returns the peers that held one. + mutating func clearPeripherals() -> [PeerID] { + let peerIDs = Array(peripheralPeers.values) + peripheralPeers.removeAll() + preferredPeripheral.removeAll() + return peerIDs + } + + /// Drops every central binding; returns the peers that held one. + mutating func clearCentrals() -> [PeerID] { + let peerIDs = Array(centralPeers.values) + centralPeers.removeAll() + return peerIDs + } + + mutating func removeAll() { + peripheralPeers.removeAll() + centralPeers.removeAll() + preferredPeripheral.removeAll() + } +} diff --git a/bitchat/Services/BLE/BLELinkEvent.swift b/bitchat/Services/BLE/BLELinkEvent.swift new file mode 100644 index 00000000..fdc8a6da --- /dev/null +++ b/bitchat/Services/BLE/BLELinkEvent.swift @@ -0,0 +1,40 @@ +import BitFoundation +import Foundation + +/// The upward half of the link-layer port: everything the bleQueue link +/// layer tells the engine, as one enumerable surface with one engine +/// entry point (`BLEService.handleLinkEvent`). CoreBluetooth delegates +/// shrink to physical bookkeeping plus event emission, and the simulated +/// mesh drives the engine through exactly the same seam. +/// +/// Naming follows the physical stores: a *peripheral link* is a +/// connection we own as central (keyed by the remote peripheral's UUID); +/// a *central link* is a remote central subscribed to our peripheral role +/// (keyed by its UUID). +enum BLELinkEvent { + /// A decoded frame arrived on a link. Attribution — binding lookup, + /// spoof rejection, raw-announce binding, ingress recording — is + /// engine work. Emission captures the panic lifecycle at the handoff. + case frameDecoded(BitchatPacket, link: BLEIngressLinkID, linkDescription: String) + + /// One peripheral link ended (disconnect, connect failure, or radio + /// policy teardown). The engine retires the link's identity half — + /// proof, epoch, binding with survivor repair — and, when + /// `runPeerBookkeeping` is set (real disconnects), marks the peer + /// disconnected once its last live link is gone and republishes the + /// peer list. + case peripheralLinkEnded(peripheralID: String, runPeerBookkeeping: Bool) + + /// A remote central unsubscribed. The engine retires the central + /// link's identity half and runs last-link peer bookkeeping. + case centralLinkEnded(centralUUID: String) + + /// The central role reset and every peripheral link is gone + /// (power-off retires proofs and notifies peers; an authorization + /// loss only drops the bindings). + case allPeripheralLinksEnded(peripheralIDs: [String], retireProofsAndNotify: Bool) + + /// The peripheral role reset and every central link is gone (same + /// power-off / authorization-loss split). + case allCentralLinksEnded(centralUUIDs: [String], retireProofsAndNotify: Bool) +} diff --git a/bitchat/Services/BLE/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift index 31914092..79440a64 100644 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ b/bitchat/Services/BLE/BLELinkStateStore.swift @@ -5,10 +5,15 @@ import Foundation struct BLEPeripheralLinkState { let peripheral: CBPeripheral var characteristic: CBCharacteristic? - var peerID: PeerID? var isConnecting: Bool var isConnected: Bool var lastConnectionAttempt: Date? + /// When didConnect last fired for this link. Nil for links restored + /// already-connected (their connect predates this process), which is + /// exactly the signal redundant-link consolidation needs: a restored + /// link lives on an old BLE address the peer no longer advertises, + /// so it must never be kept over a freshly connected duplicate. + var lastConnectedAt: Date? = nil var assembler: NotificationStreamAssembler } @@ -26,17 +31,20 @@ struct BLESubscribedCentralSnapshot { } } -/// Owns all BLE link state (peripheral connections we hold as central, and -/// central subscriptions we serve as peripheral). The store has no internal -/// locking: every access must happen on the single owning queue (the BLE -/// queue). Other queues must go through BLEService's `readLinkState`, which -/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap -/// any access from the wrong queue. +// BLEDirectLinkState and the identity↔link binding queries live on +// BLELinkBindings; this store owns only physical link state. + +/// Owns the PHYSICAL BLE link state (peripheral connections we hold as +/// central, and central subscriptions we serve as peripheral) — CB object +/// handles, connect lifecycles, characteristics, and stream assemblers. +/// Identity↔link bindings live on `BLELinkBindings`. The store has no +/// internal locking: every access must happen on the single owning queue +/// (the BLE queue). Other queues must go through BLEService's +/// `readLinkState`, which hops to that queue. Call `assumeOwnership(of:)` +/// to have debug builds trap any access from the wrong queue. final class BLELinkStateStore { private(set) var peripherals: [String: BLEPeripheralLinkState] = [:] - private(set) var peerToPeripheralUUID: [PeerID: String] = [:] private(set) var subscribedCentrals: [CBCentral] = [] - private(set) var centralToPeerID: [String: PeerID] = [:] #if DEBUG private var ownerQueue: DispatchQueue? @@ -64,14 +72,6 @@ final class BLELinkStateStore { return Array(peripherals.values) } - var subscribedCentralSnapshot: BLESubscribedCentralSnapshot { - assertOwned() - return BLESubscribedCentralSnapshot( - centrals: subscribedCentrals, - peerIDsByCentralUUID: centralToPeerID - ) - } - var subscribedCentralCount: Int { assertOwned() return subscribedCentrals.count @@ -109,7 +109,6 @@ final class BLELinkStateStore { BLEPeripheralLinkState( peripheral: peripheral, characteristic: nil, - peerID: nil, isConnecting: true, isConnected: false, lastConnectionAttempt: date, @@ -119,20 +118,21 @@ final class BLELinkStateStore { ) } - func markConnected(_ peripheral: CBPeripheral) { + func markConnected(_ peripheral: CBPeripheral, at now: Date = Date()) { let peripheralID = peripheral.identifier.uuidString if updatePeripheral(peripheralID, { $0.isConnecting = false $0.isConnected = true + $0.lastConnectedAt = now }) == nil { setPeripheralState( BLEPeripheralLinkState( peripheral: peripheral, characteristic: nil, - peerID: nil, isConnecting: false, isConnected: true, lastConnectionAttempt: nil, + lastConnectedAt: now, assembler: NotificationStreamAssembler() ), for: peripheralID @@ -146,130 +146,35 @@ final class BLELinkStateStore { } } - func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - assertOwned() - return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] } - } - - func directLinkState(for peerID: PeerID) -> BLEDirectLinkState { - assertOwned() - let peripheralUUID = peerToPeripheralUUID[peerID] - let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false - let hasCentral = centralToPeerID.values.contains(peerID) - return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral) - } - - func links(to peerID: PeerID?) -> Set { - assertOwned() - guard let peerID else { return [] } - - var links: Set = [] - // Scan all states rather than the 1:1 reverse map: after a state - // restoration the same device can hold several live peripheral links - // bound to one peer (it reappears under a fresh UUID while the - // restored connection lives on). - for (peripheralUUID, state) in peripherals where state.peerID == peerID { - links.insert(.peripheral(peripheralUUID)) - } - for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID { - links.insert(.central(centralUUID)) - } - return links - } - - /// The peer's most recently bound peripheral link, per peer. Used to keep - /// duplicate-link fanout collapse deterministic (see BLEFanoutSelector). - var preferredPeripheralBindings: [PeerID: String] { - assertOwned() - return peerToPeripheralUUID - } - - func peerID(forPeripheralID peripheralID: String) -> PeerID? { - assertOwned() - return peripherals[peripheralID]?.peerID - } - - func peerID(forCentralUUID centralUUID: String) -> PeerID? { - assertOwned() - return centralToPeerID[centralUUID] - } - func addSubscribedCentral(_ central: CBCentral) { assertOwned() guard !subscribedCentrals.contains(central) else { return } subscribedCentrals.append(central) } - func removeSubscribedCentral(_ central: CBCentral) -> PeerID? { + func removeSubscribedCentral(_ central: CBCentral) { assertOwned() - let centralUUID = central.identifier.uuidString subscribedCentrals.removeAll { $0.identifier == central.identifier } - return centralToPeerID.removeValue(forKey: centralUUID) } - func bindCentral(_ centralUUID: String, to peerID: PeerID) { + func removePeripheral(_ peripheralID: String) { assertOwned() - centralToPeerID[centralUUID] = peerID + peripherals.removeValue(forKey: peripheralID) } - func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { + func clearPeripherals() { assertOwned() - var previousPeerID: PeerID? - let updated = updatePeripheral(peripheralUUID) { - previousPeerID = $0.peerID - $0.peerID = peerID - } - guard updated != nil else { return } - // Rebinding (peer-ID rotation): drop the retired ID's reverse mapping - // so the old peer no longer claims this link. - if let previousPeerID, previousPeerID != peerID, - peerToPeripheralUUID[previousPeerID] == peripheralUUID { - peerToPeripheralUUID.removeValue(forKey: previousPeerID) - } - peerToPeripheralUUID[peerID] = peripheralUUID - } - - func removePeripheral(_ peripheralID: String) -> PeerID? { - assertOwned() - let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID - // Only clear (or repair) the reverse map when it points at the removed - // link: with duplicate links to one peer, removing a stale duplicate - // must not strand the peer's surviving bound link. - if let peerID, peerToPeripheralUUID[peerID] == peripheralID { - // Prefer a writable survivor: repairing onto a link that is - // mid-service-rediscovery would strand directed sends until the - // characteristic comes back. - let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected } - if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key { - peerToPeripheralUUID[peerID] = survivorUUID - } else { - peerToPeripheralUUID.removeValue(forKey: peerID) - } - } - return peerID - } - - func clearPeripherals() -> [PeerID] { - assertOwned() - let peerIDs = peripherals.compactMap { $0.value.peerID } peripherals.removeAll() - peerToPeripheralUUID.removeAll() - return peerIDs } - func clearCentrals() -> [PeerID] { + func clearCentrals() { assertOwned() - let peerIDs = Array(centralToPeerID.values) subscribedCentrals.removeAll() - centralToPeerID.removeAll() - return peerIDs } func clearAll() { assertOwned() peripherals.removeAll() - peerToPeripheralUUID.removeAll() subscribedCentrals.removeAll() - centralToPeerID.removeAll() } } diff --git a/bitchat/Services/BLE/BLELocalIdentityStateStore.swift b/bitchat/Services/BLE/BLELocalIdentityStateStore.swift index b379b0cd..7b5be20b 100644 --- a/bitchat/Services/BLE/BLELocalIdentityStateStore.swift +++ b/bitchat/Services/BLE/BLELocalIdentityStateStore.swift @@ -5,6 +5,20 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable { let peerID: PeerID let peerIDData: Data let nickname: String + /// Runtime-toggled capability bits (e.g. the internet-gateway toggle) + /// ORed into `PeerCapabilities.localSupported` for every announce. + let runtimeCapabilities: PeerCapabilities + /// Rendezvous cell advertised while bridging; rides announces only + /// while the `.bridge` capability is enabled. + let bridgeGeohash: String? + + var advertisedCapabilities: PeerCapabilities { + PeerCapabilities.localSupported.union(runtimeCapabilities) + } + + var advertisedBridgeGeohash: String? { + runtimeCapabilities.contains(.bridge) ? bridgeGeohash : nil + } } /// Lock-backed local identity state shared by the transport's message, @@ -12,8 +26,8 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable { /// /// `peerID` and its binary wire representation must change as one unit during /// panic rotation. A snapshot also gives announce construction one consistent -/// view of the nickname and identity instead of reading three independently -/// mutable properties across queues. +/// view of the nickname, identity, and advertised capabilities instead of +/// reading independently mutable properties across queues. final class BLELocalIdentityStateStore: @unchecked Sendable { private let lock = NSLock() private var state: BLELocalIdentitySnapshot @@ -25,7 +39,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable { state = BLELocalIdentitySnapshot( peerID: peerID, peerIDData: Data(hexString: peerID.id) ?? Data(), - nickname: nickname + nickname: nickname, + runtimeCapabilities: [], + bridgeGeohash: nil ) } @@ -38,7 +54,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable { state = BLELocalIdentitySnapshot( peerID: state.peerID, peerIDData: state.peerIDData, - nickname: nickname + nickname: nickname, + runtimeCapabilities: state.runtimeCapabilities, + bridgeGeohash: state.bridgeGeohash ) } } @@ -48,8 +66,48 @@ final class BLELocalIdentityStateStore: @unchecked Sendable { state = BLELocalIdentitySnapshot( peerID: peerID, peerIDData: Data(hexString: peerID.id) ?? Data(), - nickname: state.nickname + nickname: state.nickname, + runtimeCapabilities: state.runtimeCapabilities, + bridgeGeohash: state.bridgeGeohash ) } } + + /// Flips a runtime capability bit. Returns whether anything changed. + @discardableResult + func setCapability(_ capability: PeerCapabilities, enabled: Bool) -> Bool { + lock.withLock { + var capabilities = state.runtimeCapabilities + if enabled { + capabilities.insert(capability) + } else { + capabilities.remove(capability) + } + guard capabilities != state.runtimeCapabilities else { return false } + state = BLELocalIdentitySnapshot( + peerID: state.peerID, + peerIDData: state.peerIDData, + nickname: state.nickname, + runtimeCapabilities: capabilities, + bridgeGeohash: state.bridgeGeohash + ) + return true + } + } + + /// Sets the bridged rendezvous cell. Returns whether anything changed. + @discardableResult + func setBridgeGeohash(_ cell: String?) -> Bool { + lock.withLock { + guard cell != state.bridgeGeohash else { return false } + state = BLELocalIdentitySnapshot( + peerID: state.peerID, + peerIDData: state.peerIDData, + nickname: state.nickname, + runtimeCapabilities: state.runtimeCapabilities, + bridgeGeohash: cell + ) + return true + } + } } diff --git a/bitchat/Services/BLE/BLEMeshPingTracker.swift b/bitchat/Services/BLE/BLEMeshPingTracker.swift new file mode 100644 index 00000000..3e1e6644 --- /dev/null +++ b/bitchat/Services/BLE/BLEMeshPingTracker.swift @@ -0,0 +1,62 @@ +import BitFoundation +import Foundation + +struct BLEMeshPingProbe { + let peerID: PeerID + let sentAt: Date + let lifecycleGeneration: UInt64 + let completion: @MainActor (MeshPingResult?) -> Void + let timeout: DispatchWorkItem +} + +/// Engine-confined /ping diagnostics state: outstanding probes keyed by +/// their unguessable nonce, plus the inbound response budget. +/// +/// The budget is keyed by the ingress link (the directly connected peer +/// that delivered the packet), never the packet-claimed sender: pings are +/// unsigned, so the claimed sender is attacker-controlled and rotating it +/// would reset the budget, turning a directed unencrypted probe into an +/// amplification primitive. +/// +/// Pure state — the transport owns packet I/O, timers, and main-actor +/// completion delivery around it. +struct BLEMeshPingTracker { + private var pendingProbes: [Data: BLEMeshPingProbe] = [:] + private var responseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + + mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) { + pendingProbes[nonce] = probe + } + + /// Resolves a pong against its outstanding probe. The echoed nonce plus + /// the sender check bind the reply to the probed peer. + mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? { + guard pendingProbes[nonce]?.peerID == peerID else { return nil } + return pendingProbes.removeValue(forKey: nonce) + } + + /// Removes a timed-out probe so its completion can fire once with nil. + mutating func expire(nonce: Data) -> BLEMeshPingProbe? { + pendingProbes.removeValue(forKey: nonce) + } + + /// Whether an inbound ping delivered by this link is within budget. + mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool { + responseLimiter.shouldRespond(to: linkPeerID, now: now) + } + + /// Drops all probes and restores a fresh response budget (panic wipe). + /// Returns the orphaned timeout work items for the caller to cancel. + mutating func reset() -> [DispatchWorkItem] { + let timeouts = pendingProbes.values.map(\.timeout) + pendingProbes.removeAll() + responseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + return timeouts + } +} diff --git a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift index 722e55c4..5176149a 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift @@ -261,8 +261,6 @@ struct BLEOutboundFragmentTransferScheduler { continue } - availableSlots -= 1 - guard activeTransfers.count < maxConcurrentTransfers else { pendingTransfers.insert(request, at: 0) results.append(.queued(request: request, transferId: transferId, position: .front)) @@ -270,11 +268,17 @@ struct BLEOutboundFragmentTransferScheduler { } guard activeTransfers[transferId] == nil else { + // Blocked on an already-active copy of this content: leave + // the slot budget untouched so a later, unrelated pending + // transfer can still start in this same pass instead of + // being starved until some other transfer happens to + // complete. blockedFront.append(request) results.append(.queued(request: request, transferId: transferId, position: .front)) continue } + availableSlots -= 1 activeTransfers[transferId] = ActiveTransferState( totalFragments: 0, sentFragments: 0, diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 679b419e..62113a59 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -223,7 +223,7 @@ struct BLEPeerRegistry { peers[peerID] = BLEPeerInfo( peerID: existing?.peerID ?? peerID, - nickname: nickname, + nickname: nickname.normalizedNickname, isConnected: isConnected, noisePublicKey: noisePublicKey, // Never drop an already-pinned signing key. diff --git a/bitchat/Services/BLE/BLEPeerRegistryStore.swift b/bitchat/Services/BLE/BLEPeerRegistryStore.swift new file mode 100644 index 00000000..68110aed --- /dev/null +++ b/bitchat/Services/BLE/BLEPeerRegistryStore.swift @@ -0,0 +1,84 @@ +import BitFoundation +import Foundation + +/// Lock-backed shared ownership of the peer registry, readable from any +/// queue or the main actor without hopping onto a transport queue. +/// +/// Mutations come only from the transport's own serial queues — the +/// engine, plus the bleQueue link-drop paths that mark a peer +/// disconnected — and the lock serializes them against each other and +/// against readers, so the main actor answers questions like +/// `isPeerConnected` without blocking behind in-flight transport work. +/// Every `BLEPeerRegistry` mutation is a single whole-transition method, +/// so a reader between two mutations always observes a valid pre- or +/// post-state, never a torn one. +/// +/// Closures passed to `read`/`mutate` run under the (non-recursive) lock +/// and must not call back into the store. +final class BLEPeerRegistryStore: @unchecked Sendable { + private let lock = NSLock() + private var registry = BLEPeerRegistry() + + /// One consistent view across multiple registry reads. + func read(_ body: (BLEPeerRegistry) -> T) -> T { + lock.withLock { body(registry) } + } + + func mutate(_ body: (inout BLEPeerRegistry) -> T) -> T { + lock.withLock { body(®istry) } + } + + // MARK: - Single-question reads + + var isEmpty: Bool { read { $0.isEmpty } } + var peerIDs: [PeerID] { read { $0.peerIDs } } + var connectedCount: Int { read { $0.connectedCount } } + var connectedPeerIDs: [PeerID] { read { $0.connectedPeerIDs } } + var connectedRoutingData: [Data] { read { $0.connectedRoutingData } } + var snapshotByID: [PeerID: BLEPeerInfo] { read { $0.snapshotByID } } + + func info(for peerID: PeerID) -> BLEPeerInfo? { + read { $0.info(for: peerID) } + } + + func isConnected(_ peerID: PeerID) -> Bool { + read { $0.isConnected(peerID) } + } + + func isReachable(_ peerID: PeerID, now: Date) -> Bool { + read { $0.isReachable(peerID, now: now) } + } + + func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? { + read { $0.nickname(for: peerID, connectedOnly: connectedOnly) } + } + + func fingerprint(for peerID: PeerID) -> String? { + read { $0.fingerprint(for: peerID) } + } + + func capabilities(for peerID: PeerID) -> PeerCapabilities { + read { $0.capabilities(for: peerID) } + } + + func advertisedBridgeGeohash() -> String? { + read { $0.advertisedBridgeGeohash() } + } + + func displayNicknames(selfNickname: String) -> [PeerID: String] { + read { $0.displayNicknames(selfNickname: selfNickname) } + } + + func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] { + read { $0.transportSnapshots(selfNickname: selfNickname) } + } + + /// Peers advertising `capability` that are reachable now, in one + /// consistent view. + func reachablePeers(advertising capability: PeerCapabilities, now: Date) -> [PeerID] { + read { registry in + registry.peers(advertising: capability) + .filter { registry.isReachable($0, now: now) } + } + } +} diff --git a/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift b/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift new file mode 100644 index 00000000..461d934c --- /dev/null +++ b/bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift @@ -0,0 +1,363 @@ +import BitFoundation +import Foundation + +struct BLEAuthenticatedPeerStateObservation { + let fingerprint: String + let sessionGeneration: UUID + let capabilities: PeerCapabilities +} + +struct BLEPrivateMediaProofTimeoutMarker { + let fingerprint: String + let sessionGeneration: UUID? +} + +struct BLEPrivateMediaProofWatchdog { + let fingerprint: String + let sessionGeneration: UUID + let timeoutNonce: UUID +} + +struct BLEPendingPrivateMediaPolicyResolution { + let fingerprint: String + var sessionGeneration: UUID? + var timeoutNonce: UUID + var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void] +} + +struct BLEAuthenticatedPeerStateSendProgress { + let sessionGeneration: UUID + var sentInitial = false + var sentEcho = false +} + +/// Lock-backed private-media session state: which Noise generation each +/// peer's capability proof, peer-state exchange, and policy waiters are +/// bound to. A fresh Noise authentication rotates the generation UUID, so +/// stale proof timers and proof packets cannot classify a replacement +/// session. +/// +/// Lock-backed rather than engine-confined for two reasons: the send +/// policy is answered synchronously on the main actor, and several +/// transitions run inside noise-manager critical sections that the engine +/// is sync-waiting on (where re-entering the engine would self-deadlock, +/// but taking a leaf lock is safe). Every method is one whole transition +/// under the lock, so no caller can observe a torn intermediate state. +final class BLEPrivateMediaSessionStore: @unchecked Sendable { + private let lock = NSLock() + private var sessionGenerations: [PeerID: UUID] = [:] + private var authenticatedStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:] + private var proofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:] + private var proofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:] + private var pendingPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:] + private var stateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:] + /// Peers whose parked outbound queues must stay parked until the + /// convergence retry re-authenticates: a timeout-restore brings back + /// keys the counterpart may have already discarded, so nothing — not + /// even the capability-proof watchdog — may drain the queues under + /// them. Set on the deferred restore transition, cleared by any + /// transition that is allowed to drain. + private var outboundConvergenceDeferred: Set = [] + + // MARK: Reads + + func currentGeneration(for peerID: PeerID) -> UUID? { + lock.withLock { sessionGenerations[peerID] } + } + + /// The exact current generation iff it authenticated both encrypted + /// private media (bit 8) and durable receipts/retry (bit 9). + func receiptSessionGeneration(for peerID: PeerID, currentNoiseGeneration: UUID?) -> UUID? { + lock.withLock { + guard let generation = sessionGenerations[peerID], + generation == currentNoiseGeneration, + let authenticated = authenticatedStates[peerID], + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia), + authenticated.capabilities.contains(.privateMediaReceipts) else { + return nil + } + return generation + } + } + + /// One consistent view of the state the send-policy calculus needs. + func policyInputs(for peerID: PeerID) -> ( + sessionGeneration: UUID?, + authenticatedState: BLEAuthenticatedPeerStateObservation?, + timedOut: BLEPrivateMediaProofTimeoutMarker? + ) { + lock.withLock { + ( + sessionGenerations[peerID], + authenticatedStates[peerID], + proofTimeoutMarkers[peerID] + ) + } + } + + func hasPendingPolicyResolution(for peerID: PeerID) -> Bool { + lock.withLock { pendingPolicyResolutions[peerID] != nil } + } + + /// The live proof-timeout identity for a peer (watchdog first, then a + /// registered waiter) — what a forced/expired timeout must present. + func proofTimeoutTarget(for peerID: PeerID) -> (fingerprint: String, generation: UUID?, nonce: UUID)? { + lock.withLock { + if let watchdog = proofWatchdogs[peerID] { + return (watchdog.fingerprint, watchdog.sessionGeneration, watchdog.timeoutNonce) + } + if let pending = pendingPolicyResolutions[peerID] { + return (pending.fingerprint, pending.sessionGeneration, pending.timeoutNonce) + } + return nil + } + } + + // MARK: Generation transitions + + /// Installs a freshly authenticated generation: rotates the proof + /// watchdog, resets peer-state send progress, and re-binds any pending + /// policy waiters whose fingerprint still matches (mismatched waiters + /// are rejected and returned for completion). Returns nil when the + /// generation is already current — the same-generation reconciliation + /// path, which must not re-arm proof machinery. + func beginAuthenticatedGeneration( + for peerID: PeerID, + fingerprint: String, + generation: UUID + ) -> (watchdogNonce: UUID, rejected: [@MainActor (PrivateMediaSendPolicy) -> Void])? { + lock.withLock { + guard sessionGenerations[peerID] != generation else { return nil } + let watchdogNonce = UUID() + sessionGenerations[peerID] = generation + authenticatedStates.removeValue(forKey: peerID) + proofTimeoutMarkers.removeValue(forKey: peerID) + proofWatchdogs[peerID] = BLEPrivateMediaProofWatchdog( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: watchdogNonce + ) + stateSendProgress[peerID] = + BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) + + guard var pending = pendingPolicyResolutions[peerID] else { + return (watchdogNonce, []) + } + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { + pendingPolicyResolutions.removeValue(forKey: peerID) + return (watchdogNonce, Array(pending.completions.values)) + } + pending.sessionGeneration = generation + pending.timeoutNonce = watchdogNonce + pendingPolicyResolutions[peerID] = pending + return (watchdogNonce, []) + } + } + + /// Records a verified authenticated-peer-state packet for the current + /// generation: pins the observation, retires proof timers, and releases + /// matching policy waiters. Returns nil when the generation is no longer + /// current (the caller's lease raced a replacement). + func applyAuthenticatedPeerState( + for peerID: PeerID, + fingerprint: String, + generation: UUID, + capabilities: PeerCapabilities + ) -> [@MainActor (PrivateMediaSendPolicy) -> Void]? { + lock.withLock { + guard sessionGenerations[peerID] == generation else { return nil } + authenticatedStates[peerID] = BLEAuthenticatedPeerStateObservation( + fingerprint: fingerprint, + sessionGeneration: generation, + capabilities: capabilities + ) + proofTimeoutMarkers.removeValue(forKey: peerID) + proofWatchdogs.removeValue(forKey: peerID) + guard let pending = pendingPolicyResolutions.removeValue(forKey: peerID), + pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.sessionGeneration == generation else { + return [] + } + return Array(pending.completions.values) + } + } + + /// Consumes one peer-state send slot (initial or echo) for the current + /// generation. Returns whether the packet should actually go out. + func markPeerStateSend(for peerID: PeerID, echo: Bool) -> Bool { + lock.withLock { + guard let generation = sessionGenerations[peerID], + var progress = stateSendProgress[peerID], + progress.sessionGeneration == generation else { return false } + if echo { + guard !progress.sentEcho else { return false } + progress.sentEcho = true + } else { + guard !progress.sentInitial else { return false } + progress.sentInitial = true + } + stateSendProgress[peerID] = progress + return true + } + } + + // MARK: Outbound convergence deferral + + func setOutboundDeferredUntilConvergence(_ peerID: PeerID) { + lock.withLock { _ = outboundConvergenceDeferred.insert(peerID) } + } + + func clearOutboundDeferredUntilConvergence(_ peerID: PeerID) { + lock.withLock { _ = outboundConvergenceDeferred.remove(peerID) } + } + + // MARK: Proof timeout + + /// Expires a proof deadline if its nonce/generation/fingerprint still + /// identify the live watchdog or waiter set. On expiry the timeout + /// marker is pinned and any waiters are returned for completion. + /// `deferredOutbound` reports whether the peer's parked queues must + /// stay parked (timeout-restore pending its convergence retry). + func expireProofDeadline( + for peerID: PeerID, + fingerprint: String, + sessionGeneration: UUID?, + nonce: UUID + ) -> (expired: Bool, deferredOutbound: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) { + lock.withLock { + let pending = pendingPolicyResolutions[peerID] + let pendingMatches = pending?.timeoutNonce == nonce + && pending?.sessionGeneration == sessionGeneration + && pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame + let watchdog = proofWatchdogs[peerID] + let watchdogMatches = sessionGeneration != nil + && watchdog?.timeoutNonce == nonce + && watchdog?.sessionGeneration == sessionGeneration + && watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame + guard pendingMatches || watchdogMatches else { + return (false, false, []) + } + var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = [] + if pendingMatches, let pending { + completions = Array(pending.completions.values) + } + if pendingMatches { + pendingPolicyResolutions.removeValue(forKey: peerID) + } + if watchdogMatches { + proofWatchdogs.removeValue(forKey: peerID) + } + proofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker( + fingerprint: fingerprint, + sessionGeneration: sessionGeneration + ) + return (true, outboundConvergenceDeferred.contains(peerID), completions) + } + } + + /// Registers a policy-resolution waiter for a peer still awaiting its + /// capability proof. Joins the existing waiter set when fingerprints + /// match (bounded), otherwise starts one, reusing the live watchdog's + /// deadline identity when it covers the same fingerprint/generation so + /// only one timeout is ever in flight. `shouldSchedule` tells the + /// caller to arm a fresh deadline. + func registerPolicyResolution( + for peerID: PeerID, + fingerprint: String, + requestID: UUID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) { + lock.withLock { + let generation = sessionGenerations[peerID] + if var pending = pendingPolicyResolutions[peerID] { + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.completions.count + < TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else { + return (false, false, UUID(), generation) + } + pending.completions[requestID] = completion + pendingPolicyResolutions[peerID] = pending + return (true, false, pending.timeoutNonce, pending.sessionGeneration) + } + + guard pendingPolicyResolutions.count + < TransportConfig.privateMediaCapabilityProofPendingPeerCap else { + return (false, false, UUID(), generation) + } + let currentWatchdog = proofWatchdogs[peerID] + let reusesWatchdog = currentWatchdog?.fingerprint + .caseInsensitiveCompare(fingerprint) == .orderedSame + && currentWatchdog?.sessionGeneration == generation + let nonce: UUID + if reusesWatchdog, let currentWatchdog { + nonce = currentWatchdog.timeoutNonce + } else { + nonce = UUID() + } + pendingPolicyResolutions[peerID] = + BLEPendingPrivateMediaPolicyResolution( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: nonce, + completions: [requestID: completion] + ) + return (true, !reusesWatchdog, nonce, generation) + } + } + + // MARK: Teardown + + /// A session clear retires every generation-bound record. Waiters are + /// kept but rebased onto a nil generation with a fresh deadline nonce, + /// returned so the caller re-arms their timeout. + func clearSession(for peerID: PeerID) -> (fingerprint: String, nonce: UUID)? { + lock.withLock { + sessionGenerations.removeValue(forKey: peerID) + authenticatedStates.removeValue(forKey: peerID) + proofTimeoutMarkers.removeValue(forKey: peerID) + proofWatchdogs.removeValue(forKey: peerID) + stateSendProgress.removeValue(forKey: peerID) + outboundConvergenceDeferred.remove(peerID) + guard var pending = pendingPolicyResolutions[peerID] else { + return nil + } + let nonce = UUID() + pending.sessionGeneration = nil + pending.timeoutNonce = nonce + pendingPolicyResolutions[peerID] = pending + return (pending.fingerprint, nonce) + } + } + + /// Panic wipe: these records belong to pre-panic transfer state, and + /// invoking their callbacks would let queued UI work recreate or resend + /// wiped media — drop everything. + func panicReset() { + lock.withLock { + sessionGenerations.removeAll() + authenticatedStates.removeAll() + proofTimeoutMarkers.removeAll() + proofWatchdogs.removeAll() + pendingPolicyResolutions.removeAll() + stateSendProgress.removeAll() + outboundConvergenceDeferred.removeAll() + } + } +} + +extension BLEPrivateMediaSessionStore { + /// The current generation iff its authenticated peer state proved the + /// private-media capability (and, when required, durable receipts). + func provenGeneration(for peerID: PeerID, requireReceipts: Bool) -> UUID? { + let inputs = policyInputs(for: peerID) + guard let generation = inputs.sessionGeneration, + let authenticated = inputs.authenticatedState, + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia) else { return nil } + if requireReceipts { + guard authenticated.capabilities.contains(.privateMediaReceipts) else { return nil } + } + return generation + } +} diff --git a/bitchat/Services/BLE/BLERadioController.swift b/bitchat/Services/BLE/BLERadioController.swift new file mode 100644 index 00000000..cf9d6c2d --- /dev/null +++ b/bitchat/Services/BLE/BLERadioController.swift @@ -0,0 +1,411 @@ +import BitLogger +import CoreBluetooth +import Foundation + +/// The radio's contact points back into the transport. All calls arrive on +/// bleQueue. +protocol BLERadioControllerDelegate: AnyObject { + /// Whether a panic wipe has quiesced the radio. + func radioIsPanicSuspended() -> Bool + /// iOS app-active snapshot (drives allow-duplicates scanning and + /// background connect deferral); always true on macOS. + func radioIsAppActive() -> Bool + /// A connect attempt died (timeout or foreground stale-reclaim): retire + /// the link's transport bookkeeping — write buffers, link-auth proof, + /// reconnect epoch, and the link-state entry itself. + func radioTearDownPeripheralLink(_ peripheralID: String) +} + +/// bleQueue-confined owner of the central-role radio policy: discovery +/// admission, the connection budget and queue, connect timeouts, +/// wake-on-proximity background connects, scan duty-cycling, RSSI +/// adaptation, and the advertising payload. +/// +/// First slice of the link layer (docs/BLE-ARCHITECTURE-V3.md): this type +/// makes no peer decisions and owns no bindings or security state — it +/// shares the bleQueue-confined link-state store for admission reads and +/// asks its delegate to tear down transport bookkeeping when an attempt +/// dies. +final class BLERadioController { + weak var delegate: BLERadioControllerDelegate? + /// The transport is every peripheral's CBPeripheralDelegate; connects + /// initiated here must point new peripherals at it. + weak var peripheralDelegate: CBPeripheralDelegate? + /// Attached when the transport creates (or restores) its managers. + weak var central: CBCentralManager? + + private let queue: DispatchQueue + private let linkStateStore: BLELinkStateStore + private let recentTraffic: BLERecentTrafficMonitor + + // Connection budget & scheduling (central role) + private var scheduler = BLEConnectionScheduler() + // Recently seen peripherals retained for background wake-on-proximity + // connects + private let recentPeripheralCache = BLERecentPeripheralCache() + + // Adaptive scanning duty-cycle + private var scanDutyTimer: DispatchSourceTimer? + private var dutyEnabled: Bool = true + private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration + private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration + private var dutyActive: Bool = false + + init( + queue: DispatchQueue, + linkStateStore: BLELinkStateStore, + recentTraffic: BLERecentTrafficMonitor + ) { + self.queue = queue + self.linkStateStore = linkStateStore + self.recentTraffic = recentTraffic + } + + // MARK: - Advertising + + static func advertisementData() -> [String: Any] { + // No Local Name for privacy. + [CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]] + } + + // MARK: - Scanning + + func startScanning() { + guard delegate?.radioIsPanicSuspended() == false, + let central, + central.state == .poweredOn, + !central.isScanning else { return } + + // Allow duplicates while active for faster discovery: immediate + // discovery events instead of coalesced ones. + let allowDuplicates = delegate?.radioIsAppActive() ?? true + central.scanForPeripherals( + withServices: [BLEService.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] + ) + } + + func updateScanningDutyCycle(connectedCount: Int) { + guard let central, central.state == .poweredOn else { return } + // Duty cycle only when the app is active and at least one peer is + // connected; force full-time scanning with few neighbors or very + // recent traffic. + let hasRecentTraffic = recentTraffic.hasTraffic( + within: TransportConfig.bleRecentTrafficForceScanSeconds, + now: Date() + ) + let scanPlan = BLEScanDutyPolicy.plan( + dutyEnabled: dutyEnabled, + appIsActive: delegate?.radioIsAppActive() ?? true, + connectedCount: connectedCount, + hasRecentTraffic: hasRecentTraffic + ) + + switch scanPlan { + case .dutyCycle(let onDuration, let offDuration): + let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration + dutyOnDuration = onDuration + dutyOffDuration = offDuration + + if scanDutyTimer == nil { + // Start with scanning ON; turn OFF after onDuration. + let t = DispatchSource.makeTimerSource(queue: queue) + if !central.isScanning { startScanning() } + dutyActive = true + t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) + t.setEventHandler { [weak self] in + guard let self, let c = self.central else { return } + if self.dutyActive { + if c.isScanning { c.stopScan() } + self.dutyActive = false + self.queue.asyncAfter(deadline: .now() + self.dutyOffDuration) { + if self.central?.state == .poweredOn { self.startScanning() } + self.dutyActive = true + } + } + } + t.resume() + scanDutyTimer = t + } else if durationsChanged { + scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) + if !central.isScanning { startScanning() } + dutyActive = true + } + case .continuous: + // Cancel duty cycle and ensure scanning is ON for discovery. + scanDutyTimer?.cancel() + scanDutyTimer = nil + if !central.isScanning { startScanning() } + } + } + + func stopDutyCycle() { + scanDutyTimer?.cancel() + scanDutyTimer = nil + } + + func updateRSSIThreshold(connectedCount: Int) { + scheduler.updateRSSIThreshold( + connectedCount: connectedCount, + connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount, + now: Date() + ) + } + + // MARK: - Discovery & connection budget + + func handleDiscovery( + _ peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi: NSNumber + ) { + guard delegate?.radioIsPanicSuspended() == false, let central else { return } + let peripheralID = peripheral.identifier.uuidString + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") + let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true + + let candidate = BLEConnectionCandidate( + peripheral: peripheral, + peripheralID: peripheralID, + rssi: rssi.intValue, + name: String(advertisedName), + isConnectable: isConnectable, + discoveredAt: Date() + ) + if isConnectable { + recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt) + } + let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init) + + switch scheduler.handleDiscovery( + candidate, + connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, + existingState: existingState, + peripheralState: peripheral.state.connectionSchedulerState, + now: candidate.discoveredAt + ) { + case .ignore, .queued: + return + case .scheduleRetry(let delay): + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.tryConnectFromQueue() + } + return + case .cancelStaleConnection: + central.cancelPeripheralConnection(peripheral) + return + case .connectNow: + beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect") + } + } + + func tryConnectFromQueue() { + guard delegate?.radioIsPanicSuspended() == false, + let central, + central.state == .poweredOn else { return } + + let decision = scheduler.nextCandidate( + connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, + isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in + let state = linkStateStore.state(forPeripheralID: peripheralID) + return state?.isConnected == true || state?.isConnecting == true + }, + now: Date() + ) + + switch decision { + case .none: + return + case .retryAfter(let delay): + queue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } + case .connect(let candidate): + beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect") + } + } + + private func beginCentralConnection( + _ candidate: BLEConnectionCandidate, + using central: CBCentralManager, + logPrefix: String + ) { + guard delegate?.radioIsPanicSuspended() == false else { return } + let peripheral = candidate.peripheral + let peripheralID = candidate.peripheralID + linkStateStore.beginConnecting(to: peripheral, at: Date()) + peripheral.delegate = peripheralDelegate + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ] + central.connect(peripheral, options: options) + scheduler.recordConnectionAttempt(at: Date()) + SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session) + + queue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in + guard let self, + let state = self.linkStateStore.state(forPeripheralID: peripheralID), + state.isConnecting && !state.isConnected else { return } + + guard peripheral.state != .connected else { + SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session) + return + } + + if self.delegate?.radioIsAppActive() == false { + // Backgrounded: leave the connect pending. iOS never expires + // it — the controller completes it whenever the peer comes + // back into range, waking the app (state restoration + // relaunches us if we were terminated). Foreground return + // cancels stale pendings via cancelStalePendingConnects(). + SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session) + return + } + + SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) + central.cancelPeripheralConnection(peripheral) + self.delegate?.radioTearDownPeripheralLink(peripheralID) + self.scheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) + self.tryConnectFromQueue() + } + } + + // MARK: - Scheduler bookkeeping (called from the transport's delegates) + + var candidateCount: Int { scheduler.candidateCount } + + func recordConnectionSuccess(peripheralID: String) { + scheduler.recordConnectionSuccess(peripheralID: peripheralID) + } + + func recordConnectionFailure(peripheralID: String) { + scheduler.recordConnectionFailure(peripheralID: peripheralID) + } + + func recordDisconnectError(peripheralID: String, at date: Date) { + scheduler.recordDisconnectError(peripheralID: peripheralID, at: date) + } + + func recordRecentPeripheral(_ peripheral: CBPeripheral, peripheralID: String, at date: Date) { + recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: date) + } + + func pruneConnectionTimeouts(before cutoff: Date) { + scheduler.pruneConnectionTimeouts(before: cutoff) + } + + /// Panic wipe: drop the candidate queue, backoff state, and RSSI + /// adaptation with the identity they served. + func reset() { + scheduler.reset() + } + + #if os(iOS) + // MARK: - Background wake-on-proximity + + /// Backgrounding hands the freed connection budget to iOS as pending + /// connects against recently seen peers: the controller completes one + /// whenever its peer comes into range, waking (or relaunching) the app. + /// A couple of central slots stay reserved for connects driven by live + /// background discovery — except on the disconnect re-arm path, which + /// may consume the slot the disconnect itself just freed (a dense mesh + /// with 4+ remaining links would otherwise compute a zero budget and + /// never re-arm the lost peer). + func armPendingBackgroundConnects( + slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve + ) { + queue.async { [weak self] in + guard let self, + self.delegate?.radioIsPanicSuspended() == false, + let central = self.central, + central.state == .poweredOn else { return } + let budget = TransportConfig.bleMaxCentralLinks + - slotReserve + - self.linkStateStore.connectedOrConnectingPeripheralCount + let now = Date() + let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in + let state = self.linkStateStore.state(forPeripheralID: peripheralID) + return state?.isConnected == true || state?.isConnecting == true + } + guard !targets.isEmpty else { return } + for target in targets { + // lastConnectionAttempt stays nil: an indefinite pending + // connect has no attempt clock, and nil marks it always-stale + // so cancelStalePendingConnects() reclaims it on foreground + // even after a quick background→foreground bounce. + self.linkStateStore.setPeripheralState( + BLEPeripheralLinkState( + peripheral: target.peripheral, + characteristic: nil, + isConnecting: true, + isConnected: false, + lastConnectionAttempt: nil, + assembler: NotificationStreamAssembler() + ), + for: target.peripheralID + ) + target.peripheral.delegate = self.peripheralDelegate + central.connect(target.peripheral, options: [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ]) + } + SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session) + } + } + + /// Foreground restores normal connection management: pending connects + /// older than the connect timeout (including ones rebuilt by state + /// restoration after a relaunch) are cancelled so live scanning and the + /// scheduler take over. Anything still nearby is rediscovered within + /// seconds by the allow-duplicates foreground scan. + func cancelStalePendingConnects() { + queue.async { [weak self] in + guard let self, let central = self.central else { return } + let now = Date() + var cancelled = 0 + for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected { + let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity + guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } + let peripheralID = state.peripheral.identifier.uuidString + central.cancelPeripheralConnection(state.peripheral) + self.delegate?.radioTearDownPeripheralLink(peripheralID) + cancelled += 1 + } + if cancelled > 0 { + SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session) + self.tryConnectFromQueue() + } + } + } + #endif +} + + +// MARK: - Connection scheduling helpers + +private extension BLEExistingConnectionState { + init(_ state: BLEPeripheralLinkState) { + self.init( + isConnecting: state.isConnecting, + isConnected: state.isConnected, + lastConnectionAttempt: state.lastConnectionAttempt + ) + } +} + +private extension CBPeripheralState { + var connectionSchedulerState: BLEPeripheralConnectionState { + switch self { + case .connected: + return .connected + case .connecting: + return .connecting + case .disconnected, .disconnecting: + return .disconnected + @unknown default: + return .disconnected + } + } +} diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index e81fabd5..17af256b 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -77,6 +77,26 @@ struct BLEReceivePipeline { } } +/// Lock-backed traffic-level signal: the receive pipeline records packets, +/// and the radio layer (maintenance and scan-duty adaptation on bleQueue) +/// reads the level without crossing onto a transport queue. +final class BLERecentTrafficMonitor: @unchecked Sendable { + private let lock = NSLock() + private var tracker = BLERecentTrafficTracker() + + func recordPacket(at now: Date) { + lock.withLock { tracker.recordPacket(at: now) } + } + + func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool { + lock.withLock { tracker.hasTraffic(within: seconds, now: now) } + } + + func removeAll() { + lock.withLock { tracker.removeAll() } + } +} + struct BLERecentTrafficTracker: Equatable { private var packetTimestamps: [Date] = [] diff --git a/bitchat/Services/BLE/BLERedundantLinkPolicy.swift b/bitchat/Services/BLE/BLERedundantLinkPolicy.swift index 6a053b5a..7bf5c3f4 100644 --- a/bitchat/Services/BLE/BLERedundantLinkPolicy.swift +++ b/bitchat/Services/BLE/BLERedundantLinkPolicy.swift @@ -21,24 +21,53 @@ enum BLERedundantLinkPolicy { /// A link mid-service-rediscovery (didModifyServices cleared it) /// must never be kept over a writable duplicate. let hasCharacteristic: Bool + /// When didConnect last fired for this link in this process. Nil + /// for restored links, whose connect predates the relaunch. + let lastConnectedAt: Date? - init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) { + init( + uuid: String, + peerID: PeerID?, + isConnected: Bool, + hasCharacteristic: Bool, + lastConnectedAt: Date? = nil + ) { self.uuid = uuid self.peerID = peerID self.isConnected = isConnected self.hasCharacteristic = hasCharacteristic + self.lastConnectedAt = lastConnectedAt } } /// The link to keep when a peer has several connected bound peripheral - /// links, or nil when there is nothing to consolidate. Prefers the - /// ingress link of the verified direct announce that triggered the check - /// (the strongest liveness proof available), falling back to the peer's - /// most recently bound link — but only among writable links while any - /// exist: keeping a characteristic-less link and cancelling the writable + /// links, or nil when there is nothing to consolidate. + /// + /// Prefers the most recently CONNECTED candidate. Duplicates arise when + /// the peer reappears under a fresh BLE address (privacy address + /// rotation) while an older connection — typically state-restored — + /// lives on: only the newest connection sits on the address the peer + /// still advertises. Cancelling that one instead just gets it + /// rediscovered and reconnected, a retire↔reconnect oscillation at the + /// retirement cooldown (field-observed July 31); the older-address link + /// cannot return once cancelled, so consolidation converges immediately. + /// Physical connect recency is also a signal an announce replay cannot + /// nominate, unlike the previous ingress-link preference — announce + /// anchors (ingress, then most recently bound) now only break ties and + /// serve links with no connect timestamp at all. Link "health" signals + /// like RSSI are deliberately not inputs: they are transient and the + /// stale-address link often reads stronger; connect recency is the only + /// signal that tracks address currency. + /// + /// The survivor must be writable while any writable candidate exists: + /// keeping a characteristic-less link and cancelling the writable /// duplicate would strand outbound traffic on the central link until - /// rediscovery finishes. When neither anchor is a viable candidate, - /// consolidation waits for a later announce rather than guessing. + /// rediscovery finishes. But when the physically NEWEST connection is + /// the one that is not writable yet (service discovery still running), + /// consolidation defers entirely — selecting an older writable link + /// would cancel the freshly advertised connection and recreate the + /// oscillation. When no candidate is identifiable, consolidation waits + /// for a later announce rather than guessing. static func keptPeripheralUUID( ingressPeripheralUUID: String?, mostRecentlyBoundUUID: String?, @@ -51,6 +80,42 @@ enum BLERedundantLinkPolicy { let writable = bound.filter(\.hasCharacteristic) let candidates = writable.isEmpty ? bound : writable + // The newest connection is still mid-service-discovery while a + // writable (typically restored, stale-address) duplicate exists: + // defer to a later announce instead of keeping the older link and + // cancelling the one connection on the currently advertised address. + if !writable.isEmpty, + let newestBoundDate = bound.compactMap(\.lastConnectedAt).max(), + !writable.contains(where: { $0.lastConnectedAt == newestBoundDate }) { + return nil + } + + if let newestDate = candidates.compactMap(\.lastConnectedAt).max() { + let newest = candidates.filter { $0.lastConnectedAt == newestDate } + if newest.count == 1 { + return newest[0].uuid + } + return anchoredChoice( + among: newest, + ingressPeripheralUUID: ingressPeripheralUUID, + mostRecentlyBoundUUID: mostRecentlyBoundUUID + ) ?? newest.map(\.uuid).min() + } + + return anchoredChoice( + among: candidates, + ingressPeripheralUUID: ingressPeripheralUUID, + mostRecentlyBoundUUID: mostRecentlyBoundUUID + ) + } + + /// The pre-timestamp anchors: the verified announce's ingress link, + /// then the peer's most recently bound link. + private static func anchoredChoice( + among candidates: [PeripheralLink], + ingressPeripheralUUID: String?, + mostRecentlyBoundUUID: String? + ) -> String? { if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) { return ingressPeripheralUUID } diff --git a/bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift b/bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift new file mode 100644 index 00000000..bf45a5dc --- /dev/null +++ b/bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift @@ -0,0 +1,433 @@ +// +// BLEService+LinkLayerCentralRole.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import CoreBluetooth +import Foundation + +// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do +// physical bookkeeping (link-state store, buffers, radio policy) and report +// everything else to the engine through the link-event port +// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md. + +// MARK: - CBCentralManagerDelegate + +extension BLEService: CBCentralManagerDelegate { + #if os(iOS) + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] + guard !isPanicSuspended else { + central.stopScan() + restoredPeripherals.forEach { + central.cancelPeripheralConnection($0) + } + return + } + let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] + let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] + let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool + + SecureLogger.info( + "♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))", + category: .session + ) + + for peripheral in restoredPeripherals { + let identifier = peripheral.identifier.uuidString + peripheral.delegate = self + let existing = linkStateStore.state(forPeripheralID: identifier) + let assembler = existing?.assembler ?? NotificationStreamAssembler() + let characteristic = existing?.characteristic + let wasConnecting = existing?.isConnecting ?? false + let wasConnected = existing?.isConnected ?? false + + let restoredState = BLEPeripheralLinkState( + peripheral: peripheral, + characteristic: characteristic, + isConnecting: wasConnecting || peripheral.state == .connecting, + isConnected: wasConnected || peripheral.state == .connected, + lastConnectionAttempt: existing?.lastConnectionAttempt, + assembler: assembler + ) + linkStateStore.setPeripheralState(restoredState, for: identifier) + + // Restored peripherals are the freshest wake-on-proximity + // candidates we have after a relaunch — without this the cache + // starts empty and backgrounding right after a restore arms + // nothing. Service rediscovery for restored-connected links waits + // for poweredOn: CoreBluetooth drops commands issued during + // restoration (API MISUSE warnings). + radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date()) + } + + // Via the sampler (not a direct capture): it refreshes the cached + // background budget on main first, so the restore log shows the real + // wake window instead of the init sentinel. + logBluetoothStatus("central-restore") + + if central.state == .poweredOn { + radio.startScanning() + } + } + #endif + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + emitTransportEvent(.bluetoothStateUpdated(central.state)) + + switch central.state { + case .poweredOn: + guard !isPanicSuspended else { + central.stopScan() + return + } + // Links restored as connected have no characteristic in the new + // process; without rediscovery they sit connected-but-unusable + // until the peer disconnects. Runs here (not willRestoreState) + // because commands issued before poweredOn are dropped. + for state in linkStateStore.peripheralStates where state.isConnected + && state.characteristic == nil + && state.peripheral.state == .connected { + SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session) + state.peripheral.discoverServices([BLEService.serviceUUID]) + } + + // Start scanning - use allow duplicates for faster discovery when active + radio.startScanning() + + case .poweredOff: + // CoreBluetooth has already transitioned out of poweredOn. Do + // not issue stop/cancel commands now; they are rejected as API + // misuse. Retire our link state locally instead. + SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) + let peripheralIDs = linkStateStore.peripheralStates.map { $0.peripheral.identifier.uuidString } + for peripheralID in peripheralIDs { + pendingPeripheralWrites.discardAll(for: peripheralID) + } + linkStateStore.clearPeripherals() + emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: peripheralIDs, retireProofsAndNotify: true)) + + case .unauthorized: + // User denied Bluetooth permission + SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) + linkStateStore.clearPeripherals() + emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: [], retireProofsAndNotify: false)) + + case .unsupported: + // Device doesn't support BLE + SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session) + + case .resetting: + // Bluetooth stack is resetting - will get another state update when done + SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session) + + case .unknown: + // Initial state before we know the actual state + SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session) + + @unknown default: + SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session) + } + } + + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard !isPanicSuspended else { + central.cancelPeripheralConnection(peripheral) + return + } + let peripheralID = peripheral.identifier.uuidString + + #if os(iOS) + // A connect completing while backgrounded is the wake-on-proximity + // path doing its job — worth an info line for field verification. + if !isAppActive { + SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session) + } + #endif + + // Update state to connected + linkStateStore.markConnected(peripheral) + + // Reset backoff state on success + radio.recordConnectionSuccess(peripheralID: peripheralID) + + SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session) + + // Discover services + peripheral.discoverServices([BLEService.serviceUUID]) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + let peripheralID = peripheral.identifier.uuidString + + SecureLogger.debug("📱 Disconnect: \(peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session) + + // If disconnect carried an error (often timeout), apply short backoff to avoid thrash + if error != nil { + radio.recordDisconnectError(peripheralID: peripheralID, at: Date()) + } + + // Retain the handle: a dropped link is the best wake-on-proximity + // candidate if the app backgrounds before the peer returns. + radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date()) + + #if os(iOS) + // Link lost while backgrounded (peer walked away): re-arm a pending + // connect during this wake window so the peer's return wakes us again. + // Delayed past the disconnect-settle window to avoid reconnect thrash + // at range edge. + if !isAppActive { + bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in + guard let self, !self.isAppActive else { return } + // Reserve 0: use the slot this disconnect freed even in a + // dense mesh, so the lost peer can wake us when it returns. + self.radio.armPendingBackgroundConnects(slotReserve: 0) + } + } + #endif + + // Physical teardown now; identity retirement and peer-disconnect + // bookkeeping ride the link-event port. The scan restart and + // connect-slot refill below stay on bleQueue — they respond to + // the physical drop regardless of remaining logical links. + discardPeripheralLinkPhysical(peripheralID) + emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: true)) + + // Restart scanning with allow duplicates for faster rediscovery + if centralManager?.state == .poweredOn { + // Stop and restart scanning to ensure we get fresh discovery events + centralManager?.stopScan() + bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in + self?.radio.startScanning() + } + } + // Attempt to fill freed slot from queue + bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + let peripheralID = peripheral.identifier.uuidString + + // Clean up the references: physical now, identity via the port. + discardPeripheralLinkPhysical(peripheralID) + emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false)) + + SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) + radio.recordConnectionFailure(peripheralID: peripheralID) + // Try next candidate + bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() } + } +} + +// MARK: - CBPeripheralDelegate + +extension BLEService: CBPeripheralDelegate { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard !isPanicSuspended else { return } + if let error = error { + SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) + // Retry service discovery after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + guard peripheral.state == .connected else { return } + peripheral.discoverServices([BLEService.serviceUUID]) + } + return + } + + guard let services = peripheral.services else { + SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session) + return + } + + guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else { + // Not a BitChat peer - disconnect + centralManager?.cancelPeripheralConnection(peripheral) + return + } + + // Discovering BLE characteristics + peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard !isPanicSuspended else { return } + if let error = error { + SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) + return + } + + guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else { + SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session) + return + } + + // Found characteristic + + // Log characteristic properties for debugging + var properties: [String] = [] + if characteristic.properties.contains(.read) { properties.append("read") } + if characteristic.properties.contains(.write) { properties.append("write") } + if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") } + if characteristic.properties.contains(.notify) { properties.append("notify") } + if characteristic.properties.contains(.indicate) { properties.append("indicate") } + // Characteristic properties: \(properties.joined(separator: ", ")) + + // Verify characteristic supports reliable writes + if !characteristic.properties.contains(.write) { + SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session) + } + + // Store characteristic in our consolidated structure + let peripheralID = peripheral.identifier.uuidString + linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID) + + // Subscribe for notifications + if characteristic.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: characteristic) + SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session) + + // Send announce after subscription is confirmed (force send for new connection) + engineScheduler.schedule(after: TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in + self?.sendAnnounce(forceSend: true) + // Try flushing any spooled directed packets now that we have a link + self?.flushDirectedSpool() + } + } else { + SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard !isPanicSuspended else { return } + if let error = error { + SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session) + return + } + + guard let data = characteristic.value, !data.isEmpty else { + SecureLogger.warning("⚠️ No data in notification", category: .session) + return + } + + bufferNotificationChunk(data, from: peripheral) + } + + private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) { + let peripheralUUID = peripheral.identifier.uuidString + + var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState( + peripheral: peripheral, + characteristic: nil, + isConnecting: false, + isConnected: peripheral.state == .connected, + lastConnectionAttempt: nil, + assembler: NotificationStreamAssembler() + ) + + var assembler = state.assembler + let result = assembler.append(chunk) + state.assembler = assembler + linkStateStore.setPeripheralState(state, for: peripheralUUID) + + for byte in result.droppedPrefixes { + SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session) + } + + if result.reset { + SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session) + } + + // Attribution — spoof rejection, announce binding, ingress + // recording — is engine work now (the engine owns the bindings). + // Frames hop up in decode order; the engine's serial slot ordering + // gives the same same-batch spoof protection the old bleQueue-side + // batch-local binding enforced: an announce that binds this link is + // attributed before every frame that rode behind it. + for frame in result.frames { + guard let packet = BinaryProtocol.decode(frame) else { + let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") + SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session) + continue + } + emitLinkEvent(.frameDecoded( + packet, + link: .peripheral(peripheralUUID), + linkDescription: "Peripheral \(peripheralUUID.prefix(8))…" + )) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session) + // Don't retry - just log the error + } else { + SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) + } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + guard !isPanicSuspended else { return } + // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again + if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") { + SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) + } + drainPendingWrites(for: peripheral) + } + + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + guard !isPanicSuspended else { return } + SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) + + let shouldRediscover = BLEService.shouldRediscoverBitChatService( + invalidatedServiceUUIDs: invalidatedServices.map(\.uuid), + cachedServiceUUIDs: peripheral.services?.map(\.uuid) + ) + + guard shouldRediscover else { return } + + let peripheralID = peripheral.identifier.uuidString + linkStateStore.updatePeripheral(peripheralID) { + $0.characteristic = nil + $0.assembler = NotificationStreamAssembler() + } + + SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session) + peripheral.discoverServices([BLEService.serviceUUID]) + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard !isPanicSuspended else { return } + if let error = error { + SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session) + } else { + SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session) + + // If notifications are now on, send an announce to ensure this peer knows about us + if characteristic.isNotifying { + // Sending announce after subscription + self.sendAnnounce(forceSend: true) + } + } + } + +} + +extension BLEService { + static func shouldRediscoverBitChatService( + invalidatedServiceUUIDs: [CBUUID], + cachedServiceUUIDs: [CBUUID]? + ) -> Bool { + invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true + } +} diff --git a/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift b/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift new file mode 100644 index 00000000..c55c30e4 --- /dev/null +++ b/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift @@ -0,0 +1,320 @@ +// +// BLEService+LinkLayerPeripheralRole.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import CoreBluetooth +import Foundation + +// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do +// physical bookkeeping (link-state store, buffers, radio policy) and report +// everything else to the engine through the link-event port +// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md. + +// MARK: - CBPeripheralManagerDelegate + +extension BLEService: CBPeripheralManagerDelegate { + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session) + + switch peripheral.state { + case .poweredOn: + guard !isPanicSuspended else { + peripheral.stopAdvertising() + peripheral.removeAllServices() + characteristic = nil + return + } + // Remove all services first to ensure clean state + peripheral.removeAllServices() + + // Create characteristic + characteristic = CBMutableCharacteristic( + type: BLEService.characteristicUUID, + properties: [.notify, .write, .writeWithoutResponse, .read], + value: nil, + permissions: [.readable, .writeable] + ) + + // Create service + let service = CBMutableService(type: BLEService.serviceUUID, primary: true) + service.characteristics = [characteristic!] + + // Add service (advertising will start in didAdd delegate) + SecureLogger.debug("🔧 Adding BLE service...", category: .session) + peripheral.add(service) + + case .poweredOff: + // Bluetooth was turned off - clean up peripheral state + SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) + // Clear subscribed centrals (they are now invalid) + let centralIDs = linkStateStore.subscribedCentrals.map { $0.identifier.uuidString } + pendingNotifications.removeAll() + pendingWriteBuffers.removeAll() + linkStateStore.clearCentrals() + subscriptionAnnounceLimiter.removeAll() + characteristic = nil + emitLinkEvent(.allCentralLinksEnded(centralUUIDs: centralIDs, retireProofsAndNotify: true)) + + case .unauthorized: + // User denied Bluetooth permission + SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) + linkStateStore.clearCentrals() + subscriptionAnnounceLimiter.removeAll() + characteristic = nil + emitLinkEvent(.allCentralLinksEnded(centralUUIDs: [], retireProofsAndNotify: false)) + + case .unsupported: + // Device doesn't support BLE peripheral role + SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session) + + case .resetting: + // Bluetooth stack is resetting + SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session) + + case .unknown: + SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session) + + @unknown default: + SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session) + } + } + + #if os(iOS) + func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) { + guard !isPanicSuspended else { + peripheral.stopAdvertising() + peripheral.removeAllServices() + characteristic = nil + return + } + let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] + let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] + + SecureLogger.info( + "♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))", + category: .session + ) + + // Attempt to recover characteristic from restored services + if characteristic == nil { + if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }), + let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic { + characteristic = restoredCharacteristic + } + } + + // Via the sampler for a fresh background budget (see central-restore). + logBluetoothStatus("peripheral-restore") + + if peripheral.state == .poweredOn && !peripheral.isAdvertising { + peripheral.startAdvertising(BLERadioController.advertisementData()) + } + } + #endif + + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + guard !isPanicSuspended else { + peripheral.stopAdvertising() + return + } + if let error = error { + SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session) + return + } + + SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session) + + // Start advertising after service is confirmed added + let adData = BLERadioController.advertisementData() + peripheral.startAdvertising(adData) + + SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session) + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + guard !isPanicSuspended else { return } + let centralUUID = central.identifier.uuidString + SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session) + linkStateStore.addSubscribedCentral(central) + + // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks + let now = Date() + switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) { + case .allowed: + break + case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce): + SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security) + if suppressAnnounce { + SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security) + return + } + + // Still flush directed packets for legitimate mesh operation + engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + self?.flushDirectedSpool() + } + return + } + + // Send announce to the newly subscribed central after a small delay + engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + self?.sendAnnounce(forceSend: true) + // Flush any spooled directed packets now that we have a central subscribed + self?.flushDirectedSpool() + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { + let centralID = central.identifier.uuidString + SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session) + // bleQueue: physical retirement now. + pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } + linkStateStore.removeSubscribedCentral(central) + + // Ensure we're still advertising for other devices to find us + if !isPanicSuspended, peripheral.isAdvertising == false { + SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session) + peripheral.startAdvertising(BLERadioController.advertisementData()) + } + + // Identity retirement and peer-disconnect bookkeeping ride the + // link-event port. + emitLinkEvent(.centralLinkEnded(centralUUID: centralID)) + } + + func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + guard !isPanicSuspended else { return } + drainPendingNotifications(logPrefix: "✅ Sent") + } + + func logBackpressureSampled(_ message: @autoclosure () -> String) { + notificationBackpressureLogCount += 1 + if notificationBackpressureLogCount == 1 || + notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) { + SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session) + } + } + + func drainPendingNotifications(logPrefix: String) { + bleQueue.async { [weak self] in + guard let self = self, + let characteristic = self.characteristic, + !self.pendingNotifications.isEmpty else { return } + + let pending = self.pendingNotifications.takeAll() + let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic) + + if sentCount > 0 { + self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)") + } + } + } + + private func sendPendingNotifications(_ pending: [BLEPendingNotification], characteristic: CBMutableCharacteristic) -> Int { + var sentCount = 0 + + for (index, notification) in pending.enumerated() { + let success = peripheralManager?.updateValue( + notification.data, + for: characteristic, + onSubscribedCentrals: notification.targets + ) ?? false + + guard success else { + let remaining = Array(pending.dropFirst(index)) + pendingNotifications.prepend(remaining) + logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items") + break + } + + sentCount += 1 + } + + return sentCount + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { + // Suppress logs for single write requests to reduce noise + if requests.count > 1 { + SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session) + } + + // IMPORTANT: Respond immediately to prevent timeouts! + // We must respond within a few milliseconds or the central will timeout + for request in requests { + peripheral.respond(to: request, withResult: .success) + } + guard !isPanicSuspended else { return } + + // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets. + // Combine per-central request values by offset before decoding. + // Process directly on our message queue to match transport context + let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString }) + for (centralUUID, group) in grouped { + // Sort by offset ascending + let sorted = group.sorted { $0.offset < $1.offset } + let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0 + let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in + guard let data = request.value, !data.isEmpty else { return nil } + return BLEInboundWriteChunk(offset: request.offset, data: data) + } + + let result = pendingWriteBuffers.append( + chunks: chunks, + for: centralUUID, + capBytes: TransportConfig.blePendingWriteBufferCapBytes + ) + + switch result { + case let .decoded(packet, metadata): + logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) + processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central) + + case let .waiting(metadata): + logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) + logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) + + case let .oversized(metadata): + logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) + SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session) + logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) + } + } + } + + private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) { + guard let packetType = metadata.packetType, + packetType != MessageType.announce.rawValue else { return } + + SecureLogger.debug( + "📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)", + category: .session + ) + } + + private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) { + guard !hasMultiple, let raw = sortedRequests.first?.value else { return } + + let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") + SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session) + } + + private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) { + // bleQueue: physical bookkeeping only. A writer is a live central + // whether or not it subscribed; track it so directed replies and + // the fanout planner can reach it. + linkStateStore.addSubscribedCentral(central) + // Attribution is engine work (the engine owns the bindings). + emitLinkEvent(.frameDecoded( + packet, + link: .central(centralUUID), + linkDescription: "Central \(centralUUID.prefix(8))…" + )) + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 814b21ca..1aa0ae6f 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -176,36 +176,6 @@ private final class BLEPrivateMediaTransferAdmissionRegistry { } } -private struct BLEAuthenticatedPeerStateObservation { - let fingerprint: String - let sessionGeneration: UUID - let capabilities: PeerCapabilities -} - -private struct BLEPrivateMediaProofTimeoutMarker { - let fingerprint: String - let sessionGeneration: UUID? -} - -private struct BLEPrivateMediaProofWatchdog { - let fingerprint: String - let sessionGeneration: UUID - let timeoutNonce: UUID -} - -private struct BLEPendingPrivateMediaPolicyResolution { - let fingerprint: String - var sessionGeneration: UUID? - var timeoutNonce: UUID - var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void] -} - -private struct BLEAuthenticatedPeerStateSendProgress { - let sessionGeneration: UUID - var sentInitial = false - var sentEcho = false -} - /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). @@ -240,28 +210,48 @@ final class BLEService: NSObject { // MARK: - Core State (5 Essential Collections) // 1. Consolidated BLE link tracking for both central and peripheral roles. - private var linkStateStore = BLELinkStateStore() + var linkStateStore = BLELinkStateStore() - // A peer ID can retain an established Noise session after its physical - // link disappears. Courier handover therefore needs the stronger fact - // that the session was established *on this current ingress link*, not - // merely that some session exists for the claimed ID. bleQueue-owned. - private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:] - private var noiseReconnectPolicy = BLENoiseReconnectPolicy() - - // Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link - // store): entries older than the cooldown are pruned on insert. - private var lastLinkRebindAt: [String: Date] = [:] - - // Redundant-link retirement cooldown per peer (bleQueue-owned): bounds - // how often a replayed announce could flip which duplicate link survives. - private var lastRedundantLinkRetirementAt: [PeerID: Date] = [:] + // The engine-owned identity domain: per-link Noise authentication + + // rebind containment (courier handover needs the stronger fact that a + // session was established *on this current ingress link*, not merely + // that some session exists for the claimed ID), and the identity↔link + // bindings that qualify every attribution decision. + // + // Owned by the engine queue since the option-B flip: bleQueue hands + // decoded packets up as (packet, linkID) and the engine attributes + // them; bleQueue never touches these. A binding can therefore briefly + // outlive its physical link (the delegate's retirement hop is async) — + // every query that needs liveness joins against the physical store, + // which the engine may sync-read via `readLinkState`. + private var _linkAuth = BLELinkAuthState() + private var _linkBindings = BLELinkBindings() + private var linkAuth: BLELinkAuthState { + get { assertLinkIdentityEngineOwned(); return _linkAuth } + set { assertLinkIdentityEngineOwned(); _linkAuth = newValue } + } + private var linkBindings: BLELinkBindings { + get { assertLinkIdentityEngineOwned(); return _linkBindings } + set { assertLinkIdentityEngineOwned(); _linkBindings = newValue } + } + /// Debug-traps any identity-domain access off the engine queue — the + /// mechanical form of the option-B ownership contract. + private func assertLinkIdentityEngineOwned() { + #if DEBUG + dispatchPrecondition(condition: .onQueue(messageQueue)) + #endif + } // BCH-01-004: Rate-limiting for subscription-triggered announces. - private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() + var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() - // 3. Peer Information (single source of truth) - private var peerRegistry = BLEPeerRegistry() + // 3. Peer Information (single source of truth). Lock-backed so the main + // actor reads it directly instead of blocking on the engine queue. + // Mutations come only from the transport's own serial queues — the + // engine, plus the two bleQueue link-drop paths (didDisconnectPeripheral + // / didUnsubscribeFrom) that mark a peer disconnected the moment its + // last physical link goes; the store's lock serializes them. + private let peerRegistry = BLEPeerRegistryStore() // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() @@ -284,14 +274,14 @@ final class BLEService: NSObject { // Verified one-time prekey bundles gossiped by other peers, used to seal // courier mail forward-secretly. Injectable for tests. var prekeyBundleStore: PrekeyBundleStore = .shared - // Throttle for re-broadcasting our own (unchanged) bundle; guarded by - // collectionsQueue barriers. + // Throttle for re-broadcasting our own (unchanged) bundle + // (engine-confined). private var lastPrekeyBundleSentAt: Date? // Prekey bundles that arrived before their owner's verified announce bound - // a signing key. The receive queue is concurrent, so a bundle can race - // ahead of the announce it depends on; we retain the latest such bundle per - // owner (bounded) and re-attempt attribution when the announce lands. - // Guarded by collectionsQueue barriers. + // a signing key. Over the air a bundle can still arrive before the + // announce it depends on; we retain the latest such bundle per owner + // (bounded) and re-attempt attribution when the announce lands. + // Engine-confined. private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:] private static let pendingPrekeyBundleCap = 64 // Gateway mode: sink for received nostrCarrier packets (set by app @@ -303,8 +293,6 @@ final class BLEService: NSObject { /// Fired (off-main) when a signature-verified announce is processed — /// the bridge courier watch refreshes its tag set on new arrivals. var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? - private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue - private var localBridgeGeohash: String? // collectionsQueue #if DEBUG // Test-only tap on the outbound pipeline so multi-node tests can ferry @@ -318,9 +306,6 @@ final class BLEService: NSObject { /// May block in tests to hold the serial message queue immediately before /// the deferred private-media admission check. var _test_beforePrivateMediaDeferredSend: ((String) -> Void)? - /// May block announce handling after verified-link rebind work is queued. - /// Tests use this boundary to prove rebind and reconnect are serialized. - var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)? /// May block the convergence-recovery callback on its global-queue thread /// before it enqueues onto `messageQueue`. Tests use this boundary to /// force the quarantine-restore handler to win the dispatch race. @@ -328,27 +313,11 @@ final class BLEService: NSObject { #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() - // Route health for originated source routes; guarded by collectionsQueue. + // Route health for originated source routes (engine-confined). private var sourceRouteFailures = BLESourceRouteFailureCache() - // Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the - // inbound ping budget — keyed by the ingress link (the directly connected - // peer that delivered the packet), since the unsigned claimed sender is - // spoofable — so a directed unencrypted probe cannot be turned into an - // amplification primitive. Both are owned by collectionsQueue barriers - // like the other mutable collections. - private struct PendingMeshPing { - let peerID: PeerID - let sentAt: Date - let lifecycleGeneration: UInt64 - let completion: @MainActor (MeshPingResult?) -> Void - let timeout: DispatchWorkItem - } - private var pendingMeshPings: [Data: PendingMeshPing] = [:] - private var meshPingResponseLimiter = SyncResponseRateLimiter( - maxResponses: TransportConfig.meshPingInboundMaxPerLink, - window: TransportConfig.meshPingInboundWindowSeconds - ) + // Mesh diagnostics (/ping): engine-confined probe and budget state. + private var meshPings = BLEMeshPingTracker() // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() @@ -356,15 +325,10 @@ final class BLEService: NSObject { private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in self?.handlePrivateMediaAdmissionExpiry(transferId) } - // All six maps below are protected by `collectionsQueue`. A fresh Noise - // authentication rotates the generation UUID, so stale proof timers and - // proof packets cannot classify a replacement session. - private var privateMediaSessionGenerations: [PeerID: UUID] = [:] - private var authenticatedPeerStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:] - private var privateMediaProofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:] - private var privateMediaProofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:] - private var pendingPrivateMediaPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:] - private var authenticatedPeerStateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:] + // Generation-bound private-media session state (lock-backed store: the + // main actor answers the send policy from it synchronously, and noise + // critical sections mutate it without re-entering the engine). + private let privateMediaSessions = BLEPrivateMediaSessionStore() private let incomingFileStore: BLEIncomingFileStore // Simple announce throttling @@ -372,7 +336,7 @@ final class BLEService: NSObject { // Application state tracking (thread-safe) #if os(iOS) - private var isAppActive: Bool = true // Assume active initially + var isAppActive: Bool = true // Assume active initially /// Last `UIApplication.shared.backgroundTimeRemaining` sampled on the /// main thread, cached so bleQueue status logs can read it without ever /// dispatching to main (see `captureBluetoothStatus` for the invariant). @@ -386,9 +350,9 @@ final class BLEService: NSObject { // MARK: - Core BLE Objects - private var centralManager: CBCentralManager? - private var peripheralManager: CBPeripheralManager? - private var characteristic: CBMutableCharacteristic? + var centralManager: CBCentralManager? + var peripheralManager: CBPeripheralManager? + var characteristic: CBMutableCharacteristic? private let shouldInitializeBluetoothManagers: Bool private let panicLifecycleLock = NSLock() private var _isPanicSuspended: Bool @@ -410,40 +374,77 @@ final class BLEService: NSObject { // MARK: - Queues - private let messageQueue = DispatchQueue(label: "mesh.message", attributes: .concurrent) - private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent) + /// The engine queue: one serial domain that owns every piece of mesh + /// protocol state (the former concurrent message queue and the separate + /// collections queue it guarded state with). BLE throughput is far below + /// what one queue serializes comfortably, and a single writer makes the + /// old per-field ownership comments and barrier discipline structural. + private let messageQueue = DispatchQueue(label: "mesh.message") private let messageQueueKey = DispatchSpecificKey() - private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) + /// The only source of deferred engine work (see BLEEngineScheduling); + /// injectable so tests drive protocol deadlines with a manual clock. + let engineScheduler: BLEEngineScheduling + let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) private let bleQueueKey = DispatchSpecificKey() + + /// Runs `body` exclusively with respect to all engine-owned state. + /// Executes inline when already on the engine queue; otherwise blocks + /// until the engine drains the work ahead of it. + /// + /// Sync-edge order (deadlock freedom): main, test threads, and the + /// gossip manager's mesh.sync queue may sync-wait on the engine; the + /// engine sync-waits on bleQueue (`readLinkState`) and on the + /// crypto/identity services' internal queues. None of those may ever + /// sync-wait back on the engine — bleQueue callers hop with + /// `messageQueue.async` instead, and debug builds trap any violation + /// here. (The engine only ever async-dispatches into mesh.sync; its + /// queue.sync helpers are DEBUG test entry points on test threads.) + private func onEngine(_ body: () -> T) -> T { + #if DEBUG + dispatchPrecondition(condition: .notOnQueue(bleQueue)) + #endif + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + return body() + } + // queue-contract-ok: this is the single sanctioned sync entry — the + // trap above is exactly what BLEQueueContractTests exists to protect. + return messageQueue.sync(execute: body) + } // Noise messages and typed payloads pending handshake completion. private var pendingNoiseSessionQueues = BLENoiseSessionQueues() - // Queue for notifications that failed due to full queue - private var pendingNotifications = BLEOutboundNotificationBuffer() + // Queue for notifications that failed due to full queue (bleQueue-owned, + // like the link state store: every producer and drain runs there). + var pendingNotifications = BLEOutboundNotificationBuffer() // Backpressure logging fires per fragment during media transfers // (hundreds of lines per image); sampled via this counter, which is - // only touched inside collectionsQueue barriers (no sync needed). + // only touched on bleQueue (no sync needed). var notificationBackpressureLogCount = 0 // Accumulate long write chunks per central until a full frame decodes - private var pendingWriteBuffers = BLEInboundWriteBuffer() + // (bleQueue-owned) + var pendingWriteBuffers = BLEInboundWriteBuffer() // Relay jitter scheduling to reduce redundant floods private var scheduledRelays = BLEScheduledRelayStore() // Track short-lived traffic bursts to adapt announces/scanning under load - private var recentTrafficTracker = BLERecentTrafficTracker() + // (lock-backed: written by the receive pipeline, read on bleQueue) + private let recentTrafficTracker = BLERecentTrafficMonitor() // Ingress link tracking for duplicate and last-hop suppression - private var ingressLinks = BLEIngressLinkRegistry() + // (lock-backed: recorded on bleQueue the moment a frame decodes, read + // by engine relay/routing decisions) + private let ingressLinks = BLEIngressLinkStore() // Inner message IDs of recently opened courier envelopes. Redundant // copies of one message ride different envelopes (each seal uses a fresh // ephemeral key, and bridge drops multiply across relays/couriers), so // envelope-level dedup can't catch them; dedup on the inner ID before // delivery so a duplicate costs one decrypt instead of a delivery + ack - // + handshake each. Owned by collectionsQueue barriers. + // + handshake each. Engine-confined. private var openedCourierMessageIDs = BoundedIDSet(capacity: TransportConfig.courierOpenedMessageIDCap) - private let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5) + let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5) - private var pendingPeripheralWrites = BLEOutboundWriteBuffer() + // Per-peripheral write backpressure (bleQueue-owned) + var pendingPeripheralWrites = BLEOutboundWriteBuffer() // Debounce duplicate disconnect notifies private var disconnectNotifyDebouncer = BLEPeerEventDebouncer() // Store-and-forward for directed messages when we have no links @@ -478,18 +479,13 @@ final class BLEService: NSObject { /// churn that aggravates flaky exit hangs. private var meshBackgroundEnabled = false - // MARK: - Connection budget & scheduling (central role) - private var connectionScheduler = BLEConnectionScheduler() - // Recently seen peripherals retained for background wake-on-proximity - // connects (bleQueue-confined, like the link state store) - private let recentPeripheralCache = BLERecentPeripheralCache() - - // MARK: - Adaptive scanning duty-cycle - private var scanDutyTimer: DispatchSourceTimer? - private var dutyEnabled: Bool = true - private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration - private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration - private var dutyActive: Bool = false + // MARK: - Radio (central-role policy: discovery admission, connection + // budget, connect timeouts, background connects, scan duty, advertising) + lazy var radio = BLERadioController( + queue: bleQueue, + linkStateStore: linkStateStore, + recentTraffic: recentTrafficTracker + ) // Debounced publish to coalesce rapid changes private var peerPublishCoalescer = BLEPeerPublishCoalescer() @@ -498,7 +494,7 @@ final class BLEService: NSObject { case .publishNow: publishFullPeerData() case .schedule(let delay): - messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + engineScheduler.schedule(after: delay) { [weak self] in guard let self = self else { return } self.peerPublishCoalescer.scheduledPublishFired(now: Date()) self.publishFullPeerData() @@ -518,8 +514,10 @@ final class BLEService: NSObject { incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(), startSuspendedForPanicRecovery: Bool = false, noiseResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler() ) { + self.engineScheduler = engineScheduler self.keychain = keychain self.idBridge = idBridge self.incomingFileStore = incomingFileStore @@ -538,6 +536,9 @@ final class BLEService: NSObject { // Set queue key for identification messageQueue.setSpecific(key: messageQueueKey, value: ()) + engineScheduler.activate(engineQueue: messageQueue) + radio.delegate = self + radio.peripheralDelegate = self // Set up application state tracking (iOS only) #if os(iOS) @@ -550,6 +551,10 @@ final class BLEService: NSObject { isAppActive = UIApplication.shared.applicationState == .active refreshCachedBackgroundTimeRemaining() } else { + // queue-contract-ok: init-time only — no engine or bleQueue work + // exists yet that main could be sync-waiting on, so this cannot + // pair into a cycle. Everything after init caches main-actor + // state instead (see scheduleBluetoothStatusSample). DispatchQueue.main.sync { isAppActive = UIApplication.shared.applicationState == .active refreshCachedBackgroundTimeRemaining() @@ -597,7 +602,7 @@ final class BLEService: NSObject { } } - private var isPanicSuspended: Bool { + var isPanicSuspended: Bool { panicLifecycleLock.lock() defer { panicLifecycleLock.unlock() } return _isPanicSuspended @@ -657,6 +662,7 @@ final class BLEService: NSObject { centralManager = CBCentralManager(delegate: self, queue: bleQueue) peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) #endif + radio.central = centralManager } private func restartGossipManager() { @@ -710,8 +716,7 @@ final class BLEService: NSObject { deinit { maintenanceTimer?.cancel() - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() #if os(iOS) @@ -737,7 +742,7 @@ final class BLEService: NSObject { // generation-bound handoffs that raced this barrier reject themselves. // Clear the old identity's bounded early-ciphertext queue again after // those callbacks drain so none can repopulate it after the first wipe. - messageQueue.sync(flags: .barrier) { + onEngine { noisePacketHandler.resetForPanic() } clearEmergencySessionState() @@ -765,32 +770,32 @@ final class BLEService: NSObject { gossipSyncManager = nil // Discard deferred pre-panic ciphertext behind any in-flight receive // handlers so none can repopulate the handler's bounded queue. - messageQueue.sync(flags: .barrier) { + onEngine { noisePacketHandler.resetForPanic() } - // pendingNoiseSessionQueues is owned by collectionsQueue everywhere - // else, so clear it there too rather than on messageQueue. - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.removeAll() } - let panicReset = collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.removeAll() - pendingNotifications.removeAll() + let panicReset = onEngine { let transfers = outboundFragmentTransfers.removeAll() fragmentAssemblyBuffer.removeAll() pendingDirectedRelays.removeAll() ingressLinks.removeAll() recentTrafficTracker.removeAll() scheduledRelays.cancelAll() + // Proofs and revalidation epochs die with the identity; the + // rebind/retirement cooldowns deliberately survive (see + // BLELinkAuthState.removeAll). + linkAuth.removeAll() + // The new identity owes no announce-throttle debt: without this, + // a panic within the forced minimum interval of the last + // announce swallows the rotation announce and the new identity + // stays invisible until the next maintenance cycle. + announceThrottle.reset() // These callbacks belong to pre-panic transfer state. Invoking // them would let queued UI work recreate or resend wiped media. - pendingPrivateMediaPolicyResolutions.removeAll() - privateMediaSessionGenerations.removeAll() - authenticatedPeerStates.removeAll() - privateMediaProofTimeoutMarkers.removeAll() - privateMediaProofWatchdogs.removeAll() - authenticatedPeerStateSendProgress.removeAll() + privateMediaSessions.panicReset() // Let the post-panic identity publish its fresh bundle promptly. lastPrekeyBundleSentAt = nil return transfers @@ -802,10 +807,10 @@ final class BLEService: NSObject { } bleQueue.sync { + pendingPeripheralWrites.removeAll() + pendingNotifications.removeAll() pendingWriteBuffers.removeAll() - noiseAuthenticatedLinkOwners.removeAll() - noiseReconnectPolicy.removeAll() - connectionScheduler.reset() + radio.reset() } disconnectNotifyDebouncer.removeAll() @@ -814,7 +819,7 @@ final class BLEService: NSObject { // must never observe the new Noise service alongside the old peer ID // (it would sign with the new identity while carrying the old sender). // refreshPeerIdentity() executes inline here via its re-entrancy check. - messageQueue.sync(flags: .barrier) { + onEngine { noiseService.clearEphemeralStateForPanic() noiseService.clearPersistentIdentity() @@ -832,7 +837,7 @@ final class BLEService: NSObject { // would force-send an announce and break that silence). localIdentityState.setNickname(currentNickname) messageDeduplicator.reset() - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.selfBroadcastTracker.removeAll() } requestPeerDataPublish() @@ -904,9 +909,7 @@ final class BLEService: NSObject { weak var peerEventsDelegate: TransportPeerEventsDelegate? func currentPeerSnapshots() -> [TransportPeerSnapshot] { - collectionsQueue.sync { - peerRegistry.transportSnapshots(selfNickname: myNickname) - } + peerRegistry.transportSnapshots(selfNickname: myNickname) } // MARK: Identity @@ -969,7 +972,7 @@ final class BLEService: NSObject { // Send initial announce after services are ready // Use longer delay to avoid conflicts with other announces - messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in + engineScheduler.schedule(after: TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in guard let self, self.isCurrentPanicLifecycleGeneration( lifecycleGeneration @@ -1024,15 +1027,14 @@ final class BLEService: NSObject { } // Clear pending notifications - collectionsQueue.sync(flags: .barrier) { + bleQueue.sync { pendingNotifications.removeAll() } // Stop timer maintenanceTimer?.cancel() maintenanceTimer = nil - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() @@ -1048,14 +1050,13 @@ final class BLEService: NSObject { /// pumping the main run loop. Close the radio and timers immediately; /// the identity/session cleanup follows synchronously. private func stopServicesImmediatelyForPanic() { - collectionsQueue.sync(flags: .barrier) { + bleQueue.sync { pendingNotifications.removeAll() } maintenanceTimer?.cancel() maintenanceTimer = nil - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() @@ -1075,22 +1076,21 @@ final class BLEService: NSObject { private func clearEmergencySessionState() { // Clear all sessions and peers - let cancelled = collectionsQueue.sync(flags: .barrier) { + let cancelled = onEngine { let entries = outboundFragmentTransfers.removeAll().map { (id: $0.id, items: $0.workItems) } - let pingTimeouts = pendingMeshPings.values.map(\.timeout) - pendingMeshPings.removeAll() - meshPingResponseLimiter = SyncResponseRateLimiter( - maxResponses: TransportConfig.meshPingInboundMaxPerLink, - window: TransportConfig.meshPingInboundWindowSeconds - ) - peerRegistry.removeAll() + let pingTimeouts = meshPings.reset() + peerRegistry.mutate { $0.removeAll() } fragmentAssemblyBuffer.removeAll() sourceRouteFailures = BLESourceRouteFailureCache() // Also clear pending message queues to avoid stale state across sessions pendingNoiseSessionQueues.removeAll() pendingDirectedRelays.removeAll() + // Identity domain is engine-owned: bindings and link proofs + // clear here, physical link state clears on bleQueue below. + linkBindings.removeAll() + linkAuth.removeAll() return (transfers: entries, pingTimeouts: pingTimeouts) } @@ -1106,9 +1106,7 @@ final class BLEService: NSObject { // Clear peripheral references (synchronized access to avoid races with BLE callbacks) bleQueue.sync { linkStateStore.clearAll() - noiseAuthenticatedLinkOwners.removeAll() - noiseReconnectPolicy.removeAll() - connectionScheduler.reset() + radio.reset() subscriptionAnnounceLimiter.removeAll() } meshTopology.reset() @@ -1118,14 +1116,12 @@ final class BLEService: NSObject { func isPeerConnected(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys - return collectionsQueue.sync { peerRegistry.isConnected(peerID) } + return peerRegistry.isConnected(peerID) } func isPeerReachable(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys - return collectionsQueue.sync { - peerRegistry.isReachable(peerID, now: Date()) - } + peerRegistry.isReachable(peerID, now: Date()) } func canDeliverSecurely(to peerID: PeerID) -> Bool { @@ -1142,15 +1138,13 @@ final class BLEService: NSObject { } func peerNickname(peerID: PeerID) -> String? { - collectionsQueue.sync { - peerRegistry.nickname(for: peerID, connectedOnly: true) - } + peerRegistry.nickname(for: peerID, connectedOnly: true) } /// Capabilities the peer advertised in its last verified announce. /// Empty for peers that predate the capabilities TLV. func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { - collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } + peerRegistry.capabilities(for: peerID) } func authenticatedPrivateMediaReceiptSessionGeneration( @@ -1159,21 +1153,10 @@ final class BLEService: NSObject { let normalizedPeerID = peerID.toShort() let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID) - return collectionsQueue.sync { - guard let generation = - privateMediaSessionGenerations[normalizedPeerID], - generation == currentNoiseGeneration, - let authenticated = - authenticatedPeerStates[normalizedPeerID], - authenticated.sessionGeneration == generation, - authenticated.capabilities.contains(.privateMedia), - authenticated.capabilities.contains( - .privateMediaReceipts - ) else { - return nil - } - return generation - } + return privateMediaSessions.receiptSessionGeneration( + for: normalizedPeerID, + currentNoiseGeneration: currentNoiseGeneration + ) } private func privateMediaPolicyFingerprint( @@ -1191,11 +1174,9 @@ final class BLEService: NSObject { // registry entry populated by a public announce. return fingerprint } - return collectionsQueue.sync { - peerRegistry.info(for: normalizedPeerID)? - .noisePublicKey? - .sha256Fingerprint() - } + return peerRegistry.info(for: normalizedPeerID)? + .noisePublicKey? + .sha256Fingerprint() } func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { @@ -1206,16 +1187,17 @@ final class BLEService: NSObject { sessionGeneration: UUID?, authenticatedState: BLEAuthenticatedPeerStateObservation?, timedOut: BLEPrivateMediaProofTimeoutMarker? - ) = collectionsQueue.sync { + ) = { let info = peerRegistry.info(for: normalizedPeerID) + let session = privateMediaSessions.policyInputs(for: normalizedPeerID) return ( info?.capabilities ?? [], info?.noisePublicKey?.sha256Fingerprint(), - privateMediaSessionGenerations[normalizedPeerID], - authenticatedPeerStates[normalizedPeerID], - privateMediaProofTimeoutMarkers[normalizedPeerID] + session.sessionGeneration, + session.authenticatedState, + session.timedOut ) - } + }() let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID) // A session replacement can happen before its authentication callback @@ -1282,9 +1264,7 @@ final class BLEService: NSObject { return } - let generation = self.collectionsQueue.sync { - self.privateMediaSessionGenerations[normalizedPeerID] - } + let generation = self.privateMediaSessions.currentGeneration(for: normalizedPeerID) let fingerprint = self.privateMediaPolicyFingerprint( for: normalizedPeerID, expectedSessionGeneration: generation @@ -1295,43 +1275,12 @@ final class BLEService: NSObject { } let requestID = UUID() - let registration = self.collectionsQueue.sync(flags: .barrier) { - () -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) in - let generation = self.privateMediaSessionGenerations[normalizedPeerID] - if var pending = self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] { - guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - pending.completions.count - < TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else { - return (false, false, UUID(), generation) - } - pending.completions[requestID] = completion - self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending - return (true, false, pending.timeoutNonce, pending.sessionGeneration) - } - - guard self.pendingPrivateMediaPolicyResolutions.count - < TransportConfig.privateMediaCapabilityProofPendingPeerCap else { - return (false, false, UUID(), generation) - } - let currentWatchdog = self.privateMediaProofWatchdogs[normalizedPeerID] - let reusesWatchdog = currentWatchdog?.fingerprint - .caseInsensitiveCompare(fingerprint) == .orderedSame - && currentWatchdog?.sessionGeneration == generation - let nonce: UUID - if reusesWatchdog, let currentWatchdog { - nonce = currentWatchdog.timeoutNonce - } else { - nonce = UUID() - } - self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = - BLEPendingPrivateMediaPolicyResolution( - fingerprint: fingerprint, - sessionGeneration: generation, - timeoutNonce: nonce, - completions: [requestID: completion] - ) - return (true, !reusesWatchdog, nonce, generation) - } + let registration = self.privateMediaSessions.registerPolicyResolution( + for: normalizedPeerID, + fingerprint: fingerprint, + requestID: requestID, + completion: completion + ) guard registration.registered else { self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) @@ -1368,9 +1317,7 @@ final class BLEService: NSObject { sessionGeneration: UUID?, nonce: UUID ) { - messageQueue.asyncAfter( - deadline: .now() + TransportConfig.privateMediaCapabilityProofTimeoutSeconds - ) { [weak self] in + engineScheduler.schedule(after: TransportConfig.privateMediaCapabilityProofTimeoutSeconds) { [weak self] in self?.handlePrivateMediaProofTimeout( for: peerID, fingerprint: fingerprint, @@ -1386,39 +1333,17 @@ final class BLEService: NSObject { sessionGeneration: UUID?, nonce: UUID ) { - let expiration = collectionsQueue.sync(flags: .barrier) { - () -> (expired: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in - let pending = pendingPrivateMediaPolicyResolutions[peerID] - let pendingMatches = pending?.timeoutNonce == nonce - && pending?.sessionGeneration == sessionGeneration - && pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame - let watchdog = privateMediaProofWatchdogs[peerID] - let watchdogMatches = sessionGeneration != nil - && watchdog?.timeoutNonce == nonce - && watchdog?.sessionGeneration == sessionGeneration - && watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame - guard pendingMatches || watchdogMatches else { - return (false, []) - } - var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = [] - if pendingMatches, let pending { - completions = Array(pending.completions.values) - } - if pendingMatches { - pendingPrivateMediaPolicyResolutions.removeValue(forKey: peerID) - } - if watchdogMatches { - privateMediaProofWatchdogs.removeValue(forKey: peerID) - } - privateMediaProofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker( - fingerprint: fingerprint, - sessionGeneration: sessionGeneration - ) - return (true, completions) - } + let expiration = privateMediaSessions.expireProofDeadline( + for: peerID, + fingerprint: fingerprint, + sessionGeneration: sessionGeneration, + nonce: nonce + ) guard expiration.expired else { return } let policy = privateMediaSendPolicy(to: peerID) - sendPendingNoisePayloadsAfterHandshake(for: peerID) + if !expiration.deferredOutbound { + sendPendingNoisePayloadsAfterHandshake(for: peerID) + } completePrivateMediaPolicyResolution(expiration.completions, with: policy) } @@ -1426,67 +1351,41 @@ final class BLEService: NSObject { /// internet-gateway toggle) and re-announces so peers learn promptly. /// Build-time bits stay in `PeerCapabilities.localSupported`. func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) { - let changed: Bool = collectionsQueue.sync(flags: .barrier) { - let before = runtimeCapabilities - if enabled { - runtimeCapabilities.insert(capability) - } else { - runtimeCapabilities.remove(capability) - } - return runtimeCapabilities != before - } - guard changed else { return } + guard localIdentityState.setCapability(capability, enabled: enabled) else { return } sendAnnounce(forceSend: true) } /// Reachable peers currently advertising the `.gateway` capability. func reachableGatewayPeers() -> [PeerID] { - let now = Date() - return collectionsQueue.sync { - peerRegistry.peers(advertising: .gateway) - .filter { peerRegistry.isReachable($0, now: now) } - } + peerRegistry.reachablePeers(advertising: .gateway, now: Date()) } /// Reachable peers currently advertising the `.bridge` capability. func reachableBridgePeers() -> [PeerID] { - let now = Date() - return collectionsQueue.sync { - peerRegistry.peers(advertising: .bridge) - .filter { peerRegistry.isReachable($0, now: now) } - } + peerRegistry.reachablePeers(advertising: .bridge, now: Date()) } /// A rendezvous cell advertised by a bridge-capable peer's announce. func advertisedBridgeGeohash() -> String? { - collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() } + peerRegistry.advertisedBridgeGeohash() } /// The rendezvous cell this device advertises in its own announces while /// bridging with the gateway toggle on. Set from the main actor; the /// value rides the next (forced) announce. func setLocalBridgeGeohash(_ cell: String?) { - let changed: Bool = collectionsQueue.sync(flags: .barrier) { - guard localBridgeGeohash != cell else { return false } - localBridgeGeohash = cell - return true - } - guard changed else { return } + guard localIdentityState.setBridgeGeohash(cell) else { return } sendAnnounce(forceSend: true) } func getPeerNicknames() -> [PeerID: String] { - return collectionsQueue.sync { - peerRegistry.displayNicknames(selfNickname: myNickname) - } + peerRegistry.displayNicknames(selfNickname: myNickname) } // MARK: Protocol utilities func getFingerprint(for peerID: PeerID) -> String? { - return collectionsQueue.sync { - peerRegistry.fingerprint(for: peerID) - } + peerRegistry.fingerprint(for: peerID) } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { @@ -1550,10 +1449,10 @@ final class BLEService: NSObject { // MARK: Messaging private func handlePrivateMediaAdmissionExpiry(_ transferId: String) { - // Expiry can be discovered from the BLE maintenance queue or while a - // caller already owns collectionsQueue. Cleanup is therefore - // fire-and-forget; never synchronously re-enter the collections lock. - collectionsQueue.async(flags: .barrier) { [weak self] in + // Expiry can be discovered from the BLE maintenance queue or from an + // engine slot. Cleanup is therefore fire-and-forget; never + // synchronously re-enter the engine. + messageQueue.async { [weak self] in _ = self?.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) } TransferProgressManager.shared.rejectBeforeStart( @@ -1571,7 +1470,7 @@ final class BLEService: NSObject { // Noise cleanup remains asynchronous, but deferred private-media work // cannot pass another admission boundary after this returns. privateMediaTransferAdmissions.cancel(transferId) - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } switch self.outboundFragmentTransfers.cancelTransfer(transferId) { @@ -1646,15 +1545,6 @@ final class BLEService: NSObject { } } - func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { - sendFilePrivate( - filePacket, - to: peerID, - transferId: transferId, - allowLegacyFallback: false - ) - } - func sendFilePrivate( _ filePacket: BitchatFilePacket, to peerID: PeerID, @@ -1846,7 +1736,7 @@ final class BLEService: NSObject { self.privateMediaTransferAdmissions.finish(transferId) return } - let queued = self.collectionsQueue.sync(flags: .barrier) { + let queued = onEngine { self.privateMediaTransferAdmissions.withActive(transferId) { self.pendingNoiseSessionQueues.appendTypedPayload( typedPayload, @@ -1862,7 +1752,7 @@ final class BLEService: NSObject { } SecureLogger.debug("📥 Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session) guard self.privateMediaTransferAdmissions.isActive(transferId) else { - self.collectionsQueue.sync(flags: .barrier) { + onEngine { _ = self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) } self.privateMediaTransferAdmissions.finish(transferId) @@ -1989,7 +1879,7 @@ final class BLEService: NSObject { // Queue for after handshake; initiate only while the peer is // around to answer (see sendDeliveryAck — absent senders must // not turn queued acks into handshake floods). - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID) } if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) { @@ -2073,14 +1963,12 @@ final class BLEService: NSObject { } private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool { - return collectionsQueue.sync(flags: .barrier) { - ingressLinks.recordIfNew( - packet, - link: link, - peerID: peerID, - lifetime: TransportConfig.bleIngressRecordLifetimeSeconds - ) - } + ingressLinks.recordIfNew( + packet, + link: link, + peerID: peerID, + lifetime: TransportConfig.bleIngressRecordLifetimeSeconds + ) } // MARK: - Packet Broadcasting @@ -2285,7 +2173,7 @@ final class BLEService: NSObject { private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) { guard !isPanicSuspended else { return } - collectionsQueue.async(flags: .barrier) { [weak self] in + bleQueue.async { [weak self] in guard let self = self else { return } guard !self.isPanicSuspended else { return } let result = self.pendingNotifications.enqueue( @@ -2305,8 +2193,7 @@ final class BLEService: NSObject { } let backoff = TransportConfig.bleNotificationRetryDelayMs * max(1, attempt + 1) - let deadline = DispatchTime.now() + .milliseconds(backoff) - self.messageQueue.asyncAfter(deadline: deadline) { [weak self] in + self.engineScheduler.schedule(after: Double(backoff) / 1_000) { [weak self] in self?.enqueuePendingNotification(data: data, centrals: centrals, context: context, attempt: attempt + 1) } } @@ -2320,13 +2207,12 @@ final class BLEService: NSObject { centrals: [CBCentral], context: String ) -> Bool { - let result = collectionsQueue.sync(flags: .barrier) { - pendingNotifications.enqueue( - data: data, - targets: centrals, - capCount: TransportConfig.blePendingNotificationsCapCount - ) - } + dispatchPrecondition(condition: .onQueue(bleQueue)) + let result = pendingNotifications.enqueue( + data: data, + targets: centrals, + capCount: TransportConfig.blePendingNotificationsCapCount + ) switch result { case let .enqueued(count): SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session) @@ -2337,9 +2223,12 @@ final class BLEService: NSObject { } } - /// Serializes the final authenticated-link check with CoreBluetooth's - /// notification admission on `bleQueue`, closing the rebind/disconnect - /// race between fanout planning and the actual handoff. + /// The authenticated-link eligibility check runs here on the engine — + /// the queue that owns bindings and rebinds — so fanout planning and + /// the final check are serialized against identity changes by + /// construction. Only the physical admission (updateValue and the + /// backpressure queue) hops to `bleQueue`; a central that physically + /// departs in between is a harmless no-op delivery. private func notifyOrEnqueueIfAccepted( data: Data, centrals: [CBCentral], @@ -2347,18 +2236,19 @@ final class BLEService: NSObject { context: String, requiredAuthenticatedPeer: PeerID? ) -> Bool { - let accept = { [self] in - let eligible: [CBCentral] - if let peerID = requiredAuthenticatedPeer { - eligible = centrals.filter { central in - let link = BLEIngressLinkID.central(central.identifier.uuidString) - return noiseAuthenticatedLinkOwners[link] == peerID - && linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID - } - } else { - eligible = centrals + let eligible: [CBCentral] + if let peerID = requiredAuthenticatedPeer { + eligible = centrals.filter { central in + let link = BLEIngressLinkID.central(central.identifier.uuidString) + return linkAuth.isAuthenticated(link, for: peerID) + && linkBindings.peer(forCentralUUID: central.identifier.uuidString) == peerID } - guard !eligible.isEmpty else { return false } + } else { + eligible = centrals + } + guard !eligible.isEmpty else { return false } + + let accept = { [self] in if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true { return true } @@ -2368,10 +2258,7 @@ final class BLEService: NSObject { context: context ) } - - if DispatchQueue.getSpecific(key: bleQueueKey) != nil { - return accept() - } + // queue-contract-ok: engine → bleQueue is the sanctioned sync direction. return bleQueue.sync(execute: accept) } @@ -2389,7 +2276,7 @@ final class BLEService: NSObject { requireNoiseAuthenticatedPeerLink: Bool = false ) -> Bool { guard !isPanicSuspended else { return false } - let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) } + let ingressRecord = ingressLinks.record(for: packet) var excludedPeerLinks = links(to: ingressRecord?.peerID) if requireNoiseAuthenticatedPeerLink { guard let directedOnlyPeer else { return false } @@ -2410,8 +2297,9 @@ final class BLEService: NSObject { let subscribedCentrals = characteristic == nil ? [] : centralSnapshot.centrals let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString } let centralIDs = subscribedCentrals.map { $0.identifier.uuidString } - let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state in - state.peerID.map { (state.peripheral.identifier.uuidString, $0) } + let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state -> (String, PeerID)? in + let uuid = state.peripheral.identifier.uuidString + return linkBindings.peer(forPeripheralID: uuid).map { (uuid, $0) } }) let plan = BLEOutboundLinkPlanner.plan( packet: packet, @@ -2424,10 +2312,7 @@ final class BLEService: NSObject { excludedLinks: excludedPeerLinks, peripheralPeerBindings: peripheralPeerBindings, centralPeerBindings: centralSnapshot.peerIDsByCentralUUID, - // Perf note: this is a third bleQueue hop per send; if send-path - // profiling ever flags it, fold it into snapshotPeripheralStates - // as a combined snapshot. - preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings }, + preferredPeripheralPerPeer: linkBindings.preferredPeripheralBindings, directAnnounceTTL: messageTTL, directedOnlyPeer: directedOnlyPeer, requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink @@ -2525,7 +2410,7 @@ final class BLEService: NSObject { // MARK: - Directed store-and-forward private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) { let msgID = BLEOutboundPacketPolicy.messageID(for: packet) - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } if self.pendingDirectedRelays.enqueue( packet: packet, @@ -2538,18 +2423,20 @@ final class BLEService: NSObject { } } - private func flushDirectedSpool() { + func flushDirectedSpool() { guard !isPanicSuspended else { return } - // Move items out and attempt broadcast; if still no links, they'll be re-spooled - let toSend = collectionsQueue.sync(flags: .barrier) { - pendingDirectedRelays.drainUnexpired( + // Runs from bleQueue maintenance: hop to the engine asynchronously + // (bleQueue must never sync-wait on the engine). Move items out and + // attempt broadcast; if still no links, they'll be re-spooled. + messageQueue.async { [weak self] in + guard let self, !self.isPanicSuspended else { return } + let toSend = self.pendingDirectedRelays.drainUnexpired( now: Date(), window: TransportConfig.bleDirectedSpoolWindowSeconds ) - } - guard !toSend.isEmpty else { return } - for entry in toSend { - messageQueue.async { [weak self] in self?.broadcastPacket(entry.packet) } + for entry in toSend { + self.broadcastPacket(entry.packet) + } } } @@ -2632,7 +2519,7 @@ final class BLEService: NSObject { let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty else { return nil } let senderPeerID = PeerID(hexData: packet.senderID) - let peers = collectionsQueue.sync { peerRegistry.snapshotByID } + let peers = peerRegistry.snapshotByID // Archived senders are usually long gone, so the signature-derived // identity is the best shot at a name; a live registry entry is // next; anonymous fallback matches the live path. @@ -2671,7 +2558,7 @@ final class BLEService: NSObject { }, peersSnapshot: { [weak self] in guard let self = self else { return [:] } - return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } + return self.peerRegistry.snapshotByID }, verifyPacketSignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false @@ -2717,7 +2604,7 @@ final class BLEService: NSObject { // queued barrier must still observe the path as pending. If // insertion wins first, the next MainActor snapshot sees the // new bubble and protects the path explicitly. - self?.messageQueue.async(flags: .barrier) { + self?.messageQueue.async { self?.incomingFileStore.finishIncomingFileDelivery( at: storedURL ) @@ -2726,7 +2613,7 @@ final class BLEService: NSObject { isPrivateMediaSenderBlocked: { [weak self] peerID in guard let self else { return false } let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID) - ?? self.collectionsQueue.sync { + ?? onEngine { self.peerRegistry.info(for: peerID)?.noisePublicKey } guard let senderStaticKey else { return false } @@ -2805,7 +2692,7 @@ final class BLEService: NSObject { // initiating a handshake broadcast turns one undeliverable ack // into a mesh-wide flood. The queued ack flushes whenever a // session eventually establishes. - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID) } if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) { @@ -2820,7 +2707,7 @@ final class BLEService: NSObject { /// keeps delayed/relayed leaves verifiable after the live registry entry /// has aged out. private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { - let registrySigningKey = collectionsQueue.sync { + let registrySigningKey = onEngine { peerRegistry.info(for: peerID)?.signingPublicKey } let verifiedViaRegistry = registrySigningKey.map { @@ -2845,19 +2732,9 @@ final class BLEService: NSObject { // A valid departure retires transport state too; otherwise // canDeliverSecurely could remain true for a peer we just removed. clearNoiseSession(for: peerID) - readLinkState { _ in - let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in - owner == peerID ? link : nil - } - for link in departedLinks { - noiseAuthenticatedLinkOwners.removeValue(forKey: link) - noiseReconnectPolicy.endLinkEpoch(link) - } - } - _ = collectionsQueue.sync(flags: .barrier) { - // Remove the peer when they leave - peerRegistry.remove(peerID) - } + _ = linkAuth.retireLinks(ownedBy: peerID) + // Remove the peer when they leave + peerRegistry.mutate { _ = $0.remove(peerID) } // Remove any stored announcement for sync purposes gossipSyncManager?.removeAnnouncementForPeer(peerID) // Send on main thread @@ -2865,20 +2742,20 @@ final class BLEService: NSObject { guard let self = self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.deliverTransportEvent(.peerDisconnected(peerID)) self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } return true } - private func sendAnnounce(forceSend: Bool = false) { + func sendAnnounce(forceSend: Bool = false) { guard !isPanicSuspended else { return } // Announce construction reads the replaceable Noise service and several // related state snapshots. Serialize the whole operation with identity // rotation instead of letting CoreBluetooth and maintenance callbacks // execute it directly on their own queues. - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.sendAnnounceNow(forceSend: forceSend) } } @@ -2898,20 +2775,15 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync { - ( - // Publishing the neighbour list hands the local adjacency graph - // to a single passive receiver; see - // TransportConfig.announceIncludesDirectNeighbors. - TransportConfig.announceIncludesDirectNeighbors - ? peerRegistry.connectedRoutingData - : [], - PeerCapabilities.localSupported.union(runtimeCapabilities), - runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil - ) - } - + // Publishing the neighbour list hands the local adjacency graph + // to a single passive receiver; see + // TransportConfig.announceIncludesDirectNeighbors. + let connectedPeerIDs: [Data] = TransportConfig.announceIncludesDirectNeighbors + ? peerRegistry.connectedRoutingData + : [] let localIdentity = localIdentityState.snapshot() + let advertisedCapabilities = localIdentity.advertisedCapabilities + let advertisedBridgeCell = localIdentity.advertisedBridgeGeohash let announcement = AnnouncementPacket( nickname: localIdentity.nickname, noisePublicKey: noisePub, @@ -3080,12 +2952,22 @@ final class BLEService: NSObject { // MARK: - GossipSyncManager Delegate extension BLEService: GossipSyncManager.Delegate { + // Gossip calls arrive on the manager's own serial queue; sends read + // the engine-owned bindings, so they enter an engine slot. The sync + // hop is safe: mesh.sync sits above the engine in the sync order — + // production engine code only ever queue.async's into the manager + // (the queue.sync helpers are DEBUG test entry points that run on + // test threads), so no reverse edge exists. func sendPacket(_ packet: BitchatPacket) { - broadcastPacket(packet) + onEngine { + broadcastPacket(packet) + } } func sendPacket(to peerID: PeerID, packet: BitchatPacket) { - sendPacketDirected(packet, to: peerID) + onEngine { + _ = sendPacketDirected(packet, to: peerID) + } } func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { @@ -3093,452 +2975,101 @@ extension BLEService: GossipSyncManager.Delegate { } func getConnectedPeers() -> [PeerID] { - return collectionsQueue.sync { + return onEngine { peerRegistry.connectedPeerIDs } } } -// MARK: - CBCentralManagerDelegate +// MARK: - Radio controller integration -extension BLEService: CBCentralManagerDelegate { - #if os(iOS) - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { - let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] - guard !isPanicSuspended else { - central.stopScan() - restoredPeripherals.forEach { - central.cancelPeripheralConnection($0) - } - return - } - let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] - let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] - let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool - - SecureLogger.info( - "♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))", - category: .session - ) - - for peripheral in restoredPeripherals { - let identifier = peripheral.identifier.uuidString - peripheral.delegate = self - let existing = linkStateStore.state(forPeripheralID: identifier) - let assembler = existing?.assembler ?? NotificationStreamAssembler() - let characteristic = existing?.characteristic - let peerID = existing?.peerID - let wasConnecting = existing?.isConnecting ?? false - let wasConnected = existing?.isConnected ?? false - - let restoredState = BLEPeripheralLinkState( - peripheral: peripheral, - characteristic: characteristic, - peerID: peerID, - isConnecting: wasConnecting || peripheral.state == .connecting, - isConnected: wasConnected || peripheral.state == .connected, - lastConnectionAttempt: existing?.lastConnectionAttempt, - assembler: assembler - ) - linkStateStore.setPeripheralState(restoredState, for: identifier) - - // Restored peripherals are the freshest wake-on-proximity - // candidates we have after a relaunch — without this the cache - // starts empty and backgrounding right after a restore arms - // nothing. Service rediscovery for restored-connected links waits - // for poweredOn: CoreBluetooth drops commands issued during - // restoration (API MISUSE warnings). - recentPeripheralCache.record(peripheral, peripheralID: identifier, at: Date()) - } - - // Via the sampler (not a direct capture): it refreshes the cached - // background budget on main first, so the restore log shows the real - // wake window instead of the init sentinel. - logBluetoothStatus("central-restore") - - if central.state == .poweredOn { - startScanning() - } +extension BLEService: BLERadioControllerDelegate { + func radioIsPanicSuspended() -> Bool { + isPanicSuspended } - #endif - func centralManagerDidUpdateState(_ central: CBCentralManager) { - emitTransportEvent(.bluetoothStateUpdated(central.state)) - - switch central.state { - case .poweredOn: - guard !isPanicSuspended else { - central.stopScan() - return - } - // Links restored as connected have no characteristic in the new - // process; without rediscovery they sit connected-but-unusable - // until the peer disconnects. Runs here (not willRestoreState) - // because commands issued before poweredOn are dropped. - for state in linkStateStore.peripheralStates where state.isConnected - && state.characteristic == nil - && state.peripheral.state == .connected { - SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session) - state.peripheral.discoverServices([BLEService.serviceUUID]) - } - - // Start scanning - use allow duplicates for faster discovery when active - startScanning() - - case .poweredOff: - // CoreBluetooth has already transitioned out of poweredOn. Do - // not issue stop/cancel commands now; they are rejected as API - // misuse. Retire our link state locally instead. - SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) - let peripheralStates = linkStateStore.peripheralStates - let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID) - for state in peripheralStates { - let peripheralID = state.peripheral.identifier.uuidString - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } - noiseAuthenticatedLinkOwners.removeValue( - forKey: .peripheral(peripheralID) - ) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - } - _ = linkStateStore.clearPeripherals() - // Notify UI of disconnections - for peerID in peerIDs { - notifyUI { [weak self] in - self?.notifyPeerDisconnectedDebounced(peerID) - } - } - - case .unauthorized: - // User denied Bluetooth permission - SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) - _ = linkStateStore.clearPeripherals() - - case .unsupported: - // Device doesn't support BLE - SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session) - - case .resetting: - // Bluetooth stack is resetting - will get another state update when done - SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session) - - case .unknown: - // Initial state before we know the actual state - SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session) - - @unknown default: - SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session) - } - } - - private func startScanning() { - guard !isPanicSuspended, - let central = centralManager, - central.state == .poweredOn, - !central.isScanning else { return } - - // Use allow duplicates = true for faster discovery in foreground - // This gives us discovery events immediately instead of coalesced + func radioIsAppActive() -> Bool { #if os(iOS) - let allowDuplicates = isAppActive // Use our tracked state (thread-safe) + return isAppActive #else - let allowDuplicates = true // macOS doesn't have background restrictions + return true #endif - - central.scanForPeripherals( - withServices: [BLEService.serviceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] + } + + func radioTearDownPeripheralLink(_ peripheralID: String) { + // bleQueue (the controller's queue): physical discard now, identity + // retirement via the port. + discardPeripheralLinkPhysical(peripheralID) + emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false)) + } + + /// bleQueue half of a peripheral-link teardown: the link's write + /// backpressure and its physical link-state entry. Identity retirement + /// (proof, epoch, binding repair) rides a separate engine hop — + /// `retirePeripheralLinkIdentity`. bleQueue-confined. + func discardPeripheralLinkPhysical(_ peripheralID: String) { + pendingPeripheralWrites.discardAll(for: peripheralID) + linkStateStore.removePeripheral(peripheralID) + } + + /// Engine half of a peripheral-link teardown: retires the link's Noise + /// proof and revalidation epoch, and its binding — repairing the peer's + /// preferred link onto a connected survivor, preferring a writable one + /// (a link mid-service-rediscovery would strand directed sends until + /// its characteristic comes back). Returns the peer that owned the + /// binding. Engine-confined. + @discardableResult + func retirePeripheralLinkIdentity(_ peripheralID: String) -> PeerID? { + linkAuth.retireLink(.peripheral(peripheralID)) + return linkBindings.peripheralRemoved(peripheralID) { remaining in + let alive = readLinkState { store in + remaining.compactMap { uuid -> (uuid: String, writable: Bool)? in + guard let state = store.state(forPeripheralID: uuid), + state.isConnected else { return nil } + return (uuid, state.characteristic != nil) + } + } + return (alive.first(where: \.writable) ?? alive.first)?.uuid + } + } + + /// Binds only live physical links, preserving the store-era guard that + /// a binding can never precede its link (a lost race against a + /// concurrent physical removal is healed by that removal's queued + /// identity retirement). Engine-confined. + func bindPeripheralLink(_ peripheralUUID: String, to peerID: PeerID) { + guard readLinkState({ $0.state(forPeripheralID: peripheralUUID) }) != nil else { return } + linkBindings.bindPeripheral(peripheralUUID, to: peerID) + } + + /// Whether the peer holds a live direct link in either role: bindings + /// (engine) joined against physical liveness (readLinkState). + /// Engine-confined. + func directLinkState(for peerID: PeerID) -> BLEDirectLinkState { + let hasPeripheral = linkBindings.preferredPeripheralUUID(for: peerID) + .flatMap { uuid in readLinkState { $0.state(forPeripheralID: uuid)?.isConnected } } ?? false + return BLEDirectLinkState( + hasPeripheral: hasPeripheral, + hasCentral: linkBindings.hasCentral(boundTo: peerID) ) - - // Started BLE scanning } - - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { - guard !isPanicSuspended else { return } - let peripheralID = peripheral.identifier.uuidString - let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") - let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true - let rssiValue = RSSI.intValue - let candidate = BLEConnectionCandidate( - peripheral: peripheral, - peripheralID: peripheralID, - rssi: rssiValue, - name: String(advertisedName), - isConnectable: isConnectable, - discoveredAt: Date() - ) - if isConnectable { - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt) - } - let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init) - - switch connectionScheduler.handleDiscovery( - candidate, - connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, - existingState: existingState, - peripheralState: peripheral.state.connectionSchedulerState, - now: candidate.discoveredAt - ) { - case .ignore, .queued: - return - case .scheduleRetry(let delay): - bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.tryConnectFromQueue() - } - return - case .cancelStaleConnection: - central.cancelPeripheralConnection(peripheral) - return - case .connectNow: - beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect") - } + /// The peer's preferred peripheral link state, when physically present. + /// Engine-confined. + func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { + linkBindings.preferredPeripheralUUID(for: peerID) + .flatMap { uuid in readLinkState { $0.state(forPeripheralID: uuid) } } } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - guard !isPanicSuspended else { - central.cancelPeripheralConnection(peripheral) - return - } - let peripheralID = peripheral.identifier.uuidString - #if os(iOS) - // A connect completing while backgrounded is the wake-on-proximity - // path doing its job — worth an info line for field verification. - if !isAppActive { - SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session) - } - #endif - - // Update state to connected - linkStateStore.markConnected(peripheral) - - // Reset backoff state on success - connectionScheduler.recordConnectionSuccess(peripheralID: peripheralID) - - SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session) - - // Discover services - peripheral.discoverServices([BLEService.serviceUUID]) - } - - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - let peripheralID = peripheral.identifier.uuidString - - // Find the peer ID if we have it - let peerID = linkStateStore.peerID(forPeripheralID: peripheralID) - - SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session) - - // If disconnect carried an error (often timeout), apply short backoff to avoid thrash - if error != nil { - connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date()) - } - - // Retain the handle: a dropped link is the best wake-on-proximity - // candidate if the app backgrounds before the peer returns. - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date()) - - #if os(iOS) - // Link lost while backgrounded (peer walked away): re-arm a pending - // connect during this wake window so the peer's return wakes us again. - // Delayed past the disconnect-settle window to avoid reconnect thrash - // at range edge. - if !isAppActive { - bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in - guard let self, !self.isAppActive else { return } - // Reserve 0: use the slot this disconnect freed even in a - // dense mesh, so the lost peer can wake us when it returns. - self.armPendingBackgroundConnects(slotReserve: 0) - } - } - #endif - - // Clean up references and peer mappings - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = linkStateStore.removePeripheral(peripheralID) - // A duplicate link can drop while the peer stays live on another - // (the dual-role central link, or a second bound link after a - // restore): peer-disconnect bookkeeping only runs once the peer's - // last live link is gone. removePeripheral just repaired the reverse - // map onto a connected survivor, so directLinkState is accurate - // here. The scan restart and connect-slot refill below stay - // unguarded — they respond to the physical drop regardless of - // remaining logical links. - let remainingLinks = peerID.map { linkStateStore.directLinkState(for: $0) } - let peerStillLinked = (remainingLinks?.hasPeripheral ?? false) || (remainingLinks?.hasCentral ?? false) - if let peerID, !peerStillLinked { - // Do not remove peer; mark as not connected but retain for reachability - collectionsQueue.sync(flags: .barrier) { - peerRegistry.markDisconnected(peerID) - } - refreshLocalTopology() - } - - - // Restart scanning with allow duplicates for faster rediscovery - if centralManager?.state == .poweredOn { - // Stop and restart scanning to ensure we get fresh discovery events - centralManager?.stopScan() - bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in - self?.startScanning() - } - } - // Attempt to fill freed slot from queue - 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.peerRegistry.peerIDs } - - if let peerID, !peerStillLinked { - self.notifyPeerDisconnectedDebounced(peerID) - } - self.requestPeerDataPublish() - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) - } - } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - let peripheralID = peripheral.identifier.uuidString - - // Clean up the references - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = linkStateStore.removePeripheral(peripheralID) - - SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) - connectionScheduler.recordConnectionFailure(peripheralID: peripheralID) - // Try next candidate - bleQueue.async { [weak self] in self?.tryConnectFromQueue() } - } -} - -// MARK: - Connection scheduling helpers -private extension BLEExistingConnectionState { - init(_ state: BLEPeripheralLinkState) { - self.init( - isConnecting: state.isConnecting, - isConnected: state.isConnected, - lastConnectionAttempt: state.lastConnectionAttempt + /// Subscribed centrals with their bindings, one view. Engine-confined. + func subscribedCentralSnapshot() -> BLESubscribedCentralSnapshot { + BLESubscribedCentralSnapshot( + centrals: readLinkState(\.subscribedCentrals), + peerIDsByCentralUUID: linkBindings.centralPeersByUUID ) } } -private extension CBPeripheralState { - var connectionSchedulerState: BLEPeripheralConnectionState { - switch self { - case .connected: - return .connected - case .connecting: - return .connecting - case .disconnected, .disconnecting: - return .disconnected - @unknown default: - return .disconnected - } - } -} - -extension BLEService { - private func tryConnectFromQueue() { - guard !isPanicSuspended, - let central = centralManager, - central.state == .poweredOn else { return } - - let decision = connectionScheduler.nextCandidate( - connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, - isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in - let state = linkStateStore.state(forPeripheralID: peripheralID) - return state?.isConnected == true || state?.isConnecting == true - }, - now: Date() - ) - - switch decision { - case .none: - return - case .retryAfter(let delay): - bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } - case .connect(let candidate): - beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect") - } - } - - private func beginCentralConnection( - _ candidate: BLEConnectionCandidate, - using central: CBCentralManager, - logPrefix: String - ) { - guard !isPanicSuspended else { return } - let peripheral = candidate.peripheral - let peripheralID = candidate.peripheralID - linkStateStore.beginConnecting(to: peripheral, at: Date()) - peripheral.delegate = self - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ] - central.connect(peripheral, options: options) - connectionScheduler.recordConnectionAttempt(at: Date()) - SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session) - - bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in - guard let self = self, - let state = self.linkStateStore.state(forPeripheralID: peripheralID), - state.isConnecting && !state.isConnected else { return } - - guard peripheral.state != .connected else { - SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session) - return - } - - #if os(iOS) - if !self.isAppActive { - // Backgrounded: leave the connect pending. iOS never expires - // it — the controller completes it whenever the peer comes - // back into range, waking the app (state restoration relaunches - // us if we were terminated). Foreground return cancels stale - // pendings via cancelStalePendingConnects(). - SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session) - return - } - #endif - - SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) - central.cancelPeripheralConnection(peripheral) - self.collectionsQueue.sync(flags: .barrier) { - self.pendingPeripheralWrites.discardAll(for: peripheralID) - } - self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = self.linkStateStore.removePeripheral(peripheralID) - self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) - self.tryConnectFromQueue() - } - } -} - -private extension BLEService { - static func shouldRediscoverBitChatService( - invalidatedServiceUUIDs: [CBUUID], - cachedServiceUUIDs: [CBUUID]? - ) -> Bool { - invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true - } -} #if DEBUG // Test-only helper to inject packets into the receive pipeline @@ -3574,6 +3105,37 @@ extension BLEService { } } + /// Simulated-link ingress: the full production attribution path — + /// binding lookup, spoof rejection, raw-announce binding, ingress + /// recording — for a frame arriving on a synthetic link. The + /// SimulatedMesh harness feeds every node through this, so multi-node + /// tests exercise the same engine code as CoreBluetooth ingress. + func _test_ingestFrame(_ packet: BitchatPacket, link: BLEIngressLinkID) { + emitLinkEvent(.frameDecoded(packet, link: link, linkDescription: "Simulated \(link)")) + } + + /// Sends an unthrottled announce, exactly like the maintenance forced + /// path. SimulatedMesh uses this as the deterministic discovery step. + func _test_forceAnnounce() { + onEngine { sendAnnounceNow(forceSend: true) } + } + + /// Clears the announce throttle's wall-clock debt — the simulator's + /// stand-in for "enough real time has passed", since scheduler time + /// cannot move the throttle's Date-based window. Deliberately NOT + /// part of `_test_forceAnnounce`: the panic-rotation mesh test relies + /// on the production panic path performing its own reset, and a + /// blanket reset here would mask that regression. + func _test_resetAnnounceThrottle() { + announceThrottle.reset() + } + + /// Blocks until every engine slot enqueued so far has run — the + /// deterministic settling fence for simulated-mesh pumping. + func _test_fenceEngine() { + onEngine {} + } + func _test_emitTransportEvent( _ event: TransportEvent, completion: @escaping () -> Void, @@ -3590,15 +3152,15 @@ extension BLEService { if preseedPeer { // Ensure the synthetic peer is known and marked verified for public-message tests let normalizedID = PeerID(hexData: packet.senderID) - collectionsQueue.sync(flags: .barrier) { - if var existing = peerRegistry.info(for: normalizedID) { + peerRegistry.mutate { registry in + if var existing = registry.info(for: normalizedID) { existing.isConnected = true existing.isVerifiedNickname = true if let signingPublicKey { existing.signingPublicKey = signingPublicKey } existing.lastSeen = Date() - peerRegistry.upsert(existing) + registry.upsert(existing) } else { - peerRegistry.upsert(BLEPeerInfo( + registry.upsert(BLEPeerInfo( peerID: normalizedID, nickname: "TestPeer_\(fromPeerID.id.prefix(4))", isConnected: true, @@ -3619,7 +3181,7 @@ extension BLEService { /// avoiding wall-clock sleeps that become flaky under a parallel suite. func _test_drainFragmentPipeline() async { await withCheckedContinuation { continuation in - messageQueue.async(flags: .barrier) { + messageQueue.async { // Reassembled packets are reinjected synchronously on // `messageQueue`; their UI delivery task is therefore already // enqueued before this later MainActor marker. @@ -3653,23 +3215,31 @@ extension BLEService { } func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) { - bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) } + onEngine { linkBindings.bindCentral(centralUUID, to: peerID) } } func _test_centralBinding(_ centralUUID: String) -> PeerID? { - bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) } + onEngine { linkBindings.peer(forCentralUUID: centralUUID) } + } + + func _test_linkBinding(_ link: BLEIngressLinkID) -> PeerID? { + onEngine { linkBindings.boundPeer(for: link) } + } + + func _test_knownPeerIDs() -> [PeerID] { + peerRegistry.peerIDs } func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) { - bleQueue.sync { - guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return } - noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID + onEngine { + guard linkBindings.peer(forCentralUUID: centralUUID) == peerID else { return } + linkAuth.markAuthenticated(.central(centralUUID), owner: peerID) } } func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool { - bleQueue.sync { - noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID + onEngine { + linkAuth.isAuthenticated(.central(centralUUID), for: peerID) } } @@ -3679,8 +3249,8 @@ extension BLEService { capabilities: PeerCapabilities? = nil, noisePublicKey: Data? = nil ) { - collectionsQueue.sync(flags: .barrier) { - peerRegistry.upsert(BLEPeerInfo( + peerRegistry.mutate { + $0.upsert(BLEPeerInfo( peerID: peerID, nickname: nickname, isConnected: true, @@ -3709,7 +3279,7 @@ extension BLEService { messageID: String, for peerID: PeerID ) { - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendPrivateMessage( content: content, messageID: messageID, @@ -3724,7 +3294,7 @@ extension BLEService { for peerID: PeerID ) { guard privateMediaTransferAdmissions.begin(transferId) == .admitted else { return } - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload( payload, transferId: transferId, @@ -3738,31 +3308,12 @@ extension BLEService { } func _test_hasPendingPrivateMediaPolicyResolution(for peerID: PeerID) -> Bool { - collectionsQueue.sync { - pendingPrivateMediaPolicyResolutions[peerID.toShort()] != nil - } + privateMediaSessions.hasPendingPolicyResolution(for: peerID.toShort()) } func _test_forcePrivateMediaProofTimeout(for peerID: PeerID) { let normalizedPeerID = peerID.toShort() - let target = collectionsQueue.sync { - () -> (fingerprint: String, generation: UUID?, nonce: UUID)? in - if let watchdog = privateMediaProofWatchdogs[normalizedPeerID] { - return ( - watchdog.fingerprint, - watchdog.sessionGeneration, - watchdog.timeoutNonce - ) - } - if let pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] { - return ( - pending.fingerprint, - pending.sessionGeneration, - pending.timeoutNonce - ) - } - return nil - } + let target = privateMediaSessions.proofTimeoutTarget(for: normalizedPeerID) guard let target else { return } handlePrivateMediaProofTimeout( for: normalizedPeerID, @@ -3775,7 +3326,7 @@ extension BLEService { func _test_privateMediaTransferState( transferId: String ) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) { - let scheduler = collectionsQueue.sync { + let scheduler = onEngine { ( pendingNoiseSessionQueues.containsTypedPayload(transferId: transferId), outboundFragmentTransfers.activeCount, @@ -3808,10 +3359,12 @@ extension BLEService { } func _test_drainPrivateMediaSendPipeline() async { - let collectionsQueue = self.collectionsQueue + // Capture only the (Sendable) queue, not self, so the @Sendable + // dispatch closures carry no non-Sendable state. + let queue = messageQueue await withCheckedContinuation { continuation in - messageQueue.async { - collectionsQueue.async(flags: .barrier) { + queue.async { + queue.async { continuation.resume() } } @@ -3830,10 +3383,10 @@ extension BLEService { } func _test_drainNoiseMessagePipeline() async { - let collectionsQueue = self.collectionsQueue + let queue = messageQueue await withCheckedContinuation { continuation in - messageQueue.async(flags: .barrier) { - collectionsQueue.async(flags: .barrier) { + queue.async { + queue.async { continuation.resume() } } @@ -3844,7 +3397,7 @@ extension BLEService { /// this to prove same-generation reconciliation is idempotent. func _test_reconcileCurrentNoiseSession(for peerID: PeerID) { let normalizedPeerID = peerID.toShort() - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self, let generation = self.noiseService.sessionGeneration( for: normalizedPeerID @@ -3882,627 +3435,11 @@ extension BLEService { } #endif -// MARK: - CBPeripheralDelegate - -extension BLEService: CBPeripheralDelegate { - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - guard !isPanicSuspended else { return } - if let error = error { - SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) - // Retry service discovery after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard peripheral.state == .connected else { return } - peripheral.discoverServices([BLEService.serviceUUID]) - } - return - } - - guard let services = peripheral.services else { - SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session) - return - } - - guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else { - // Not a BitChat peer - disconnect - centralManager?.cancelPeripheralConnection(peripheral) - return - } - - // Discovering BLE characteristics - peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service) - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - guard !isPanicSuspended else { return } - if let error = error { - SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) - return - } - - guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else { - SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session) - return - } - - // Found characteristic - - // Log characteristic properties for debugging - var properties: [String] = [] - if characteristic.properties.contains(.read) { properties.append("read") } - if characteristic.properties.contains(.write) { properties.append("write") } - if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") } - if characteristic.properties.contains(.notify) { properties.append("notify") } - if characteristic.properties.contains(.indicate) { properties.append("indicate") } - // Characteristic properties: \(properties.joined(separator: ", ")) - - // Verify characteristic supports reliable writes - if !characteristic.properties.contains(.write) { - SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session) - } - - // Store characteristic in our consolidated structure - let peripheralID = peripheral.identifier.uuidString - linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID) - - // Subscribe for notifications - if characteristic.properties.contains(.notify) { - peripheral.setNotifyValue(true, for: characteristic) - SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session) - - // Send announce after subscription is confirmed (force send for new connection) - messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in - self?.sendAnnounce(forceSend: true) - // Try flushing any spooled directed packets now that we have a link - self?.flushDirectedSpool() - } - } else { - SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session) - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - guard !isPanicSuspended else { return } - if let error = error { - SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session) - return - } - - guard let data = characteristic.value, !data.isEmpty else { - SecureLogger.warning("⚠️ No data in notification", category: .session) - return - } - - bufferNotificationChunk(data, from: peripheral) - } - - private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) { - let peripheralUUID = peripheral.identifier.uuidString - - var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState( - peripheral: peripheral, - characteristic: nil, - peerID: nil, - isConnecting: false, - isConnected: peripheral.state == .connected, - lastConnectionAttempt: nil, - assembler: NotificationStreamAssembler() - ) - - var assembler = state.assembler - let result = assembler.append(chunk) - state.assembler = assembler - linkStateStore.setPeripheralState(state, for: peripheralUUID) - - for byte in result.droppedPrefixes { - SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session) - } - - if result.reset { - SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session) - } - - // Codex review identified TOCTOU in this patch. - // Enforce per-link sender binding immediately within the same notification batch. - // NOTE: `processNotificationPacket` may bind the stored peer ID when an announce - // is processed, but `state` above is a snapshot. Track a local binding that we update as soon as - // we see a binding-eligible announce so subsequent frames can't spoof a different sender. - var boundPeerID: PeerID? = state.peerID - - for frame in result.frames { - guard let packet = BinaryProtocol.decode(frame) else { - let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") - SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session) - continue - } - - let claimedSenderID = PeerID(hexData: packet.senderID) - let context = acceptedIngressContext( - for: packet, - claimedSenderID: claimedSenderID, - boundPeerID: boundPeerID, - linkDescription: "Peripheral \(peripheralUUID.prefix(8))…" - ) - - guard let context else { continue } - - // If this is a direct-link announce, bind immediately for the remainder of this batch. - if boundPeerID == nil, - packet.type == MessageType.announce.rawValue, - packet.ttl == messageTTL { - boundPeerID = claimedSenderID - state.peerID = claimedSenderID - linkStateStore.bindPeripheral(peripheralUUID, to: claimedSenderID) - } - - if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) { - continue - } - processNotificationPacket( - packet, - from: peripheral, - peripheralUUID: peripheralUUID, - receivedFrom: context.receivedFromPeerID - ) - } - } - - private func processNotificationPacket(_ packet: BitchatPacket, from _: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) { - let senderID = PeerID(hexData: packet.senderID) - - if packet.type != MessageType.announce.rawValue { - SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))…", category: .session) - } - - if packet.type == MessageType.announce.rawValue, - packet.ttl == messageTTL { - // Only bind an unbound link here: this runs before signature - // verification, so a bound link must not be re-bound by a raw - // announce (spoofable). Rotation rebinds happen after the announce - // verifies (rebindLinkAfterVerifiedDirectAnnounce). - let boundPeerID = linkStateStore.peerID(forPeripheralID: peripheralUUID) - if boundPeerID == nil || boundPeerID == senderID { - linkStateStore.bindPeripheral(peripheralUUID, to: senderID) - refreshLocalTopology() - } - } - - handleReceivedPacket(packet, from: peerID) - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session) - // Don't retry - just log the error - } else { - SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) - } - } - - func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { - guard !isPanicSuspended else { return } - // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again - if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") { - SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) - } - drainPendingWrites(for: peripheral) - } - - func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { - guard !isPanicSuspended else { return } - SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) - - let shouldRediscover = BLEService.shouldRediscoverBitChatService( - invalidatedServiceUUIDs: invalidatedServices.map(\.uuid), - cachedServiceUUIDs: peripheral.services?.map(\.uuid) - ) - - guard shouldRediscover else { return } - - let peripheralID = peripheral.identifier.uuidString - linkStateStore.updatePeripheral(peripheralID) { - $0.characteristic = nil - $0.assembler = NotificationStreamAssembler() - } - - SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session) - peripheral.discoverServices([BLEService.serviceUUID]) - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - guard !isPanicSuspended else { return } - if let error = error { - SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session) - } else { - SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session) - - // If notifications are now on, send an announce to ensure this peer knows about us - if characteristic.isNotifying { - // Sending announce after subscription - self.sendAnnounce(forceSend: true) - } - } - } - -} - -// MARK: - CBPeripheralManagerDelegate - -extension BLEService: CBPeripheralManagerDelegate { - func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { - SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session) - - switch peripheral.state { - case .poweredOn: - guard !isPanicSuspended else { - peripheral.stopAdvertising() - peripheral.removeAllServices() - characteristic = nil - return - } - // Remove all services first to ensure clean state - peripheral.removeAllServices() - - // Create characteristic - characteristic = CBMutableCharacteristic( - type: BLEService.characteristicUUID, - properties: [.notify, .write, .writeWithoutResponse, .read], - value: nil, - permissions: [.readable, .writeable] - ) - - // Create service - let service = CBMutableService(type: BLEService.serviceUUID, primary: true) - service.characteristics = [characteristic!] - - // Add service (advertising will start in didAdd delegate) - SecureLogger.debug("🔧 Adding BLE service...", category: .session) - peripheral.add(service) - - case .poweredOff: - // Bluetooth was turned off - clean up peripheral state - SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) - // Clear subscribed centrals (they are now invalid) - let centralSnapshot = linkStateStore.subscribedCentralSnapshot - for central in centralSnapshot.centrals { - let centralID = central.identifier.uuidString - noiseAuthenticatedLinkOwners.removeValue( - forKey: .central(centralID) - ) - noiseReconnectPolicy.endLinkEpoch(.central(centralID)) - } - collectionsQueue.sync(flags: .barrier) { - pendingNotifications.removeAll() - pendingWriteBuffers.removeAll() - } - let centralPeerIDs = linkStateStore.clearCentrals() - subscriptionAnnounceLimiter.removeAll() - characteristic = nil - // Notify UI of disconnections - for peerID in centralPeerIDs { - notifyUI { [weak self] in - self?.notifyPeerDisconnectedDebounced(peerID) - } - } - - case .unauthorized: - // User denied Bluetooth permission - SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) - _ = linkStateStore.clearCentrals() - subscriptionAnnounceLimiter.removeAll() - characteristic = nil - - case .unsupported: - // Device doesn't support BLE peripheral role - SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session) - - case .resetting: - // Bluetooth stack is resetting - SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session) - - case .unknown: - SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session) - - @unknown default: - SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session) - } - } - - #if os(iOS) - func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) { - guard !isPanicSuspended else { - peripheral.stopAdvertising() - peripheral.removeAllServices() - characteristic = nil - return - } - let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] - let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] - - SecureLogger.info( - "♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))", - category: .session - ) - - // Attempt to recover characteristic from restored services - if characteristic == nil { - if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }), - let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic { - characteristic = restoredCharacteristic - } - } - - // Via the sampler for a fresh background budget (see central-restore). - logBluetoothStatus("peripheral-restore") - - if peripheral.state == .poweredOn && !peripheral.isAdvertising { - peripheral.startAdvertising(buildAdvertisementData()) - } - } - #endif - - func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { - guard !isPanicSuspended else { - peripheral.stopAdvertising() - return - } - if let error = error { - SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session) - return - } - - SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session) - - // Start advertising after service is confirmed added - let adData = buildAdvertisementData() - peripheral.startAdvertising(adData) - - SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session) - } - - func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { - guard !isPanicSuspended else { return } - let centralUUID = central.identifier.uuidString - SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session) - linkStateStore.addSubscribedCentral(central) - - // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks - let now = Date() - switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) { - case .allowed: - break - case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce): - SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security) - if suppressAnnounce { - SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security) - return - } - - // Still flush directed packets for legitimate mesh operation - messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in - self?.flushDirectedSpool() - } - return - } - - // Send announce to the newly subscribed central after a small delay - messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in - self?.sendAnnounce(forceSend: true) - // Flush any spooled directed packets now that we have a central subscribed - self?.flushDirectedSpool() - } - } - - func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { - let centralID = central.identifier.uuidString - SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session) - collectionsQueue.sync(flags: .barrier) { - pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } - } - noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) - noiseReconnectPolicy.endLinkEpoch(.central(centralID)) - let removedPeerID = linkStateStore.removeSubscribedCentral(central) - - // Ensure we're still advertising for other devices to find us - if !isPanicSuspended, peripheral.isAdvertising == false { - SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session) - peripheral.startAdvertising(buildAdvertisementData()) - } - - // Find and disconnect the peer associated with this central - if let peerID = removedPeerID { - // The remote side retiring a redundant duplicate connection - // arrives here as an unsubscribe while the peer stays live on - // its other links; only the peer's last link disconnecting - // counts. If every link truly dropped, the surviving-link - // callbacks (didDisconnectPeripheral, or this one again) run - // the bookkeeping. - guard linkStateStore.links(to: peerID).isEmpty else { return } - // Mark peer as not connected; retain for reachability - collectionsQueue.sync(flags: .barrier) { - peerRegistry.markDisconnected(peerID) - } - - refreshLocalTopology() - - // 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.peerRegistry.peerIDs } - - self.notifyPeerDisconnectedDebounced(peerID) - // Publish snapshots so UnifiedPeerService can refresh icons promptly - self.requestPeerDataPublish() - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) - } - } - } - - func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { - guard !isPanicSuspended else { return } - drainPendingNotifications(logPrefix: "✅ Sent") - } - - private func logBackpressureSampled(_ message: @autoclosure () -> String) { - notificationBackpressureLogCount += 1 - if notificationBackpressureLogCount == 1 || - notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) { - SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session) - } - } - - private func drainPendingNotifications(logPrefix: String) { - collectionsQueue.async(flags: .barrier) { [weak self] in - guard let self = self, - let characteristic = self.characteristic, - !self.pendingNotifications.isEmpty else { return } - - let pending = self.pendingNotifications.takeAll() - let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic) - - if sentCount > 0 { - self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)") - } - } - } - - private func sendPendingNotifications(_ pending: [BLEPendingNotification], characteristic: CBMutableCharacteristic) -> Int { - var sentCount = 0 - - for (index, notification) in pending.enumerated() { - let success = peripheralManager?.updateValue( - notification.data, - for: characteristic, - onSubscribedCentrals: notification.targets - ) ?? false - - guard success else { - let remaining = Array(pending.dropFirst(index)) - pendingNotifications.prepend(remaining) - logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items") - break - } - - sentCount += 1 - } - - return sentCount - } - - func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { - // Suppress logs for single write requests to reduce noise - if requests.count > 1 { - SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session) - } - - // IMPORTANT: Respond immediately to prevent timeouts! - // We must respond within a few milliseconds or the central will timeout - for request in requests { - peripheral.respond(to: request, withResult: .success) - } - guard !isPanicSuspended else { return } - - // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets. - // Combine per-central request values by offset before decoding. - // Process directly on our message queue to match transport context - let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString }) - for (centralUUID, group) in grouped { - // Sort by offset ascending - let sorted = group.sorted { $0.offset < $1.offset } - let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0 - let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in - guard let data = request.value, !data.isEmpty else { return nil } - return BLEInboundWriteChunk(offset: request.offset, data: data) - } - - let result = pendingWriteBuffers.append( - chunks: chunks, - for: centralUUID, - capBytes: TransportConfig.blePendingWriteBufferCapBytes - ) - - switch result { - case let .decoded(packet, metadata): - logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) - processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central) - - case let .waiting(metadata): - logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) - logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) - - case let .oversized(metadata): - logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) - SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session) - logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) - } - } - } - - private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) { - guard let packetType = metadata.packetType, - packetType != MessageType.announce.rawValue else { return } - - SecureLogger.debug( - "📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)", - category: .session - ) - } - - private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) { - guard !hasMultiple, let raw = sortedRequests.first?.value else { return } - - let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") - SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session) - } - - private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) { - let claimedSenderID = PeerID(hexData: packet.senderID) - let context = acceptedIngressContext( - for: packet, - claimedSenderID: claimedSenderID, - boundPeerID: linkStateStore.peerID(forCentralUUID: centralUUID), - linkDescription: "Central \(centralUUID.prefix(8))…" - ) - guard let context else { return } - - if packet.type != MessageType.announce.rawValue { - SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID.id.prefix(8))…", category: .session) - } - - linkStateStore.addSubscribedCentral(central) - - if packet.type == MessageType.announce.rawValue, - packet.ttl == messageTTL { - // Same rule as the peripheral path: raw announces only bind - // unbound links; rotation rebinds require a verified announce. - let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID) - if boundPeerID == nil || boundPeerID == claimedSenderID { - linkStateStore.bindCentral(centralUUID, to: claimedSenderID) - refreshLocalTopology() - } - } - - guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else { - return - } - - handleReceivedPacket(packet, from: context.receivedFromPeerID) - } -} // MARK: - Advertising Builders & Alias Rotation extension BLEService { - private func buildAdvertisementData() -> [String: Any] { - let data: [String: Any] = [ - CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID] - ] - // No Local Name for privacy - return data - } - - // No alias rotation or advertising restarts required. + // Advertising payload and alias policy live on BLERadioController. } // MARK: - Private Media Deletion @@ -4516,7 +3453,7 @@ extension BLEService: PrivateMediaDeletionPersisting { completion: @escaping @MainActor (Bool) -> Void ) { let fileStore = incomingFileStore - messageQueue.async(flags: .barrier) { + messageQueue.async { guard let reservation = fileStore .reservePrivateMediaDeletion( messageIDs: messageIDs, @@ -4544,7 +3481,7 @@ extension BLEService: PrivateMediaDeletionPersisting { @MainActor func removeLegacyPrivateMediaPayload(relativePath: String) { let fileStore = incomingFileStore - messageQueue.async(flags: .barrier) { + messageQueue.async { fileStore.removeLegacyIncomingFile(relativePath: relativePath) } } @@ -4614,7 +3551,7 @@ extension BLEService { } } - private func emitTransportEvent( + func emitTransportEvent( _ event: TransportEvent, shouldDeliver: (() -> Bool)? = nil, completion: (() -> Void)? = nil, @@ -4699,7 +3636,7 @@ extension BLEService { } } - private func logBluetoothStatus(_ context: String) { + func logBluetoothStatus(_ context: String) { scheduleBluetoothStatusSample(after: 0, context: context) } @@ -4739,11 +3676,12 @@ extension BLEService { let peripheralState = peripheralManager?.state ?? .unknown let isAdvertising = peripheralManager?.isAdvertising ?? false - let peerSummary = collectionsQueue.sync { + let candidateCount = radio.candidateCount + let peerSummary = peerRegistry.read { ( - connected: peerRegistry.connectedCount, - known: peerRegistry.count, - candidates: connectionScheduler.candidateCount + connected: $0.connectedCount, + known: $0.count, + candidates: candidateCount ) } @@ -4779,10 +3717,7 @@ extension BLEService { } private func refreshLocalTopology() { - let neighbors: [Data] = collectionsQueue.sync { - peerRegistry.connectedRoutingData - } - meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors) + meshTopology.updateNeighbors(for: myPeerIDData, neighbors: peerRegistry.connectedRoutingData) } private func computeRoute(to peerID: PeerID) -> [Data]? { @@ -4804,7 +3739,7 @@ extension BLEService { localPeerIDData: myPeerIDData, isRecipientConnected: { self.isPeerConnected($0) }, shouldAttemptRoute: { peer in - self.collectionsQueue.sync(flags: .barrier) { + onEngine { self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now) } }, @@ -4828,7 +3763,7 @@ extension BLEService { SecureLogger.error("❌ Failed to re-sign packet with route", category: .security) return packet // Return original packet if signing fails } - collectionsQueue.sync(flags: .barrier) { + onEngine { sourceRouteFailures.noteRoutedSend(to: recipient, now: now) } return signedPacket @@ -4876,8 +3811,8 @@ extension BLEService { ) let timeout = DispatchWorkItem { [weak self] in guard let self else { return } - let expired = self.collectionsQueue.sync(flags: .barrier) { - self.pendingMeshPings.removeValue(forKey: nonce) + let expired = onEngine { + self.meshPings.expire(nonce: nonce) } guard let expired else { return } self.notifyUI { [weak self] in @@ -4890,17 +3825,20 @@ extension BLEService { expired.completion(nil) } } - self.collectionsQueue.sync(flags: .barrier) { - self.pendingMeshPings[nonce] = PendingMeshPing( - peerID: PeerID(hexData: recipientData), - sentAt: Date(), - lifecycleGeneration: generation, - completion: completion, - timeout: timeout + onEngine { + self.meshPings.register( + BLEMeshPingProbe( + peerID: PeerID(hexData: recipientData), + sentAt: Date(), + lifecycleGeneration: generation, + completion: completion, + timeout: timeout + ), + nonce: nonce ) } - self.messageQueue.asyncAfter( - deadline: .now() + TransportConfig.meshPingTimeoutSeconds, + self.engineScheduler.schedule( + after: TransportConfig.meshPingTimeoutSeconds, execute: timeout ) self.broadcastPacket(packet) @@ -4922,8 +3860,8 @@ extension BLEService { SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session) return } - let allowed = collectionsQueue.sync(flags: .barrier) { - meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date()) + let allowed = onEngine { + meshPings.shouldRespond(toLink: linkPeerID, now: Date()) } guard allowed else { if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") { @@ -4950,9 +3888,8 @@ extension BLEService { private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) { guard packet.recipientID == myPeerIDData else { return } guard let pong = MeshPingPayload.decode(packet.payload) else { return } - let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in - guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil } - return pendingMeshPings.removeValue(forKey: pong.nonce) + let pending = onEngine { + meshPings.resolve(nonce: pong.nonce, from: peerID) } guard let pending else { return } pending.timeout.cancel() @@ -5026,41 +3963,32 @@ extension BLEService { return plan.shouldSuppressFloodRelay } - /// Safely fetch the current direct-link state for a peer using the BLE queue. + /// The current direct-link state for a peer. Engine-confined (bindings + /// joined against physical liveness inside directLinkState). private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) { - let state = readLinkState { $0.directLinkState(for: peerID) } + let state = directLinkState(for: peerID) return (state.hasPeripheral, state.hasCentral) } private func links(to peerID: PeerID?) -> Set { - readLinkState { $0.links(to: peerID) } + linkBindings.links(to: peerID) } - private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? { - switch link { - case .peripheral(let peripheralUUID): - store.peerID(forPeripheralID: peripheralUUID) - case .central(let centralUUID): - store.peerID(forCentralUUID: centralUUID) - } - } + /// Marks the exact physical ingress link that completed a fresh Noise /// handshake. An old session keyed only by peer ID is insufficient: a /// replayed announce can rebind an attacker's link to that ID. + /// Engine-confined. private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) { - guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return } - readLinkState { store in - guard boundPeerID(for: link, in: store) == peerID else { return } - noiseAuthenticatedLinkOwners[link] = peerID - } + guard let link = ingressLinks.link(for: packet) else { return } + guard linkBindings.boundPeer(for: link) == peerID else { return } + linkAuth.markAuthenticated(link, owner: peerID) } private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool { - guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false } - return readLinkState { store in - noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID - } + guard let link = ingressLinks.link(for: packet) else { return false } + return linkAuth.isAuthenticated(link, for: peerID) && linkBindings.boundPeer(for: link) == peerID } private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool { @@ -5068,37 +3996,36 @@ extension BLEService { } private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set { - readLinkState { store in - Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in - owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil - }) - } + Set(linkAuth.links(ownedBy: peerID).filter { link in + linkBindings.boundPeer(for: link) == peerID + }) } /// A peer-level session can outlive the physical link that established it. /// Revalidate a fresh direct link with an ordinary XX exchange, retiring /// cached sending keys atomically before message 1 can leave. + /// + /// Takes the already-resolved ingress link. Engine-confined: it runs + /// inside the rebind's engine slot, so no observer can see the new + /// binding while a cached peer-level sender is still considered + /// established. private func refreshNoiseSessionForVerifiedDirectLink( - _ packet: BitchatPacket, + link: BLEIngressLinkID, peerID: PeerID ) { - guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { - return - } - let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID) - let shouldRevalidate = readLinkState { store in - guard boundPeerID(for: link, in: store) == peerID else { - return false - } - return noiseReconnectPolicy.shouldRevalidate( + let shouldRevalidate: Bool + if linkBindings.boundPeer(for: link) == peerID { + shouldRevalidate = linkAuth.shouldRevalidate( on: link, + for: peerID, hasEstablishedSession: hasEstablishedSession, - isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID, hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty, now: Date() ) + } else { + shouldRevalidate = false } guard shouldRevalidate else { return } @@ -5115,7 +4042,7 @@ extension BLEService { // Authentication can be reported while an initiator is still // returning XX message 3. Serialize generation-bound state and // every post-handshake drain behind the handshake packet handler. - self?.messageQueue.async(flags: .barrier) { [weak self] in + self?.messageQueue.async { [weak self] in self?.handleNoisePeerAuthenticated( peerID: peerID, fingerprint: fingerprint, @@ -5125,7 +4052,7 @@ extension BLEService { } service.onRekeyHandshakeReady = { [weak self, weak service] peerID, initiation in - self?.messageQueue.async(flags: .barrier) { + self?.messageQueue.async { [weak self, weak service] in guard let self, let service, @@ -5148,7 +4075,7 @@ extension BLEService { #if DEBUG self._test_beforeHandshakeRecoveryEnqueued?(request.peerID) #endif - self.messageQueue.async(flags: .barrier) { + self.messageQueue.async { [weak self, weak service] in guard let self, let service, @@ -5195,7 +4122,7 @@ extension BLEService { guard let self, let service else { return } // The manager makes restored keys visible atomically. Reconcile // transport state and queued sends as the next serialized phase. - self.messageQueue.async(flags: .barrier) { [weak self, weak service] in + self.messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service, @@ -5231,47 +4158,30 @@ extension BLEService { sessionGeneration generation: UUID, deferOutboundUntilConvergence: Bool = false ) { + // Engine-only: the store transition below runs inside the noise + // manager's critical section while this engine slot stays blocked. + // The store is a leaf lock, so that nesting is safe — but nothing in + // that closure may sync-re-enter the engine (self-deadlock). + #if DEBUG + dispatchPrecondition(condition: .onQueue(messageQueue)) + #endif let normalizedPeerID = peerID.toShort() - guard let transition = noiseService.withCurrentSessionGeneration( + // The generation lease serializes this transition against session + // replacement; nil-inside-nil distinguishes a lost lease (outer) + // from the same-generation reconciliation path (inner). + guard let leased = noiseService.withCurrentSessionGeneration( for: normalizedPeerID, expected: generation, { - collectionsQueue.sync(flags: .barrier) { - () -> ( - watchdog: (fingerprint: String, nonce: UUID)?, - rejected: [@MainActor (PrivateMediaSendPolicy) -> Void] - ) in - guard privateMediaSessionGenerations[normalizedPeerID] != generation else { - return (nil, []) - } - let watchdogNonce = UUID() - privateMediaSessionGenerations[normalizedPeerID] = generation - authenticatedPeerStates.removeValue(forKey: normalizedPeerID) - privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) - privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog( - fingerprint: fingerprint, - sessionGeneration: generation, - timeoutNonce: watchdogNonce - ) - authenticatedPeerStateSendProgress[normalizedPeerID] = - BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) - - guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { - return ((fingerprint, watchdogNonce), []) - } - guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { - pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID) - return ((fingerprint, watchdogNonce), Array(pending.completions.values)) - } - pending.sessionGeneration = generation - pending.timeoutNonce = watchdogNonce - pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending - return ((pending.fingerprint, watchdogNonce), []) - } + privateMediaSessions.beginAuthenticatedGeneration( + for: normalizedPeerID, + fingerprint: fingerprint, + generation: generation + ) } ) else { return } - guard let watchdog = transition.watchdog else { + guard let fresh = leased else { // A quarantined transport restored the same cryptographic // generation. Its capability proof and announce state never // became stale; only work queued while outbound keys were paused @@ -5289,20 +4199,23 @@ extension BLEService { // The restore's mandatory convergence retry — or any later // handshake the reconnect policy initiates — re-enters this // transition with a fresh generation and drains them under - // keys both sides hold. + // keys both sides hold. The flag also holds the proof + // watchdog's drain to the same rule. + privateMediaSessions.setOutboundDeferredUntilConvergence(normalizedPeerID) return } + privateMediaSessions.clearOutboundDeferredUntilConvergence(normalizedPeerID) sendPendingMessagesAfterHandshake(for: normalizedPeerID) sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) return } - completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) + completePrivateMediaPolicyResolution(fresh.rejected, with: .blockedDowngrade) schedulePrivateMediaProofTimeout( for: normalizedPeerID, - fingerprint: watchdog.fingerprint, + fingerprint: fingerprint, sessionGeneration: generation, - nonce: watchdog.nonce + nonce: fresh.watchdogNonce ) // Cross-link delivery can put ciphertext sent immediately after // message 3 ahead of message 3 itself. Retry the bounded queue only @@ -5318,11 +4231,15 @@ extension BLEService { // mandatory convergence retry — or any later handshake the // reconnect policy initiates — re-enters this transition with a // fresh generation and drains them under keys both sides hold. + // The flag also holds the proof watchdog's drain to the same + // rule — its timeout can fire while this restore is current. + privateMediaSessions.setOutboundDeferredUntilConvergence(normalizedPeerID) #if DEBUG _test_onPrivateMediaSessionReconciled?(normalizedPeerID) #endif return } + privateMediaSessions.clearOutboundDeferredUntilConvergence(normalizedPeerID) // `onPeerAuthenticated` can fire while the initiator is returning XX // message 3. This callback is queued behind the handshake handler, so @@ -5339,25 +4256,9 @@ extension BLEService { private func sendAuthenticatedPeerState(to peerID: PeerID, echo: Bool) { let normalizedPeerID = peerID.toShort() - let shouldSend = collectionsQueue.sync(flags: .barrier) { - guard let generation = privateMediaSessionGenerations[normalizedPeerID], - var progress = authenticatedPeerStateSendProgress[normalizedPeerID], - progress.sessionGeneration == generation else { return false } - if echo { - guard !progress.sentEcho else { return false } - progress.sentEcho = true - } else { - guard !progress.sentInitial else { return false } - progress.sentInitial = true - } - authenticatedPeerStateSendProgress[normalizedPeerID] = progress - return true - } - guard shouldSend else { return } + guard privateMediaSessions.markPeerStateSend(for: normalizedPeerID, echo: echo) else { return } - let capabilities = collectionsQueue.sync { - PeerCapabilities.localSupported.union(runtimeCapabilities) - } + let capabilities = localIdentityState.snapshot().advertisedCapabilities let state = AuthenticatedPeerStatePacket( capabilities: capabilities, signingPublicKey: noiseService.getSigningPublicKeyData() @@ -5374,6 +4275,13 @@ extension BLEService { from peerID: PeerID, sessionGeneration generation: UUID ) { + // Engine-only, like handleNoisePeerAuthenticated: the closure below + // runs on the noise manager's queue while this engine slot stays + // blocked, so it accesses engine-owned state directly instead of + // sync-re-entering the engine (self-deadlock). + #if DEBUG + dispatchPrecondition(condition: .onQueue(messageQueue)) + #endif let normalizedPeerID = peerID.toShort() guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else { SecureLogger.warning( @@ -5396,14 +4304,13 @@ extension BLEService { expected: generation, { () -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in - guard collectionsQueue.sync(execute: { - privateMediaSessionGenerations[normalizedPeerID] == generation - }) else { + guard privateMediaSessions.currentGeneration(for: normalizedPeerID) == generation else { return (false, []) } - // The generation lease prevents rekey/session promotion from - // interleaving between validation and these durable mutations. + // The generation lease (plus the engine slot this section + // holds) prevents rekey/session promotion from interleaving + // between validation and these durable mutations. identityManager.bindAuthenticatedSigningPublicKey( state.signingPublicKey, fingerprint: fingerprint @@ -5418,29 +4325,19 @@ extension BLEService { identityManager.markPrivateMediaCapable(fingerprint: fingerprint) } - let completions = collectionsQueue.sync(flags: .barrier) { - () -> [@MainActor (PrivateMediaSendPolicy) -> Void] in - guard privateMediaSessionGenerations[normalizedPeerID] == generation else { - return [] - } - peerRegistry.bindAuthenticatedSigningPublicKey( + peerRegistry.mutate { + $0.bindAuthenticatedSigningPublicKey( state.signingPublicKey, for: normalizedPeerID ) - authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation( - fingerprint: fingerprint, - sessionGeneration: generation, - capabilities: state.capabilities - ) - privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) - privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) - guard let pending = pendingPrivateMediaPolicyResolutions.removeValue( - forKey: normalizedPeerID - ), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - pending.sessionGeneration == generation else { - return [] - } - return Array(pending.completions.values) + } + guard let completions = privateMediaSessions.applyAuthenticatedPeerState( + for: normalizedPeerID, + fingerprint: fingerprint, + generation: generation, + capabilities: state.capabilities + ) else { + return (false, []) } return (true, completions) } @@ -5456,22 +4353,7 @@ extension BLEService { private func noteNoiseSessionCleared(for peerID: PeerID) { let normalizedPeerID = peerID.toShort() - let reset = collectionsQueue.sync(flags: .barrier) { - () -> (fingerprint: String, nonce: UUID)? in - privateMediaSessionGenerations.removeValue(forKey: normalizedPeerID) - authenticatedPeerStates.removeValue(forKey: normalizedPeerID) - privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) - privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) - authenticatedPeerStateSendProgress.removeValue(forKey: normalizedPeerID) - guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { - return nil - } - let nonce = UUID() - pending.sessionGeneration = nil - pending.timeoutNonce = nonce - pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending - return (pending.fingerprint, nonce) - } + let reset = privateMediaSessions.clearSession(for: normalizedPeerID) if let reset { schedulePrivateMediaProofTimeout( for: normalizedPeerID, @@ -5495,17 +4377,12 @@ extension BLEService { /// `messageQueue`; the re-entrancy check keeps any future on-queue caller /// from deadlocking. private func refreshPeerIdentity() { - let swap = { - let fingerprint = self.noiseService.getIdentityFingerprint() - self.localIdentityState.replacePeerIdentity( + onEngine { + let fingerprint = noiseService.getIdentityFingerprint() + localIdentityState.replacePeerIdentity( with: PeerID(str: fingerprint.prefix(16)) ) - self.meshTopology.reset() - } - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - swap() - } else { - messageQueue.sync(flags: .barrier, execute: swap) + meshTopology.reset() } } @@ -5525,7 +4402,7 @@ extension BLEService { // No established session yet - queue the payload synchronously // before initiating a handshake // to prevent race where fast handshake completion drains empty queue - collectionsQueue.sync(flags: .barrier) { + onEngine { self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID) SecureLogger.debug("📥 Queued noise payload for \(peerID.id.prefix(8))… pending handshake", category: .session) } @@ -5547,21 +4424,10 @@ extension BLEService { let encrypted: Data let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) if isPrivateFile { - let provenGeneration: UUID? = collectionsQueue.sync { - () -> UUID? in - guard let generation = privateMediaSessionGenerations[peerID], - let authenticated = authenticatedPeerStates[peerID], - authenticated.sessionGeneration == generation, - authenticated.capabilities.contains(.privateMedia) else { return nil } - if requiresAuthenticatedPrivateMediaReceipts { - guard authenticated.capabilities.contains( - .privateMediaReceipts - ) else { - return nil - } - } - return generation - } + let provenGeneration = privateMediaSessions.provenGeneration( + for: peerID, + requireReceipts: requiresAuthenticatedPrivateMediaReceipts + ) guard let provenGeneration else { throw NoiseEncryptionError.sessionNotEstablished } @@ -5701,21 +4567,19 @@ extension BLEService { /// replay-rebound link, or process-local spool is not delivery. @discardableResult func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool { - guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false } guard let payload = envelope.encode() else { return false } let packet = makeCourierPacket(payload, to: peerID) - let send = { [weak self] in - self?.sendPacketDirected( + return onEngine { + // Engine slot: the auth-link check and the directed send see one + // consistent view of the identity domain. + guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false } + return sendPacketDirected( packet, to: peerID, requireDirectPeerLink: true, requireNoiseAuthenticatedPeerLink: true - ) ?? false + ) } - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - return send() - } - return messageQueue.sync(execute: send) } /// Our own Noise static public key (for computing our courier tags). @@ -5727,7 +4591,7 @@ extension BLEService { /// gateway watches courier drops for. func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] { let now = Date() - return collectionsQueue.sync { + return onEngine { peerRegistry.snapshotByID.values.compactMap { info in guard info.isVerifiedNickname, let key = info.noisePublicKey, @@ -5746,7 +4610,7 @@ extension BLEService { /// message consumes exactly one prekey ID regardless of courier count. private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { let shortID = PeerID(publicKey: recipientNoiseKey) - let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + let knownOnMesh = peerRegistry.info(for: shortID) != nil if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) { return nil } @@ -5824,7 +4688,7 @@ extension BLEService { // so dedup here on the inner message ID — before delivery, ack, // and handshake work. A duplicate costs only the decrypt above // and at most one ack ever goes out per message ID. - let firstOpen = collectionsQueue.sync(flags: .barrier) { + let firstOpen = onEngine { openedCourierMessageIDs.insert(innerMessageID) } guard firstOpen else { @@ -5844,7 +4708,7 @@ extension BLEService { // favorite conversation instead of an unresolvable short-ID // thread labeled "Unknown". let shortID = PeerID(publicKey: senderStaticKey) - let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + let isKnownOnMesh = peerRegistry.info(for: shortID) != nil let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey) SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session) sfMetrics?.record(.courierOpened) @@ -5874,7 +4738,7 @@ extension BLEService { SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security) return } - let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) } + let depositorInfo = peerRegistry.info(for: peerID) guard let depositorKey = depositorInfo?.noisePublicKey else { SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) return @@ -5984,7 +4848,7 @@ extension BLEService { /// Forced sends (bundle changed after consumption) go immediately. private func sendPrekeyBundle(force: Bool = false) { let now = Date() - let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) { + let shouldSend: Bool = onEngine { if !force, let last = lastPrekeyBundleSentAt, now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds { @@ -6052,7 +4916,7 @@ extension BLEService { // ahead of the announce that binds the key. Reading the live registry // and stashing atomically closes the check-then-act gap against // handleAnnounce's drain (see drainPendingPrekeyBundles). - let signingKey: Data? = collectionsQueue.sync(flags: .barrier) { + let signingKey: Data? = onEngine { if let info = peerRegistry.info(for: owner), info.noisePublicKey == bundle.noiseStaticPublicKey, let key = info.signingPublicKey { @@ -6097,7 +4961,7 @@ extension BLEService { /// announce, in a barrier ordered after the registry write, so a bundle /// stashed before the write is always observed here. private func drainPendingPrekeyBundles(for owner: PeerID) { - let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) { + let pending: BitchatPacket? = onEngine { pendingPrekeyBundles.removeValue(forKey: owner) } guard let packet = pending, @@ -6111,7 +4975,7 @@ extension BLEService { /// from identities persisted for offline verification. private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? { let shortID = PeerID(publicKey: noiseKey) - if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }), + if let info = peerRegistry.info(for: shortID), info.noisePublicKey == noiseKey, let signingKey = info.signingPublicKey { return signingKey @@ -6184,7 +5048,7 @@ extension BLEService { // packet signature must verify against the sender's announced // signing key. Unlike courier deposits the depositor may be // multi-hop away, so ingress-link identity is not required. - let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey } + let signingKey = peerRegistry.info(for: senderID)?.signingPublicKey guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security) @@ -6200,7 +5064,10 @@ extension BLEService { } } - // MARK: Link capability snapshots (thread-safe via bleQueue) + // MARK: Link capability snapshots + // Physical link state is bleQueue-owned; the engine (and main) may + // sync-read it here. The bindings half of a combined view comes from + // the engine-owned identity domain directly. private func readLinkState(_ body: (BLELinkStateStore) -> T) -> T { if DispatchQueue.getSpecific(key: bleQueueKey) != nil { @@ -6211,7 +5078,7 @@ extension BLEService { } private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - readLinkState { $0.directPeripheralState(for: peerID) } + directPeripheralState(for: peerID) } private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] { @@ -6219,7 +5086,7 @@ extension BLEService { } private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot { - readLinkState(\.subscribedCentralSnapshot) + subscribedCentralSnapshot() } // MARK: Helpers: IDs, selection, and write backpressure @@ -6232,22 +5099,20 @@ extension BLEService { if peripheral.canSendWriteWithoutResponse { peripheral.writeValue(data, for: characteristic, type: .withoutResponse) } else { - self.collectionsQueue.async(flags: .barrier) { - let result = self.pendingPeripheralWrites.enqueue( - data: data, - for: uuid, - priority: priority, - capBytes: TransportConfig.blePendingWriteBufferCapBytes - ) + let result = self.pendingPeripheralWrites.enqueue( + data: data, + for: uuid, + priority: priority, + capBytes: TransportConfig.blePendingWriteBufferCapBytes + ) - switch result { - case .oversized(let bytes): - SecureLogger.warning("⚠️ Dropping oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) - case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0: - SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session) - case .enqueued: - break - } + switch result { + case .oversized(let bytes): + SecureLogger.warning("⚠️ Dropping oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) + case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0: + SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session) + case .enqueued: + break } } } @@ -6257,6 +5122,11 @@ extension BLEService { /// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return /// value distinguishes a retained queue item from one rejected or trimmed /// immediately, which lets durable courier state commit truthfully. + /// + /// The authenticated-link eligibility check runs on the engine (which + /// owns bindings and rebinds, so it is serialized against identity + /// changes by construction); only the physical admission hops to + /// `bleQueue`. private func writeOrEnqueueIfAccepted( _ data: Data, to peripheral: CBPeripheral, @@ -6264,34 +5134,32 @@ extension BLEService { priority: BLEOutboundWritePriority, requiredAuthenticatedPeer: PeerID? ) -> Bool { + let uuid = peripheral.identifier.uuidString + if let peerID = requiredAuthenticatedPeer { + let link = BLEIngressLinkID.peripheral(uuid) + guard linkBindings.peer(forPeripheralID: uuid) == peerID, + linkAuth.isAuthenticated(link, for: peerID) else { + return false + } + } let accept = { [self] in - let uuid = peripheral.identifier.uuidString guard let state = linkStateStore.state(forPeripheralID: uuid), state.isConnected, state.characteristic?.uuid == characteristic.uuid else { return false } - if let peerID = requiredAuthenticatedPeer { - let link = BLEIngressLinkID.peripheral(uuid) - guard state.peerID == peerID, - noiseAuthenticatedLinkOwners[link] == peerID else { - return false - } - } if peripheral.canSendWriteWithoutResponse { peripheral.writeValue(data, for: characteristic, type: .withoutResponse) return true } - let attempt = collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.enqueueReportingAcceptance( - data: data, - for: uuid, - priority: priority, - capBytes: TransportConfig.blePendingWriteBufferCapBytes - ) - } + let attempt = pendingPeripheralWrites.enqueueReportingAcceptance( + data: data, + for: uuid, + priority: priority, + capBytes: TransportConfig.blePendingWriteBufferCapBytes + ) switch attempt.result { case .oversized(let bytes): SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session) @@ -6309,18 +5177,14 @@ extension BLEService { return bleQueue.sync(execute: accept) } - private func drainPendingWrites(for peripheral: CBPeripheral) { + func drainPendingWrites(for peripheral: CBPeripheral) { let uuid = peripheral.identifier.uuidString bleQueue.async { [weak self] in guard let self = self else { return } guard !self.isPanicSuspended else { return } guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return } - // Atomically take all pending items from the queue to avoid race conditions - // where new items could be enqueued between read and update - let itemsToSend: [BLEPendingWrite] = self.collectionsQueue.sync(flags: .barrier) { - self.pendingPeripheralWrites.takeAll(for: uuid) - } + let itemsToSend = self.pendingPeripheralWrites.takeAll(for: uuid) guard !itemsToSend.isEmpty else { return } // Send as many as possible @@ -6337,9 +5201,7 @@ extension BLEService { // Re-enqueue any items that couldn't be sent (maintaining order) let unsent = Array(itemsToSend.dropFirst(sent)) if !unsent.isEmpty { - self.collectionsQueue.async(flags: .barrier) { - self.pendingPeripheralWrites.prepend(unsent, for: uuid) - } + self.pendingPeripheralWrites.prepend(unsent, for: uuid) } } } @@ -6351,7 +5213,7 @@ extension BLEService { /// Periodically try to drain pending writes for all connected peripherals private func drainAllPendingWrites() { - let uuids = collectionsQueue.sync { pendingPeripheralWrites.peripheralIDs } + let uuids = pendingPeripheralWrites.peripheralIDs for uuid in uuids { guard let state = linkStateStore.state(forPeripheralID: uuid), state.isConnected else { continue } drainPendingWrites(for: state.peripheral) @@ -6367,9 +5229,9 @@ extension BLEService { // Restart scanning with allow duplicates when app becomes active if centralManager?.state == .poweredOn { centralManager?.stopScan() - startScanning() + radio.startScanning() } - cancelStalePendingConnects() + radio.cancelStalePendingConnects() logBluetoothStatus("became-active") scheduleBluetoothStatusSample(after: 5.0, context: "active-5s") // No Local Name; nothing to refresh for advertising policy @@ -6381,9 +5243,9 @@ extension BLEService { // Restart scanning without allow duplicates in background if centralManager?.state == .poweredOn { centralManager?.stopScan() - startScanning() + radio.startScanning() } - armPendingBackgroundConnects() + radio.armPendingBackgroundConnects() // Backgrounding may precede a kill; flush the public-history archive // outside its 30s maintenance cadence. gossipSyncManager?.persistNow() @@ -6391,89 +5253,6 @@ extension BLEService { scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy } - - /// Issue indefinite `connect()` requests to recently seen peripherals on - /// backgrounding. Pending connects live in the Bluetooth controller's - /// allowlist — no scanning and no app CPU — and complete whenever a peer - /// comes into range, waking (or relaunching) the app. A couple of central - /// slots stay reserved for connects driven by live background discovery — - /// except on the disconnect re-arm path, which may consume the slot the - /// disconnect itself just freed (a dense mesh with 4+ remaining links - /// would otherwise compute a zero budget and never re-arm the lost peer). - private func armPendingBackgroundConnects( - slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve - ) { - bleQueue.async { [weak self] in - guard let self, - !self.isPanicSuspended, - let central = self.centralManager, - central.state == .poweredOn else { return } - let budget = TransportConfig.bleMaxCentralLinks - - slotReserve - - self.linkStateStore.connectedOrConnectingPeripheralCount - let now = Date() - let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in - let state = self.linkStateStore.state(forPeripheralID: peripheralID) - return state?.isConnected == true || state?.isConnecting == true - } - guard !targets.isEmpty else { return } - for target in targets { - // lastConnectionAttempt stays nil: an indefinite pending connect - // has no attempt clock, and nil marks it always-stale so - // cancelStalePendingConnects() reclaims it on foreground even - // after a quick background→foreground bounce. - self.linkStateStore.setPeripheralState( - BLEPeripheralLinkState( - peripheral: target.peripheral, - characteristic: nil, - peerID: nil, - isConnecting: true, - isConnected: false, - lastConnectionAttempt: nil, - assembler: NotificationStreamAssembler() - ), - for: target.peripheralID - ) - target.peripheral.delegate = self - central.connect(target.peripheral, options: [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ]) - } - SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session) - } - } - - /// Foreground restores normal connection management: pending connects - /// older than the connect timeout (including ones rebuilt by state - /// restoration after a relaunch) are cancelled so live scanning and the - /// scheduler take over. Anything still nearby is rediscovered within - /// seconds by the allow-duplicates foreground scan. - private func cancelStalePendingConnects() { - bleQueue.async { [weak self] in - guard let self, let central = self.centralManager else { return } - let now = Date() - var cancelled = 0 - for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected { - let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity - guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } - let peripheralID = state.peripheral.identifier.uuidString - central.cancelPeripheralConnection(state.peripheral) - self.collectionsQueue.sync(flags: .barrier) { - self.pendingPeripheralWrites.discardAll(for: peripheralID) - } - self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = self.linkStateStore.removePeripheral(peripheralID) - cancelled += 1 - } - if cancelled > 0 { - SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session) - self.tryConnectFromQueue() - } - } - } #endif // MARK: Private Message Handling @@ -6516,7 +5295,7 @@ extension BLEService { SecureLogger.debug("🤝 No session with \(recipientID.id.prefix(8))…, initiating handshake and queueing message", category: .session) // Queue the message (especially important for favorite notifications) - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendPrivateMessage(content: content, messageID: messageID, for: recipientID) } @@ -6538,7 +5317,7 @@ extension BLEService { ) else { return } - messageQueue.async(flags: .barrier) { + messageQueue.async { [weak self, weak service] in guard let self, let service, @@ -6580,7 +5359,7 @@ extension BLEService { with: peerID, retryOnTimeout: true ) - messageQueue.async(flags: .barrier) { [weak self, weak service] in + messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service else { @@ -6607,7 +5386,7 @@ extension BLEService { private func sendPendingMessagesAfterHandshake(for peerID: PeerID) { // Atomically take all pending messages to process (prevents concurrent modification) - let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingPrivateMessage] in + let pendingMessages = onEngine { () -> [BLEPendingPrivateMessage] in pendingNoiseSessionQueues.takePrivateMessages(for: peerID) } @@ -6650,7 +5429,7 @@ extension BLEService { // Re-queue any failed messages for retry on next handshake if !failedMessages.isEmpty { - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } // Prepend failed messages to maintain order self.pendingNoiseSessionQueues.prependPrivateMessages(failedMessages, for: peerID) @@ -6682,12 +5461,12 @@ extension BLEService { requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink ) - let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) { + let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = onEngine { if requiresPrivateMediaAdmission { guard let transferId else { return nil } - // This lock is taken while the scheduler is already protected - // by collectionsQueue. Cancellation takes the admission lock - // synchronously but never waits on collectionsQueue, avoiding + // This lock is taken while the scheduler is already + // engine-confined. Cancellation takes the admission lock + // synchronously but never waits on the engine, avoiding // lock inversion while giving submit/cancel one linear order. return privateMediaTransferAdmissions.withActive(transferId) { outboundFragmentTransfers.submit( @@ -6752,7 +5531,7 @@ extension BLEService { let releaseReservedSlot: (String) -> Void = { [weak self] id in guard let self = self else { return } TransferProgressManager.shared.cancel(id: id) - self.collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in _ = self?.outboundFragmentTransfers.releaseReservation(id) } self.messageQueue.async { [weak self] in @@ -6780,14 +5559,14 @@ extension BLEService { let totalFragments = plan.totalFragments let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in - self?.startScanning() + self?.radio.startScanning() } } } let transferIdentifier: String? if let id = reservedTransferId { - let activated = collectionsQueue.sync(flags: .barrier) { + let activated = onEngine { self.outboundFragmentTransfers.activateReservedTransfer( id: id, totalFragments: plan.totalFragments, @@ -6846,7 +5625,7 @@ extension BLEService { let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } if let transferId = transferIdentifier { - let isActive = self.collectionsQueue.sync { self.outboundFragmentTransfers.isActive(transferId) } + let isActive = onEngine { self.outboundFragmentTransfers.isActive(transferId) } guard isActive else { return } } if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true { @@ -6863,14 +5642,14 @@ extension BLEService { if let transferId = transferIdentifier { let workItems = scheduledItems.map { $0.item } - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in _ = self?.outboundFragmentTransfers.updateWorkItems(workItems, for: transferId) } } for (workItem, index) in scheduledItems { let delayMs = index * plan.spacingMs - messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem) + engineScheduler.schedule(after: Double(delayMs) / 1_000, execute: workItem) } return true } @@ -6878,7 +5657,7 @@ extension BLEService { // MARK: - Fragmentation (Required for messages > BLE MTU) private func markFragmentSent(transferId: String) { - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } switch self.outboundFragmentTransfers.markFragmentSent(transferId: transferId) { @@ -6898,7 +5677,7 @@ extension BLEService { } private func startNextPendingTransferIfNeeded() { - let results = collectionsQueue.sync(flags: .barrier) { + let results = onEngine { outboundFragmentTransfers.reservePendingStarts(maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers) } @@ -6913,7 +5692,7 @@ extension BLEService { if DispatchQueue.getSpecific(key: messageQueueKey) != nil { fragmentHandler.handle(packet, from: peerID) } else { - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.fragmentHandler.handle(packet, from: peerID) } } @@ -6933,7 +5712,7 @@ extension BLEService { guard let self = self else { return .stored(header: header, started: false) } - return self.collectionsQueue.sync(flags: .barrier) { + return onEngine { self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies) } }, @@ -6946,8 +5725,187 @@ extension BLEService { ) } + // MARK: Link-event port (bleQueue → engine) + + /// The single upward entry of the link-layer port: the bleQueue side + /// (CoreBluetooth delegates, radio policy) and the simulated mesh + /// report everything through here. Frames capture the panic lifecycle + /// at the handoff; lifecycle events ride plain engine slots (the + /// panic path clears their state wholesale either way). + func emitLinkEvent(_ event: BLELinkEvent) { + if case let .frameDecoded(packet, link, linkDescription) = event { + ingestDecodedPacket(packet, link: link, linkDescription: linkDescription) + return + } + messageQueue.async { [weak self] in + self?.handleLinkEvent(event) + } + } + + /// Engine-confined consumer of the link-layer port: identity + /// retirement, survivor repair, and peer-disconnect bookkeeping for + /// every physical lifecycle transition the link layer reports. + private func handleLinkEvent(_ event: BLELinkEvent) { + switch event { + case .frameDecoded: + // Routed through ingestDecodedPacket by emitLinkEvent; frames + // never reach the lifecycle switch. + assertionFailure("frameDecoded must enter via emitLinkEvent") + + case let .peripheralLinkEnded(peripheralID, runPeerBookkeeping): + let peerID = retirePeripheralLinkIdentity(peripheralID) + guard runPeerBookkeeping else { return } + if let peerID { + SecureLogger.debug("📱 Disconnected link was bound to \(peerID.id.prefix(8))…", category: .session) + } + // A duplicate link can drop while the peer stays live on + // another (the dual-role central link, or a second bound link + // after a restore): peer-disconnect bookkeeping only runs once + // the peer's last live link is gone. The retirement just + // repaired the reverse map onto a connected survivor, so + // directLinkState is accurate here. + let remainingLinks = peerID.map { directLinkState(for: $0) } + let peerStillLinked = (remainingLinks?.hasPeripheral ?? false) || (remainingLinks?.hasCentral ?? false) + if let peerID, !peerStillLinked { + // Do not remove peer; mark as not connected but retain for reachability + peerRegistry.mutate { $0.markDisconnected(peerID) } + refreshLocalTopology() + } + notifyUI { [weak self] in + guard let self = self else { return } + let currentPeerIDs = self.peerRegistry.peerIDs + if let peerID, !peerStillLinked { + self.notifyPeerDisconnectedDebounced(peerID) + } + self.requestPeerDataPublish() + self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) + } + + case let .centralLinkEnded(centralUUID): + linkAuth.retireLink(.central(centralUUID)) + guard let peerID = linkBindings.centralRemoved(centralUUID) else { return } + // The remote side retiring a redundant duplicate connection + // arrives as an unsubscribe while the peer stays live on its + // other links; only the peer's last link disconnecting counts. + guard linkBindings.links(to: peerID).isEmpty else { return } + peerRegistry.mutate { $0.markDisconnected(peerID) } + refreshLocalTopology() + notifyUI { [weak self] in + guard let self = self else { return } + let currentPeerIDs = self.peerRegistry.peerIDs + self.notifyPeerDisconnectedDebounced(peerID) + self.requestPeerDataPublish() + self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) + } + + case let .allPeripheralLinksEnded(peripheralIDs, retireProofsAndNotify): + guard retireProofsAndNotify else { + _ = linkBindings.clearPeripherals() + return + } + for peripheralID in peripheralIDs { + linkAuth.retireLink(.peripheral(peripheralID)) + } + let peerIDs = linkBindings.clearPeripherals() + for peerID in peerIDs { + notifyUI { [weak self] in + self?.notifyPeerDisconnectedDebounced(peerID) + } + } + + case let .allCentralLinksEnded(centralUUIDs, retireProofsAndNotify): + guard retireProofsAndNotify else { + _ = linkBindings.clearCentrals() + return + } + for centralUUID in centralUUIDs { + linkAuth.retireLink(.central(centralUUID)) + } + let peerIDs = linkBindings.clearCentrals() + for peerID in peerIDs { + notifyUI { [weak self] in + self?.notifyPeerDisconnectedDebounced(peerID) + } + } + } + } + // MARK: Packet Reception - + + /// The bleQueue → engine handoff for every frame the link layer + /// decodes: the radio side hands up (packet, linkID) and all + /// attribution — binding lookup, spoof rejection, raw-announce + /// binding, ingress recording — happens on the engine, the queue that + /// owns the identity domain. Captures the panic lifecycle at the + /// handoff, like `handleReceivedPacket`. + /// + /// Per-link frame order is preserved end to end (bleQueue and the + /// engine are both serial), so an announce that binds a link is + /// attributed before the directed frames that ride behind it — the + /// same-batch spoof protection the old bleQueue-side attribution + /// enforced with a batch-local binding. + private func ingestDecodedPacket( + _ packet: BitchatPacket, + link: BLEIngressLinkID, + linkDescription: String + ) { + guard let lifecycleGeneration = capturePanicLifecycleGeneration() else { return } + messageQueue.async { [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration(lifecycleGeneration) else { + return + } + self.attributeAndHandlePacket(packet, link: link, linkDescription: linkDescription) + } + } + + /// Engine-confined attribution: resolves the link's bound owner, + /// admits or rejects the claimed sender, lets a direct raw announce + /// bind an unbound link (rotation rebinds still require a verified + /// announce — `rebindLinkAfterVerifiedDirectAnnounce`), records + /// ingress, and hands the packet to the handler pipeline. + private func attributeAndHandlePacket( + _ packet: BitchatPacket, + link: BLEIngressLinkID, + linkDescription: String + ) { + let claimedSenderID = PeerID(hexData: packet.senderID) + let context = acceptedIngressContext( + for: packet, + claimedSenderID: claimedSenderID, + boundPeerID: linkBindings.boundPeer(for: link), + linkDescription: linkDescription + ) + guard let context else { return } + + if packet.type != MessageType.announce.rawValue { + SecureLogger.debug("📦 Decoded packet type: \(packet.type) from sender: \(claimedSenderID.id.prefix(8))… (\(linkDescription))", category: .session) + } + + if packet.type == MessageType.announce.rawValue, + packet.ttl == messageTTL { + // Raw announces only bind unbound links: this runs before + // signature verification, so a bound link must not be re-bound + // by a raw announce (spoofable). + let boundPeerID = linkBindings.boundPeer(for: link) + if boundPeerID == nil || boundPeerID == claimedSenderID { + switch link { + case .peripheral(let peripheralUUID): + bindPeripheralLink(peripheralUUID, to: claimedSenderID) + case .central(let centralUUID): + linkBindings.bindCentral(centralUUID, to: claimedSenderID) + } + refreshLocalTopology() + } + } + + guard recordIngressIfNew(packet, link: link, peerID: context.receivedFromPeerID) else { + return + } + + handleReceivedPacket(packet, from: context.receivedFromPeerID) + } + private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) { let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue || packet.type == MessageType.noiseEncrypted.rawValue @@ -6984,7 +5942,7 @@ extension BLEService { capturePanicLifecycleGeneration() else { return } - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self, self.isCurrentPanicLifecycleGeneration( lifecycleGeneration @@ -7019,7 +5977,7 @@ extension BLEService { // Track recent traffic timestamps for adaptive behavior; the same // barrier hop confirms route health for the packet's originator. - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } self.recentTrafficTracker.recordPacket(at: Date()) self.sourceRouteFailures.noteInboundActivity(from: senderID) @@ -7124,9 +6082,9 @@ extension BLEService { SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session) } - let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } + let connectedCount = peerRegistry.connectedCount if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) { - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.scheduledRelays.cancel(messageID: messageID) } } @@ -7135,7 +6093,7 @@ extension BLEService { } private func scheduleRelayIfNeeded(_ packet: BitchatPacket, senderID: PeerID, messageID: String) { - let degree = collectionsQueue.sync { peerRegistry.connectedCount } + let degree = peerRegistry.connectedCount let decision = BLEReceivePipeline.relayDecision( for: packet, senderID: senderID, @@ -7147,7 +6105,7 @@ extension BLEService { let work = DispatchWorkItem { [weak self] in guard let self = self else { return } - self.collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.scheduledRelays.remove(messageID: messageID) } var relayPacket = packet @@ -7155,10 +6113,10 @@ extension BLEService { self.broadcastPacket(relayPacket) } - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in self?.scheduledRelays.schedule(work, messageID: messageID) } - messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work) + engineScheduler.schedule(after: Double(decision.delayMs) / 1_000, execute: work) } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { @@ -7188,9 +6146,6 @@ extension BLEService { // consolidate duplicate same-role connections onto that link. if let result, result.isVerified, result.isDirectAnnounce { rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID) - #if DEBUG - _test_afterVerifiedDirectRebindEnqueued?() - #endif retireRedundantPeripheralLinks(packet, to: result.peerID) } @@ -7241,91 +6196,131 @@ extension BLEService { /// spoofed. A signature-verified direct announce proves the claimed /// sender owns the link it arrived on, so rebind the link to the new ID /// and retire the old identity. + /// Engine-confined: the whole rebind — containment checks, proof + /// retirement, binding flip, reconnect decision, and rotated-identity + /// retirement — is one engine slot, so no observer can see a + /// half-applied rotation. Only the physical connection cancels hop to + /// bleQueue. private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) { - guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return } - bleQueue.async { [weak self] in - guard let self else { return } - let linkUUID: String - let previousPeerID: PeerID? - switch link { - case .peripheral(let peripheralUUID): - linkUUID = peripheralUUID - previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID) - case .central(let centralUUID): - linkUUID = centralUUID - previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID) - } - guard let previousPeerID else { return } - guard previousPeerID != peerID else { - self.refreshNoiseSessionForVerifiedDirectLink( - packet, - peerID: peerID - ) - return - } - - // The signature does not authenticate directness (TTL is excluded - // from signing because relays mutate it), so a "verified direct" - // announce can be a replay of another peer's fresh announce with - // its TTL restored. Contain what a forged rebind could do: - // never steal an identity another live link already owns, and - // allow at most one rebind per link per cooldown window so two - // identities can't fight over a link in a replay flip-flop. - guard self.linkStateStore.links(to: peerID).isEmpty else { - SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security) - return - } - let now = Date() - self.lastLinkRebindAt = self.lastLinkRebindAt.filter { - now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds - } - guard self.lastLinkRebindAt[linkUUID] == nil else { - SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security) - return - } - self.lastLinkRebindAt[linkUUID] = now - - // A Noise proof belongs to the old physical binding. Never carry - // it across an announce-driven rebind, whose direct TTL is - // replayable; the new owner must complete a fresh handshake. - self.noiseAuthenticatedLinkOwners.removeValue(forKey: link) - self.noiseReconnectPolicy.endLinkEpoch(link) - switch link { - case .peripheral(let peripheralUUID): - self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID) - case .central(let centralUUID): - self.linkStateStore.bindCentral(centralUUID, to: peerID) - } - // Keep the rebind and reconnect decision in one bleQueue critical - // section. No observer may see the new binding while a cached - // peer-level sender is still considered established. - self.refreshNoiseSessionForVerifiedDirectLink( - packet, + guard let link = ingressLinks.link(for: packet) else { return } + let linkUUID: String + let previousPeerID: PeerID? + switch link { + case .peripheral(let peripheralUUID): + linkUUID = peripheralUUID + previousPeerID = linkBindings.peer(forPeripheralID: peripheralUUID) + case .central(let centralUUID): + linkUUID = centralUUID + previousPeerID = linkBindings.peer(forCentralUUID: centralUUID) + } + guard let previousPeerID else { return } + guard previousPeerID != peerID else { + refreshNoiseSessionForVerifiedDirectLink( + link: link, peerID: peerID ) - SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) - self.refreshLocalTopology() - // The announce that triggered this rebind was upserted as - // disconnected: the registry ran while the link still belonged - // to the previous ID (the ambiguous state BLEAnnounceHandler - // denies the connected shortcut). The rebind has now - // containment-checked the claim and the identity owns a live - // link, so promote it — otherwise a healed rotation leaves a - // live link that reads as disconnected until the next announce. - self.messageQueue.async { [weak self] in - self?.promoteReboundPeerToConnected(peerID) - } - // Any other peripheral links still bound to the rotated-away ID - // are stale duplicates of the same physical device (its restored - // connections outlived the relaunch that rotated the ID): cancel - // them now instead of leaving ghost links that spray duplicate - // traffic until the inactivity timeout. - self.cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID) - // Retire the rotated-away ID only once its last link is gone; a - // remaining stale link heals the same way or ages out. - guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return } - self.messageQueue.async { [weak self] in - self?.retireRotatedPeer(previousPeerID) + return + } + + // The signature does not authenticate directness (TTL is excluded + // from signing because relays mutate it), so a "verified direct" + // announce can be a replay of another peer's fresh announce with + // its TTL restored. Contain what a forged rebind could do: + // never steal an identity another live link already owns, and + // allow at most one rebind per link per cooldown window so two + // identities can't fight over a link in a replay flip-flop. + guard linkBindings.links(to: peerID).isEmpty else { + SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security) + return + } + let now = Date() + guard linkAuth.permitRebind( + linkUUID: linkUUID, + now: now, + cooldown: TransportConfig.bleLinkRebindCooldownSeconds + ) else { + SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security) + return + } + + // A Noise proof belongs to the old physical binding. Never carry + // it across an announce-driven rebind, whose direct TTL is + // replayable; the new owner must complete a fresh handshake. + linkAuth.retireLink(link) + switch link { + case .peripheral(let peripheralUUID): + bindPeripheralLink(peripheralUUID, to: peerID) + case .central(let centralUUID): + linkBindings.bindCentral(centralUUID, to: peerID) + } + // Same engine slot as the rebind: no observer may see the new + // binding while a cached peer-level sender is still considered + // established. + refreshNoiseSessionForVerifiedDirectLink( + link: link, + peerID: peerID + ) + SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) + refreshLocalTopology() + // The announce that triggered this rebind was upserted as + // disconnected: the registry ran while the link still belonged + // to the previous ID (the ambiguous state BLEAnnounceHandler + // denies the connected shortcut). The rebind has now + // containment-checked the claim and the identity owns a live + // link, so promote it — otherwise a healed rotation leaves a + // live link that reads as disconnected until the next announce. + promoteReboundPeerToConnected(peerID) + // Any other peripheral links still bound to the rotated-away ID + // are stale duplicates of the same physical device (its restored + // connections outlived the relaunch that rotated the ID): cancel + // them now instead of leaving ghost links that spray duplicate + // traffic until the inactivity timeout. + cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID) + // Links we cannot cancel (the remote owns its central connections) + // must still stop claiming the dead identity, or it lingers as a + // ghost peer that the NEW identity's own traffic keeps refreshing + // (issue #1538). + releaseLinksBoundToRotatedPeer(previousPeerID) + retireRotatedPeer(previousPeerID) + } + + /// Unbinds every link still bound to an identity a verified direct + /// announce just rotated away from, and retires those links' Noise + /// proofs. + /// + /// Release, deliberately not rebind: a rotation announce proves only + /// that *its own* link's device now presents as the new ID, so binding + /// a different link to that ID on this evidence is exactly what the + /// #1401 containment rule ("never steal an identity another live link + /// already owns") forbids — and that rule stays intact. Unbinding is + /// strictly less trusting than any binding, and it is correct under + /// both readings of a second link bound to the retired ID: either it is + /// the same physical device (dual links to one phone, the field case), + /// or one of the two links is a spoofer holding a forged binding — + /// since a peer ID is derived from a Noise key fingerprint, two devices + /// cannot both legitimately own it. Dropping the binding is right in + /// the first case and a win in the second. + /// + /// Released links then converge through the ordinary unbound-link path: + /// the next raw direct announce on the link binds it to whoever it + /// actually carries. Until then the link's frames attribute to their + /// claimed sender rather than to a dead ID. + /// + /// Residual (unchanged in kind from what the containment already + /// accepts): an attacker who has bound their own link to X — possible + /// by replaying X's raw announce onto an unbound link — can drive a + /// rebind on it and so evict X's registry entry. X's next announce + /// re-binds its real links and restores presence, and the per-link + /// rebind cooldown bounds the repetition rate. + private func releaseLinksBoundToRotatedPeer(_ peerID: PeerID) { + for link in linkBindings.links(to: peerID) { + linkAuth.retireLink(link) + switch link { + case .peripheral(let peripheralUUID): + // No survivor: every link this peer holds is being released. + _ = linkBindings.peripheralRemoved(peripheralUUID) { _ in nil } + case .central(let centralUUID): + _ = linkBindings.centralRemoved(centralUUID) } } } @@ -7348,40 +6343,38 @@ extension BLEService { /// retirement per peer per cooldown window, and the peer keeps a live /// link either way. private func retireRedundantPeripheralLinks(_ packet: BitchatPacket, to peerID: PeerID) { - let ingressLink = collectionsQueue.sync { ingressLinks.link(for: packet) } - bleQueue.async { [weak self] in - guard let self else { return } - let now = Date() - self.lastRedundantLinkRetirementAt = self.lastRedundantLinkRetirementAt.filter { - now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds - } - guard self.lastRedundantLinkRetirementAt[peerID] == nil else { return } - - var ingressPeripheralUUID: String? - if case .peripheral(let uuid) = ingressLink { - ingressPeripheralUUID = uuid - } - guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: ingressPeripheralUUID, - mostRecentlyBoundUUID: self.linkStateStore.preferredPeripheralBindings[peerID], - links: self.peripheralLinkPolicySnapshot(), - peerID: peerID - ) else { return } - - self.lastRedundantLinkRetirementAt[peerID] = now - // The survivor becomes the peer's reverse-mapped link so directed - // sends follow the consolidation. - self.linkStateStore.bindPeripheral(keptUUID, to: peerID) - self.cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID) - self.refreshLocalTopology() + let ingressLink = ingressLinks.link(for: packet) + let now = Date() + var ingressPeripheralUUID: String? + if case .peripheral(let uuid) = ingressLink { + ingressPeripheralUUID = uuid } + guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: ingressPeripheralUUID, + mostRecentlyBoundUUID: linkBindings.preferredPeripheralUUID(for: peerID), + links: peripheralLinkPolicySnapshot(), + peerID: peerID + ) else { return } + + guard linkAuth.permitRedundantRetirement( + peerID: peerID, + now: now, + cooldown: TransportConfig.bleLinkRebindCooldownSeconds + ) else { return } + // The survivor becomes the peer's reverse-mapped link so directed + // sends follow the consolidation. + bindPeripheralLink(keptUUID, to: peerID) + cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID) + refreshLocalTopology() } /// Cancels our central-role connections whose link is bound to `peerID`, - /// except `keptUUID`. bleQueue only. Each entry is removed from the link - /// store BEFORE cancelling so didDisconnectPeripheral sees no peer - /// binding and skips its peer-disconnect bookkeeping — the peer is still - /// live (on the kept link, or under its rotated identity). + /// except `keptUUID`. Engine-confined: each binding is retired BEFORE + /// the cancel is issued, so didDisconnectPeripheral's identity hop sees + /// no peer binding and skips its peer-disconnect bookkeeping — the peer + /// is still live (on the kept link, or under its rotated identity). + /// Only the physical discard and the CoreBluetooth cancel hop to + /// bleQueue. private func cancelBoundPeripheralLinks(to peerID: PeerID, keeping keptUUID: String?) { let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire( links: peripheralLinkPolicySnapshot(), @@ -7389,29 +6382,38 @@ extension BLEService { keeping: keptUUID ?? "" ) for uuid in retiring { - guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue } - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: uuid) - } - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid)) - _ = linkStateStore.removePeripheral(uuid) + retirePeripheralLinkIdentity(uuid) SecureLogger.info( "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")", category: .session ) - centralManager?.cancelPeripheralConnection(state.peripheral) + bleQueue.async { [weak self] in + guard let self, + let state = self.linkStateStore.state(forPeripheralID: uuid) else { return } + self.discardPeripheralLinkPhysical(uuid) + self.centralManager?.cancelPeripheralConnection(state.peripheral) + } } } - /// bleQueue only (reads the link store). + /// Engine-confined: physical link rows joined with their engine-owned + /// bindings. private func peripheralLinkPolicySnapshot() -> [BLERedundantLinkPolicy.PeripheralLink] { - linkStateStore.peripheralStates.map { + let physical = readLinkState { store in + store.peripheralStates.map { + (uuid: $0.peripheral.identifier.uuidString, + isConnected: $0.isConnected, + hasCharacteristic: $0.characteristic != nil, + lastConnectedAt: $0.lastConnectedAt) + } + } + return physical.map { BLERedundantLinkPolicy.PeripheralLink( - uuid: $0.peripheral.identifier.uuidString, - peerID: $0.peerID, + uuid: $0.uuid, + peerID: linkBindings.peer(forPeripheralID: $0.uuid), isConnected: $0.isConnected, - hasCharacteristic: $0.characteristic != nil + hasCharacteristic: $0.hasCharacteristic, + lastConnectedAt: $0.lastConnectedAt ) } } @@ -7423,15 +6425,13 @@ extension BLEService { /// link. The `.peerConnected` UI event already fired from the announce /// path (new/reconnected + direct), so only list state needs refreshing. private func promoteReboundPeerToConnected(_ peerID: PeerID) { - let promoted = collectionsQueue.sync(flags: .barrier) { - peerRegistry.markConnected(peerID) - } + let promoted = peerRegistry.mutate { $0.markConnected(peerID) } guard promoted else { return } refreshLocalTopology() publishFullPeerData() notifyUI { [weak self] in guard let self else { return } - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } } @@ -7440,15 +6440,13 @@ extension BLEService { /// instead of letting a ghost duplicate linger for the reachability /// retention window. private func retireRotatedPeer(_ peerID: PeerID) { - let removed = collectionsQueue.sync(flags: .barrier) { - peerRegistry.remove(peerID) != nil - } + let removed = peerRegistry.mutate { $0.remove(peerID) != nil } guard removed else { return } gossipSyncManager?.removeAnnouncementForPeer(peerID) refreshLocalTopology() notifyUI { [weak self] in guard let self else { return } - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.deliverTransportEvent(.peerDisconnected(peerID)) self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } @@ -7465,7 +6463,7 @@ extension BLEService { now: { Date() }, existingPeerKeys: { [weak self] peerID in guard let self = self else { return (nil, nil) } - return self.collectionsQueue.sync { + return onEngine { let info = self.peerRegistry.info(for: peerID) return (info?.noisePublicKey, info?.signingPublicKey) } @@ -7497,41 +6495,35 @@ extension BLEService { // connected. See the caller in BLEAnnounceHandler for why the // residual forged-presence window this leaves is accepted. guard let self else { return false } - guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false } - let boundPeerID: PeerID? = self.readLinkState { store in - switch link { - case .peripheral(let peripheralUUID): - return store.peerID(forPeripheralID: peripheralUUID) - case .central(let centralUUID): - return store.peerID(forCentralUUID: centralUUID) - } - } - guard let boundPeerID else { return false } + guard let link = self.ingressLinks.link(for: packet) else { return false } + guard let boundPeerID = self.linkBindings.boundPeer(for: link) else { return false } return boundPeerID != peerID }, withRegistryBarrier: { [weak self] body in - self?.collectionsQueue.sync(flags: .barrier) { body() } + self?.onEngine { body() } }, upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in // Called from inside withRegistryBarrier; access registry directly. guard let self = self else { return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) } - return self.peerRegistry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: announcement.nickname, - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - isConnected: isConnected, - // Propagate `nil` (registry refused the announce because it - // carries a signing key different from the pinned one) so - // the handler's guard rejects it instead of overwriting the - // pinned identity. Main's capabilities/bridgeGeohash are - // preserved. - now: now, - capabilities: announcement.capabilities, - bridgeGeohash: announcement.bridgeGeohash - ) + return self.peerRegistry.mutate { + $0.upsertVerifiedAnnounce( + peerID: peerID, + nickname: announcement.nickname, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isConnected: isConnected, + // Propagate `nil` (registry refused the announce because it + // carries a signing key different from the pinned one) so + // the handler's guard rejects it instead of overwriting the + // pinned identity. Main's capabilities/bridgeGeohash are + // preserved. + now: now, + capabilities: announcement.capabilities, + bridgeGeohash: announcement.bridgeGeohash + ) + } }, shouldEmitReconnectLog: { [weak self] peerID, now in // Called from inside withRegistryBarrier; access debouncer directly. @@ -7570,7 +6562,7 @@ extension BLEService { self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) } // Get current peer list (after addition) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs self.requestPeerDataPublish() self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } @@ -7582,7 +6574,7 @@ extension BLEService { self?.sendAnnounce(forceSend: true) }, scheduleAfterglow: { [weak self] delay in - self?.messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.engineScheduler.schedule(after: delay) { [weak self] in self?.sendAnnounce(forceSend: true) } } @@ -7661,7 +6653,7 @@ extension BLEService { // A response can replay the entire gossip store, so require proof the // requester owns the claimed sender ID: the request must verify // against the signing key from that peer's announce. - let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey } + let signingKey = peerRegistry.info(for: peerID)?.signingPublicKey guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") { SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security) @@ -7695,7 +6687,7 @@ extension BLEService { now: { Date() }, peersSnapshot: { [weak self] in guard let self = self else { return [:] } - return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } + return self.peerRegistry.snapshotByID }, verifyPacketSignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false @@ -7765,7 +6757,7 @@ extension BLEService { maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds ) else { return false } - let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID } + let peersSnapshot = peerRegistry.snapshotByID let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID) @@ -7860,15 +6852,16 @@ extension BLEService { }, decrypt: { [weak self] payload, peerID in guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } + // Decrypt runs on the engine queue; the readiness callback + // fires on the noise manager's queue; the session store is + // a leaf lock, so the read is safe from there. let result = try self.noiseService.decryptWithSessionGeneration( payload, from: peerID, establishedGenerationIsReady: { generation in - self.collectionsQueue.sync { - self.privateMediaSessionGenerations[ - peerID.toShort() - ] == generation - } + self.privateMediaSessions.currentGeneration( + for: peerID.toShort() + ) == generation } ) return BLENoiseDecryptionResult( @@ -7911,7 +6904,7 @@ extension BLEService { // MARK: Helper Functions private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { - let payloads = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingTypedPayload] in + let payloads = onEngine { () -> [BLEPendingTypedPayload] in pendingNoiseSessionQueues.takeTypedPayloads(for: peerID) } guard !payloads.isEmpty else { return } @@ -7929,7 +6922,7 @@ extension BLEService { // Handshake completion alone is insufficient. Put the // exact payload back until authenticated 0x21 state // arrives; that handler calls this drain again. - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload( pending.payload, transferId: pending.transferId, @@ -7993,10 +6986,7 @@ extension BLEService { } private func updatePeerLastSeen(_ peerID: PeerID) { - // Use async to avoid deadlock - we don't need immediate consistency for last seen updates - collectionsQueue.async(flags: .barrier) { - self.peerRegistry.updateLastSeen(peerID, at: Date()) - } + peerRegistry.mutate { $0.updateLastSeen(peerID, at: Date()) } } // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window @@ -8013,9 +7003,7 @@ extension BLEService { // NEW: Publish peer snapshots to subscribers and notify Transport delegates private func publishFullPeerData() { - let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { - peerRegistry.transportSnapshots(selfNickname: myNickname) - } + let transportPeers = peerRegistry.transportSnapshots(selfNickname: myNickname) notifyUI { [weak self] in self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers) } @@ -8029,12 +7017,10 @@ extension BLEService { lastMaintenanceAt = Date() let now = Date() - let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } + let connectedCount = peerRegistry.connectedCount let elapsed = announceThrottle.elapsed(since: now) - let recentSeen = collectionsQueue.sync { () -> Bool in - recentTrafficTracker.hasTraffic(within: 5.0, now: now) - } - let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty } + let recentSeen = recentTrafficTracker.hasTraffic(within: 5.0, now: now) + let hasNoPeers = peerRegistry.isEmpty let plan = BLEMaintenancePolicy.plan( cycle: maintenanceCounter, connectedCount: connectedCount, @@ -8050,20 +7036,20 @@ extension BLEService { if plan.shouldEnsureAdvertising { // Ensure we're advertising as peripheral if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising { - pm.startAdvertising(buildAdvertisementData()) + pm.startAdvertising(BLERadioController.advertisementData()) } } // Update scanning duty-cycle based on connectivity - updateScanningDutyCycle(connectedCount: connectedCount) - updateRSSIThreshold(connectedCount: connectedCount) + radio.updateScanningDutyCycle(connectedCount: connectedCount) + radio.updateRSSIThreshold(connectedCount: connectedCount) // Drain the connection candidate queue. Weak-RSSI discoveries are // enqueued rather than connected immediately, and the event-driven // drains (disconnect/failure/timeout) never fire when we're idle — // without this, an isolated node surrounded only by weak (distant) // peers would queue them all and never connect to anyone. - tryConnectFromQueue() + radio.tryConnectFromQueue() // Check peer connectivity every cycle for snappier UI updates checkPeerConnectivity() @@ -8104,8 +7090,16 @@ extension BLEService { #endif private func checkPeerConnectivity() { + // Maintenance ticks on bleQueue; connectivity reconciliation reads + // the engine-owned bindings, so it rides an engine slot. + messageQueue.async { [weak self] in + self?.checkPeerConnectivityOnEngine() + } + } + + private func checkPeerConnectivityOnEngine() { let now = Date() - let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs } + let peerIDsForLinkState: [PeerID] = peerRegistry.peerIDs var cachedLinkStates: [PeerID: BLEPeerLinkPresence] = [:] for peerID in peerIDsForLinkState { let state = linkState(for: peerID) @@ -8115,8 +7109,8 @@ extension BLEService { ) } - let changes = collectionsQueue.sync(flags: .barrier) { - peerRegistry.reconcileConnectivity(now: now, linkStates: cachedLinkStates) + let changes = peerRegistry.mutate { + $0.reconcileConnectivity(now: now, linkStates: cachedLinkStates) } for removedPeer in changes.removedPeers { SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(removedPeer.peerID.id.prefix(8))… (\(removedPeer.nickname))", category: .session) @@ -8129,7 +7123,7 @@ extension BLEService { guard let self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + let currentPeerIDs = self.peerRegistry.peerIDs for peerID in changes.disconnectedPeerIDs { self.deliverTransportEvent(.peerDisconnected(peerID)) @@ -8160,33 +7154,35 @@ extension BLEService { // Clean old fragments (> configured seconds old), then ask peers for // the specific fragment streams whose reassembly has stalled instead // of waiting for the next periodic GCS fragment round. - let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in + messageQueue.async { [weak self] in + guard let self else { return } let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) - fragmentAssemblyBuffer.removeExpired(before: cutoff) - sourceRouteFailures.prune(now: now) - return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs( + self.fragmentAssemblyBuffer.removeExpired(before: cutoff) + self.sourceRouteFailures.prune(now: now) + let stalledFragmentIDs = self.fragmentAssemblyBuffer.stalledBroadcastFragmentIDs( stalledAfter: TransportConfig.bleFragmentResyncStallSeconds, retryAfter: TransportConfig.bleFragmentResyncRetrySeconds, now: now ) - } - if !stalledFragmentIDs.isEmpty { - gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs) + if !stalledFragmentIDs.isEmpty { + // GossipSyncManager serializes on its own internal queue. + self.gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs) + } } // Clean old connection timeout backoff entries (> window) let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds) - connectionScheduler.pruneConnectionTimeouts(before: timeoutCutoff) + radio.pruneConnectionTimeouts(before: timeoutCutoff) // Clean up stale scheduled relays that somehow persisted (> 2s) - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } // Nothing to compare times to; just cap the size defensively self.scheduledRelays.removeAllIfOverCapacity(512) } // Clean ingress link records older than configured seconds - collectionsQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds) if !self.ingressLinks.isEmpty { @@ -8199,7 +7195,7 @@ extension BLEService { ) } - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self = self else { return } guard !self.selfBroadcastTracker.isEmpty else { return } let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds) @@ -8207,74 +7203,4 @@ extension BLEService { } } - private func updateScanningDutyCycle(connectedCount: Int) { - guard let central = centralManager, central.state == .poweredOn else { return } - // Duty cycle only when app is active and at least one peer connected - #if os(iOS) - let active = isAppActive - #else - let active = true - #endif - // Force full-time scanning if we have very few neighbors or very recent traffic - let hasRecentTraffic: Bool = collectionsQueue.sync { - recentTrafficTracker.hasTraffic( - within: TransportConfig.bleRecentTrafficForceScanSeconds, - now: Date() - ) - } - let scanPlan = BLEScanDutyPolicy.plan( - dutyEnabled: dutyEnabled, - appIsActive: active, - connectedCount: connectedCount, - hasRecentTraffic: hasRecentTraffic - ) - - switch scanPlan { - case .dutyCycle(let onDuration, let offDuration): - let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration - dutyOnDuration = onDuration - dutyOffDuration = offDuration - - if scanDutyTimer == nil { - // Start timer to toggle scanning on/off - let t = DispatchSource.makeTimerSource(queue: bleQueue) - // Start with scanning ON; we'll turn OFF after onDuration - if !central.isScanning { startScanning() } - dutyActive = true - t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) - t.setEventHandler { [weak self] in - guard let self = self, let c = self.centralManager else { return } - if self.dutyActive { - // Turn OFF scanning for offDuration - if c.isScanning { c.stopScan() } - self.dutyActive = false - // Schedule turning back ON after offDuration - self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) { - if self.centralManager?.state == .poweredOn { self.startScanning() } - self.dutyActive = true - } - } - } - t.resume() - scanDutyTimer = t - } else if durationsChanged { - scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) - if !central.isScanning { startScanning() } - dutyActive = true - } - case .continuous: - // Cancel duty cycle and ensure scanning is ON for discovery - scanDutyTimer?.cancel() - scanDutyTimer = nil - if !central.isScanning { startScanning() } - } - } - - private func updateRSSIThreshold(connectedCount: Int) { - connectionScheduler.updateRSSIThreshold( - connectedCount: connectedCount, - connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount, - now: Date() - ) - } } diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift index 60c6b827..82f78a8f 100644 --- a/bitchat/Services/Board/BoardManager.swift +++ b/bitchat/Services/Board/BoardManager.swift @@ -19,6 +19,8 @@ final class BoardManager: ObservableObject { @Published private(set) var posts: [BoardPostPacket] = [] private let transport: Transport + /// Board broadcast rides the mesh only; absent on other transports. + private var boardTransport: MeshBoardBroadcasting? { transport as? MeshBoardBroadcasting } /// Publishes a bridged kind-1 note (expiring with the board post via /// NIP-40) and returns its Nostr event id, or nil when bridging failed or /// was skipped. @@ -122,7 +124,7 @@ final class BoardManager: ObservableObject { flags: flags, signature: signature ) - transport.sendBoardPayload(BoardWire.post(post).encode()) + boardTransport?.sendBoardPayload(BoardWire.post(post).encode()) // Nostr bridge: geohash posts also go out as kind-1 location notes so // online users see them. Remember the event id for merged deletes. @@ -148,7 +150,7 @@ final class BoardManager: ObservableObject { deletedAt: deletedAt, signature: signature ) - transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) + boardTransport?.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) // Merged delete: also retract the bridged Nostr copy when we still // know its event id. diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index a68bf65b..f86f2eca 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -90,6 +90,9 @@ protocol CommandContextProvider: AnyObject { final class CommandProcessor { weak var contextProvider: CommandContextProvider? weak var meshService: Transport? + /// Mesh-only command surfaces, absent when the transport lacks them. + private var meshDiagnostics: MeshDiagnosing? { meshService as? MeshDiagnosing } + private var meshArchive: MeshPublicArchiving? { meshService as? MeshPublicArchiving } private let identityManager: SecureIdentityStateManagerProtocol init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { @@ -371,7 +374,7 @@ final class CommandProcessor { } // Scrub their carried public messages now, while the peerID is // resolvable, so they can't resurface as archived echoes. - meshService?.purgeArchivedPublicMessages(from: peerID) + meshArchive?.purgeArchivedPublicMessages(from: peerID) return .success(message: "blocked \(nickname). you will no longer receive messages from them") } // Mesh lookup failed; try geohash (Nostr) participant by display name @@ -474,7 +477,7 @@ final class CommandProcessor { // meshPingTimeoutSeconds later, and reading the selected chat at // callback time would misroute the result after a chat switch. let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline - meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in + meshDiagnostics?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in let provider = currentProvider guard let result else { provider?.addCommandOutput("no reply from \(nickname)", to: destination) @@ -496,7 +499,7 @@ final class CommandProcessor { } guard let mesh = meshService, - let intermediates = mesh.computeMeshPath(to: target.peerID) else { + let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else { return .success(message: "no known path to \(target.nickname)") } // Graph-derived from gossiped neighbor claims, not route-recorded — diff --git a/bitchat/Services/MeshTransportCapabilities.swift b/bitchat/Services/MeshTransportCapabilities.swift new file mode 100644 index 00000000..fd2203ea --- /dev/null +++ b/bitchat/Services/MeshTransportCapabilities.swift @@ -0,0 +1,152 @@ +import BitFoundation +import CoreBluetooth +import Foundation + +/// Optional transport capabilities, discovered with `as?` instead of casting +/// to a concrete transport class. `Transport` stays the contract every +/// transport genuinely implements; a capability protocol here is the +/// contract for one mesh-only feature surface, so app wiring depends on the +/// feature it needs rather than on `BLEService` itself. + +/// Radio-state reporting for transports backed by a local radio. +protocol BluetoothStateReporting: AnyObject { + func getCurrentBluetoothState() -> CBManagerState +} + +/// Panic-mode lifecycle for transports that own durable identity state. +/// A transport implementing this owns its own restart sequencing: +/// `completePanicReset` decides whether services come back, so generic +/// `startServices()` calls after a panic belong only to transports that +/// don't implement it. +protocol PanicResettingTransport: AnyObject { + /// Quiesces the radio and drains in-flight work ahead of a panic wipe. + func suspendForPanicReset() + /// Finishes a panic wipe, optionally restarting services. + func completePanicReset(restartServices: Bool) + /// Rotates the transport identity as part of a panic reset. + func resetIdentityForPanic(currentNickname: String, restartServices: Bool) +} + +/// File and private-media transfer over a mesh transport, including the +/// capability-proof policy that gates encrypted private media. +protocol MeshFileTransferring: AnyObject { + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) + /// Automatic whole-file retry is admitted only while this exact Noise + /// generation authenticates bit 9. It must never queue across a session + /// replacement or enter the signed raw legacy path. + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) + func cancelTransfer(_ transferId: String) + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy + /// The exact current Noise generation that authenticated both encrypted + /// private media (bit 8) and durable receipts/retry (bit 9). + func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID? + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) +} + +/// Live voice / push-to-talk: one encoded `VoiceBurstPacket`, +/// fire-and-forget inside the Noise session (private) or as a signed +/// ephemeral broadcast (public). Frames are only useful now — the +/// transport drops them (never queues) without an established session. +protocol MeshVoiceStreaming: AnyObject { + func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) + func sendVoiceFrameBroadcast(_ burstContent: Data) +} + +/// Courier store-and-forward: seal a message to the recipient's static +/// key and hand it to connected couriers for physical delivery while the +/// recipient is offline. Returns false when the transport cannot courier. +protocol MeshCourierTransporting: AnyObject { + @discardableResult + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool +} + +/// Private groups: creator-signed state travels 1:1 over Noise sessions; +/// group messages flood like public broadcasts. +protocol MeshGroupMessaging: AnyObject { + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) + func broadcastGroupMessage(_ envelope: Data) +} + +/// Bulletin board: broadcast a pre-signed board payload (post or +/// tombstone) so it spreads over relay and gossip sync. +protocol MeshBoardBroadcasting: AnyObject { + func sendBoardPayload(_ payload: Data) +} + +/// Mesh diagnostics (/ping, /trace, topology map). +protocol MeshDiagnosing: AnyObject { + /// Sends a directed ping probe; the completion fires exactly once on + /// the main actor with the measured result, or nil on timeout. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) + /// Estimated intermediate hops toward `peerID` from gossiped topology + /// ([] = direct link, nil = no known path). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? + /// Current mesh graph for the topology map. + func currentMeshTopology() -> MeshTopologySnapshot? +} + +/// QR verification and transitive vouching over the Noise session. +protocol MeshVerifying: AnyObject { + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + /// Sends an encoded vouch-attestation batch inside the Noise session. + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) +} + +/// Store-and-forward archive: the public messages this device is carrying +/// for gossip sync, decoded for display as "heard here earlier" echoes. +protocol MeshPublicArchiving: AnyObject { + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) + /// Drops any carried public messages from a (newly blocked) sender so + /// they can't resurface as archived echoes on a later launch. + func purgeArchivedPublicMessages(from peerID: PeerID) + /// Erases the whole carried public-message archive, on disk included. + func purgeAllArchivedPublicMessages() +} + +/// Internet-gateway and geohash-bridge wiring surface (BLE mesh today). +/// Everything the gateway/bridge/courier services need from the mesh +/// transport, so their bootstrap wiring never touches the concrete class. +protocol MeshBridgingTransport: AnyObject { + // Runtime-advertised capability bits + func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) + func setLocalBridgeGeohash(_ cell: String?) + func advertisedBridgeGeohash() -> String? + + // Peers currently advertising bridging roles + func reachableGatewayPeers() -> [PeerID] + func reachableBridgePeers() -> [PeerID] + + // Gateway carrier packets (mesh <-> Nostr uplink/downlink) + @discardableResult + func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool + func broadcastNostrCarrier(_ payload: Data) + /// Sink for received carrier packets (set once by app wiring; called on + /// the main actor after transport-level checks). + var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? { get set } + + // Bridge courier drops (sealed envelopes carried across the bridge) + func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? + @discardableResult + func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool + @discardableResult + func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool + func myNoiseStaticPublicKey() -> Data + func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] + /// Fired (off-main) when a signature-verified announce is processed. + var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? { get set } +} diff --git a/bitchat/Services/MessageDeduplicationService.swift b/bitchat/Services/MessageDeduplicationService.swift index c6773461..693365ca 100644 --- a/bitchat/Services/MessageDeduplicationService.swift +++ b/bitchat/Services/MessageDeduplicationService.swift @@ -104,12 +104,10 @@ final class LRUDeduplicationCache { enum ContentNormalizer { /// Regex to simplify HTTP URLs by stripping query strings and fragments - private static let simplifyHTTPURL: NSRegularExpression = { - try! NSRegularExpression( - pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", - options: [.caseInsensitive] - ) - }() + private static let simplifyHTTPURL = SafeRegex.compile( + "https?://[^\\s?#]+(?:[?#][^\\s]*)?", + options: [.caseInsensitive] + ) /// Normalizes content for deduplication comparison. /// - Parameters: diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 43adcb19..88a32e3c 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -39,37 +39,23 @@ final class MessageFormattingEngine { /// Precompiled regex patterns for message content parsing enum Patterns { - static let hashtag: NSRegularExpression = { - try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: []) - }() + static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)") - static let mention: NSRegularExpression = { - try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: []) - }() + static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)") - static let cashu: NSRegularExpression = { - try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) - }() + static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b") - static let bolt11: NSRegularExpression = { - try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) - }() + static let bolt11 = SafeRegex.compile("(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b") - static let lnurl: NSRegularExpression = { - try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) - }() + static let lnurl = SafeRegex.compile("(?i)\\blnurl1[a-z0-9]{20,}\\b") - static let lightningScheme: NSRegularExpression = { - try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) - }() + static let lightningScheme = SafeRegex.compile("(?i)\\blightning:[^\\s]+") static let linkDetector: NSDataDetector? = { try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) }() - static let quickCashuPresence: NSRegularExpression = { - try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) - }() + static let quickCashuPresence = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b") } // MARK: - Match Types @@ -124,11 +110,12 @@ final class MessageFormattingEngine { ) // Format content + let myNickname = context.nickname.normalizedNickname let contentResult = formatContent( message.content, baseColor: baseColor, isSelf: isSelf, - isMentioned: message.mentions?.contains(context.nickname) ?? false + isMentioned: message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false ) result.append(contentResult) diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index a070c7bf..4d8286ce 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -281,6 +281,7 @@ final class MessageRouter { guard remainingSlots > 0 else { return } for transport in transports { + guard let courierTransport = transport as? MeshCourierTransporting else { continue } let couriers = eligibleCouriers( on: transport, recipientKey: recipientKey, @@ -288,7 +289,7 @@ final class MessageRouter { limit: remainingSlots ) guard !couriers.isEmpty else { continue } - if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) { + if courierTransport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) { SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session) recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey)) onMessageCarried?(messageID, peerID) @@ -304,6 +305,7 @@ final class MessageRouter { /// `maxCouriersPerMessage` distinct couriers or expires. func courierBecameAvailable(_ peerID: PeerID) { for transport in transports { + guard let courierTransport = transport as? MeshCourierTransporting else { continue } guard transport.isPeerConnected(peerID), let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }), let courierKey = snapshot.noisePublicKey, @@ -319,7 +321,7 @@ final class MessageRouter { guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage, !message.depositedCourierKeys.contains(courierKey), currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue } - if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) { + if courierTransport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) { SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session) recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey]) onMessageCarried?(message.messageID, recipient) diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 1bf2b574..5ee7608d 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -1089,6 +1089,10 @@ final class NoiseEncryptionService { func _test_initiateAutomaticRekey(for peerID: PeerID) throws { try initiateAutomaticRekey(for: peerID) } + + func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) { + sessionManager._test_fireSuppressedInitiationRecovery(for: peerID) + } #endif deinit { diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 977bdcb3..dcf4631b 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -203,14 +203,12 @@ final class PrivateChatManager: ObservableObject { func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set) { for message in messages(for: peerID) { if message.sender == nickname { - if let status = message.deliveryStatus { - switch status { - case .read, .delivered: - externalReceipts.insert(message.id) - sentReadReceipts.insert(message.id) - case .failed, .partiallyDelivered, .sending, .sent, .carried: - break - } + switch message.deliveryStatus { + case .read, .delivered: + externalReceipts.insert(message.id) + sentReadReceipts.insert(message.id) + case .notSentYet, .failed, .partiallyDelivered, .sending, .sent, .carried: + break } } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 76e6406f..3bb6db11 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -203,99 +203,14 @@ protocol Transport: AnyObject { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendBroadcastAnnounce() func sendDeliveryAck(for messageID: String, to peerID: PeerID) - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) - /// Automatic whole-file retry is admitted only while this exact Noise - /// generation authenticates bit 9. It must never queue across a session - /// replacement or enter the signed raw legacy path. - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) - func cancelTransfer(_ transferId: String) - - // Live voice / push-to-talk (mesh transports only): one encoded - // `VoiceBurstPacket`, fire-and-forget inside the Noise session. Frames are - // only useful now — transports drop them (never queue) when no - // established session exists. - func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) - // Public-mesh counterpart: signed ephemeral broadcast, never synced. - func sendVoiceFrameBroadcast(_ burstContent: Data) - - // Courier store-and-forward (mesh transports only): seal a message to the - // recipient's static key and hand it to connected couriers for physical - // delivery while the recipient is offline. Returns false when the - // transport cannot courier (no connected courier, or unsupported). - func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool - - // Private groups (mesh transports only): creator-signed state travels - // 1:1 over Noise sessions; group messages flood like public broadcasts. - func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) - func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) - func broadcastGroupMessage(_ envelope: Data) - - // Bulletin board (mesh transports only): broadcast a pre-signed board - // payload (post or tombstone) so it spreads over relay and gossip sync. - func sendBoardPayload(_ payload: Data) - - // Mesh diagnostics (optional for transports). Defaults are inert so - // queue-backed transports (e.g. NostrTransport) stay untouched. - /// Sends a directed ping probe; the completion fires exactly once on the - /// main actor with the measured result, or nil on timeout/unsupported. - func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) - /// Estimated intermediate hops toward `peerID` from gossiped topology - /// ([] = direct link, nil = no known path). - func computeMeshPath(to peerID: PeerID) -> [PeerID]? - /// Current mesh graph for the topology map; nil when unsupported. - func currentMeshTopology() -> MeshTopologySnapshot? - - // QR verification (optional for transports) - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) - func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) - - // Vouching / transitive verification (optional for transports) /// Capabilities the peer advertised in its last verified announce; /// empty for peers that predate the capabilities TLV. func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy - /// The exact current Noise generation that authenticated both encrypted - /// private media (bit 8) and durable receipts/retry (bit 9). - func authenticatedPrivateMediaReceiptSessionGeneration( - to peerID: PeerID - ) -> UUID? - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) - /// Sends an encoded vouch-attestation batch inside the Noise session. - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) /// Appends a peer-authenticated observer. Unlike /// `installNoiseSessionCallbacks` this never touches the (single-slot) /// handshake-required callback, so secondary features can observe /// session establishment without disturbing the primary registration. func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) - - // Pending file management (BCH-01-002: files held in memory until user accepts) - func acceptPendingFile(id: String) -> URL? - func declinePendingFile(id: String) - - // Store-and-forward archive (mesh transports only): the public messages - // this device is carrying for gossip sync, decoded for display as - // "heard here earlier" timeline echoes. - func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) - /// Drops any carried public messages from a (newly blocked) sender so - /// they can't resurface as archived echoes on a later launch. - func purgeArchivedPublicMessages(from peerID: PeerID) - /// Erases the whole carried public-message archive, on disk included, so - /// clearing the mesh timeline deletes that history rather than hiding it. - func purgeAllArchivedPublicMessages() } /// A carried public mesh message from the store-and-forward window, decoded @@ -341,72 +256,12 @@ extension Transport { onHandshakeRequired: @escaping (PeerID) -> Void ) {} - func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} - func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} - func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {} - func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} - func broadcastGroupMessage(_ envelope: Data) {} func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } - func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade } - func authenticatedPrivateMediaReceiptSessionGeneration( - to peerID: PeerID - ) -> UUID? { - nil - } - func resolvePrivateMediaSendPolicy( - to peerID: PeerID, - completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void - ) { - let policy = privateMediaSendPolicy(to: peerID) - Task { @MainActor in - completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy) - } - } - func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} - func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } - func sendBoardPayload(_ payload: Data) {} - func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {} - func sendVoiceFrameBroadcast(_ burstContent: Data) {} - - // Mesh diagnostics are mesh-transport-only; other transports report - // "no reply"/"no path" rather than pretending to measure anything. - func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { - Task { @MainActor in completion(nil) } - } - func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil } - func currentMeshTopology() -> MeshTopologySnapshot? { nil } - func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} - func sendFilePrivate( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String, - allowLegacyFallback: Bool - ) { - guard !allowLegacyFallback else { return } - sendFilePrivate(packet, to: peerID, transferId: transferId) - } - func sendFilePrivateReceiptRetry( - _ packet: BitchatFilePacket, - to peerID: PeerID, - transferId: String - ) {} - func cancelTransfer(_ transferId: String) {} func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { sendMessage(content, mentions: mentions) } - - func acceptPendingFile(id: String) -> URL? { nil } - func declinePendingFile(id: String) {} - - func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { - Task { @MainActor in completion([]) } - } - - func purgeArchivedPublicMessages(from peerID: PeerID) {} - func purgeAllArchivedPublicMessages() {} } protocol TransportPeerEventsDelegate: AnyObject { @@ -450,3 +305,14 @@ extension BitchatDelegate { } extension BLEService: Transport {} +extension BLEService: MeshFileTransferring {} +extension BLEService: MeshVoiceStreaming {} +extension BLEService: MeshCourierTransporting {} +extension BLEService: MeshGroupMessaging {} +extension BLEService: MeshBoardBroadcasting {} +extension BLEService: MeshDiagnosing {} +extension BLEService: MeshVerifying {} +extension BLEService: MeshPublicArchiving {} +extension BLEService: BluetoothStateReporting {} +extension BLEService: PanicResettingTransport {} +extension BLEService: MeshBridgingTransport {} diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index f54523ec..c22c7e43 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -236,8 +236,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { /// Get peer ID for nickname func getPeerID(for nickname: String) -> PeerID? { + // Normalize both sides: the query may come from typed content and + // stored names may predate NFC-at-ingest (e.g. persisted favorites). + let target = nickname.normalizedNickname for peer in peers { - if peer.displayName == nickname || peer.nickname == nickname { + if peer.displayName.normalizedNickname == target || peer.nickname.normalizedNickname == target { return peer.peerID } } @@ -279,7 +282,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { // Purge while the fingerprint↔peerID mapping is still known: the // archived-echo seed filter can't resolve offline strangers, so // scrub their carried messages now rather than at relaunch. - meshService.purgeArchivedPublicMessages(from: peerID) + (meshService as? MeshPublicArchiving)?.purgeArchivedPublicMessages(from: peerID) } updatePeers() return fingerprint diff --git a/bitchat/Utils/InputValidator.swift b/bitchat/Utils/InputValidator.swift index e9c86868..929cfee5 100644 --- a/bitchat/Utils/InputValidator.swift +++ b/bitchat/Utils/InputValidator.swift @@ -39,9 +39,10 @@ struct InputValidator { return trimmed } - /// Validates nickname + /// Validates nickname and returns it in canonical (NFC) form so + /// visually identical names always compare equal. static func validateNickname(_ nickname: String) -> String? { - return validateUserString(nickname, maxLength: Limits.maxNicknameLength) + return validateUserString(nickname, maxLength: Limits.maxNicknameLength)?.normalizedNickname } // MARK: - Protocol Field Validation diff --git a/bitchat/Utils/SafeRegex.swift b/bitchat/Utils/SafeRegex.swift new file mode 100644 index 00000000..125f2f80 --- /dev/null +++ b/bitchat/Utils/SafeRegex.swift @@ -0,0 +1,36 @@ +// +// SafeRegex.swift +// bitchat +// +// Non-trapping construction for the app's compiled-in regex patterns. +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +enum SafeRegex { + /// Compiles a bundled pattern. On failure it logs and returns a regex + /// that can never match, so a bad pattern degrades that one feature + /// instead of crashing at startup. + static func compile(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression { + do { + return try NSRegularExpression(pattern: pattern, options: options) + } catch { + SecureLogger.error("Regex pattern failed to compile, matching disabled: \(pattern) (\(error))", category: .session) + return neverMatching + } + } + + /// `(?!)` — an empty negative lookahead — always compiles and can never match. + private static let neverMatching: NSRegularExpression = { + if let regex = try? NSRegularExpression(pattern: "(?!)", options: []) { + return regex + } + // Unreachable: "(?!)" is a valid ICU pattern. The inherited plain + // initializer (empty pattern) is the least-bad non-trapping fallback + // if ICU itself were ever broken. + return NSRegularExpression() + }() +} diff --git a/bitchat/Utils/String+Nickname.swift b/bitchat/Utils/String+Nickname.swift index b586aa66..9a652667 100644 --- a/bitchat/Utils/String+Nickname.swift +++ b/bitchat/Utils/String+Nickname.swift @@ -9,6 +9,14 @@ import Foundation extension String { + /// Canonical form for nickname storage and comparison (Unicode NFC). + /// "café" typed with a combining accent and "café" typed precomposed + /// must resolve to the same user wherever nicknames are stored or + /// matched (mentions, DM resolution, autocomplete, geo presence). + var normalizedNickname: String { + precomposedStringWithCanonicalMapping + } + /// Split a nickname into base and a '#abcd' suffix if present func splitSuffix() -> (String, String) { let name = self.replacingOccurrences(of: "@", with: "") diff --git a/bitchat/ViewModels/ChatComposerCoordinator.swift b/bitchat/ViewModels/ChatComposerCoordinator.swift index c554a9e3..d0d347f2 100644 --- a/bitchat/ViewModels/ChatComposerCoordinator.swift +++ b/bitchat/ViewModels/ChatComposerCoordinator.swift @@ -31,6 +31,10 @@ protocol ChatComposerContext: AnyObject { /// The transport's own nickname (excluded from autocomplete candidates). var meshNickname: String { get } func meshPeerNicknames() -> [PeerID: String] + /// True when this mesh nickname belongs to a blocked peer. + func isMeshNicknameBlocked(_ nickname: String) -> Bool + /// True when this geohash pubkey is blocked for location chats. + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool // MARK: Geohash identity (shared with the other contexts) var geoNicknames: [String: String] { get } @@ -40,8 +44,8 @@ protocol ChatComposerContext: AnyObject { extension ChatViewModel: ChatComposerContext { // `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`, // `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`, - // `geoNicknames`, `meshPeerNicknames()`, and - // `deriveNostrIdentity(forGeohash:)` are shared requirements with the + // `geoNicknames`, `meshPeerNicknames()`, `isNostrBlocked(pubkeyHexLowercased:)`, + // and `deriveNostrIdentity(forGeohash:)` are shared requirements with the // other contexts or satisfied by existing `ChatViewModel` members. The // members below flatten nested service accesses into intent-named calls. @@ -60,6 +64,13 @@ extension ChatViewModel: ChatComposerContext { var meshNickname: String { meshService.myNickname } + + func isMeshNicknameBlocked(_ nickname: String) -> Bool { + for (peerID, nick) in meshService.getPeerNicknames() where nick == nickname { + if isPeerBlocked(peerID) { return true } + } + return false + } } @MainActor @@ -136,11 +147,14 @@ private extension ChatComposerCoordinator { switch context.activeChannel { case .mesh: let values = context.meshPeerNicknames().values - return Array(values.filter { $0 != context.meshNickname }) + return Array(values.filter { nick in + nick != context.meshNickname && !context.isMeshNicknameBlocked(nick) + }) case .location(let channel): var tokens = Set() for (pubkey, nick) in context.geoNicknames { + guard !context.isNostrBlocked(pubkeyHexLowercased: pubkey) else { continue } tokens.insert("\(nick)#\(pubkey.suffix(4))") } if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift index 6e146fa5..b8a2b2a6 100644 --- a/bitchat/ViewModels/ChatGroupCoordinator.swift +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -105,16 +105,19 @@ extension ChatViewModel: ChatGroupContext { identityManager.isBlocked(fingerprint: fingerprint) } + /// Group state rides the mesh's Noise sessions only. + private var groupTransport: MeshGroupMessaging? { meshService as? MeshGroupMessaging } + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) { - meshService.sendGroupInvite(payload, to: peerID) + groupTransport?.sendGroupInvite(payload, to: peerID) } func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) { - meshService.sendGroupKeyUpdate(payload, to: peerID) + groupTransport?.sendGroupKeyUpdate(payload, to: peerID) } func broadcastGroupMessagePayload(_ payload: Data) { - meshService.broadcastGroupMessage(payload) + groupTransport?.broadcastGroupMessage(payload) } // MARK: CommandContextProvider group commands (parsed by CommandProcessor) diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 9b0fab0d..a2d8e8ea 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -106,8 +106,8 @@ extension ChatViewModel: ChatLifecycleContext { } func refreshBluetoothState() { - if let bleService = meshService as? BLEService { - updateBluetoothState(bleService.getCurrentBluetoothState()) + if let radio = meshService as? BluetoothStateReporting { + updateBluetoothState(radio.getCurrentBluetoothState()) } } @@ -360,9 +360,9 @@ private extension ChatLifecycleCoordinator { } } - func deliveryStatusRank(_ status: DeliveryStatus?) -> Int { - guard let status else { return 0 } + func deliveryStatusRank(_ status: DeliveryStatus) -> Int { switch status { + case .notSentYet: return 0 case .failed: return 1 case .sending: return 2 case .sent: return 3 diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift index c3ec5ce9..78fa83e1 100644 --- a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift +++ b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift @@ -353,7 +353,11 @@ final class ChatLiveVoiceCoordinator { // Eviction skips voice_live_* names, so partials still streaming in // are safe no matter which caller triggers enforcement. fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes) - fileManager.createFile(atPath: fileURL.path, contents: nil) + fileManager.createFile( + atPath: fileURL.path, + contents: nil, + attributes: BLEIncomingFileStore.mediaProtectionAttributes + ) guard let handle = try? FileHandle(forWritingTo: fileURL) else { SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session) try? fileManager.removeItem(at: fileURL) diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index bad3a7fb..84295135 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -151,14 +151,19 @@ extension ChatViewModel: ChatMediaTransferContext { // other contexts or satisfied by existing `ChatViewModel` members. The // members below flatten mesh service accesses. + /// File transfer rides the mesh only. Without that capability the + /// policy degrades to the safe floor (blocked), matching the old + /// inert protocol defaults. + private var fileTransport: MeshFileTransferring? { meshService as? MeshFileTransferring } + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { - meshService.privateMediaSendPolicy(to: peerID) + fileTransport?.privateMediaSendPolicy(to: peerID) ?? .blockedDowngrade } func authenticatedPrivateMediaReceiptSessionGeneration( to peerID: PeerID ) -> UUID? { - meshService.authenticatedPrivateMediaReceiptSessionGeneration( + fileTransport?.authenticatedPrivateMediaReceiptSessionGeneration( to: peerID ) } @@ -167,7 +172,11 @@ extension ChatViewModel: ChatMediaTransferContext { to peerID: PeerID, completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void ) { - meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion) + guard let fileTransport else { + Task { @MainActor in completion(.blockedDowngrade) } + return + } + fileTransport.resolvePrivateMediaSendPolicy(to: peerID, completion: completion) } func requestLegacyPrivateMediaConsent( @@ -197,7 +206,7 @@ extension ChatViewModel: ChatMediaTransferContext { transferId: String, allowLegacyFallback: Bool ) { - meshService.sendFilePrivate( + fileTransport?.sendFilePrivate( packet, to: peerID, transferId: transferId, @@ -210,7 +219,7 @@ extension ChatViewModel: ChatMediaTransferContext { to peerID: PeerID, transferId: String ) { - meshService.sendFilePrivateReceiptRetry( + fileTransport?.sendFilePrivateReceiptRetry( packet, to: peerID, transferId: transferId @@ -218,11 +227,11 @@ extension ChatViewModel: ChatMediaTransferContext { } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { - meshService.sendFileBroadcast(packet, transferId: transferId) + fileTransport?.sendFileBroadcast(packet, transferId: transferId) } func cancelTransfer(_ transferId: String) { - meshService.cancelTransfer(transferId) + fileTransport?.cancelTransfer(transferId) } func removeUntombstonedMediaMessage(withID messageID: String) { @@ -1890,7 +1899,7 @@ private extension ChatMediaTransferCoordinator { try FileManager.default.createDirectory( at: filesDirectory, withIntermediateDirectories: true, - attributes: nil + attributes: BLEIncomingFileStore.mediaProtectionAttributes ) return filesDirectory } diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 62723d7a..ae663f84 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -41,7 +41,9 @@ final class ChatMessageFormatter { }() let isDark = colorScheme == .dark - if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) { + let isVerifiedSender = !isSelf && isVerifiedSender(of: message) + let cacheVariant = theme.formatCacheVariant + (isVerifiedSender ? "-vf" : "") + if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: cacheVariant) { return cachedText } @@ -66,6 +68,9 @@ final class ChatMessageFormatter { suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } + if isVerifiedSender { + appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) + } result.append(AttributedString("> ").mergingAttributes(senderStyle)) let content = message.content @@ -183,7 +188,8 @@ final class ChatMessageFormatter { allMatches.sort { $0.range.location < $1.range.location } var lastEnd = content.startIndex - let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false + let myNickname = viewModel.nickname.normalizedNickname + let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false for (range, type) in allMatches { guard let swiftRange = Range(range, in: content) else { continue } @@ -335,7 +341,7 @@ final class ChatMessageFormatter { result.append(timestamp.mergingAttributes(timestampStyle)) } - message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) + message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: cacheVariant) return result } @@ -356,6 +362,7 @@ final class ChatMessageFormatter { let isDark = colorScheme == .dark let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) + let isVerifiedSender = !isSelf && isVerifiedSender(of: message) if message.sender == "system" { var style = AttributeContainer() @@ -381,6 +388,9 @@ final class ChatMessageFormatter { suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } + if isVerifiedSender { + appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) + } result.append(AttributedString("> ").mergingAttributes(senderStyle)) return result } @@ -427,6 +437,29 @@ final class ChatMessageFormatter { } private extension ChatMessageFormatter { + /// Whether the message sender has a fingerprint the user has verified. + /// Used for the in-chat seal next to `<@name>` so verification is visible + /// without opening the fingerprint sheet (#1439). + func isVerifiedSender(of message: BitchatMessage) -> Bool { + guard let peerID = message.senderPeerID, + let fingerprint = viewModel.getFingerprint(for: peerID) else { + return false + } + return viewModel.peerIdentityStore.isVerified(fingerprint) + } + + func appendVerifiedSeal( + to result: inout AttributedString, + baseColor: Color, + design: Font.Design + ) { + var sealStyle = AttributeContainer() + // Match the peer-list verified seal: filled checkmark in the sender tint. + sealStyle.foregroundColor = baseColor + sealStyle.font = .bitchatSystem(size: 11, weight: .semibold, design: design) + result.append(AttributedString(" ✓").mergingAttributes(sealStyle)) + } + func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { if let spid = message.senderPeerID { if spid.isGeoChat || spid.isGeoDM { diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 93b0f32a..65b30df9 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -501,6 +501,9 @@ final class ChatPeerIdentityCoordinator { @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { + // Queries arrive from typed commands and message content, so bring + // them to the same canonical (NFC) form nicknames are stored in. + let nickname = nickname.normalizedNickname switch context.activeChannel { case .location: if nickname.contains("#"), diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 301c4bee..d12e2c1d 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -506,14 +506,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func checkForMentions(_ message: BitchatMessage) { - var myTokens: Set = [context.nickname] + let myNickname = context.nickname.normalizedNickname + var myTokens: Set = [myNickname] let meshPeers = context.meshPeerNicknames() - let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") } + let collisions = meshPeers.values.filter { $0.normalizedNickname.hasPrefix(myNickname + "#") } if !collisions.isEmpty { let suffix = "#" + String(context.myPeerID.id.prefix(4)) - myTokens = [context.nickname + suffix] + myTokens = [myNickname + suffix] } - let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false + let isMentioned = message.mentions?.contains { myTokens.contains($0.normalizedNickname) } ?? false if isMentioned && message.sender != context.nickname { SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index 2de291ff..f6499988 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -129,12 +129,15 @@ extension ChatViewModel: ChatVerificationContext { messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases) } + /// QR verification rides the mesh's Noise sessions only. + private var verifyTransport: MeshVerifying? { meshService as? MeshVerifying } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { - meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) + verifyTransport?.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { - meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) + verifyTransport?.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } func postLocalNotification(title: String, body: String, identifier: String) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 197616e7..12adfda9 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -176,10 +176,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage var networkActivationAllowed: Bool { !panicRecoveryBlocked } @Published var nickname: String = "" { didSet { - // Trim whitespace whenever nickname is set; whitespace-only becomes "" - let trimmed = nickname.trimmedOrNilIfEmpty ?? "" - if trimmed != nickname { - nickname = trimmed + // Canonicalize whenever nickname is set: trim whitespace + // (whitespace-only becomes "") and apply Unicode NFC so accented + // names match regardless of how they were typed. + let cleaned = (nickname.trimmedOrNilIfEmpty ?? "").normalizedNickname + if cleaned != nickname { + nickname = cleaned return } // Update mesh service nickname if it's initialized @@ -1069,7 +1071,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage } func purgeArchivedPublicMessages() { - meshService.purgeAllArchivedPublicMessages() + (meshService as? MeshPublicArchiving)?.purgeAllArchivedPublicMessages() } /// Queues a system message for the next geohash channel visit. (Tiny @@ -1564,8 +1566,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // Quiesce the mesh before clearing stores. Identity replacement below // deliberately stays stopped until media deletion and marker commit. - if let bleService = meshService as? BLEService { - bleService.suspendForPanicReset() + if let panicTransport = meshService as? PanicResettingTransport { + panicTransport.suspendForPanicReset() } else { meshService.emergencyDisconnectAll() } @@ -1700,8 +1702,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // Replace the BLE identity while keeping the radio stopped. It may // reopen only after the durable panic transaction commits. - if let bleService = meshService as? BLEService { - bleService.resetIdentityForPanic( + if let panicTransport = meshService as? PanicResettingTransport { + panicTransport.resetIdentityForPanic( currentNickname: nickname, restartServices: false ) @@ -1746,18 +1748,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage guard panicCompleted else { return false } - if let bleService = meshService as? BLEService { + if let panicTransport = meshService as? PanicResettingTransport { // Startup recovery reopens admission but leaves actual service // start to the bootstrapper immediately after this method. - bleService.completePanicReset( + panicTransport.completePanicReset( restartServices: restartServices ) } if restartServices { // All persistent state and media are gone. Bring each service back - // only now, under the new identity. - if !(meshService is BLEService) { + // only now, under the new identity — a panic-resetting transport + // owns its own restart sequencing above. + if !(meshService is PanicResettingTransport) { meshService.startServices() } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 99ca2886..3ccb7d12 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -195,9 +195,8 @@ private extension ChatViewModelBootstrapper { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in guard let viewModel, - let bleService = viewModel.meshService as? BLEService else { return } - let state = bleService.getCurrentBluetoothState() - viewModel.updateBluetoothState(state) + let radio = viewModel.meshService as? BluetoothStateReporting else { return } + viewModel.updateBluetoothState(radio.getCurrentBluetoothState()) } viewModel.nostrRelayManager = NostrRelayManager.shared @@ -219,8 +218,9 @@ private extension ChatViewModelBootstrapper { /// right after transport start, so give it a beat before asking. private func loadArchivedEchoes() { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in - guard let viewModel else { return } - viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in + guard let viewModel, + let archive = viewModel.meshService as? MeshPublicArchiving else { return } + archive.collectArchivedPublicMessages { [weak viewModel] allArchived in guard let viewModel else { return } // A previous /clear dismissed everything heard up to its // watermark; only newer archive entries come back. Blocking a @@ -331,7 +331,7 @@ private extension ChatViewModelBootstrapper { func configureGateway() { // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests) // has no carrier packets to bridge. - guard let bleService = viewModel.meshService as? BLEService else { return } + guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } let gateway = GatewayService.shared gateway.publishToRelays = { event, geohash in @@ -410,7 +410,7 @@ private extension ChatViewModelBootstrapper { /// transport, the relay manager, location, and the public timeline. Same /// closure-injection style as `configureGateway`. func configureBridge() { - guard let bleService = viewModel.meshService as? BLEService else { return } + guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } let bridge = BridgeService.shared let idBridge = viewModel.idBridge @@ -545,7 +545,7 @@ private extension ChatViewModelBootstrapper { /// manager, the mesh transport's sealing/opening primitives, the courier /// store, and the message router's deposit path. func configureBridgeCourier() { - guard let bleService = viewModel.meshService as? BLEService else { return } + guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return } let courier = BridgeCourierService.shared courier.bridgeEnabled = { BridgeService.shared.isEnabled } diff --git a/bitchat/ViewModels/ChatVouchCoordinator.swift b/bitchat/ViewModels/ChatVouchCoordinator.swift index 26cb8158..29c82440 100644 --- a/bitchat/ViewModels/ChatVouchCoordinator.swift +++ b/bitchat/ViewModels/ChatVouchCoordinator.swift @@ -90,7 +90,7 @@ extension ChatViewModel: ChatVouchContext { } func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { - meshService.sendVouchAttestations(payload, to: peerID) + (meshService as? MeshVerifying)?.sendVouchAttestations(payload, to: peerID) } func notifyPeerTrustChanged() { diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index d77a4221..9dad8030 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -97,14 +97,17 @@ extension ChatViewModel { /// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble. @MainActor func makeVoiceCaptureSession() -> VoiceCaptureSession { + // Live voice rides the mesh only; frames are useful now or never, + // so a transport without the capability just drops them. + let voiceTransport = meshService as? MeshVoiceStreaming switch liveVoiceTarget() { case .peer(let peerID): - return PTTLiveVoiceSession(sendPacket: { [meshService] packet in - meshService.sendVoiceFrame(packet, to: peerID) + return PTTLiveVoiceSession(sendPacket: { packet in + voiceTransport?.sendVoiceFrame(packet, to: peerID) }) case .publicMesh: - return PTTLiveVoiceSession(sendPacket: { [meshService] packet in - meshService.sendVoiceFrameBroadcast(packet) + return PTTLiveVoiceSession(sendPacket: { packet in + voiceTransport?.sendVoiceFrameBroadcast(packet) }) case nil: SecureLogger.info("PTT: hold uses classic voice note (liveVoiceEnabled=\(PTTSettings.liveVoiceEnabled), dmSelected=\(selectedPrivateChatPeer != nil))", category: .session) diff --git a/bitchat/Views/Components/DeliveryStatusView.swift b/bitchat/Views/Components/DeliveryStatusView.swift index e106d2c1..ad8039e9 100644 --- a/bitchat/Views/Components/DeliveryStatusView.swift +++ b/bitchat/Views/Components/DeliveryStatusView.swift @@ -15,6 +15,8 @@ extension DeliveryStatus { /// the glyphs alone are unexplained 10pt icons. var bitchatDescription: String { switch self { + case .notSentYet: + return String(localized: "content.delivery.not_sent_yet", defaultValue: "Not sent yet", comment: "Delivery status description for a message that has not entered any send pipeline") case .sending: return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent") case .sent: @@ -72,6 +74,13 @@ struct DeliveryStatusView: View { @ViewBuilder private var statusGlyph: some View { switch status { + case .notSentYet: + // Normally hidden by callers; shown as a hollow dotted circle if + // it ever surfaces so the state is visible rather than invisible. + Image(systemName: "circle.dotted") + .font(.bitchatSystem(size: 10)) + .foregroundColor(secondaryTextColor.opacity(0.6)) + case .sending: Image(systemName: "circle") .font(.bitchatSystem(size: 10)) @@ -125,6 +134,7 @@ struct DeliveryStatusView: View { #Preview { let statuses: [DeliveryStatus] = [ + .notSentYet, .sending, .sent, .carried, diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 48ccb427..a16e3ac6 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -23,7 +23,7 @@ struct TextMessageView: View { /// SAME instance would otherwise compare "unchanged" and this row's body /// would be skipped even though the parent list re-rendered. Snapshotting /// the enum makes the change visible to SwiftUI's structural diff. - private let deliveryStatus: DeliveryStatus? + private let deliveryStatus: DeliveryStatus @State private var expandedMessageIDs: Set = [] @State private var showDeliveryDetail = false @@ -68,11 +68,11 @@ struct TextMessageView: View { // .help() tooltips only exist on macOS, so iOS users get the // explanation as a caption under the row instead. if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - let status = deliveryStatus { + deliveryStatus != .notSentYet { Button { showDeliveryDetail.toggle() } label: { - DeliveryStatusView(status: status) + DeliveryStatusView(status: deliveryStatus) .padding(.leading, 4) .contentShape(Rectangle()) } @@ -86,15 +86,15 @@ struct TextMessageView: View { // Failure reasons stay visible without a tap; other statuses // reveal on demand. if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - let status = deliveryStatus { - if case .failed = status { - Text(verbatim: status.bitchatDescription) + deliveryStatus != .notSentYet { + if case .failed = deliveryStatus { + Text(verbatim: deliveryStatus.bitchatDescription) .bitchatFont(size: 11) .foregroundColor(Color.red.opacity(0.9)) .fixedSize(horizontal: false, vertical: true) .padding(.top, 2) } else if showDeliveryDetail { - Text(verbatim: status.bitchatDescription) + Text(verbatim: deliveryStatus.bitchatDescription) .bitchatFont(size: 11) .foregroundColor(palette.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index be2bdeec..1fe95c75 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -92,6 +92,9 @@ struct ContentView: View { @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var sharedContentImportModel: SharedContentImportModel + @EnvironmentObject private var peerListModel: PeerListModel + @EnvironmentObject private var publicChatModel: PublicChatModel + @EnvironmentObject private var privateInboxModel: PrivateInboxModel @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @State private var messageText = "" @@ -182,7 +185,13 @@ struct ContentView: View { !hasRootModalPresentation else { return } - appChromeModel.showBluetoothAlert = false + // SwiftUI can invoke this setter inside a view update (the + // alert dismisses when a scenePhase change re-evaluates the + // `get`); publishing synchronously there is undefined + // behavior, so defer the write one hop. + Task { @MainActor in + appChromeModel.showBluetoothAlert = false + } } ) } @@ -205,7 +214,11 @@ struct ContentView: View { !hasRootModalPresentationBesidesVoiceAlert else { return } - voiceRecordingVM.showAlert = false + // Same deferral as the Bluetooth alert above: the setter can + // run inside a view update when the sheet state changes. + Task { @MainActor in + voiceRecordingVM.showAlert = false + } } ) } @@ -287,6 +300,17 @@ struct ContentView: View { showImagePicker: $showImagePicker, imagePickerSourceType: $imagePickerSourceType ) + // Sheets + NavigationStack can drop inherited EnvironmentObjects on + // some iOS versions (#1558). Re-inject every model the sheet tree + // reads so ContentPeopleListView / MessageListView never crash. + .environmentObject(appChromeModel) + .environmentObject(privateConversationModel) + .environmentObject(verificationModel) + .environmentObject(conversationUIModel) + .environmentObject(locationChannelsModel) + .environmentObject(peerListModel) + .environmentObject(publicChatModel) + .environmentObject(privateInboxModel) #else ContentPeopleSheetView( showSidebar: $showSidebar, @@ -304,6 +328,14 @@ struct ContentView: View { onSendMessage: sendMessage, showMacImagePicker: $showMacImagePicker ) + .environmentObject(appChromeModel) + .environmentObject(privateConversationModel) + .environmentObject(verificationModel) + .environmentObject(conversationUIModel) + .environmentObject(locationChannelsModel) + .environmentObject(peerListModel) + .environmentObject(publicChatModel) + .environmentObject(privateInboxModel) #endif } .sheet(isPresented: $appChromeModel.isAppInfoPresented) { diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 2b12ee24..2e2d1429 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -20,7 +20,7 @@ struct MediaMessageView: View { /// is a reference type mutated in place, and SwiftUI compares reference /// fields by identity, so without the snapshot a status-only change /// (send progress, delivered → read) would not re-render this row. - private let deliveryStatus: DeliveryStatus? + private let deliveryStatus: DeliveryStatus @State private var showDeliveryDetail = false @Binding var imagePreviewURL: URL? @@ -57,11 +57,11 @@ struct MediaMessageView: View { // .help() tooltips only exist on macOS, so iOS users get the // explanation as a caption under the row instead. if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - let status = deliveryStatus { + deliveryStatus != .notSentYet { Button { showDeliveryDetail.toggle() } label: { - DeliveryStatusView(status: status) + DeliveryStatusView(status: deliveryStatus) .padding(.leading, 4) .contentShape(Rectangle()) } @@ -75,14 +75,14 @@ struct MediaMessageView: View { // Failure reasons stay visible without a tap; other statuses // reveal on demand. if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - let status = deliveryStatus { - if case .failed = status { - Text(verbatim: status.bitchatDescription) + deliveryStatus != .notSentYet { + if case .failed = deliveryStatus { + Text(verbatim: deliveryStatus.bitchatDescription) .bitchatFont(size: 11) .foregroundColor(Color.red.opacity(0.9)) .fixedSize(horizontal: false, vertical: true) } else if showDeliveryDetail { - Text(verbatim: status.bitchatDescription) + Text(verbatim: deliveryStatus.bitchatDescription) .bitchatFont(size: 11) .foregroundColor(palette.secondary) .fixedSize(horizontal: false, vertical: true) @@ -132,26 +132,24 @@ struct MediaMessageView: View { } } - private func mediaSendState(for deliveryStatus: DeliveryStatus?, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + private func mediaSendState(for deliveryStatus: DeliveryStatus, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) { // A received message is never in a send state: BitchatMessage defaults // private messages to .sending, so an incoming message's status must // not drive the reveal mask or disable the reveal tap. guard isFromMe else { return (false, nil, false) } var isSending = false var progress: Double? - if let status = deliveryStatus { - switch status { - case .sending: + switch deliveryStatus { + case .sending: + isSending = true + progress = 0 + case .partiallyDelivered(let reached, let total): + if total > 0 { isSending = true - progress = 0 - case .partiallyDelivered(let reached, let total): - if total > 0 { - isSending = true - progress = Double(reached) / Double(total) - } - case .sent, .carried, .read, .delivered, .failed: - break + progress = Double(reached) / Double(total) } + case .notSentYet, .sent, .carried, .read, .delivered, .failed: + break } let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message) let clamped = progress.map { max(0, min(1, $0)) } diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 2742aebd..6e1c6b95 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -430,7 +430,7 @@ private extension MessageListView { guard message.isPrivate, conversationUIModel.isSentByCurrentUser(message), conversationUIModel.mediaAttachment(for: message) == nil, - case .some(.failed) = message.deliveryStatus + case .failed = message.deliveryStatus else { return false } return true } diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 5c48832a..13f2bb25 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -13,6 +13,58 @@ import BitFoundation struct BLEServiceCoreTests { + /// Records ping completions (delivered on the main actor) so the + /// injected-clock test can assert from its own thread. + private final class MeshPingResultCollector: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [MeshPingResult?] = [] + var results: [MeshPingResult?] { lock.withLock { recorded } } + func record(_ result: MeshPingResult?) { + lock.withLock { recorded.append(result) } + } + } + + /// The ping deadline asserted on an injected clock: the real 10s + /// product constant, no wall-clock in the loop. This is the pattern + /// for every engine deadline — the timeout must not fire early, must + /// fire exactly once at the deadline, and must stay consumed after. + @Test + func meshPingTimesOutOnTheInjectedClockExactlyOnce() async throws { + let scheduler = BLEEngineManualScheduler() + let ble = makeService(engineScheduler: scheduler) + let peer = PeerID(str: "aabbccdd00112233") + ble._test_seedConnectedPeer(peer, nickname: "Alice") + + let collector = MeshPingResultCollector() + ble.sendMeshPing(to: peer) { result in + collector.record(result) + } + // The probe registers and its deadline schedules on the engine; + // fence that submission before touching the clock. + await ble._test_drainNoiseMessagePipeline() + #expect(scheduler.pendingCount == 1) + + // A hair before the deadline nothing may fire. + scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds - 0.01) + await ble._test_drainNoiseMessagePipeline() + #expect(collector.results.isEmpty) + + // Crossing the deadline expires the probe: nil, exactly once, on + // the main actor. + scheduler.advance(by: 0.02) + let completed = await TestHelpers.waitUntil( + { collector.results.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(completed) + #expect(collector.results == [nil]) + + // The deadline is consumed — more time cannot re-fire it. + scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds * 2) + await ble._test_drainNoiseMessagePipeline() + #expect(collector.results.count == 1) + } + @Test func duplicatePacket_isDeduped() async throws { let ble = makeService() @@ -39,7 +91,7 @@ struct BLEServiceCoreTests { ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey) let receivedDuplicate = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!receivedDuplicate) @@ -117,7 +169,7 @@ struct BLEServiceCoreTests { let unsignedRelayed = await TestHelpers.waitUntil( { outbound.count(ofType: .leave) > 0 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!unsignedRelayed) #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }) @@ -133,7 +185,7 @@ struct BLEServiceCoreTests { let badSignatureRelayed = await TestHelpers.waitUntil( { outbound.count(ofType: .leave) > 0 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!badSignatureRelayed) #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }) @@ -502,26 +554,19 @@ struct BLEServiceCoreTests { ) let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce") #expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink)) - let rebindGate = VerifiedDirectRebindGate() - ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause - defer { - rebindGate.release() - ble._test_afterVerifiedDirectRebindEnqueued = nil - } ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false) - let announcePaused = await TestHelpers.waitUntil( - { rebindGate.hasPaused }, + // The rebind, its Noise-proof retirement, and the ordinary + // reconnect preparation are one engine slot: no observer can see + // the new binding while the victim's stale sending keys are still + // available. Once the binding is visible, the keys must already be + // gone. + let rebound = await TestHelpers.waitUntil( + { ble._test_centralBinding(attackerLink) == victimPeerID }, timeout: TestConstants.longTimeout ) - try #require(announcePaused) - - // Rebind and ordinary reconnect preparation are one bleQueue - // critical section. Once the binding is visible, stale sending keys - // must already be unavailable. - #expect(ble._test_centralBinding(attackerLink) == victimPeerID) + try #require(rebound) #expect(!ble.canDeliverSecurely(to: victimPeerID)) - rebindGate.release() let outbound = OutboundPacketTap() ble._test_onOutboundPacket = { outbound.record($0) } @@ -908,6 +953,17 @@ struct BLEServiceCoreTests { // old generation the remote may no longer be able to read. #expect(outbound.count(ofType: .noiseEncrypted) == 0) + // The capability-proof watchdog armed at the original authentication + // is still live and can genuinely reach its real 5s deadline here on + // a stalled CI runner. Fire it deterministically: its drain must + // respect the deferred-until-convergence state instead of encrypting + // the parked queues under the restored keys (the exact silent loss + // the defer path exists to prevent). The retry below then still + // finds the queues parked. + ble._test_forcePrivateMediaProofTimeout(for: alicePeerID) + await ble._test_drainNoiseMessagePipeline() + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + // Release the mandatory convergence retry: it retires the restored // session and starts a fresh XX exchange with the live peer. recoveryGate.release() @@ -1209,7 +1265,7 @@ struct BLEServiceCoreTests { let didObservePanicClosure = await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let didObserveClosure = panicIngressObserver.waitUntilClosed( - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) gate.release() continuation.resume(returning: didObserveClosure) @@ -1340,7 +1396,7 @@ struct BLEServiceCoreTests { // rotated sender IDs never bought a sixth response. let exceededBudget = await TestHelpers.waitUntil( { outbound.count(ofType: .pong) > budget }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!exceededBudget) #expect(outbound.count(ofType: .pong) == budget) @@ -1406,35 +1462,6 @@ private final class SessionReconcileCounter: @unchecked Sendable { } } -private final class VerifiedDirectRebindGate: @unchecked Sendable { - private let condition = NSCondition() - private var paused = false - private var released = false - - var hasPaused: Bool { - condition.lock() - defer { condition.unlock() } - return paused - } - - func pause() { - condition.lock() - paused = true - condition.broadcast() - while !released { - condition.wait() - } - condition.unlock() - } - - func release() { - condition.lock() - released = true - condition.broadcast() - condition.unlock() - } -} - private final class ReceivePacketHandoffGate: @unchecked Sendable { private let condition = NSCondition() private var paused = false @@ -1499,7 +1526,8 @@ private final class PanicIngressObserver: @unchecked Sendable { private func makeService( noiseResponderHandshakeTimeout: TimeInterval = - NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler() ) -> BLEService { let keychain = MockKeychain() let identityManager = MockIdentityManager(keychain) @@ -1509,7 +1537,8 @@ private func makeService( idBridge: idBridge, identityManager: identityManager, initializeBluetoothManagers: false, - noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout + noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout, + engineScheduler: engineScheduler ) } diff --git a/bitchatTests/ChatComposerCoordinatorContextTests.swift b/bitchatTests/ChatComposerCoordinatorContextTests.swift index 24624897..d38ddc8a 100644 --- a/bitchatTests/ChatComposerCoordinatorContextTests.swift +++ b/bitchatTests/ChatComposerCoordinatorContextTests.swift @@ -52,9 +52,19 @@ private final class MockChatComposerContext: ChatComposerContext { var activeChannel: ChannelID = .mesh var meshNickname = "me" var meshNicknamesByPeerID: [PeerID: String] = [:] + var blockedMeshNicknames: Set = [] + var blockedNostrPubkeys: Set = [] func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID } + func isMeshNicknameBlocked(_ nickname: String) -> Bool { + blockedMeshNicknames.contains(nickname) + } + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) + } + // Geohash identity var geoNicknames: [String: String] = [:] static let dummyIdentity = NostrIdentity( @@ -120,6 +130,34 @@ struct ChatComposerCoordinatorContextTests { #expect(context.queriedPeerCandidates == [["carol#dddd"]]) } + @Test @MainActor + func updateAutocomplete_excludesBlockedMeshAndGeohashPeers() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.meshNicknamesByPeerID = [ + PeerID(str: "1111111111111111"): "alice", + PeerID(str: "2222222222222222"): "eve", + PeerID(str: "3333333333333333"): "me" + ] + context.blockedMeshNicknames = ["eve"] + context.queryResult = (["@alice"], NSRange(location: 0, length: 3)) + + coordinator.updateAutocomplete(for: "@a", cursorPosition: 2) + #expect(context.queriedPeerCandidates == [["alice"]]) + + let geoContext = MockChatComposerContext() + let geoCoordinator = ChatComposerCoordinator(context: geoContext) + geoContext.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq")) + geoContext.geoNicknames = [ + "aaaabbbbccccdddd": "carol", + "bbbbccccddddeeee": "blocked" + ] + geoContext.blockedNostrPubkeys = ["bbbbccccddddeeee"] + + geoCoordinator.updateAutocomplete(for: "@", cursorPosition: 1) + #expect(geoContext.queriedPeerCandidates == [["carol#dddd"]]) + } + @Test @MainActor func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() { let context = MockChatComposerContext() diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 15a1edea..409b897f 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -147,6 +147,10 @@ struct ChatViewModelDeliveryStatusTests { #expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending)) // ...but a retry after a real failure stays visible. #expect(!Conversation.shouldSkipStatusUpdate(current: .failed(reason: "no route"), new: .sending)) + // .notSentYet is the pre-transport initial state: leaving it is always + // allowed, returning to it never is. + #expect(!Conversation.shouldSkipStatusUpdate(current: .notSentYet, new: .sending)) + #expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .notSentYet)) } @Test @MainActor @@ -729,9 +733,10 @@ struct ChatViewModelDeliveryStatusTests { @Test @MainActor func statusRank_orderingIsCorrect() async { // This tests the implicit ordering used in refreshVisibleMessages - // failed < sending < sent < carried < partiallyDelivered < delivered < read + // notSentYet < failed < sending < sent < carried < partiallyDelivered < delivered < read let statuses: [DeliveryStatus] = [ + .notSentYet, .failed(reason: "test"), .sending, .sent, @@ -745,13 +750,14 @@ struct ChatViewModelDeliveryStatusTests { // This is more of a documentation test to ensure the ranking logic is understood for (index, status) in statuses.enumerated() { switch status { - case .failed: #expect(index == 0) - case .sending: #expect(index == 1) - case .sent: #expect(index == 2) - case .carried: #expect(index == 3) - case .partiallyDelivered: #expect(index == 4) - case .delivered: #expect(index == 5) - case .read: #expect(index == 6) + case .notSentYet: #expect(index == 0) + case .failed: #expect(index == 1) + case .sending: #expect(index == 2) + case .sent: #expect(index == 3) + case .carried: #expect(index == 4) + case .partiallyDelivered: #expect(index == 5) + case .delivered: #expect(index == 6) + case .read: #expect(index == 7) } } } diff --git a/bitchatTests/ChatViewModelRefactoringTests.swift b/bitchatTests/ChatViewModelRefactoringTests.swift index d576106d..afc8e443 100644 --- a/bitchatTests/ChatViewModelRefactoringTests.swift +++ b/bitchatTests/ChatViewModelRefactoringTests.swift @@ -43,14 +43,14 @@ struct ChatViewModelRefactoringTests { transport.simulateConnect(peerID, nickname: "alice") let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil }, - timeout: TestConstants.shortTimeout) + timeout: TestConstants.settleTimeout) #expect(didResolve) // Action: User types /msg command viewModel.sendMessage("/msg @alice Hello Private World") let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 }, - timeout: TestConstants.shortTimeout) + timeout: TestConstants.settleTimeout) #expect(didSend) // Assert: @@ -74,7 +74,7 @@ struct ChatViewModelRefactoringTests { transport.simulateConnect(peerID, nickname: "troll") let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil }, - timeout: TestConstants.shortTimeout) + timeout: TestConstants.settleTimeout) #expect(didResolve) // Action @@ -83,7 +83,7 @@ struct ChatViewModelRefactoringTests { // Assert // Verify identity manager was called to block "fingerprint_123" let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") }, - timeout: TestConstants.shortTimeout) + timeout: TestConstants.settleTimeout) #expect(didBlock) } @@ -114,7 +114,7 @@ struct ChatViewModelRefactoringTests { // Wait for async processing with proper timeout let found = await TestHelpers.waitUntil( { viewModel.privateChats[senderID]?.first?.content == "Secret" }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) // Assert @@ -140,7 +140,7 @@ struct ChatViewModelRefactoringTests { { viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" }) }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) // Assert diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 2093359d..7e4e46a1 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -321,7 +321,7 @@ struct ChatViewModelCommandTests { transport.simulateConnect(peerID, nickname: "Alice") let resolved = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("Alice") == peerID - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.negativeWaitWindow) #expect(resolved) viewModel.handleCommand("/msg Alice") @@ -422,7 +422,7 @@ struct ChatViewModelServiceLifecycleTests { transport.sentReadReceipts.contains { $0.peerID == peerID && $0.receipt.originalMessageID == "read-1" } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.negativeWaitWindow) #expect(sentReadReceipt) #expect(!viewModel.unreadPrivateMessages.contains(peerID)) @@ -506,7 +506,7 @@ struct ChatViewModelReceivingTests { let found = await TestHelpers.waitUntil({ viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(found) } @@ -535,11 +535,11 @@ struct ChatViewModelNoisePayloadTests { let stored = await TestHelpers.waitUntil({ viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) let acked = await TestHelpers.waitUntil({ transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(stored) #expect(acked) @@ -579,7 +579,7 @@ struct ChatViewModelNoisePayloadTests { return name == "Bob" } return false - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(delivered) } @@ -617,7 +617,7 @@ struct ChatViewModelNoisePayloadTests { return true } return false - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) let conversationStoreUpdated = await TestHelpers.waitUntil({ let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] @@ -626,7 +626,7 @@ struct ChatViewModelNoisePayloadTests { return true } return false - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(privateChatUpdated) #expect(conversationStoreUpdated) @@ -730,7 +730,7 @@ struct ChatViewModelVerificationTests { let bound = await TestHelpers.waitUntil({ viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(bound) let qr = VerificationService.VerificationQR( @@ -982,7 +982,7 @@ struct ChatViewModelPeerTests { let cleaned = await TestHelpers.waitUntil({ !viewModel.unreadPrivateMessages.contains(stalePeer) - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(cleaned) } diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index ac68c145..6ce23355 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -142,7 +142,7 @@ struct CourierEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -151,7 +151,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) @@ -161,7 +161,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(announced) let announcePacket = try #require(bobOut.first(ofType: .announce)) @@ -169,7 +169,7 @@ struct CourierEndToEndTests { let handedOver = await TestHelpers.waitUntil( { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(handedOver) // With CoreBluetooth disabled there is no physical link for the send @@ -183,7 +183,7 @@ struct CourierEndToEndTests { bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) let received = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(received) @@ -229,7 +229,7 @@ struct CourierEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -237,7 +237,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) @@ -245,7 +245,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(announced) let announcePacket = try #require(bobOut.first(ofType: .announce)) @@ -253,7 +253,7 @@ struct CourierEndToEndTests { let handedOver = await TestHelpers.waitUntil( { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(handedOver) let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) @@ -265,7 +265,7 @@ struct CourierEndToEndTests { bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) let delivered = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!delivered) } @@ -293,7 +293,7 @@ struct CourierEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -301,7 +301,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) @@ -310,7 +310,7 @@ struct CourierEndToEndTests { let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) > 0 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!leakedOnUnverifiedAnnounce) #expect(!carol.courierStore.isEmpty) @@ -318,7 +318,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(announced) let verifiedAnnounce = try #require(bobOut.first(ofType: .announce)) @@ -326,7 +326,7 @@ struct CourierEndToEndTests { let handedOver = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) == 1 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(handedOver) #expect(!carol.courierStore.isEmpty) @@ -355,7 +355,7 @@ struct CourierEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -363,14 +363,14 @@ struct CourierEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(announced) let directAnnounce = try #require(bobOut.first(ofType: .announce)) @@ -385,7 +385,7 @@ struct CourierEndToEndTests { let remoteHandover = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) == 1 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(remoteHandover) #expect(!carol.courierStore.isEmpty) @@ -398,7 +398,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let reannounced = await TestHelpers.waitUntil( { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(reannounced) let freshAnnounce = try #require( @@ -410,7 +410,7 @@ struct CourierEndToEndTests { let refloodedInCooldown = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!refloodedInCooldown) #expect(!carol.courierStore.isEmpty) @@ -424,7 +424,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let announcedAgain = await TestHelpers.waitUntil( { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(announcedAgain) let directAgain = try #require( @@ -434,7 +434,7 @@ struct CourierEndToEndTests { let handedOverWithoutLinkProof = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!handedOverWithoutLinkProof) #expect(!carol.courierStore.isEmpty) @@ -457,7 +457,7 @@ struct CourierEndToEndTests { let queuedPacket = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!queuedPacket) } @@ -494,7 +494,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) let stored = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!stored) } @@ -532,7 +532,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) let stored = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!stored) } @@ -575,7 +575,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false) let stored = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!stored) } @@ -602,14 +602,14 @@ struct CourierEndToEndTests { let delivered = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(delivered) // Give a duplicate delivery a chance to surface, then confirm the // second copy never reached the delegate. let duplicated = await TestHelpers.waitUntil( { bobDelegate.snapshot().count > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!duplicated) #expect(bobDelegate.snapshot().count == 1) @@ -629,7 +629,7 @@ struct CourierEndToEndTests { let initiated = await TestHelpers.waitUntil( { outbound.count(ofType: .noiseHandshake) > 0 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!initiated) @@ -639,7 +639,7 @@ struct CourierEndToEndTests { ble.sendDeliveryAck(for: "msg-2", to: present) let initiatedForPresent = await TestHelpers.waitUntil( { outbound.count(ofType: .noiseHandshake) > 0 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(initiatedForPresent) } @@ -669,7 +669,7 @@ struct CourierEndToEndTests { /// Minimal transport stub for exercising MessageRouter's courier deposit /// logic without BLE plumbing. -private final class CourierCaptureTransport: Transport { +private final class CourierCaptureTransport: Transport, MeshCourierTransporting { weak var delegate: BitchatDelegate? weak var eventDelegate: TransportEventDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate? diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift index 79bd28b5..692c8f6d 100644 --- a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift @@ -87,7 +87,7 @@ struct PrekeyEndToEndTests { peer.sendBroadcastAnnounce() let published = await TestHelpers.waitUntil( { tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(published) return ( @@ -124,7 +124,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(cached) @@ -138,7 +138,7 @@ struct PrekeyEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -149,7 +149,7 @@ struct PrekeyEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) @@ -158,7 +158,7 @@ struct PrekeyEndToEndTests { bob.sendBroadcastAnnounce() let reannounced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(reannounced) let handoverTrigger = try #require(bobOut.first(ofType: .announce)) @@ -166,7 +166,7 @@ struct PrekeyEndToEndTests { let handedOver = await TestHelpers.waitUntil( { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(handedOver) let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) @@ -178,7 +178,7 @@ struct PrekeyEndToEndTests { bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) let received = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(received) @@ -207,7 +207,7 @@ struct PrekeyEndToEndTests { bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID) let redelivered = await TestHelpers.waitUntil( { bobDelegate.snapshot().count == 2 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!redelivered) #expect(bobDelegate.snapshot().count == 1) @@ -235,7 +235,7 @@ struct PrekeyEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -248,7 +248,7 @@ struct PrekeyEndToEndTests { bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false) let received = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(received) let delivered = try #require(bobDelegate.snapshot().first) @@ -272,7 +272,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!cached) } @@ -310,7 +310,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!cached) } @@ -328,7 +328,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(cached) // The verified bundle now participates in Alice's sync rounds. @@ -364,7 +364,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!cached) #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) @@ -396,7 +396,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!cached) #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 44d6f51e..30f071f5 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -204,7 +204,8 @@ struct PrivateMediaEndToEndTests { alice.sendFilePrivate( file, to: bob.myPeerID, - transferId: deniedID + transferId: deniedID, + allowLegacyFallback: false ) let denied = await TestHelpers.waitUntil( { cancellations.contains(deniedID) }, @@ -254,7 +255,7 @@ struct PrivateMediaEndToEndTests { // Consent is invocation-scoped, not a sticky peer preference. let retryID = "legacy-retry-without-consent-\(UUID().uuidString)" - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID) + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID, allowLegacyFallback: false) let retryDenied = await TestHelpers.waitUntil( { cancellations.contains(retryID) }, timeout: TestConstants.longTimeout @@ -998,7 +999,7 @@ struct PrivateMediaEndToEndTests { let encryptedID = "encrypted-over-256-\(UUID().uuidString)" let legacyID = "legacy-over-256-\(UUID().uuidString)" - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID) + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID, allowLegacyFallback: false) alice.sendFilePrivate( file, to: oldCarol.myPeerID, @@ -1318,7 +1319,7 @@ struct PrivateMediaEndToEndTests { mimeType: mimeType, content: content ) - alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)") + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)", allowLegacyFallback: false) let fragmented = await TestHelpers.waitUntil( { tap.hasCompleteFragmentTrain }, diff --git a/bitchatTests/EndToEnd/PublicChatE2ETests.swift b/bitchatTests/EndToEnd/PublicChatE2ETests.swift index b67901d2..4dfdf53f 100644 --- a/bitchatTests/EndToEnd/PublicChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PublicChatE2ETests.swift @@ -50,7 +50,7 @@ struct PublicChatE2ETests { var bobReceivedMessage = false var charlieReceivedMessage = false - await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in + await confirmation("Both receive message", expectedCount: 2) { receiveMessage in bob.messageDeliveryHandler = { message in if message.content == TestConstants.testMessage1 { if !bobReceivedMessage { diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index ff2c406d..e0f3580c 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -37,7 +37,7 @@ struct GossipSyncManagerTests { } manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0) - try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.settleTimeout) } let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent") @@ -394,7 +394,7 @@ struct GossipSyncManagerTests { ) manager.handleRequestSync(from: peer, request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.settleTimeout) // Barrier: flush the sync queue so a late third packet would be visible. manager._performMaintenanceSynchronously(now: Date()) let sentPackets = delegate.packets @@ -477,7 +477,7 @@ struct GossipSyncManagerTests { manager.handleRequestSync(from: peer, request: request) manager.handleRequestSync(from: peer, request: request) - try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.settleTimeout) // Barrier: both requests have been processed once this returns. manager._performMaintenanceSynchronously(now: Date()) #expect(delegate.packets.count == 1) @@ -498,7 +498,7 @@ struct GossipSyncManagerTests { manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let packet = try #require(delegate.packets.first) let request = try #require(RequestSyncPacket.decode(from: packet.payload)) let types = try #require(request.types) @@ -553,7 +553,7 @@ struct GossipSyncManagerTests { let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment) manager.handleRequestSync(from: peer, request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let sentPackets = delegate.packets #expect(sentPackets.count == 1) #expect(sentPackets[0].type == MessageType.fragment.rawValue) @@ -615,7 +615,7 @@ struct GossipSyncManagerTests { ) manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) // Barrier: flush the sync queue so a late second packet would be visible. manager._performMaintenanceSynchronously(now: Date()) let sentPackets = delegate.packets @@ -641,7 +641,7 @@ struct GossipSyncManagerTests { let stalledID = try #require(Data(hexString: "0102030405060708")) manager.requestMissingFragments(fragmentIDs: [stalledID]) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let sent = try #require(delegate.packets.first) #expect(sent.type == MessageType.requestSync.rawValue) #expect(sent.ttl == 0) @@ -697,7 +697,7 @@ struct GossipSyncManagerTests { // And a .prekeyBundle sync request is answered with the stored packet. let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle) manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let served = try #require(delegate.packets.first) #expect(served.type == MessageType.prekeyBundle.rawValue) #expect(served.isRSR) @@ -774,7 +774,7 @@ struct GossipSyncManagerTests { ) let restored = await TestHelpers.waitUntil( { second._messageCount(for: PeerID(hexData: senderID)) == 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.settleTimeout ) #expect(restored) } @@ -844,7 +844,7 @@ struct GossipSyncManagerTests { !FileManager.default.fileExists(atPath: fileURL.path) && manager._messageCount(for: PeerID(hexData: senderID)) == 0 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.settleTimeout ) #expect(erased) } diff --git a/bitchatTests/Mocks/BLEEngineManualScheduler.swift b/bitchatTests/Mocks/BLEEngineManualScheduler.swift new file mode 100644 index 00000000..cfe4d712 --- /dev/null +++ b/bitchatTests/Mocks/BLEEngineManualScheduler.swift @@ -0,0 +1,49 @@ +import Foundation +@testable import bitchat + +/// Manually advanced engine scheduler: deferred work runs when the test +/// advances the clock past its deadline, on the real engine queue (deferred +/// bodies touch engine-confined state), and `advance` returns only after +/// the released work has finished — so assertions that follow observe its +/// engine-side effects without polling. +final class BLEEngineManualScheduler: BLEEngineScheduling, @unchecked Sendable { + private let lock = NSLock() + private var engineQueue: DispatchQueue? + private var now: TimeInterval = 0 + private var pending: [(deadline: TimeInterval, work: DispatchWorkItem)] = [] + + func activate(engineQueue: DispatchQueue) { + lock.withLock { self.engineQueue = engineQueue } + } + + func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) { + lock.withLock { pending.append((now + delay, work)) } + } + + var pendingCount: Int { + lock.withLock { pending.count } + } + + /// Advances the clock, releasing due work in deadline order. + /// Cancellation keeps its production semantics: dispatch skips a + /// cancelled `DispatchWorkItem` at execution. + func advance(by interval: TimeInterval) { + let (due, queue): ([DispatchWorkItem], DispatchQueue?) = lock.withLock { + now += interval + let cutoff = now + let released = pending + .filter { $0.deadline <= cutoff } + .sorted { $0.deadline < $1.deadline } + .map(\.work) + pending.removeAll { $0.deadline <= cutoff } + return (released, engineQueue) + } + guard let queue else { return } + for work in due { + queue.async(execute: work) + } + // Fence: released work (and anything it enqueued) has run before + // the test's next assertion. + queue.sync {} + } +} diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 4620408f..9587e86f 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -14,7 +14,10 @@ import BitFoundation /// Mock Transport implementation for testing ChatViewModel in isolation. /// Records all method calls and allows test code to verify interactions. -final class MockTransport: Transport, PrivateMediaDeletionPersisting { +final class MockTransport: Transport, PrivateMediaDeletionPersisting, + MeshFileTransferring, MeshVerifying, MeshCourierTransporting, + MeshDiagnosing, MeshPublicArchiving, MeshVoiceStreaming, + MeshGroupMessaging, MeshBoardBroadcasting { // MARK: - Protocol Properties @@ -205,11 +208,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { sentBroadcastFiles.append((packet, transferId)) } - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { - sentPrivateFiles.append((packet, peerID, transferId)) - sentPrivateFileLegacyAllowances.append(false) - } - func sendFilePrivate( _ packet: BitchatFilePacket, to peerID: PeerID, @@ -242,6 +240,15 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { cancelledTransfers.append(transferId) } + private(set) var sentFileReceiptRetries: [(BitchatFilePacket, PeerID, String)] = [] + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) { + sentFileReceiptRetries.append((packet, peerID, transferId)) + } + @MainActor func persistDeletedPrivateMedia( messageIDs: [String], @@ -317,6 +324,50 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { meshTopologySnapshot } + // MARK: - Remaining mesh capabilities (recording stubs) + + private(set) var sentVouchAttestations: [(Data, PeerID)] = [] + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + sentVouchAttestations.append((payload, peerID)) + } + + var archivedPublicMessages: [ArchivedPublicMessage] = [] + private(set) var purgedAllArchived = false + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { + let archived = archivedPublicMessages + Task { @MainActor in completion(archived) } + } + func purgeAllArchivedPublicMessages() { + purgedAllArchived = true + } + + private(set) var sentVoiceFrames: [(Data, PeerID)] = [] + private(set) var sentVoiceBroadcasts: [Data] = [] + func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) { + sentVoiceFrames.append((burstContent, peerID)) + } + func sendVoiceFrameBroadcast(_ burstContent: Data) { + sentVoiceBroadcasts.append(burstContent) + } + + private(set) var sentGroupInvites: [(Data, PeerID)] = [] + private(set) var sentGroupKeyUpdates: [(Data, PeerID)] = [] + private(set) var broadcastGroupMessages: [Data] = [] + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) { + sentGroupInvites.append((statePayload, peerID)) + } + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) { + sentGroupKeyUpdates.append((statePayload, peerID)) + } + func broadcastGroupMessage(_ envelope: Data) { + broadcastGroupMessages.append(envelope) + } + + private(set) var sentBoardPayloads: [Data] = [] + func sendBoardPayload(_ payload: Data) { + sentBoardPayloads.append(payload) + } + // MARK: - Test Helpers /// Clears all recorded method calls for fresh assertions diff --git a/bitchatTests/NearbyNotesCounterTests.swift b/bitchatTests/NearbyNotesCounterTests.swift index b548a8ae..5e328197 100644 --- a/bitchatTests/NearbyNotesCounterTests.swift +++ b/bitchatTests/NearbyNotesCounterTests.swift @@ -392,7 +392,7 @@ final class NearbyNotesCounterTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/NicknameNormalizationTests.swift b/bitchatTests/NicknameNormalizationTests.swift new file mode 100644 index 00000000..a32225b9 --- /dev/null +++ b/bitchatTests/NicknameNormalizationTests.swift @@ -0,0 +1,53 @@ +// +// NicknameNormalizationTests.swift +// bitchatTests +// +// Nicknames must compare equal regardless of how the user's keyboard +// produced them: "café" as precomposed U+00E9 and as "e" + combining +// U+0301 are canonically equivalent but bytewise different, which broke +// mention matching, DM resolution, and autocomplete (#214). Storage and +// comparison both canonicalize to NFC via String.normalizedNickname. +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct NicknameNormalizationTests { + /// "café" with a combining acute accent (NFD form) + private let decomposed = "cafe\u{0301}" + /// "café" with precomposed é (NFC form) + private let precomposed = "caf\u{00E9}" + + @Test + func canonicallyEquivalentFormsNormalizeIdentically() { + // Sanity: the raw forms really are different strings byte-wise … + #expect(decomposed.unicodeScalars.count != precomposed.unicodeScalars.count) + // … and normalization unifies them. + #expect(decomposed.normalizedNickname == precomposed.normalizedNickname) + #expect(decomposed.normalizedNickname == precomposed) + } + + @Test + func asciiNicknamesPassThroughUnchanged() { + #expect("alice_42".normalizedNickname == "alice_42") + #expect("".normalizedNickname == "") + } + + @Test + func validateNicknameReturnsCanonicalForm() { + #expect(InputValidator.validateNickname(decomposed) == precomposed) + #expect(InputValidator.validateNickname(" \(decomposed) ") == precomposed) + // Validation behavior is otherwise unchanged. + #expect(InputValidator.validateNickname(" ") == nil) + } + + @Test + func collisionSuffixSplittingSurvivesNormalization() { + let (base, suffix) = (decomposed.normalizedNickname + "#ab12").splitSuffix() + #expect(base == precomposed) + #expect(suffix == "#ab12") + } +} diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index 7f3eb01a..d083bd47 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -723,9 +723,9 @@ struct NoiseCoverageTests { // A failed startup requirement must not strand a late thread in // the blocking test double after the test has returned. oldSession.resumeDecrypt() - _ = decryptResult.wait(timeout: 5) + _ = decryptResult.wait(timeout: TestConstants.settleTimeout) if let promotionResultForCleanup { - _ = promotionResultForCleanup.wait(timeout: 5) + _ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout) } } @@ -751,15 +751,19 @@ struct NoiseCoverageTests { promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote" promotionThread.qualityOfService = .userInitiated promotionThread.start() - try #require(promotionStarted.wait(timeout: .now() + 5) == .success) + try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success) #expect( + // test-timing-ok: a NEGATIVE wait — it asserts the promotion has + // NOT completed yet, so a long deadline would only make the suite + // slow while still passing. A starved runner can only make this + // more likely to hold, never less. promotionResult.wait(timeout: 0.05) == nil, "Promotion must wait for the exact decrypting-session lease" ) oldSession.resumeDecrypt() - let decrypted = try #require(decryptResult.wait(timeout: 5)).get() - _ = try #require(promotionResult.wait(timeout: 5)).get() + let decrypted = try #require(decryptResult.wait(timeout: TestConstants.settleTimeout)).get() + _ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get() #expect(decrypted.plaintext == Data("old session".utf8)) #expect(decrypted.sessionGeneration == oldGeneration) diff --git a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift index 3a9c92ff..1a4ce385 100644 --- a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift +++ b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift @@ -580,8 +580,16 @@ final class GeoRelayDirectoryTests: XCTestCase { /// constrained CI runners (2-core, serialized testing) can starve the /// detached utility-priority fetch task for seconds before it runs, and /// a successful wait returns as soon as the condition becomes true. + /// Default deliberately far larger than the work being awaited. + /// + /// The directory performs its fetch in a `Task.detached(priority: .utility)`, + /// and utility priority competes with every other suite on a CI runner. At + /// ten seconds the retry-scheduling test timed out at exactly 10.06s with + /// the retry never scheduled — which reads like a missing retry rather than + /// a starved background task. Returning as soon as the condition holds means + /// a longer deadline only extends the genuine-failure case. private func waitUntil( - timeout: TimeInterval = 10.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () async -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/PTTBurstPlayerTests.swift b/bitchatTests/PTTBurstPlayerTests.swift index 00881f75..77d60a69 100644 --- a/bitchatTests/PTTBurstPlayerTests.swift +++ b/bitchatTests/PTTBurstPlayerTests.swift @@ -188,7 +188,7 @@ struct PTTBurstPlayerTests { _ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) while !condition(), ContinuousClock.now < deadline { await Task.yield() try? await Task.sleep(nanoseconds: 1_000_000) diff --git a/bitchatTests/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift index d295ff4e..881dc3aa 100644 --- a/bitchatTests/ProtocolContractTests.swift +++ b/bitchatTests/ProtocolContractTests.swift @@ -85,24 +85,16 @@ struct ProtocolContractTests { func transportDefaults_forwardOrNoOp() { let probe = DefaultTransportProbe() let peerID = PeerID(str: "0123456789abcdef") - let filePacket = BitchatFilePacket( - fileName: "voice.m4a", - fileSize: 4, - mimeType: "audio/mp4", - content: Data([1, 2, 3, 4]) - ) probe.sendMessage("hello", mentions: ["@alice"], messageID: "msg-1", timestamp: Date()) - probe.sendVerifyChallenge(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x01])) - probe.sendVerifyResponse(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x02])) - probe.sendFileBroadcast(filePacket, transferId: "tx-1") - probe.sendFilePrivate(filePacket, to: peerID, transferId: "tx-2") - probe.cancelTransfer("tx-3") - probe.declinePendingFile(id: "pending") #expect(probe.sentMessages.count == 1) #expect(probe.sentMessages.first?.content == "hello") - #expect(probe.acceptPendingFile(id: "pending") == nil) + // Mesh-only features are capability protocols now, not inert + // defaults: a core-only transport simply doesn't have them. + #expect(!(probe as AnyObject is MeshFileTransferring)) + #expect(!(probe as AnyObject is MeshDiagnosing)) + #expect(probe.peerCapabilities(peerID).isEmpty) // Secure delivery defaults to prompt delivery (itself defaulting to // reachability) for transports without a forgeable link layer. #expect(probe.canDeliverSecurely(to: peerID) == false) diff --git a/bitchatTests/Protocols/BitchatFilePacketTests.swift b/bitchatTests/Protocols/BitchatFilePacketTests.swift index 2476647f..22ea83f9 100644 --- a/bitchatTests/Protocols/BitchatFilePacketTests.swift +++ b/bitchatTests/Protocols/BitchatFilePacketTests.swift @@ -75,6 +75,70 @@ final class BitchatFilePacketTests: XCTestCase { XCTAssertEqual(decoded.content, content) } + /// The TLV tag list is a floor, not a ceiling: a decoder that bails on the + /// first tag it does not know makes the format unextendable, because a field + /// the sender considered optional costs the receiver the whole file. This + /// decoder skips them (`case nil: continue`) and that has to stay true — it + /// is load-bearing for any peer, version or third-party client that adds a + /// field we have not seen. `PrivateMediaMessageIdentity` exists precisely + /// because the Android decoder does *not* do this, so the asymmetry is real + /// and worth pinning on the side that gets it right. + func testDecodeSkipsUnknownTLVTypesInsteadOfDroppingTheFile() throws { + let content = Data((0..<64).map { UInt8($0) }) + let unknownValue = Data("some-message-id".utf8) + var data = Data() + + // fileName + data.append(0x01) + data.append(contentsOf: [0x00, 0x09]) + data.append(Data("photo.jpg".utf8)) + // fileSize + data.append(0x02) + data.append(contentsOf: [0x00, 0x04]) + data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)]) + // mimeType + data.append(0x03) + data.append(contentsOf: [0x00, 0x0A]) + data.append(Data("image/jpeg".utf8)) + // An unknown tag, where an encoder appending content last would put it + data.append(0x05) + data.append(contentsOf: [0x00, UInt8(unknownValue.count)]) + data.append(unknownValue) + // content + data.append(0x04) + data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)]) + data.append(content) + + let decoded = try XCTUnwrap(BitchatFilePacket.decode(data)) + XCTAssertEqual(decoded.fileName, "photo.jpg") + XCTAssertEqual(decoded.mimeType, "image/jpeg") + XCTAssertEqual(decoded.fileSize, UInt64(content.count)) + XCTAssertEqual(decoded.content, content) + } + + /// Same contract for an extension that trails the content, which a decoder + /// stopping at the first unknown tag would also lose. + func testDecodeSkipsAnUnknownTLVTrailingTheContent() throws { + let content = Data(repeating: 0x7F, count: 16) + var data = Data() + + data.append(0x01) + data.append(contentsOf: [0x00, 0x08]) + data.append(Data("note.m4a".utf8)) + data.append(0x04) + data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)]) + data.append(content) + data.append(0x7F) + data.append(contentsOf: [0x00, 0x04]) + data.append(Data([0x11, 0x11, 0x11, 0x11])) + + let decoded = try XCTUnwrap(BitchatFilePacket.decode(data)) + XCTAssertEqual(decoded.fileName, "note.m4a") + XCTAssertNil(decoded.mimeType) + XCTAssertEqual(decoded.fileSize, UInt64(content.count)) + XCTAssertEqual(decoded.content, content) + } + func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws { let senderKey = Data(repeating: 0x11, count: 32) let recipientKey = Data(repeating: 0x22, count: 32) diff --git a/bitchatTests/Services/BLEAnnounceThrottleTests.swift b/bitchatTests/Services/BLEAnnounceThrottleTests.swift index 0ce23814..0e135a3b 100644 --- a/bitchatTests/Services/BLEAnnounceThrottleTests.swift +++ b/bitchatTests/Services/BLEAnnounceThrottleTests.swift @@ -68,6 +68,21 @@ struct BLEAnnounceThrottleTests { #expect(accepted.value == 1) #expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3) } + + @Test + func resetForgetsThrottleDebtSoARotationAnnounceIsNeverSwallowed() { + let throttle = BLEAnnounceThrottle( + normalMinimumInterval: 1, + forcedMinimumInterval: 1 + ) + let now = Date() + #expect(throttle.shouldSend(force: true, now: now)) + // A panic inside the forced window would be throttled... + #expect(!throttle.shouldSend(force: true, now: now.addingTimeInterval(0.2))) + // ...so the rotation resets the debt and announces immediately. + throttle.reset() + #expect(throttle.shouldSend(force: true, now: now.addingTimeInterval(0.3))) + } } private final class LockedCounter: @unchecked Sendable { diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index ea3b3554..835bcdba 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -222,7 +222,7 @@ struct BLEFileTransferHandlerTests { #expect(message?.isPrivate == false) #expect(message?.senderPeerID == remotePeerID) #expect(message?.timestamp == Date(timeIntervalSince1970: 900)) - #expect(message?.deliveryStatus == nil) + #expect(message?.deliveryStatus == .notSentYet) } @Test diff --git a/bitchatTests/Services/BLELinkAuthStateTests.swift b/bitchatTests/Services/BLELinkAuthStateTests.swift new file mode 100644 index 00000000..c16d41a0 --- /dev/null +++ b/bitchatTests/Services/BLELinkAuthStateTests.swift @@ -0,0 +1,75 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLELinkAuthStateTests { + private let peerID = PeerID(str: "1122334455667788") + private let link = BLEIngressLinkID.peripheral("periph-a") + + @Test + func authenticationBindsToTheExactLinkAndOwner() { + var auth = BLELinkAuthState() + auth.markAuthenticated(link, owner: peerID) + + #expect(auth.isAuthenticated(link, for: peerID)) + #expect(!auth.isAuthenticated(link, for: PeerID(str: "8899aabbccddeeff"))) + #expect(!auth.isAuthenticated(.peripheral("periph-b"), for: peerID)) + + auth.retireLink(link) + #expect(!auth.isAuthenticated(link, for: peerID)) + } + + @Test + func retireLinksOwnedByPeerReturnsAndRetiresThemAll() { + var auth = BLELinkAuthState() + auth.markAuthenticated(.peripheral("periph-a"), owner: peerID) + auth.markAuthenticated(.central("central-a"), owner: peerID) + auth.markAuthenticated(.central("central-b"), owner: PeerID(str: "8899aabbccddeeff")) + + let departed = Set(auth.retireLinks(ownedBy: peerID)) + + #expect(departed == [.peripheral("periph-a"), .central("central-a")]) + #expect(auth.links(ownedBy: peerID).isEmpty) + #expect(auth.isAuthenticated(.central("central-b"), for: PeerID(str: "8899aabbccddeeff"))) + } + + @Test + func rebindCooldownPermitsOncePerWindowAndAgesOut() { + var auth = BLELinkAuthState() + let start = Date(timeIntervalSince1970: 1_000) + + let first = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30) + #expect(first) + let withinWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(10), cooldown: 30) + #expect(!withinWindow) + // A different link has its own allowance. + let otherLink = auth.permitRebind(linkUUID: "periph-b", now: start.addingTimeInterval(10), cooldown: 30) + #expect(otherLink) + // The window ages out. + let afterWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(31), cooldown: 30) + #expect(afterWindow) + } + + @Test + func containmentCooldownsSurviveASessionReset() { + var auth = BLELinkAuthState() + let start = Date(timeIntervalSince1970: 2_000) + auth.markAuthenticated(link, owner: peerID) + let rebindBefore = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30) + let retirementBefore = auth.permitRedundantRetirement(peerID: peerID, now: start, cooldown: 30) + #expect(rebindBefore) + #expect(retirementBefore) + + // Panic/emergency resets wipe proofs and epochs — but a stable + // CoreBluetooth UUID must not earn a fresh rebind or retirement + // allowance just because the session state around it was wiped. + auth.removeAll() + + #expect(!auth.isAuthenticated(link, for: peerID)) + let rebindAfterReset = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(5), cooldown: 30) + let retirementAfterReset = auth.permitRedundantRetirement(peerID: peerID, now: start.addingTimeInterval(5), cooldown: 30) + #expect(!rebindAfterReset) + #expect(!retirementAfterReset) + } +} diff --git a/bitchatTests/Services/BLELinkBindingsTests.swift b/bitchatTests/Services/BLELinkBindingsTests.swift new file mode 100644 index 00000000..3730cb2a --- /dev/null +++ b/bitchatTests/Services/BLELinkBindingsTests.swift @@ -0,0 +1,111 @@ +import BitFoundation +import Testing +@testable import bitchat + +struct BLELinkBindingsTests { + private let peerID = PeerID(str: "1122334455667788") + private let otherPeerID = PeerID(str: "8899aabbccddeeff") + + @Test + func centralBindingExposesBoundPeerAndLinks() { + var bindings = BLELinkBindings() + + bindings.bindCentral("central-a", to: peerID) + + #expect(bindings.peer(forCentralUUID: "central-a") == peerID) + #expect(bindings.hasCentral(boundTo: peerID)) + #expect(bindings.boundPeer(for: .central("central-a")) == peerID) + #expect(bindings.links(to: peerID) == [.central("central-a")]) + } + + @Test + func linksReturnsAllBindingsForPeerAcrossRoles() { + var bindings = BLELinkBindings() + + bindings.bindCentral("central-a", to: peerID) + bindings.bindCentral("central-b", to: peerID) + bindings.bindCentral("central-c", to: otherPeerID) + bindings.bindPeripheral("periph-a", to: peerID) + + #expect(bindings.links(to: peerID) == [.central("central-a"), .central("central-b"), .peripheral("periph-a")]) + } + + @Test + func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() { + var bindings = BLELinkBindings() + + bindings.bindCentral("central-a", to: peerID) + bindings.bindCentral("central-b", to: otherPeerID) + + let removedPeerIDs = Set(bindings.clearCentrals()) + + #expect(removedPeerIDs == Set([peerID, otherPeerID])) + #expect(bindings.peer(forCentralUUID: "central-a") == nil) + #expect(bindings.links(to: peerID).isEmpty) + } + + @Test + func rotationRebindDropsTheRetiredIdentitysReverseMapping() { + var bindings = BLELinkBindings() + bindings.bindPeripheral("periph-a", to: peerID) + #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a") + + // The link's owner rotates: the old identity must no longer claim + // this link as its preferred peripheral. + bindings.bindPeripheral("periph-a", to: otherPeerID) + + #expect(bindings.preferredPeripheralUUID(for: peerID) == nil) + #expect(bindings.preferredPeripheralUUID(for: otherPeerID) == "periph-a") + #expect(bindings.peer(forPeripheralID: "periph-a") == otherPeerID) + } + + @Test + func removingThePreferredLinkRepairsOntoTheChosenSurvivor() { + var bindings = BLELinkBindings() + bindings.bindPeripheral("periph-a", to: peerID) + bindings.bindPeripheral("periph-b", to: peerID) + // periph-b bound last: it is the preferred link. + #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b") + + let removed = bindings.peripheralRemoved("periph-b") { remaining in + #expect(remaining == ["periph-a"]) + return remaining.first + } + + #expect(removed == peerID) + #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a") + #expect(bindings.links(to: peerID) == [.peripheral("periph-a")]) + } + + @Test + func removingADuplicateLinkDoesNotStrandThePreferredOne() { + var bindings = BLELinkBindings() + bindings.bindPeripheral("periph-a", to: peerID) + bindings.bindPeripheral("periph-b", to: peerID) + + // Removing the non-preferred duplicate must leave the reverse map + // untouched (no repair callback consulted for a non-preferred link). + let removed = bindings.peripheralRemoved("periph-a") { _ in + Issue.record("survivor choice must not run for a non-preferred link") + return nil + } + + #expect(removed == peerID) + #expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b") + } + + @Test + func removingTheLastLinkClearsThePreferredMapping() { + var bindings = BLELinkBindings() + bindings.bindPeripheral("periph-a", to: peerID) + + let removed = bindings.peripheralRemoved("periph-a") { remaining in + #expect(remaining.isEmpty) + return nil + } + + #expect(removed == peerID) + #expect(bindings.preferredPeripheralUUID(for: peerID) == nil) + #expect(bindings.links(to: peerID).isEmpty) + } +} diff --git a/bitchatTests/Services/BLELinkStateStoreTests.swift b/bitchatTests/Services/BLELinkStateStoreTests.swift deleted file mode 100644 index 9a5179df..00000000 --- a/bitchatTests/Services/BLELinkStateStoreTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import BitFoundation -import Testing -@testable import bitchat - -struct BLELinkStateStoreTests { - @Test - func centralBindingExposesDirectLinkStateAndLinks() { - let store = BLELinkStateStore() - let peerID = PeerID(str: "1122334455667788") - - store.bindCentral("central-a", to: peerID) - - #expect(store.peerID(forCentralUUID: "central-a") == peerID) - #expect(store.directLinkState(for: peerID) == BLEDirectLinkState(hasPeripheral: false, hasCentral: true)) - #expect(store.links(to: peerID) == [.central("central-a")]) - } - - @Test - func linksReturnsAllCentralBindingsForPeer() { - let store = BLELinkStateStore() - let peerID = PeerID(str: "1122334455667788") - let otherPeerID = PeerID(str: "8899aabbccddeeff") - - store.bindCentral("central-a", to: peerID) - store.bindCentral("central-b", to: peerID) - store.bindCentral("central-c", to: otherPeerID) - - #expect(store.links(to: peerID) == [.central("central-a"), .central("central-b")]) - } - - @Test - func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() { - let store = BLELinkStateStore() - let firstPeerID = PeerID(str: "1122334455667788") - let secondPeerID = PeerID(str: "8899aabbccddeeff") - - store.bindCentral("central-a", to: firstPeerID) - store.bindCentral("central-b", to: secondPeerID) - - let removedPeerIDs = Set(store.clearCentrals()) - - #expect(removedPeerIDs == Set([firstPeerID, secondPeerID])) - #expect(store.peerID(forCentralUUID: "central-a") == nil) - #expect(store.links(to: firstPeerID).isEmpty) - } -} diff --git a/bitchatTests/Services/BLEMeshPingTrackerTests.swift b/bitchatTests/Services/BLEMeshPingTrackerTests.swift new file mode 100644 index 00000000..a3a81008 --- /dev/null +++ b/bitchatTests/Services/BLEMeshPingTrackerTests.swift @@ -0,0 +1,84 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEMeshPingTrackerTests { + private func makeProbe(peerID: PeerID) -> BLEMeshPingProbe { + BLEMeshPingProbe( + peerID: peerID, + sentAt: Date(timeIntervalSince1970: 1_000), + lifecycleGeneration: 1, + completion: { _ in }, + timeout: DispatchWorkItem {} + ) + } + + @Test func resolveReturnsProbeOnlyForTheProbedPeer() { + var tracker = BLEMeshPingTracker() + let nonce = Data([1, 2, 3, 4, 5, 6, 7, 8]) + let probed = PeerID(str: "aaaaaaaaaaaaaaaa") + tracker.register(makeProbe(peerID: probed), nonce: nonce) + + // A pong claiming the right nonce from the wrong peer must not + // consume the probe. + let wrongPeer = tracker.resolve(nonce: nonce, from: PeerID(str: "bbbbbbbbbbbbbbbb")) + #expect(wrongPeer == nil) + let rightPeer = tracker.resolve(nonce: nonce, from: probed) + #expect(rightPeer != nil) + // Consumed exactly once. + let secondResolve = tracker.resolve(nonce: nonce, from: probed) + #expect(secondResolve == nil) + } + + @Test func expireConsumesTheProbeSoResolveCannotFireTwice() { + var tracker = BLEMeshPingTracker() + let nonce = Data([9, 9, 9, 9, 9, 9, 9, 9]) + let probed = PeerID(str: "aaaaaaaaaaaaaaaa") + tracker.register(makeProbe(peerID: probed), nonce: nonce) + + let firstExpire = tracker.expire(nonce: nonce) + #expect(firstExpire != nil) + let secondExpire = tracker.expire(nonce: nonce) + #expect(secondExpire == nil) + let resolveAfterExpire = tracker.resolve(nonce: nonce, from: probed) + #expect(resolveAfterExpire == nil) + } + + @Test func inboundBudgetIsPerLinkAndBounded() { + var tracker = BLEMeshPingTracker() + let now = Date(timeIntervalSince1970: 2_000) + let linkA = PeerID(str: "aaaaaaaaaaaaaaaa") + let linkB = PeerID(str: "bbbbbbbbbbbbbbbb") + + var allowedOnA = 0 + for _ in 0..<(TransportConfig.meshPingInboundMaxPerLink + 5) { + if tracker.shouldRespond(toLink: linkA, now: now) { allowedOnA += 1 } + } + #expect(allowedOnA == TransportConfig.meshPingInboundMaxPerLink) + // One saturated link must not consume another link's budget. + let allowedOnB = tracker.shouldRespond(toLink: linkB, now: now) + #expect(allowedOnB) + } + + @Test func resetDropsProbesRestoresBudgetAndHandsBackTimeouts() { + var tracker = BLEMeshPingTracker() + let now = Date(timeIntervalSince1970: 3_000) + let link = PeerID(str: "aaaaaaaaaaaaaaaa") + let nonce = Data([4, 4, 4, 4, 4, 4, 4, 4]) + tracker.register(makeProbe(peerID: link), nonce: nonce) + for _ in 0.. [Line] { + let enumerator = FileManager.default.enumerator( + at: bleRoot, + includingPropertiesForKeys: nil + ) + var out: [Line] = [] + while let url = enumerator?.nextObject() as? URL { + guard url.pathExtension == "swift" else { continue } + let name = url.lastPathComponent + let texts = try String(contentsOf: url, encoding: .utf8) + .components(separatedBy: .newlines) + for (index, text) in texts.enumerated() { + var waived = text.contains(waiver) + var back = index - 1 + while !waived, back >= 0 { + let previous = texts[back].trimmingCharacters(in: .whitespaces) + guard previous.hasPrefix("//") else { break } + waived = previous.contains(waiver) + back -= 1 + } + out.append(Line(file: name, number: index + 1, text: text, waived: waived)) + } + } + return out + } + + private func offenders(matching pattern: String) throws -> [String] { + try Self.bleLines() + .filter { !$0.waived && $0.text.contains(pattern) } + .map { "\($0.file):\($0.number): \($0.text.trimmingCharacters(in: .whitespaces))" } + } + + @Test func onlyOnEngineSyncEntersTheEngine() throws { + let hits = try offenders(matching: "messageQueue.sync") + #expect(hits.isEmpty, "Raw messageQueue.sync bypasses onEngine's bleQueue trap; route through onEngine (or waive with a reason): \(hits)") + } + + @Test func transportCodeNeverSyncDispatchesToMain() throws { + let hits = try offenders(matching: "DispatchQueue.main.sync") + #expect(hits.isEmpty, "A main.sync from transport code can complete an ABBA cycle with the main actor's sync reads: \(hits)") + } + + @Test func theCollectionsQueueStaysDeleted() throws { + let hits = try offenders(matching: "collectionsQueue") + #expect(hits.isEmpty, "Engine state has exactly one serial domain; do not reintroduce a side queue: \(hits)") + } + + @Test func deferredEngineWorkGoesThroughTheScheduler() throws { + let hits = try offenders(matching: "messageQueue.asyncAfter") + #expect(hits.isEmpty, "Engine delays must use BLEEngineScheduling so tests can drive protocol deadlines with a manual clock: \(hits)") + } +} diff --git a/bitchatTests/Services/BLERedundantLinkPolicyTests.swift b/bitchatTests/Services/BLERedundantLinkPolicyTests.swift index db23a5e8..d544535b 100644 --- a/bitchatTests/Services/BLERedundantLinkPolicyTests.swift +++ b/bitchatTests/Services/BLERedundantLinkPolicyTests.swift @@ -7,8 +7,8 @@ struct BLERedundantLinkPolicyTests { private let peer = PeerID(str: "1122334455667788") private let otherPeer = PeerID(str: "8877665544332211") - private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true) -> BLERedundantLinkPolicy.PeripheralLink { - BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable) + private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true, connectedAt: Date? = nil) -> BLERedundantLinkPolicy.PeripheralLink { + BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable, lastConnectedAt: connectedAt) } @Test @@ -131,4 +131,144 @@ struct BLERedundantLinkPolicyTests { ) #expect(Set(retiring) == Set(["p-stale-1", "p-stale-2"])) } + + // MARK: Connect-recency preference (the July 31 retire↔reconnect fix) + + @Test + func newestConnectionWinsOverIngressAndBindingAnchors() { + // Field oscillation: the restored old-address link (no connect + // timestamp) carried the announce ingress AND the binding, so it + // kept winning — and the cancelled fresh-address link kept getting + // rediscovered and reconnected. Physical connect recency must beat + // both announce anchors. + let now = Date() + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-restored", + mostRecentlyBoundUUID: "p-restored", + links: [ + link("p-restored", peer), + link("p-fresh", peer, connectedAt: now) + ], + peerID: peer + ) + #expect(kept == "p-fresh") + } + + @Test + func amongTimestampedLinksTheNewestWins() { + let now = Date() + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-older", + mostRecentlyBoundUUID: "p-older", + links: [ + link("p-older", peer, connectedAt: now.addingTimeInterval(-30)), + link("p-newer", peer, connectedAt: now) + ], + peerID: peer + ) + #expect(kept == "p-newer") + } + + @Test + func newestLinkMidDiscoveryDefersInsteadOfKeepingOlderWritable() { + // The fresh connection hasn't finished service discovery, so it is + // not writable yet. Keeping the older writable (restored) link now + // would cancel the one connection on the currently advertised + // address and recreate the oscillation — defer to a later announce. + let now = Date() + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-writable", + mostRecentlyBoundUUID: "p-writable", + links: [ + link("p-writable", peer, connectedAt: now.addingTimeInterval(-30)), + link("p-fresh-bare", peer, writable: false, connectedAt: now) + ], + peerID: peer + ) + #expect(kept == nil) + } + + @Test + func restoredWritableAnchorAlsoDefersToFreshUnwritableLink() { + // Same discovery window as above, but the writable duplicate is a + // restored link with no connect timestamp at all — the exact field + // topology. It must not win just because the fresh link is bare. + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-restored", + mostRecentlyBoundUUID: "p-restored", + links: [ + link("p-restored", peer), + link("p-fresh-bare", peer, writable: false, connectedAt: Date()) + ], + peerID: peer + ) + #expect(kept == nil) + } + + @Test + func coNewestWritableLinkStillWinsOverBareTwin() { + // Two links share the newest timestamp and one is writable: no + // discovery window to wait out — the writable co-newest survives. + let now = Date() + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: nil, + mostRecentlyBoundUUID: nil, + links: [ + link("p-bare", peer, writable: false, connectedAt: now), + link("p-writable", peer, connectedAt: now) + ], + peerID: peer + ) + #expect(kept == "p-writable") + } + + @Test + func allUnwritableDuplicatesConsolidateByConnectRecency() { + // No writable link exists at all: nothing can be stranded, so the + // newest connection consolidates immediately. + let now = Date() + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-older", + mostRecentlyBoundUUID: "p-older", + links: [ + link("p-older", peer, writable: false, connectedAt: now.addingTimeInterval(-30)), + link("p-newer", peer, writable: false, connectedAt: now) + ], + peerID: peer + ) + #expect(kept == "p-newer") + } + + @Test + func allRestoredLinksFallBackToAnnounceAnchors() { + // No connect timestamps at all (every link restored): the legacy + // ingress-then-binding preference still decides. + let kept = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-ingress", + mostRecentlyBoundUUID: "p-bound", + links: [link("p-ingress", peer), link("p-bound", peer)], + peerID: peer + ) + #expect(kept == "p-ingress") + } + + @Test + func timestampTiesBreakByAnchorsThenDeterministically() { + let now = Date() + let anchored = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: "p-b", + mostRecentlyBoundUUID: nil, + links: [link("p-a", peer, connectedAt: now), link("p-b", peer, connectedAt: now)], + peerID: peer + ) + #expect(anchored == "p-b") + + let unanchored = BLERedundantLinkPolicy.keptPeripheralUUID( + ingressPeripheralUUID: nil, + mostRecentlyBoundUUID: nil, + links: [link("p-b", peer, connectedAt: now), link("p-a", peer, connectedAt: now)], + peerID: peer + ) + #expect(unanchored == "p-a") + } } diff --git a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift index aea80020..29eff020 100644 --- a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift +++ b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift @@ -15,7 +15,7 @@ final class FavoritesPersistenceServiceTests: XCTestCase { service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice") - wait(for: [expectation], timeout: 1.0) + wait(for: [expectation], timeout: TestConstants.settleTimeout) XCTAssertTrue(service.isFavorite(peerKey)) XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice") XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey)) diff --git a/bitchatTests/Services/GeohashPresenceServiceTests.swift b/bitchatTests/Services/GeohashPresenceServiceTests.swift index f463c4a1..127c0052 100644 --- a/bitchatTests/Services/GeohashPresenceServiceTests.swift +++ b/bitchatTests/Services/GeohashPresenceServiceTests.swift @@ -227,7 +227,7 @@ final class GeohashPresenceServiceTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/LocationStateManagerTests.swift b/bitchatTests/Services/LocationStateManagerTests.swift index 69515f76..46114d26 100644 --- a/bitchatTests/Services/LocationStateManagerTests.swift +++ b/bitchatTests/Services/LocationStateManagerTests.swift @@ -355,7 +355,7 @@ final class LocationStateManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/MediaRetentionTests.swift b/bitchatTests/Services/MediaRetentionTests.swift index 18b33ca7..6c92af10 100644 --- a/bitchatTests/Services/MediaRetentionTests.swift +++ b/bitchatTests/Services/MediaRetentionTests.swift @@ -114,4 +114,83 @@ struct MediaRetentionTests { func defaultRetentionIsSevenDays() { #expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60) } + + #if os(iOS) + /// Media was the one persistence layer that never stated a protection + /// class at its write site, so payloads inherited the container + /// default. Saves must survive the added write option, + /// and on device the class must read back. The simulator's filesystem + /// does not model data protection (the attribute reads back nil there), + /// so the readback assertion is device-only. + @Test + func savedMediaSurvivesExplicitProtectionClass() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + + let payload = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let saved = try #require(store.save( + data: payload, + preferredName: "note.m4a", + subdirectory: "voicenotes/incoming", + fallbackExtension: "m4a", + defaultPrefix: "voice" + )) + + #expect(try Data(contentsOf: saved) == payload) + #if !targetEnvironment(simulator) + let protection = try FileManager.default.attributesOfItem( + atPath: saved.path + )[.protectionKey] as? FileProtectionType + #expect(protection == .completeUntilFirstUserAuthentication) + #endif + } + + /// Files written before payloads carried an explicit class are stamped + /// by the launch-time migration that follows the retention sweep: the + /// directory plus each resident file, without error. In-flight live + /// captures are left alone, exactly as the sweep leaves them: the + /// coordinator may still be writing to one through an open FileHandle, + /// and new captures receive the class at creation. Readback is device-only for the same + /// reason as above. + @Test + func migrationStampsPreexistingMediaAndSkipsLiveCaptures() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") + + let legacy = try write( + "received.m4a", + in: incoming, + modified: Date(timeIntervalSinceNow: -60) + ) + _ = try write( + "\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac", + in: incoming, + modified: Date(timeIntervalSinceNow: -60) + ) + + // Exactly the directory itself plus the legacy file; strict equality + // is what proves the live capture was not stamped. + #expect(store.migrateFileProtectionIfNeeded() == 2) + #expect(FileManager.default.fileExists(atPath: legacy.path)) + #if !targetEnvironment(simulator) + let protection = try FileManager.default.attributesOfItem( + atPath: legacy.path + )[.protectionKey] as? FileProtectionType + #expect(protection == .completeUntilFirstUserAuthentication) + #endif + } + + /// A store with no media on disk has nothing to stamp. + @Test + func migrationWithNoMediaIsANoOp() { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + + #expect(store.migrateFileProtectionIfNeeded() == 0) + } + #endif } diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift index 06d3cf33..7faa4681 100644 --- a/bitchatTests/Services/NetworkActivationServiceTests.swift +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -63,7 +63,7 @@ final class NetworkActivationServiceTests: XCTestCase { context.service.start() context.service.setUserTorEnabled(false) - wait(for: [notified], timeout: 1.0) + wait(for: [notified], timeout: TestConstants.negativeWaitWindow) context.notificationCenter.removeObserver(token) XCTAssertFalse(context.service.userTorEnabled) @@ -243,7 +243,7 @@ final class NetworkActivationServiceTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift index e3c916af..525f417a 100644 --- a/bitchatTests/Services/NetworkReachabilityGateTests.swift +++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift @@ -69,25 +69,45 @@ final class NetworkReachabilityGateTests: XCTestCase { XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5))) } - func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async { - let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0) + /// Wiring only: a duplicate mid-window still yields exactly one committed + /// `false`, published through the monitor's debounce. + /// + /// This deliberately makes no assertion about *when* the flush fires. It + /// used to bound elapsed wall-clock time at 1.4 s to prove the deadline was + /// not restarted, which flaked on loaded CI runners — one observed run took + /// 3.75 s, because `Task.sleep` and the `asyncAfter` flush are both real + /// time and neither is bounded above on a busy machine. No wall-clock bound + /// can distinguish "deadline preserved" from "runner is slow", so the timing + /// property is asserted where it is computable instead: + /// `test_debounce_duplicateObservationsPreservePendingDeadline` drives + /// `ReachabilityDebounce` with injected timestamps and checks + /// `pendingRemaining` directly. + /// + /// The clock is injected here so the debounce arithmetic is deterministic + /// even though the flush itself is scheduled in real time. + func test_monitor_duplicateUpdatesCommitOnceThroughTheDebounce() async { + let clock = MutableDate(now: Date(timeIntervalSince1970: 1_784_000_000)) + let monitor = NWPathReachabilityMonitor( + debounceInterval: 0.2, + now: { clock.now } + ) var received: [Bool] = [] let cancellable = monitor.reachabilityPublisher.sink { received.append($0) } defer { cancellable.cancel() } - let start = Date() monitor.ingest(reachable: false) - try? await Task.sleep(nanoseconds: 500_000_000) - // Duplicate unsatisfied update mid-window (e.g. interface detail change - // while still offline) must not restart the debounce window. + // Duplicate unsatisfied update mid-window (e.g. an interface detail + // change while still offline). + clock.now = clock.now.addingTimeInterval(0.1) monitor.ingest(reachable: false) + // Past the original deadline, so the scheduled flush commits. + clock.now = clock.now.addingTimeInterval(0.2) - let committed = await waitUntil(timeout: 2.0) { !received.isEmpty } + // Generous: this is a liveness check, not a latency bound. A real + // regression — never committing — still fails, just later. + let committed = await waitUntil(timeout: 10.0) { !received.isEmpty } XCTAssertTrue(committed) XCTAssertEqual(received, [false]) - // The flush must fire at the original ~1.0s deadline, not ~1.5s - // (a full interval after the duplicate). - XCTAssertLessThan(Date().timeIntervalSince(start), 1.4) } // MARK: - Service gating @@ -179,7 +199,7 @@ final class NetworkReachabilityGateTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -239,3 +259,13 @@ private final class GateMockProxyController: NetworkActivationProxyControlling { private(set) var proxyModes: [Bool] = [] func setProxyMode(useTor: Bool) { proxyModes.append(useTor) } } + +/// Controllable clock, so debounce arithmetic is deterministic even where the +/// flush itself is scheduled in real time. +private final class MutableDate: @unchecked Sendable { + var now: Date + + init(now: Date) { + self.now = now + } +} diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index 7409cbba..c0cd293f 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -105,11 +105,11 @@ struct NoiseEncryptionServiceTests { try establishSessions(alice: alice, bob: bob) - let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0) + let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: TestConstants.settleTimeout) #expect(authenticated) let generationAuthenticated = await TestHelpers.waitUntil( { recorder.generationCount >= 1 }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(generationAuthenticated) #expect(alice.hasEstablishedSession(with: bobPeerID)) @@ -166,7 +166,7 @@ struct NoiseEncryptionServiceTests { #expect(!receiver.hasSession(with: claimedAlicePeerID)) let emittedAuthentication = await TestHelpers.waitUntil( { recorder.count > 0 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!emittedAuthentication) } @@ -216,7 +216,7 @@ struct NoiseEncryptionServiceTests { #expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8)) let emittedReplacementAuthentication = await TestHelpers.waitUntil( { recorder.count > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!emittedReplacementAuthentication) } @@ -652,12 +652,12 @@ struct NoiseEncryptionServiceTests { ) let retried = await TestHelpers.waitUntil( { recorder.messages.count == 1 }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(retried) let retryExpired = await TestHelpers.waitUntil( { !service.hasSession(with: peerID) }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(retryExpired) #expect(recorder.timeoutCount == 1) @@ -710,7 +710,12 @@ struct NoiseEncryptionServiceTests { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService( keychain: MockKeychain(), - ordinaryResponderHandshakeTimeout: 0.06, + // Generous for the same reason as the quarantine-restore test + // (#1483): this timeout also arms during the `establishSessions` + // setup handshake below, where bob is the responder. At 0.06 a + // preempted runner could fire it mid-setup, tear down the half-open + // responder, and make message 3 be answered as a fresh initiation. + ordinaryResponderHandshakeTimeout: 1.0, ordinaryReconnectRollbackCooldown: 0.3 ) let mallory = NoiseEncryptionService(keychain: MockKeychain()) @@ -741,12 +746,12 @@ struct NoiseEncryptionServiceTests { let restored = await TestHelpers.waitUntil( { bob.hasEstablishedSession(with: alicePeerID) }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(restored) let callbackArrived = await TestHelpers.waitUntil( { recovery.timeoutCount == 1 }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(callbackArrived) @@ -780,7 +785,13 @@ struct NoiseEncryptionServiceTests { let bob = NoiseEncryptionService( keychain: MockKeychain(), ordinaryHandshakeTimeout: 0.04, - ordinaryResponderHandshakeTimeout: 0.04 + // Also arms during the `establishSessions` setup handshake below, + // where bob is the responder. Observed failing on a loaded CI + // runner with exactly the signature #1483 documented: the setup's + // `#expect(finalMessage == nil)` saw a 96-byte message 2, because + // the half-open responder had already been torn down and message 3 + // was answered as a fresh initiation. + ordinaryResponderHandshakeTimeout: 1.0 ) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) @@ -814,12 +825,12 @@ struct NoiseEncryptionServiceTests { // initiates one bounded convergence retry; drop that message 1 too. let retryPrepared = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(retryPrepared) let retryExpired = await TestHelpers.waitUntil( { !bob.hasSession(with: alicePeerID) }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(retryExpired) #expect(recovery.timeoutCount == 1) @@ -951,15 +962,20 @@ struct NoiseEncryptionServiceTests { @Test("Immediate legacy restart during completion grace converges once") func immediateLegacyRestartDuringCompletionGrace() async throws { + // The grace period must still be open when the restart initiation + // arrives below. A small value races the wall clock on a starved + // runner, so inject one no test run can outlive; the recovery half + // is then fired explicitly instead of waiting out the timer. + let unlosableGracePeriod: TimeInterval = 600 let firstKeychain = MockKeychain() let secondKeychain = MockKeychain() let first = NoiseEncryptionService( keychain: firstKeychain, - recentInitiatorCompletionGracePeriod: 0.03 + recentInitiatorCompletionGracePeriod: unlosableGracePeriod ) let second = NoiseEncryptionService( keychain: secondKeychain, - recentInitiatorCompletionGracePeriod: 0.03 + recentInitiatorCompletionGracePeriod: unlosableGracePeriod ) let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) @@ -1024,9 +1040,10 @@ struct NoiseEncryptionServiceTests { ) #expect(lower.hasEstablishedSession(with: higherPeerID)) + lower._test_fireSuppressedInitiationRecovery(for: higherPeerID) let requested = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(requested) let retryMessage1 = try #require(recovery.messages.first) diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 8e3849f0..e6c79201 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -960,7 +960,7 @@ final class NostrRelayManagerTests: XCTestCase { try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event) } - let allDelivered = await waitUntil(timeout: 5.0) { + let allDelivered = await waitUntil(timeout: TestConstants.settleTimeout) { receivedIDs.count == events.count } XCTAssertTrue(allDelivered) @@ -1006,7 +1006,7 @@ final class NostrRelayManagerTests: XCTestCase { } try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent) - let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 } + let quietDelivered = await waitUntil(timeout: TestConstants.settleTimeout) { quietDeliveredAfterBusyCount >= 0 } XCTAssertTrue(quietDelivered, "relay B's event was never delivered") // The signal: B did not have to wait for A's entire backlog. If the two @@ -1019,7 +1019,7 @@ final class NostrRelayManagerTests: XCTestCase { ) // Both relays still drain fully and in order. - let allDelivered = await waitUntil(timeout: 5.0) { + let allDelivered = await waitUntil(timeout: TestConstants.settleTimeout) { busyDeliveredCount == busyEvents.count } XCTAssertTrue(allDelivered) @@ -1907,7 +1907,7 @@ final class NostrRelayManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index e8fd19b5..5f657000 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -164,7 +164,7 @@ struct NostrTransportTests { transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1") - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) @@ -209,7 +209,7 @@ struct NostrTransportTests { transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) @@ -250,7 +250,7 @@ struct NostrTransportTests { transport.sendDeliveryAck(for: "ack-1", to: fullPeerID) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) @@ -288,7 +288,7 @@ struct NostrTransportTests { messageID: "geo-1" ) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) let event = probe.sentEvents[0] let result = try decodeEmbeddedPayload(from: event, recipient: recipient) diff --git a/bitchatTests/Services/SafeRegexTests.swift b/bitchatTests/Services/SafeRegexTests.swift new file mode 100644 index 00000000..fe92a6b8 --- /dev/null +++ b/bitchatTests/Services/SafeRegexTests.swift @@ -0,0 +1,64 @@ +// +// SafeRegexTests.swift +// bitchatTests +// +// SafeRegex must never trap: valid patterns compile normally, invalid ones +// degrade to a regex that matches nothing. The production-pattern test keeps +// the compile-time guarantee try! used to provide - a typo in any bundled +// pattern fails here instead of crashing the app at startup. +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct SafeRegexTests { + + private func matchCount(_ regex: NSRegularExpression, _ text: String) -> Int { + regex.numberOfMatches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) + } + + @Test + func validPatternCompilesAndMatches() { + let regex = SafeRegex.compile("#([a-zA-Z0-9_]+)") + #expect(matchCount(regex, "tag #bitchat here") == 1) + } + + @Test + func invalidPatternDegradesToNeverMatching() { + let regex = SafeRegex.compile("(unclosed") + #expect(matchCount(regex, "(unclosed anything") == 0) + #expect(matchCount(regex, "") == 0) + } + + @Test + func productionPatternsCompileAndMatchTheirTargets() { + // A pattern that failed to compile would have degraded to + // never-matching, so each positive match proves the literal compiled. + #expect(matchCount(MessageFormattingEngine.Patterns.hashtag, "see #mesh") == 1) + #expect(matchCount(MessageFormattingEngine.Patterns.mention, "hi @alice#ab12") == 1) + + let cashuToken = "cashuA" + String(repeating: "x", count: 45) + #expect(matchCount(MessageFormattingEngine.Patterns.cashu, cashuToken) == 1) + #expect(matchCount(MessageFormattingEngine.Patterns.quickCashuPresence, cashuToken) == 1) + + let bolt11 = "lnbc1" + String(repeating: "q", count: 55) + #expect(matchCount(MessageFormattingEngine.Patterns.bolt11, bolt11) == 1) + + let lnurl = "lnurl1" + String(repeating: "q", count: 25) + #expect(matchCount(MessageFormattingEngine.Patterns.lnurl, lnurl) == 1) + + #expect(matchCount(MessageFormattingEngine.Patterns.lightningScheme, "pay lightning:abc123") == 1) + } + + @Test + func contentNormalizerStillSimplifiesURLs() { + // Exercises ContentNormalizer's regex through its public entry point: + // same URL with different query strings must normalize identically. + let a = ContentNormalizer.normalizedKey("check https://example.com/page?q=1") + let b = ContentNormalizer.normalizedKey("check https://example.com/page?q=2") + #expect(a == b) + } +} diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index c56049bb..aae65dab 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -562,7 +562,7 @@ final class SecureIdentityStateManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift index 6ab46490..ae71c91a 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift @@ -320,7 +320,7 @@ struct SecureIdentityStateManagerVouchTests { // MARK: - Helpers private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift new file mode 100644 index 00000000..6caf379d --- /dev/null +++ b/bitchatTests/Simulation/SimulatedMesh.swift @@ -0,0 +1,216 @@ +import BitFoundation +import Foundation +@testable import bitchat + +/// A deterministic multi-node mesh over real `BLEService` engines and no +/// CoreBluetooth: nodes are wired edge-to-edge through the outbound packet +/// tap and the production ingress-attribution path (`_test_ingestFrame`), +/// so announces bind links, signatures verify, Noise handshakes complete, +/// and rotation rebinds run exactly the engine code a radio would drive. +/// +/// Determinism model: outbound packets are buffered under a lock (the tap +/// fires on each sender's engine); the test thread pumps deliveries and +/// fences every engine between rounds. Timer-driven work (relay jitter, +/// deferred flushes) is released explicitly through each node's +/// `BLEEngineManualScheduler` via `advanceTime`. +/// +/// Fidelity boundary: there are no physical links, so per-link fanout +/// planning always reports failure to the sender (directed packets spool) +/// — every capture happens at the pre-planning tap. Protocol-level +/// behavior (attribution, binding, dedup, TTL, relay decisions, sessions) +/// is faithful; link-selection and backpressure behavior is not exercised. +final class SimulatedMesh { + struct Node { + let service: BLEService + let scheduler: BLEEngineManualScheduler + } + + private let lock = NSLock() + private var pendingDeliveries: [(from: Int, packet: BitchatPacket)] = [] + /// Total (packet, receiving-node) deliveries pumped — the storm bound. + private(set) var deliveredFrameCount = 0 + + private(set) var nodes: [Node] = [] + private var neighbors: [Set] = [] + private var duplicateLinkEdges: Set = [] + private var emitted: [[BitchatPacket]] = [] + + /// Every packet a node has put on the wire — the attacker's capture + /// buffer for replay tests. + func emittedPackets(from index: Int) -> [BitchatPacket] { + lock.lock() + defer { lock.unlock() } + return emitted[index] + } + + @discardableResult + func addNode(nickname: String) -> Node { + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let scheduler = BLEEngineManualScheduler() + let service = BLEService( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + initializeBluetoothManagers: false, + engineScheduler: scheduler + ) + let index = nodes.count + let node = Node(service: service, scheduler: scheduler) + // An earlier node's engine can fire its tap (which reads `emitted` + // under the lock) while this append reallocates the array. + lock.lock() + nodes.append(node) + neighbors.append([]) + emitted.append([]) + lock.unlock() + // The tap must be live before `setNickname` below: setNickname + // force-announces asynchronously on the engine, and if that slot + // ran in the gap before a later tap install, the announce was + // emitted invisibly while still stamping the wall-clock announce + // throttle — swallowing `announceAll`'s forced announce on a + // starved runner (the CI flake this ordering fixes). + service._test_onOutboundPacket = { [weak self] packet in + // Runs on the sender's engine; only buffer here — delivering + // inline would nest one engine inside another. + guard let self else { return } + self.lock.lock() + self.pendingDeliveries.append((from: index, packet: packet)) + self.emitted[index].append(packet) + self.lock.unlock() + } + service.setNickname(nickname) + return node + } + + func connect(_ a: Int, _ b: Int) { + neighbors[a].insert(b) + neighbors[b].insert(a) + } + + /// Radio silence: stops delivering between two nodes without reporting + /// any link event, so existing bindings persist exactly as they do when + /// a peer walks out of range before its link times out. Lets a test + /// capture a packet the far side never received. + func silence(_ a: Int, _ b: Int) { + neighbors[a].remove(b) + neighbors[b].remove(a) + } + + /// Models two live links to the same phone (issue #1538): every frame + /// from the neighbour arrives twice, on two link IDs that both bind to + /// the sender. + /// + /// Both are central links — the remote's connections to our peripheral + /// role. That is deliberate and faithful to the defect: central links + /// are the ones we cannot cancel (they belong to the remote), so they + /// are exactly the links the peripheral-cancel path cannot reach after + /// a rotation. Peripheral-role bindings additionally require physical + /// link state keyed by a real CBPeripheral, which no CB-free harness + /// can fabricate. + func connectDuplicateLinks(_ a: Int, _ b: Int) { + connect(a, b) + duplicateLinkEdges.insert(Self.edgeKey(a, b)) + } + + /// The synthetic central link a frame from `sender` arrives on at + /// `receiver`. Stable per directed edge, like a CoreBluetooth central + /// UUID. + func linkUUID(from sender: Int, at receiver: Int) -> String { + "SIM-\(sender)-TO-\(receiver)" + } + + /// Order-independent edge key. + private static func edgeKey(_ a: Int, _ b: Int) -> String { + "\(min(a, b))-\(max(a, b))" + } + + /// The second link of a duplicate-link edge. + func duplicateLinkUUID(from sender: Int, at receiver: Int) -> String { + "SIM-DUP-\(sender)-TO-\(receiver)" + } + + private func links(from sender: Int, at receiver: Int) -> [BLEIngressLinkID] { + var links: [BLEIngressLinkID] = [.central(linkUUID(from: sender, at: receiver))] + if duplicateLinkEdges.contains(Self.edgeKey(sender, receiver)) { + links.append(.central(duplicateLinkUUID(from: sender, at: receiver))) + } + return links + } + + func forceAnnounce(from index: Int) { + nodes[index].service._test_forceAnnounce() + pump() + } + + /// Pumps buffered deliveries until the mesh is quiescent: no pending + /// frames and every engine drained. Timer-deferred work stays pending + /// until `advanceTime`. + func pump(maxRounds: Int = 64) { + for _ in 0.. Bool) { + for _ in 0.. Int { + lock.lock() + defer { lock.unlock() } + return publicMessages.filter { $0 == content }.count + } + + func drainedPublicMessageCount(content: String, drains: Int = 50) async -> Int { + for _ in 0.. 0 { break } + await MainActor.run {} + } + return count(content: content) + } +} diff --git a/bitchatTests/Sync/GossipSyncBoardTests.swift b/bitchatTests/Sync/GossipSyncBoardTests.swift index 7fe8bdcf..6e5b5357 100644 --- a/bitchatTests/Sync/GossipSyncBoardTests.swift +++ b/bitchatTests/Sync/GossipSyncBoardTests.swift @@ -48,7 +48,7 @@ struct GossipSyncBoardTests { let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let sent = try #require(delegate.packets.first) #expect(sent.type == MessageType.boardPost.rawValue) #expect(sent.isRSR) @@ -69,7 +69,7 @@ struct GossipSyncBoardTests { let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) #expect(delegate.packets.count == 1) #expect(delegate.packets.first?.type == MessageType.boardPost.rawValue) } diff --git a/bitchatTests/Sync/RequestSyncManagerTests.swift b/bitchatTests/Sync/RequestSyncManagerTests.swift index aada4bfc..497271e6 100644 --- a/bitchatTests/Sync/RequestSyncManagerTests.swift +++ b/bitchatTests/Sync/RequestSyncManagerTests.swift @@ -63,7 +63,7 @@ final class RequestSyncManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/TestUtilities/TestConstants.swift b/bitchatTests/TestUtilities/TestConstants.swift index 83f5b46b..fb0cb65c 100644 --- a/bitchatTests/TestUtilities/TestConstants.swift +++ b/bitchatTests/TestUtilities/TestConstants.swift @@ -11,14 +11,54 @@ import Foundation struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 - static let shortTimeout: TimeInterval = 1.0 /// For positive waits on work that hops through `Task.detached` or /// background queues: those contend with every parallel test worker for /// the global executor, so a loaded CI runner can exceed /// `defaultTimeout`. `waitUntil` returns as soon as the condition holds, /// so passing runs never pay the longer timeout. static let longTimeout: TimeInterval = 10.0 - + + /// **Default deadline for any "wait until this async thing settles" helper.** + /// + /// Four separate tests flaked on CI during July 2026 with the same root + /// cause, and it is worth stating the rule rather than re-learning it a + /// fifth time: *a wait deadline is not a latency budget.* It exists so a + /// genuine hang eventually fails the suite. Size it for the worst-case + /// scheduler, never for how long the operation "should" take. + /// + /// A CI runner executes many suites at once. Work behind `@MainActor`, + /// `Task.detached(priority: .utility)`, or a `DispatchQueue.asyncAfter` can + /// be starved for seconds — one observed run took 3.75 s for a 1 s + /// operation. Deadlines sized to the operation (the old 1 s defaults) turn + /// that starvation into a red build that reads like a product bug. + /// + /// This costs nothing when tests pass, because every helper returns as soon + /// as its condition holds. It only extends the genuine-failure case. + /// + /// `TestTimingHygieneTests` enforces that wait helpers default to at least + /// `minimumSettleTimeout`. + static let settleTimeout: TimeInterval = 30.0 + + /// Floor enforced by `TestTimingHygieneTests`. Anything below this is a + /// latency assumption in disguise. + static let minimumSettleTimeout: TimeInterval = 10.0 + + /// For waits whose **expected outcome is `false`** — "prove this does not + /// happen". + /// + /// The floor above is wrong for these, and inverted: a negative wait always + /// runs its deadline out, so `settleTimeout` would spend 30 s per case + /// proving nothing extra. Starvation cannot cause a false failure here + /// either — a starved runner only makes the thing *less* likely to happen, + /// so the assertion still holds. Short is correct, and naming it says the + /// polarity out loud instead of leaving a bare literal that reads like the + /// mistake this file exists to prevent. + /// + /// `TestTimingHygieneTests` accepts this by name. Using it for a wait you + /// expect to succeed reintroduces exactly the flake class it sits next to. + static let negativeWaitWindow: TimeInterval = 1.0 + + static let testNickname1 = "Alice" static let testNickname2 = "Bob" static let testNickname3 = "Charlie" diff --git a/bitchatTests/TestUtilities/TestTimingHygieneTests.swift b/bitchatTests/TestUtilities/TestTimingHygieneTests.swift new file mode 100644 index 00000000..c6a15147 --- /dev/null +++ b/bitchatTests/TestUtilities/TestTimingHygieneTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing + +/// Guards the test suite against the flake class that produced four separate +/// red builds in July 2026: **treating a wait deadline as a latency budget.** +/// +/// A CI runner executes many suites at once, so work behind `@MainActor`, +/// `Task.detached(priority: .utility)`, or `DispatchQueue.asyncAfter` can be +/// starved for seconds. One observed run took 3.75 s for a 1 s operation. +/// Deadlines sized to how long the operation "should" take turn that starvation +/// into a red build that reads like a product bug, and the debugging cost lands +/// on whoever opened an unrelated PR. +/// +/// Two rules, both enforced below: +/// +/// 1. A wait helper's default deadline must be at least +/// `TestConstants.minimumSettleTimeout`. Waits return as soon as their +/// condition holds, so a generous deadline is free in the passing case. +/// 2. No test asserts an *upper bound* on elapsed wall-clock time. Such an +/// assertion cannot distinguish the behaviour under test from a slow +/// machine, so it can only be flaky. Assert the property somewhere it is +/// computable — with an injected clock, on the pure logic — instead. +/// +/// Both rules can be waived per line with `\(Self.waiver)` plus a reason, for +/// the rare case where the timing itself is genuinely the thing under test. +struct TestTimingHygieneTests { + /// Opt-out marker. Reviewers should expect a reason next to it. + static let waiver = "test-timing-ok:" + + private static let testsRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // TestUtilities + .deletingLastPathComponent() // bitchatTests + + private struct Line { + let file: String + let number: Int + let text: String + /// True when the waiver appears on this line or in the comment block + /// immediately above it, so a reason can be written at readable length + /// rather than crammed onto the end of the code line. + let waived: Bool + } + + private static func swiftLines() throws -> [Line] { + let enumerator = FileManager.default.enumerator( + at: testsRoot, + includingPropertiesForKeys: nil + ) + var out: [Line] = [] + while let url = enumerator?.nextObject() as? URL { + guard url.pathExtension == "swift" else { continue } + // This file necessarily contains the patterns it bans. + guard url.lastPathComponent != "TestTimingHygieneTests.swift" else { continue } + let name = url.lastPathComponent + let texts = try String(contentsOf: url, encoding: .utf8) + .components(separatedBy: .newlines) + for (index, text) in texts.enumerated() { + // Scan back over an unbroken run of comment lines. + var waived = text.contains(waiver) + var back = index - 1 + while !waived, back >= 0 { + let above = texts[back].trimmingCharacters(in: .whitespaces) + guard above.hasPrefix("//") else { break } + waived = above.contains(waiver) + back -= 1 + } + out.append(Line(file: name, number: index + 1, text: text, waived: waived)) + } + } + return out + } + + private static func isWaived(_ line: Line) -> Bool { + line.waived + } + + /// Rule 1: no wait helper may default to a deadline below the floor. + @Test func waitHelpersDoNotDefaultToShortDeadlines() throws { + let lines = try Self.swiftLines() + #expect(!lines.isEmpty, "hygiene scan found no test sources — check the path") + + // Two shapes, both of which have flaked here: + // a declaration default — `timeout: TimeInterval = 2.5` + // a wait call site — `wait(for:…, timeout: 1.0)`, `waitUntil(timeout: 5.0)` + // + // Deliberately NOT matched: a bare `timeout:` label on something that is + // not a wait, such as the injected production handshake timeouts in the + // Noise tests. Those are the behaviour under test, and a short value is + // correct there. + let patterns = [ + #"(?:timeout|deadline)\s*:\s*TimeInterval\s*=\s*([0-9]+(?:\.[0-9]+)?)"#, + #"(?:wait|waitUntil|waitFor|fulfillment)\s*\([^)]*\btimeout:\s*([0-9]+(?:\.[0-9]+)?)"# + ].map { try? NSRegularExpression(pattern: $0) }.compactMap { $0 } + #expect(patterns.count == 2, "hygiene regexes failed to compile") + + // Named constants hide the same mistake behind a symbol, and did: the + // fifth flake of the session was `timeout: TestConstants.shortTimeout` + // (1 s) on a positive wait, which a literals-only scan cannot see. + // `shortTimeout` itself is deleted (Periphery flagged it dead once its + // last wait site converted); the ban stays so it cannot come back. + // `negativeWaitWindow` is deliberately absent — short is correct there. + let bannedConstants = ["shortTimeout", "defaultTimeout"] + + var offenders: [String] = [] + for line in lines where !Self.isWaived(line) { + let range = NSRange(line.text.startIndex..., in: line.text) + var flagged = false + for pattern in patterns { + guard let match = pattern.firstMatch(in: line.text, range: range), + let valueRange = Range(match.range(at: 1), in: line.text), + let value = TimeInterval(line.text[valueRange]), + value < TestConstants.minimumSettleTimeout else { continue } + offenders.append("\(line.file):\(line.number) — \(value)s: \(line.text.trimmingCharacters(in: .whitespaces))") + flagged = true + break + } + guard !flagged else { continue } + for name in bannedConstants + where line.text.contains("timeout: TestConstants.\(name)") { + offenders.append("\(line.file):\(line.number) — TestConstants.\(name): \(line.text.trimmingCharacters(in: .whitespaces))") + break + } + } + + #expect( + offenders.isEmpty, + """ + Wait deadlines below \(TestConstants.minimumSettleTimeout)s are latency \ + assumptions and will flake on a loaded runner. Use \ + TestConstants.settleTimeout, or add "\(Self.waiver) " if the \ + timing really is what the test asserts. + + \(offenders.joined(separator: "\n")) + """ + ) + } + + /// Rule 2: no test bounds elapsed wall-clock time from above. + /// + /// This is the assertion that started it all — `XCTAssertLessThan( + /// Date().timeIntervalSince(start), 1.4)` proving a debounce deadline was + /// not restarted. It cannot separate "behaved correctly" from "runner was + /// busy", so it only ever fails for the wrong reason. + @Test func testsDoNotAssertUpperBoundsOnElapsedTime() throws { + let lines = try Self.swiftLines() + + let elapsedAssertion = try NSRegularExpression( + pattern: #"(?:XCTAssertLessThan|XCTAssertLessThanOrEqual)\s*\(\s*(?:Date\(\)\.timeIntervalSince|[A-Za-z_][A-Za-z0-9_]*\.timeIntervalSince|ContinuousClock)"# + ) + + var offenders: [String] = [] + for line in lines where !Self.isWaived(line) { + let range = NSRange(line.text.startIndex..., in: line.text) + guard elapsedAssertion.firstMatch(in: line.text, range: range) != nil else { continue } + offenders.append("\(line.file):\(line.number) — \(line.text.trimmingCharacters(in: .whitespaces))") + } + + #expect( + offenders.isEmpty, + """ + An upper bound on elapsed wall-clock time cannot distinguish the \ + behaviour under test from a slow machine. Assert the property where \ + it is computable — inject a clock, or test the pure logic — or add \ + "\(Self.waiver) ". + + \(offenders.joined(separator: "\n")) + """ + ) + } + + /// The floor must stay meaningfully above the operations being waited on, + /// and the default must satisfy the rule this file enforces. + @Test func settleTimeoutsAreSelfConsistent() { + #expect(TestConstants.settleTimeout >= TestConstants.minimumSettleTimeout) + #expect(TestConstants.minimumSettleTimeout > TestConstants.defaultTimeout) + } +} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index ec7b3b62..eab6d989 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -542,6 +542,9 @@ struct ViewSmokeTests { ]) try? await Task.sleep(nanoseconds: 50_000_000) + // ContentView + people sheet must mount with the full feature-model + // set (peerList / publicChat / privateInbox included). Missing any of + // those crashes the NavigationStack sheet on some iOS versions (#1558). _ = mount(installSmokeEnvironment(ContentView(), featureModels: featureModels)) _ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels)) diff --git a/bitchatTests/VoiceCaptureSessionTests.swift b/bitchatTests/VoiceCaptureSessionTests.swift index 344697a6..bfa0ac96 100644 --- a/bitchatTests/VoiceCaptureSessionTests.swift +++ b/bitchatTests/VoiceCaptureSessionTests.swift @@ -101,7 +101,7 @@ struct VoiceCaptureSessionTests { _ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) while !condition(), ContinuousClock.now < deadline { await Task.yield() try? await Task.sleep(nanoseconds: 1_000_000) diff --git a/bitchatTests/VoiceNotePlaybackControllerTests.swift b/bitchatTests/VoiceNotePlaybackControllerTests.swift index f7853f1d..29991d0e 100644 --- a/bitchatTests/VoiceNotePlaybackControllerTests.swift +++ b/bitchatTests/VoiceNotePlaybackControllerTests.swift @@ -59,11 +59,24 @@ struct VoiceNotePlaybackControllerTests { return url } + /// Waits for an async settle, then asserts. + /// + /// The deadline is deliberately far larger than the work it waits on. Every + /// condition here depends on a `@MainActor` Task that playback schedules + /// (the session acquire and its failure path), and on a CI runner executing + /// many suites in parallel that Task can simply not be scheduled for + /// seconds. At five seconds this timed out on CI and reported *two* + /// failures — the wait itself, and the `!isPlaying` that the un-run failure + /// path had not yet reset — which reads like a playback bug rather than a + /// starved scheduler. + /// + /// A generous deadline costs nothing when the condition holds, since this + /// returns as soon as it does; it only extends the genuine-failure case. private func waitUntil( _ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) while !condition(), ContinuousClock.now < deadline { await Task.yield() try? await Task.sleep(nanoseconds: 1_000_000) diff --git a/bitchatTests/VoiceRecorderTests.swift b/bitchatTests/VoiceRecorderTests.swift index c9b6839e..68069f11 100644 --- a/bitchatTests/VoiceRecorderTests.swift +++ b/bitchatTests/VoiceRecorderTests.swift @@ -10,10 +10,41 @@ import Foundation import Testing @testable import bitchat +/// One-shot event that bridges synchronous production seams to async tests +/// without blocking a shared dispatch worker while waiting for the seam. +private final class VoiceRecorderAsyncEvent: @unchecked Sendable { + private let lock = NSLock() + private var isSignaled = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + let resumeImmediately = lock.withLock { () -> Bool in + guard !isSignaled else { return true } + waiters.append(continuation) + return false + } + if resumeImmediately { + continuation.resume() + } + } + } + + func signal() { + let continuations = lock.withLock { () -> [CheckedContinuation] in + guard !isSignaled else { return [] } + isSignaled = true + defer { waiters.removeAll() } + return waiters + } + continuations.forEach { $0.resume() } + } +} + private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable { private let lock = NSLock() private let activationGate = DispatchSemaphore(value: 0) - private let activationBeganGate = DispatchSemaphore(value: 0) + private let activationBegan = VoiceRecorderAsyncEvent() private let shouldGateFirstActivation: Bool private var gatedFirstActivation = false private var _activationCalls: [Bool] = [] @@ -34,23 +65,13 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab return true } if shouldWait { - activationBeganGate.signal() + activationBegan.signal() activationGate.wait() } } - func waitUntilActivationBegan( - timeout: DispatchTimeInterval = .seconds(5) - ) async -> Bool { - await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume( - returning: self.activationBeganGate.wait( - timeout: DispatchTime.now() + timeout - ) == .success - ) - } - } + func waitUntilActivationBegan() async { + await activationBegan.wait() } func resumeActivation() { @@ -155,7 +176,7 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating { /// this remains deterministic when the full test suite saturates the executor. private final class VoiceRecorderPaddingGate: @unchecked Sendable { private let lock = NSLock() - private let enteredGate = DispatchSemaphore(value: 0) + private let entered = VoiceRecorderAsyncEvent() private var isOpen = false private var openWaiters: [CheckedContinuation] = [] @@ -166,25 +187,15 @@ private final class VoiceRecorderPaddingGate: @unchecked Sendable { openWaiters.append(continuation) return false } - enteredGate.signal() + entered.signal() if resumeImmediately { continuation.resume() } } } - func waitUntilEntered( - timeout: DispatchTimeInterval = .seconds(5) - ) async -> Bool { - await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume( - returning: self.enteredGate.wait( - timeout: DispatchTime.now() + timeout - ) == .success - ) - } - } + func waitUntilEntered() async { + await entered.wait() } func open() { @@ -223,7 +234,7 @@ struct VoiceRecorderTests { let owner = VoiceRecorder.RecordingOwner() let startTask = Task { try await voiceRecorder.startRecording(owner: owner) } - #expect(await session.waitUntilActivationBegan()) + await session.waitUntilActivationBegan() await voiceRecorder.cancelRecording(owner: owner) session.resumeActivation() @@ -321,7 +332,7 @@ struct VoiceRecorderTests { try await finishingHold.start() let firstURL = try #require(factory.urls.first) let finishTask = Task { await finishingHold.finish() } - #expect(await paddingGate.waitUntilEntered()) + await paddingGate.waitUntilEntered() await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) { try await rejectedHold.start() diff --git a/docs/BLE-ARCHITECTURE-V3.md b/docs/BLE-ARCHITECTURE-V3.md new file mode 100644 index 00000000..9f607cb3 --- /dev/null +++ b/docs/BLE-ARCHITECTURE-V3.md @@ -0,0 +1,248 @@ +# BLE Transport Architecture V3 + +The plan of record for restructuring `BLEService` from an 8.3k-line god +object into a layered mesh stack. ARCHITECTURE_V2 rebuilt the app layer +above the transport and deliberately deferred the transport itself; this +document covers that remainder: what already landed, the target shape, and +the order for the rest. + +## Why the satellite strategy stalled + +V2's transport approach was to peel pure policies and closure-driven +handlers out of `BLEService` while the class kept coordinating. The ~30 +pure policy structs were a clear win. The five big handler extractions +were not: each needed an "environment" of 20–30 closures that weakly +capture the service and hop queues back into its state. Logic left, but +state ownership and synchronization never moved, so extraction paid a +plumbing tax that grew as fast as the logic shrank — the five +`make*HandlerEnvironment()` factories alone were ~1.5k lines. The file +held ~60 mutable fields across four concurrency domains whose ownership +lived in comments, and every new feature added Transport requirements, +state maps, and switch cases to the same class. + +Two chronic costs came straight from that structure: queue-order +deadlocks (the July 9 main↔bleQueue ABBA freeze), and timing-dependent +tests (correctness only observable through real queues and real time). + +## Target shape + +A packet-radio stack with one rule per layer about state and threads: + +1. **`BLELinkLayer`** — the only CoreBluetooth import. Owns both managers, + scanning/advertising, duty cycle, connection scheduling, MTU, write + and notification backpressure buffers, state restoration. Speaks + `LinkEvent` up (link up/down, bytes in, writable) and `LinkCommand` + down (send bytes on link, scan/advertise policy). Knows nothing about + packets, peers, or Noise. bleQueue-confined. A `SimulatedLinkLayer` + implementing the same port gives multi-node tests real topologies with + no radios and no wall-clock waits. +2. **Mesh engine** — one serial queue owning all protocol state: wire + codec, fragmentation, dedup, relay policy, peer registry, topology, + gossip sync, Noise orchestration. Synchronous single-writer logic; the + pure policy satellites slot in unchanged. Endgame: the engine core + becomes `handle(event, now) -> [Effect]` (sans-I/O), which makes the + whole mesh property-testable and fuzzable in simulation. +3. **Feature modules** — courier, board, prekeys, private media, file + transfer, voice, diagnostics, groups, verify/vouch each own their + state and register for their message types. A new feature is a new + module, not edits to the engine. +4. **App boundary** — a small `Transport` core both transports genuinely + implement, plus capability protocols discovered with `as?` + (`MeshBridgingTransport` etc.), replacing the ~90-requirement + god-protocol and its inert defaults. + +### Concurrency contract + +State is owned one of three ways: + +- **Engine-confined** — mutated only on the serial engine queue + (`mesh.message`). Cross-thread callers use `onEngine`. +- **bleQueue-confined** — link-layer state next to CoreBluetooth objects + (link store, write/notification buffers, link-auth maps). +- **Lock-backed store** — state with legitimate cross-domain readers + (peer registry, local identity/capabilities, traffic monitor). Writes + still come from one domain; the lock exists so readers never block on + a queue. Every mutation is a single whole-transition method, so + readers never observe torn state. + +Sync-edge order (deadlock freedom by construction, debug-enforced in +`onEngine`): + +``` +main / test threads ──sync──▶ engine ──sync──▶ bleQueue + └──sync──▶ noise / identity queues (leaves) +``` + +Nothing may sync-wait in the reverse direction: bleQueue and the crypto +queues reach the engine only via `async`, and nothing sync-dispatches to +main. Two subtleties worth knowing: + +- A closure executed inside a noise-manager critical section entered + *from* an engine slot may touch engine state directly (the blocked + slot makes it exclusive) but must never sync-re-enter the engine — + that is a self-deadlock. +- bleQueue critical sections (e.g. the verified-announce link rebind) + must receive engine-derived values as arguments rather than fetching + them through `onEngine`. + +## What landed in this pass + +- **Lock-backed peer state** (`BLEPeerRegistryStore`): every main-actor + Transport read (`isPeerConnected`, nicknames, snapshots, capability + queries) reads a lock, not a queue. Runtime capability bits moved into + `BLELocalIdentityStateStore` beside the identity they ride announces + with. +- **bleQueue owns the link buffers**: `pendingPeripheralWrites`, + `pendingNotifications`, `pendingWriteBuffers` are bleQueue-confined + (their producers and drains already ran there); the notification drain + no longer invokes CoreBluetooth from a transport queue. +- **One serial engine queue**: the concurrent message queue and the + collections queue it guarded state with are one serial domain; every + barrier flag and per-field ownership comment deleted; ~98 cross-queue + hops removed. `onEngine` documents and debug-enforces the sync-edge + order — and its trap caught two latent inversions during migration + (the announce-rebind path and the noise session-generation closures). +- **Capability ports**: gateway/bridge/courier wiring, the panic + lifecycle, and radio-state reads go through `MeshBridgingTransport`, + `PanicResettingTransport`, and `BluetoothStateReporting`; no app code + casts to `BLEService` anymore. +- **Feature-owned state**: `BLEMeshPingTracker` (the /ping probe map and + per-link response budget) and `BLEPrivateMediaSessionStore` (the six + generation-keyed private-media maps plus the convergence-deferral set, + as whole-transition methods under a leaf lock), both with direct unit + tests. The private-media store also took the last routine main-actor + sync reads off the engine and turned the noise-critical-section + transitions into ordinary leaf-lock calls. Remaining feature state + (courier, board, prekeys) already lives in injected stores. +- **Transport split**: the mesh-only surface left the god-protocol. + `Transport` is core only (lifecycle, identity, snapshots, basic + messaging, noise wrappers); files/private media, voice, courier, + groups, board, diagnostics, verification, and the public archive are + eight capability protocols discovered with `as?`, alongside the + bridging/panic/radio-state ports. The inert-defaults extension is + gone; consumers that relied on a default keep its safe floor + explicitly at the call site. +- **Contract pinning**: `BLEQueueContractTests` greps the transport + sources — only `onEngine` may sync-enter the engine, transport code + never sync-dispatches to main, and the collections queue stays + deleted (waivable per line with `queue-contract-ok:` plus a reason). + +Full suite green throughout (1,964 tests), identical wall-clock — BLE +throughput is nowhere near what one serial queue sustains. + +## Remaining roadmap (in order) + +1. **Link-layer extraction.** Move the CB delegates, scheduling, duty + cycle, and buffers behind `LinkEvent`/`LinkCommand` ports. + + **Link-auth boundary (decided): bindings become engine-owned.** + Today `noiseAuthenticatedLinkOwners`, the rebind containment rules, + and the peer↔link binding maps live on bleQueue so that "check + binding + auth, then act" is one critical section (the rebind path + and the authenticated-send commit point in + `notifyOrEnqueueIfAccepted`). That atomicity exists to stop a + binding from changing between a security check and its action — and + the engine's serial slot provides exactly the same guarantee once + every rebind is an engine operation. The residual stolen-link risk + is unchanged: directed payloads are Noise ciphertext, useless on a + link that changed hands after the decision. Making bindings engine + state also puts the receive path in its sans-I/O shape: the link + layer reports `received(bytes, linkID)` and the engine resolves the + sender binding, instead of the CB delegate resolving peers before + handoff. The link layer keeps only physical link state (CB objects, + connect/subscribe lifecycles, backpressure buffers) keyed by opaque + link IDs. + + Extraction order: (a) the binding-free radio half — scanning, + advertising, duty cycle, connection budget/scheduling — moves first + (it makes no peer decisions); (b) bindings + link-auth migrate to + the engine, converting `readLinkState` callers; (c) the delegates + shrink to event emission and move behind the port. + + **(a) and (b) are done.** (a) landed as `BLERadioController` + (#1539). (b) landed in two steps: #1540 cohered the loose maps into + `BLELinkAuthState` + `BLELinkBindings` (still bleQueue-owned, + behavior-identical), and the option-B flip then moved ownership to + the engine. Since the flip: + + - `linkAuth`/`linkBindings` are engine-owned behind a DEBUG + `dispatchPrecondition` trap; bleQueue code cannot touch them. + - The receive path is in its sans-I/O shape: bleQueue decodes + frames and hands `(packet, linkID)` up through + `ingestDecodedPacket` (which captures the panic lifecycle at the + handoff); `attributeAndHandlePacket` resolves the sender binding, + admits or rejects the claimed sender, applies raw-announce + binding, and records ingress — all on the engine. Per-link frame + order is preserved end to end (both queues are serial), which + supersedes the old batch-local TOCTOU binding. + - The rotation rebind is one engine slot + (`rebindLinkAfterVerifiedDirectAnnounce`): containment checks, + proof retirement, binding flip, reconnect decision, and + rotated-identity retirement, with only CoreBluetooth cancels + hopping to bleQueue. + - Authenticated-send eligibility (`notifyOrEnqueueIfAccepted`, + `writeOrEnqueueIfAccepted`) is checked on the engine — serialized + against rebinds by construction — and only the physical admission + (updateValue / write / backpressure queues) runs on bleQueue. + - Teardown splits: bleQueue delegates do physical work inline + (`discardPeripheralLinkPhysical`) and queue the identity half + (`retirePeripheralLinkIdentity`, binding survivor repair) to the + engine. A binding can briefly outlive its physical link; queries + that need liveness join against the physical store via + `readLinkState` (the engine→bleQueue sync direction), and the + queued retirement converges the two. +2. **Sans-I/O engine core + simulator.** Make the engine formally + `handle(event) -> [Effect]`, feed it from a `SimulatedLinkLayer`, and + move the multi-node E2E suite onto deterministic simulation (no + `waitUntil`, no timing hygiene battles). Property tests become + possible: relay-storm bounds, partition-heal convergence, dedup + soundness under duplicate floods. The remaining feature *code* moves + (courier, board, prekey, voice, file, group handlers out of the + packet switch) ride this seam as handler-registered modules instead + of getting closure-environment extractions now. + + **The simulator half is done — simulator-first.** Because the B2 + receive path already hands `(packet, linkID)` up through one choke + point, `SimulatedMesh` (bitchatTests/Simulation/) wires real + CB-free `BLEService` engines edge-to-edge through the outbound tap + and `_test_ingestFrame` (the production attribution path), with + per-edge synthetic link IDs and manual-scheduler time. Five + deterministic multi-node tests run in ~40ms: announce/bind + convergence, end-to-end Noise establishment, line-topology relay + within a TTL/frame budget, duplicate-flood dedup, and the panic + rotation single-slot rebind + containment — the scenario that + previously required two phones. Fidelity boundary: no physical + links, so fanout planning/backpressure is not exercised; protocol + behavior is. On its first day the simulator found a real bug: the + forced-announce throttle survived panic, so a rotation within + `bleForceAnnounceMinIntervalSeconds` of the last announce left the + new identity invisible until the next maintenance cycle + (`BLEAnnounceThrottle.reset()` now runs in the panic slot). + **The upward port is named and the delegates live behind it.** + `BLELinkEvent` (frameDecoded + the four physical lifecycle + transitions) is the enumerable bleQueue→engine surface; every + crossing goes through `emitLinkEvent` into one engine consumer + (`handleLinkEvent`), and the simulated mesh drives lifecycle events + through the identical enum a radio does (see + `linkDropEventRetiresBindingAndReconnectHeals`). The CoreBluetooth + delegate extensions moved to their own files — + `BLEService+LinkLayerCentralRole.swift` / + `BLEService+LinkLayerPeripheralRole.swift` — as physical + bookkeeping plus event emission; the physical-domain members they + share are `internal` with the queue contract enforced by the + existing traps and grep guards rather than access control. + + **Deliberately not done:** a formal `handle(event) -> [Effect]` + effect system, and splitting the engine-domain feature handlers + into more files. Both would flip the engine's private state + (noiseService, peerRegistry, the identity domain) to internal for + purely cosmetic file counts — the domains are already uniform + (one queue, one rule set) and mechanically guarded. The effect + formalization should ride actual feature-module extractions when a + feature earns its own module, not precede them. + +## What this is not + +No wire changes: packet formats, signing (padding is signed), the +peerID identity binding, and courier tag construction are untouched — +see the wire-landmines notes before assuming any of that is local. diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift index cf7039fa..064cd5da 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift @@ -29,7 +29,7 @@ public final class BitchatMessage: Codable { public let recipientNickname: String? public let senderPeerID: PeerID? public let mentions: [String]? // Array of mentioned nicknames - public var deliveryStatus: DeliveryStatus? // Delivery tracking + public var deliveryStatus: DeliveryStatus // Delivery tracking /// True when this message reached us across a mesh bridge (signed by its /// author for an internet rendezvous) rather than over local radio. public let isBridged: Bool @@ -64,7 +64,9 @@ public final class BitchatMessage: Codable { recipientNickname = try container.decodeIfPresent(String.self, forKey: .recipientNickname) senderPeerID = try container.decodeIfPresent(PeerID.self, forKey: .senderPeerID) mentions = try container.decodeIfPresent([String].self, forKey: .mentions) - deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus) + // Archives written while the field was optional omit it for public + // messages; absent means the message never entered a send pipeline. + deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus) ?? .notSentYet // Absent in archives written before bridging existed. isBridged = try container.decodeIfPresent(Bool.self, forKey: .isBridged) ?? false } @@ -93,7 +95,7 @@ public final class BitchatMessage: Codable { self.recipientNickname = recipientNickname self.senderPeerID = senderPeerID self.mentions = mentions - self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) + self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : .notSentYet) self.isBridged = isBridged } } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift index 32fa4e76..ded2efa9 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift @@ -9,6 +9,7 @@ import struct Foundation.Date public enum DeliveryStatus: Codable, Equatable, Hashable { + case notSentYet // Created but not yet handed to any transport case sending case sent // Left our device case carried // Sealed envelope handed to a courier; best-effort physical delivery @@ -19,6 +20,8 @@ public enum DeliveryStatus: Codable, Equatable, Hashable { public var displayText: String { switch self { + case .notSentYet: + return "Not sent yet" case .sending: return "Sending..." case .sent: diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/DeliveryStatusNotSentYetTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/DeliveryStatusNotSentYetTests.swift new file mode 100644 index 00000000..71a7d36a --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/DeliveryStatusNotSentYetTests.swift @@ -0,0 +1,59 @@ +// +// DeliveryStatusNotSentYetTests.swift +// bitchatTests +// +// DeliveryStatus is a total state machine: every message carries a concrete +// status from creation. Public messages start .notSentYet, private messages +// keep their historical .sending default, and archives persisted while the +// field was optional decode with the absent field mapped to .notSentYet. +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct DeliveryStatusNotSentYetTests { + + private func makeMessage(isPrivate: Bool, deliveryStatus: DeliveryStatus? = nil) -> BitchatMessage { + BitchatMessage( + sender: "alice", + content: "hello", + timestamp: Date(timeIntervalSince1970: 1_000), + isRelay: false, + isPrivate: isPrivate, + deliveryStatus: deliveryStatus + ) + } + + @Test + func publicMessagesStartNotSentYetAndPrivateStartSending() { + #expect(makeMessage(isPrivate: false).deliveryStatus == .notSentYet) + #expect(makeMessage(isPrivate: true).deliveryStatus == .sending) + // An explicit status always wins over the defaults. + #expect(makeMessage(isPrivate: false, deliveryStatus: .sent).deliveryStatus == .sent) + } + + @Test + func decodingLegacyArchiveWithoutStatusYieldsNotSentYet() throws { + // Pre-existing archives omitted the key for public messages while the + // field was optional; absent must map to .notSentYet, not fail. + let encoded = try JSONEncoder().encode(makeMessage(isPrivate: false)) + var json = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + json.removeValue(forKey: "deliveryStatus") + let legacyData = try JSONSerialization.data(withJSONObject: json) + + let decoded = try JSONDecoder().decode(BitchatMessage.self, from: legacyData) + #expect(decoded.deliveryStatus == .notSentYet) + } + + @Test + func decodingRoundTripPreservesConcreteStatus() throws { + let message = makeMessage(isPrivate: true, deliveryStatus: .delivered(to: "bob", at: Date(timeIntervalSince1970: 2_000))) + let decoded = try JSONDecoder().decode(BitchatMessage.self, from: JSONEncoder().encode(message)) + #expect(decoded.deliveryStatus == .delivered(to: "bob", at: Date(timeIntervalSince1970: 2_000))) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift index 2bb3a0e8..cd0ef876 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift @@ -11,7 +11,6 @@ import Foundation // Kept local until the test-helper module is split out. struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 - static let shortTimeout: TimeInterval = 1.0 static let longTimeout: TimeInterval = 10.0 static let testNickname1 = "Alice"