From 0152196ac2648ef3f6a1bbab4f24f1a88e11b3b2 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:45:20 +0100 Subject: [PATCH 01/35] Deflake CI, and make the flake class unrepeatable (#1491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Deflake two iOS-sim CI tests with unbounded timing assumptions Both failed on main-adjacent CI runs during this session's PRs. Neither was a product bug; both asserted things about real time that a loaded runner is under no obligation to honour. **NetworkReachabilityGateTests: a wall-clock upper bound.** test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit slept 500ms for real, then asserted total elapsed time was under 1.4s to prove a duplicate mid-window had not restarted the 1.0s debounce. One CI run took 3.75s. No wall-clock bound can separate "deadline preserved" from "runner is slow", because Task.sleep and the asyncAfter flush are both real time and neither is bounded above. The deadline property was already covered deterministically one level down: test_debounce_duplicateObservationsPreservePendingDeadline drives ReachabilityDebounce with injected timestamps and checks pendingRemaining directly. So the monitor test now asserts only what needs a real monitor — that a duplicate still yields exactly one committed false through the debounce — with an injected clock for the arithmetic and a generous liveness budget. Renamed to say what it actually checks. No coverage lost, and it runs in 0.14s instead of ~3.8s because the real sleep is gone. **NoiseEncryptionServiceTests: injected timeouts that also arm during setup.** #1483 diagnosed and fixed exactly this in the quarantine-restore test, but two sibling tests kept the shape. Their injected ordinaryResponderHandshakeTimeout (0.04 and 0.06) also arms during the establishSessions setup handshake, where bob is the responder — so a preempted runner fires it mid-setup, tears down the half-open responder, and message 3 gets answered as a fresh initiation. The CI failure named setup's own `#expect(finalMessage == nil)` seeing a 96-byte message 2, which is precisely the signature #1483 recorded. Raised both to 1.0s, matching #1483's remedy: the scenarios still need the responder timeout to fire, and it still does, just with room for setup to complete first. Thin 1-second waitUntil budgets in the file now use the shared TestConstants.longTimeout; every one of them backs a positive assertion, so waitUntil still returns the moment the condition holds and nothing gets slower in the passing case. No assertion weakened in either file. Verified 3x sequentially and 3x with all 18 cores saturated. Co-Authored-By: Claude Opus 5 * Raise two more starvation-prone test deadlines Both surfaced on the CI runs for this PR and #1487, both in tests neither PR touches, both the same shape as the two already fixed here: a deadline sized for the work rather than for a runner executing many suites at once. VoiceNotePlaybackControllerTests waited 5s for a @MainActor Task that playback schedules for the session acquire and its failure path. When that Task is not scheduled in time the helper reports *two* failures — the wait, and the `!isPlaying` the un-run failure path has not reset yet — which reads like a playback bug rather than a starved scheduler. That is exactly what CI showed. GeoRelayDirectoryTests waited 10s for work the directory runs in Task.detached(priority: .utility); utility priority competes with every other suite. The retry-scheduling case timed out at exactly 10.06s with the retry never scheduled, reading like a missing retry rather than a starved background task. Raised to 30s each with the reasoning recorded at the helper. Both helpers return as soon as their condition holds, so nothing slows down when tests pass — only the genuine-failure case takes longer to report. Verified 3x with all cores saturated: both suites clean. Co-Authored-By: Claude Opus 5 * Make the flake class unrepeatable, not just fixed Raising four deadlines fixed the four tests that happened to fire. The class was still there: fourteen separate waitUntil helpers, most defaulting to 1.0s, plus wait call sites with literal budgets. The fifth instance would have landed the same way, on someone else's unrelated PR. The rule, now written down in TestConstants.settleTimeout: **a wait deadline is not a latency budget.** It exists so a genuine hang eventually fails the suite, so size it for the worst-case scheduler, never for how long the operation should take. Waits return as soon as their condition holds, so a generous deadline is free in the passing case and only extends genuine failures. - Every wait helper now defaults to TestConstants.settleTimeout (30s), and the literal wait call sites below the floor were converted too — sixteen sites across ten files. - TestTimingHygieneTests enforces it by scanning the test sources: wait defaults and wait call sites must be at least minimumSettleTimeout, and no test may assert an upper bound on elapsed wall-clock time (the assertion that started this, which cannot separate correct behaviour from a slow machine). - Both rules waive per line with "test-timing-ok: ", accepted on the line or in the comment block above it so the reason has room to be a sentence. One legitimate use so far: a NEGATIVE wait in NoiseCoverageTests asserting a promotion has *not* completed within 50ms, where a long deadline would only make the suite slow while still passing. Injected production timeouts are deliberately not matched — the Noise handshake timeouts are the behaviour under test, and short values are correct there. Verified the guard actually fails: a canary file with both banned shapes was flagged with file and line, and the suite went green again once it was removed. Full suite passes with all 18 cores saturated. Co-Authored-By: Claude Opus 5 * Close the guard's blind spot: named short timeouts A fifth flake landed on CI while the first guard was in review — GossipSyncBoardTests timing out at 1.03s — and the guard did not catch it, because the deadline was `TestConstants.shortTimeout` rather than a literal. A literals-only scan cannot see a short value behind a symbol, which is the more common way it is written: 81 wait sites used shortTimeout (1s) or defaultTimeout (5s), every one of them below the floor. Rather than guess which of those were safe to raise, all 81 were converted and the suite timed. Runtime went 15s -> 72s, which located the genuine negative waits precisely: ten tests that assert something does *not* happen and therefore always run their deadline out. Measurement instead of a heuristic, since a mis-guess in either direction is invisible — too short reintroduces the flake, too long silently costs a minute a run. Those 28 sites now use `TestConstants.negativeWaitWindow`, a named constant whose doc explains the inverted reasoning: for a negative wait, starvation can only make the assertion *more* likely to hold, so short is correct, and the name states the polarity instead of leaving a bare literal that reads like the mistake. Suite is back to 15.3s. The guard now also rejects `timeout: TestConstants.shortTimeout` and `defaultTimeout`, while accepting `negativeWaitWindow` by name. Re-verified with a canary carrying every banned shape — literal default, both named constants, and an elapsed-time upper bound. All four were flagged with file and line; the suite went green again on removal. Co-Authored-By: Claude Opus 5 * Delete shortTimeout now that nothing may use it The hygiene guard bans `TestConstants.shortTimeout` at every wait site and the last users were converted, so Periphery correctly flagged the constant itself as dead and failed CI. Remove it from both TestConstants copies; the banned-name entry stays so the symbol cannot quietly return. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Opus 5 --- bitchatTests/BLEServiceCoreTests.swift | 10 +- .../ChatViewModelRefactoringTests.swift | 12 +- bitchatTests/ChatViewModelTests.swift | 20 +- .../EndToEnd/CourierEndToEndTests.swift | 62 +++--- .../EndToEnd/PrekeyEndToEndTests.swift | 30 +-- bitchatTests/GossipSyncManagerTests.swift | 20 +- bitchatTests/NearbyNotesCounterTests.swift | 2 +- bitchatTests/Noise/NoiseCoverageTests.swift | 14 +- .../Nostr/GeoRelayDirectoryTests.swift | 10 +- bitchatTests/PTTBurstPlayerTests.swift | 2 +- .../FavoritesPersistenceServiceTests.swift | 2 +- .../GeohashPresenceServiceTests.swift | 2 +- .../Services/LocationStateManagerTests.swift | 2 +- .../NetworkActivationServiceTests.swift | 4 +- .../NetworkReachabilityGateTests.swift | 52 +++-- .../NoiseEncryptionServiceTests.swift | 37 ++-- .../Services/NostrRelayManagerTests.swift | 8 +- .../Services/NostrTransportTests.swift | 8 +- .../SecureIdentityStateManagerTests.swift | 2 +- ...SecureIdentityStateManagerVouchTests.swift | 2 +- bitchatTests/Sync/GossipSyncBoardTests.swift | 4 +- .../Sync/RequestSyncManagerTests.swift | 2 +- .../TestUtilities/TestConstants.swift | 44 ++++- .../TestTimingHygieneTests.swift | 177 ++++++++++++++++++ bitchatTests/VoiceCaptureSessionTests.swift | 2 +- .../VoiceNotePlaybackControllerTests.swift | 15 +- .../BitFoundationTests/TestConstants.swift | 1 - 27 files changed, 414 insertions(+), 132 deletions(-) create mode 100644 bitchatTests/TestUtilities/TestTimingHygieneTests.swift diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 5c48832a..3c856e19 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -39,7 +39,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 +117,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 +133,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 }) @@ -1209,7 +1209,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 +1340,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) 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..53de683b 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) } 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/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/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/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/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/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..a2031983 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) @@ -1026,7 +1037,7 @@ struct NoiseEncryptionServiceTests { 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/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/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/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/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" From eadd3a20c1f428ea518ab35134e2ed5017a013a9 Mon Sep 17 00:00:00 2001 From: Oleksandr Kravchuk Date: Tue, 28 Jul 2026 22:21:33 +0200 Subject: [PATCH 02/35] Add Play Store link to README (#1524) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index fc9313c1..b9b6e000 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. From c6b7096b2ff5f0ba80d1c62511f16444700c7b56 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:03:20 +0100 Subject: [PATCH 03/35] BLE transport architecture V3: one engine domain, capability ports, feature-owned state (#1498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make peer registry and local announce state lock-backed The main actor answered isPeerConnected/peerNickname/currentPeerSnapshots and flipped runtime capability bits by blocking on collectionsQueue behind whatever transport work was in flight. Peer state now lives in a lock-backed BLEPeerRegistryStore (every registry mutation is a single whole-transition method, so readers never observe a torn state), and the runtime capability bits move into BLELocalIdentityStateStore next to the identity they ride announces with. No transport entry point called from the main actor blocks on a transport queue for peer state anymore. Co-Authored-By: Claude Fable 5 * Move BLE link egress/ingress buffers to bleQueue ownership pendingPeripheralWrites, pendingNotifications, and pendingWriteBuffers were collectionsQueue-guarded, but every producer and drain already runs on bleQueue next to the CoreBluetooth objects they feed — each access paid a cross-queue barrier for state that never leaves the radio thread, and the notification drain even invoked peripheralManager.updateValue from the collections queue. They are now bleQueue-confined like the link state store: CB delegate callbacks and drains touch them directly, and the few engine-side entry points hop to bleQueue (the direction the transport's sync-edge order already allows). This clears most bleQueue-to-collectionsQueue sync edges ahead of merging the collections queue into the message queue. Co-Authored-By: Claude Fable 5 * Stop bleQueue maintenance and status paths from blocking on collectionsQueue The traffic-burst tracker becomes a lock-backed monitor (written by the receive pipeline, read by scan-duty adaptation and announce pacing on bleQueue), the status-log peer summary and topology refresh read the already lock-backed registry directly, and the stalled-fragment reap moves to an async collections hop with the gossip resync request inside it. bleQueue no longer sync-waits on the collections queue anywhere. Co-Authored-By: Claude Fable 5 * Unify the message and collections queues into one serial engine queue The old model ran a concurrent message queue over a second concurrent collections queue whose barrier flags served as the real mutual exclusion — every field carried an ownership comment, and correctness lived in per-site discipline. The message queue is now a single serial engine queue that owns all mesh protocol state; the collections queue, its 98 sync/async hops, and every barrier flag are gone. Cross-thread callers go through onEngine, which documents and (in debug) enforces the transport's sync-edge order: main and test threads may block on the engine, the engine may block on bleQueue and the crypto/identity queues, and nothing may block the other way. The debug trap caught two latent inversions the leaf-lock structure had been masking: the verified-announce rebind path re-resolved the ingress link through the engine from inside its bleQueue critical section (it now receives the already-resolved link), and the noise session-generation closures sync-re-entered the engine from the noise manager's queue while their own engine slot was blocked on it (they now touch engine state directly, which the held slot makes exclusive). BLE throughput is orders of magnitude below what one serial queue sustains; the full suite runs at identical speed. Co-Authored-By: Claude Fable 5 * Wire gateway/bridge/panic features to capability ports, not BLEService App wiring discovered mesh-only features by casting the Transport to the concrete BLEService class in nine places. Those surfaces are now three capability protocols — BluetoothStateReporting, PanicResettingTransport, and MeshBridgingTransport — discovered with as? like any optional capability, so the bootstrapper, panic flow, and lifecycle coordinator no longer name the concrete transport at all. A future second mesh transport picks up gateway/bridge wiring and the panic lifecycle by conforming, and the remaining Transport god-protocol requirements can migrate to the same pattern. Co-Authored-By: Claude Fable 5 * Extract mesh-ping diagnostics state into a pure engine-confined tracker First slice of the feature-module direction: BLEMeshPingTracker owns the outstanding-probe map and the per-link inbound response budget as pure state (register/resolve/expire/reset), so the security invariants — a pong only resolves against the probed peer, the budget keys on the ingress link because claimed senders are forgeable, panic reset drops probes and budget together — are now unit-tested without queues or radios. The transport keeps only packet I/O, timers, and main-actor delivery around it. Co-Authored-By: Claude Fable 5 * Document the V3 transport architecture and remaining roadmap Co-Authored-By: Claude Fable 5 * Resolve the pass-6 review findings and the proof-timeout drain defect Periphery: the registry store's unused forwarders are gone (the struct method stays — it has direct tests). F1: refreshPeerIdentity, deliverBridgedEnvelope, and the three panic fences route through onEngine, so every sync entry onto the engine now carries the bleQueue trap. F2: the registry-store ownership comments state the real writer set (engine plus the two bleQueue link-drop paths). F7: BLEQueueContractTests pins the contract — only onEngine may sync-enter the engine, transport code never sync-dispatches to main, and the collections queue stays deleted — with a queue-contract-ok waiver for the two sanctioned lines. The real defect behind the timeoutRestoredSession CI flake: a timeout-restore parks the outbound queues until the convergence retry, but the capability-proof watchdog armed at the original authentication kept draining them when it fired — encrypting the parked traffic under restored keys the counterpart may have discarded, the exact silent loss the defer path exists to prevent. Deferred peers are now tracked and the watchdog drain respects the same rule; the test fires the watchdog deterministically inside the deferred window instead of losing that race only on stalled runners. Co-Authored-By: Claude Fable 5 * Extract private-media session state into a lock-backed store The six generation-keyed maps plus the convergence-deferral set move out of BLEService into BLEPrivateMediaSessionStore, each transition one whole method under a leaf lock with direct unit tests (generation rotation rejects mismatched waiters, stale proofs cannot classify a replacement session, expiry requires the live deadline identity, clears rebase waiters onto a nil-generation deadline, peer-state sends are once per generation per kind). Being a leaf lock also simplifies two contracts: the send policy is now answered entirely from locks (the main actor no longer sync-enters the engine for it), and the noise-manager critical sections call ordinary store methods instead of relying on the held-engine-slot direct-access subtlety. Co-Authored-By: Claude Fable 5 * Split the mesh-only Transport surface into capability protocols Transport kept ~50 requirements that only the BLE mesh implements — files/private media, voice, courier, groups, board, diagnostics, verification, archive — held together by an extension of inert defaults, so every call site compiled against a surface most transports faked. Those are now eight capability protocols (MeshFileTransferring, MeshVoiceStreaming, MeshCourierTransporting, MeshGroupMessaging, MeshBoardBroadcasting, MeshDiagnosing, MeshVerifying, MeshPublicArchiving) discovered with as?, joining the bridging/panic ports from the previous pass. Consumers resolve the capability they need; where the old defaults encoded a safe floor the caller keeps it explicitly (private-media policy degrades to blockedDowngrade). The inert-defaults extension is deleted, along with the never-implemented acceptPendingFile/declinePendingFile pair. NostrTransport is untouched — it only ever implemented the core. Co-Authored-By: Claude Fable 5 * Update the V3 doc for the completed feature-peeling and Transport split Co-Authored-By: Claude Fable 5 * Drop the dead three-argument sendFilePrivate overload Every production caller goes through the allowLegacyFallback variant; the short form only existed as a Transport-era forwarding default. Tests that used it on the concrete service now state the fallback decision explicitly, which is the point of the parameter. Co-Authored-By: Claude Fable 5 * Decide the link-auth boundary: bindings become engine-owned The atomicity that keeps link-auth on bleQueue exists to stop a binding from changing between a security check and its action; once every rebind is an engine operation, the engine's serial slot gives the same guarantee, the stolen-link residual is unchanged (directed payloads are Noise ciphertext), and the receive path lands in its sans-I/O shape — the link layer reports bytes-plus-linkID and the engine resolves the sender. Records the extraction order too: the binding-free radio half first (after #1521 lands — it collides in the scanPlan region), then bindings, then the delegates behind the port. Co-Authored-By: Claude Fable 5 * Fix two bleQueue-to-engine sync edges the queue merge created The collections-to-engine conversion turned two formerly leaf-lock sync calls into onEngine calls reachable from bleQueue, where the debug trap (correctly) aborts: flushDirectedSpool runs from bleQueue maintenance and now hops to the engine asynchronously, and ingress recording — which must answer the duplicate gate on bleQueue the moment a frame decodes — moves to a lock-backed BLEIngressLinkStore read by the engine's relay and routing decisions. Unit suites never hit either path (no CoreBluetooth managers means no maintenance timer and no live receive path); the iOS simulator job boots the real app as its test host, which is exactly where the maintenance trap fired. The ingress one would have trapped a real device on its first received packet — worth a device pass before release. Co-Authored-By: Claude Fable 5 * Route all deferred engine work through an injectable scheduler Relay jitter, announce delays, the ping and capability-proof deadlines, notification retry backoff, and fragment pacing all reached the engine through raw messageQueue.asyncAfter with product constants as deadlines — the hidden-elapsed-deadline flake class that the test-timing hygiene rules exist to contain, testable only by racing the wall clock. BLEEngineScheduling is now the transport's single source of engine delay: production is a thin veneer over the engine queue, tests inject a manually advanced clock whose advance() returns only after the released work has finished on the engine. The queue-contract test pins the seam (no raw messageQueue.asyncAfter), and the ping deadline gets the pattern's proof: the real 10s constant asserted in milliseconds — must not fire early, fires exactly once at the deadline, stays consumed after. Co-Authored-By: Claude Fable 5 * Assert the armed deadline count in the injected-clock ping test Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 3 +- bitchat/Services/BLE/BLEEngineScheduler.swift | 39 + .../Services/BLE/BLEIngressLinkRegistry.swift | 42 + .../BLE/BLELocalIdentityStateStore.swift | 68 +- bitchat/Services/BLE/BLEMeshPingTracker.swift | 62 + .../Services/BLE/BLEPeerRegistryStore.swift | 84 ++ .../BLE/BLEPrivateMediaSessionStore.swift | 363 ++++++ bitchat/Services/BLE/BLEReceivePipeline.swift | 20 + bitchat/Services/BLE/BLEService.swift | 1132 +++++++---------- bitchat/Services/Board/BoardManager.swift | 6 +- bitchat/Services/CommandProcessor.swift | 9 +- .../Services/MeshTransportCapabilities.swift | 152 +++ bitchat/Services/MessageRouter.swift | 6 +- bitchat/Services/Transport.swift | 156 +-- bitchat/Services/UnifiedPeerService.swift | 2 +- bitchat/ViewModels/ChatGroupCoordinator.swift | 9 +- .../ViewModels/ChatLifecycleCoordinator.swift | 4 +- .../ChatMediaTransferCoordinator.swift | 23 +- .../ChatVerificationCoordinator.swift | 7 +- bitchat/ViewModels/ChatViewModel.swift | 19 +- .../ChatViewModelBootstrapper.swift | 16 +- bitchat/ViewModels/ChatVouchCoordinator.swift | 2 +- .../ChatViewModel+PrivateChat.swift | 11 +- bitchatTests/BLEServiceCoreTests.swift | 69 +- .../EndToEnd/CourierEndToEndTests.swift | 2 +- .../EndToEnd/PrivateMediaEndToEndTests.swift | 9 +- .../Mocks/BLEEngineManualScheduler.swift | 49 + bitchatTests/Mocks/MockTransport.swift | 63 +- bitchatTests/ProtocolContractTests.swift | 18 +- .../Services/BLEMeshPingTrackerTests.swift | 84 ++ .../BLEPrivateMediaSessionStoreTests.swift | 192 +++ .../Services/BLEQueueContractTests.swift | 85 ++ docs/BLE-ARCHITECTURE-V3.md | 175 +++ 33 files changed, 2063 insertions(+), 918 deletions(-) create mode 100644 bitchat/Services/BLE/BLEEngineScheduler.swift create mode 100644 bitchat/Services/BLE/BLEMeshPingTracker.swift create mode 100644 bitchat/Services/BLE/BLEPeerRegistryStore.swift create mode 100644 bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift create mode 100644 bitchat/Services/MeshTransportCapabilities.swift create mode 100644 bitchatTests/Mocks/BLEEngineManualScheduler.swift create mode 100644 bitchatTests/Services/BLEMeshPingTrackerTests.swift create mode 100644 bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift create mode 100644 bitchatTests/Services/BLEQueueContractTests.swift create mode 100644 docs/BLE-ARCHITECTURE-V3.md 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/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/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/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/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/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/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 919c3820..488fb098 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`). @@ -254,8 +224,13 @@ final class BLEService: NSObject { // BCH-01-004: Rate-limiting for subscription-triggered announces. private 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() @@ -278,14 +253,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 @@ -297,8 +272,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 @@ -322,27 +295,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() @@ -350,15 +307,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 @@ -404,39 +356,74 @@ 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() + /// The only source of deferred engine work (see BLEEngineScheduling); + /// injectable so tests drive protocol deadlines with a manual clock. + private let engineScheduler: BLEEngineScheduling private 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 and test threads 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. + 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 + // Queue for notifications that failed due to full queue (bleQueue-owned, + // like the link state store: every producer and drain runs there). private 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 + // (bleQueue-owned) private 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) + // Per-peripheral write backpressure (bleQueue-owned) private var pendingPeripheralWrites = BLEOutboundWriteBuffer() // Debounce duplicate disconnect notifies private var disconnectNotifyDebouncer = BLEPeerEventDebouncer() @@ -492,7 +479,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() @@ -512,8 +499,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 @@ -532,6 +521,7 @@ final class BLEService: NSObject { // Set queue key for identification messageQueue.setSpecific(key: messageQueueKey, value: ()) + engineScheduler.activate(engineQueue: messageQueue) // Set up application state tracking (iOS only) #if os(iOS) @@ -544,6 +534,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() @@ -731,7 +725,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() @@ -759,18 +753,14 @@ 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() @@ -779,12 +769,7 @@ final class BLEService: NSObject { scheduledRelays.cancelAll() // 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 @@ -796,6 +781,8 @@ final class BLEService: NSObject { } bleQueue.sync { + pendingPeripheralWrites.removeAll() + pendingNotifications.removeAll() pendingWriteBuffers.removeAll() noiseAuthenticatedLinkOwners.removeAll() noiseReconnectPolicy.removeAll() @@ -808,7 +795,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() @@ -826,7 +813,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() @@ -896,9 +883,7 @@ final class BLEService: NSObject { weak var peerEventsDelegate: TransportPeerEventsDelegate? func currentPeerSnapshots() -> [TransportPeerSnapshot] { - collectionsQueue.sync { - peerRegistry.transportSnapshots(selfNickname: myNickname) - } + peerRegistry.transportSnapshots(selfNickname: myNickname) } // MARK: Identity @@ -961,7 +946,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 @@ -1016,7 +1001,7 @@ final class BLEService: NSObject { } // Clear pending notifications - collectionsQueue.sync(flags: .barrier) { + bleQueue.sync { pendingNotifications.removeAll() } @@ -1040,7 +1025,7 @@ 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() } @@ -1067,17 +1052,12 @@ 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 @@ -1110,14 +1090,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 { @@ -1134,15 +1112,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( @@ -1151,21 +1127,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( @@ -1183,11 +1148,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 { @@ -1198,16 +1161,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 @@ -1274,9 +1238,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 @@ -1287,43 +1249,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) @@ -1360,9 +1291,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, @@ -1378,39 +1307,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) } @@ -1418,67 +1325,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 { @@ -1542,10 +1423,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( @@ -1563,7 +1444,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) { @@ -1638,15 +1519,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, @@ -1838,7 +1710,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, @@ -1854,7 +1726,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) @@ -1981,7 +1853,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) { @@ -2065,14 +1937,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 @@ -2277,7 +2147,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( @@ -2297,8 +2167,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) } } @@ -2312,13 +2181,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) @@ -2381,7 +2249,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 } @@ -2517,7 +2385,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, @@ -2532,16 +2400,18 @@ final class BLEService: NSObject { private 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) + } } } @@ -2624,7 +2494,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. @@ -2663,7 +2533,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 @@ -2709,7 +2579,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 ) @@ -2718,7 +2588,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 } @@ -2797,7 +2667,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) { @@ -2812,7 +2682,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 { @@ -2846,10 +2716,8 @@ final class BLEService: NSObject { noiseReconnectPolicy.endLinkEpoch(link) } } - _ = collectionsQueue.sync(flags: .barrier) { - // Remove the peer when they leave - peerRegistry.remove(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 @@ -2857,7 +2725,7 @@ 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)) @@ -2870,7 +2738,7 @@ final class BLEService: NSObject { // 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) } } @@ -2890,15 +2758,10 @@ 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 { - ( - peerRegistry.connectedRoutingData, - PeerCapabilities.localSupported.union(runtimeCapabilities), - runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil - ) - } - + let connectedPeerIDs = peerRegistry.connectedRoutingData let localIdentity = localIdentityState.snapshot() + let advertisedCapabilities = localIdentity.advertisedCapabilities + let advertisedBridgeCell = localIdentity.advertisedBridgeGeohash let announcement = AnnouncementPacket( nickname: localIdentity.nickname, noisePublicKey: noisePub, @@ -3070,7 +2933,7 @@ extension BLEService: GossipSyncManager.Delegate { } func getConnectedPeers() -> [PeerID] { - return collectionsQueue.sync { + return onEngine { peerRegistry.connectedPeerIDs } } @@ -3171,9 +3034,7 @@ extension BLEService: CBCentralManagerDelegate { let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID) for state in peripheralStates { let peripheralID = state.peripheral.identifier.uuidString - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } + pendingPeripheralWrites.discardAll(for: peripheralID) noiseAuthenticatedLinkOwners.removeValue( forKey: .peripheral(peripheralID) ) @@ -3333,9 +3194,7 @@ extension BLEService: CBCentralManagerDelegate { #endif // Clean up references and peer mappings - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } + pendingPeripheralWrites.discardAll(for: peripheralID) noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) @@ -3351,9 +3210,7 @@ extension BLEService: CBCentralManagerDelegate { 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) - } + peerRegistry.mutate { $0.markDisconnected(peerID) } refreshLocalTopology() } @@ -3374,7 +3231,7 @@ extension BLEService: CBCentralManagerDelegate { 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 if let peerID, !peerStillLinked { self.notifyPeerDisconnectedDebounced(peerID) @@ -3388,13 +3245,11 @@ extension BLEService: CBCentralManagerDelegate { let peripheralID = peripheral.identifier.uuidString // Clean up the references - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: peripheralID) - } + 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 @@ -3496,9 +3351,7 @@ extension BLEService { SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) central.cancelPeripheralConnection(peripheral) - self.collectionsQueue.sync(flags: .barrier) { - self.pendingPeripheralWrites.discardAll(for: peripheralID) - } + self.pendingPeripheralWrites.discardAll(for: peripheralID) self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) @@ -3567,15 +3420,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, @@ -3596,7 +3449,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. @@ -3656,8 +3509,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, @@ -3686,7 +3539,7 @@ extension BLEService { messageID: String, for peerID: PeerID ) { - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendPrivateMessage( content: content, messageID: messageID, @@ -3701,7 +3554,7 @@ extension BLEService { for peerID: PeerID ) { guard privateMediaTransferAdmissions.begin(transferId) == .admitted else { return } - collectionsQueue.sync(flags: .barrier) { + onEngine { pendingNoiseSessionQueues.appendTypedPayload( payload, transferId: transferId, @@ -3715,31 +3568,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, @@ -3752,7 +3586,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, @@ -3785,10 +3619,9 @@ extension BLEService { } func _test_drainPrivateMediaSendPipeline() async { - let collectionsQueue = self.collectionsQueue await withCheckedContinuation { continuation in - messageQueue.async { - collectionsQueue.async(flags: .barrier) { + self.messageQueue.async { [weak self] in + self?.messageQueue.async { continuation.resume() } } @@ -3807,10 +3640,9 @@ extension BLEService { } func _test_drainNoiseMessagePipeline() async { - let collectionsQueue = self.collectionsQueue await withCheckedContinuation { continuation in - messageQueue.async(flags: .barrier) { - collectionsQueue.async(flags: .barrier) { + self.messageQueue.async { + self.messageQueue.async { continuation.resume() } } @@ -3821,7 +3653,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 @@ -3927,7 +3759,7 @@ extension BLEService: CBPeripheralDelegate { 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 + 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() @@ -4147,10 +3979,8 @@ extension BLEService: CBPeripheralManagerDelegate { ) noiseReconnectPolicy.endLinkEpoch(.central(centralID)) } - collectionsQueue.sync(flags: .barrier) { - pendingNotifications.removeAll() - pendingWriteBuffers.removeAll() - } + pendingNotifications.removeAll() + pendingWriteBuffers.removeAll() let centralPeerIDs = linkStateStore.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil @@ -4255,14 +4085,14 @@ extension BLEService: CBPeripheralManagerDelegate { } // Still flush directed packets for legitimate mesh operation - messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + engineScheduler.schedule(after: 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 + 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() @@ -4272,9 +4102,7 @@ extension BLEService: CBPeripheralManagerDelegate { 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 } - } + pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) noiseReconnectPolicy.endLinkEpoch(.central(centralID)) let removedPeerID = linkStateStore.removeSubscribedCentral(central) @@ -4295,9 +4123,7 @@ extension BLEService: CBPeripheralManagerDelegate { // 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) - } + peerRegistry.mutate { $0.markDisconnected(peerID) } refreshLocalTopology() @@ -4306,7 +4132,7 @@ extension BLEService: CBPeripheralManagerDelegate { 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.notifyPeerDisconnectedDebounced(peerID) // Publish snapshots so UnifiedPeerService can refresh icons promptly @@ -4330,7 +4156,7 @@ extension BLEService: CBPeripheralManagerDelegate { } private func drainPendingNotifications(logPrefix: String) { - collectionsQueue.async(flags: .barrier) { [weak self] in + bleQueue.async { [weak self] in guard let self = self, let characteristic = self.characteristic, !self.pendingNotifications.isEmpty else { return } @@ -4493,7 +4319,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, @@ -4521,7 +4347,7 @@ extension BLEService: PrivateMediaDeletionPersisting { @MainActor func removeLegacyPrivateMediaPayload(relativePath: String) { let fileStore = incomingFileStore - messageQueue.async(flags: .barrier) { + messageQueue.async { fileStore.removeLegacyIncomingFile(relativePath: relativePath) } } @@ -4716,10 +4542,10 @@ extension BLEService { let peripheralState = peripheralManager?.state ?? .unknown let isAdvertising = peripheralManager?.isAdvertising ?? false - let peerSummary = collectionsQueue.sync { + let peerSummary = peerRegistry.read { ( - connected: peerRegistry.connectedCount, - known: peerRegistry.count, + connected: $0.connectedCount, + known: $0.count, candidates: connectionScheduler.candidateCount ) } @@ -4756,10 +4582,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]? { @@ -4781,7 +4604,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) } }, @@ -4805,7 +4628,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 @@ -4853,8 +4676,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 @@ -4867,17 +4690,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) @@ -4899,8 +4725,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)") { @@ -4927,9 +4753,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,7 +4851,7 @@ extension BLEService { /// handshake. An old session keyed only by peer ID is insufficient: a /// replayed announce can rebind an attacker's link to that ID. private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) { - guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return } + guard let link = ingressLinks.link(for: packet) else { return } readLinkState { store in guard boundPeerID(for: link, in: store) == peerID else { return } noiseAuthenticatedLinkOwners[link] = peerID @@ -5034,7 +4859,7 @@ extension BLEService { } private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool { - guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false } + guard let link = ingressLinks.link(for: packet) else { return false } return readLinkState { store in noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID } @@ -5055,14 +4880,14 @@ extension BLEService { /// 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: both callers run inside the + /// rebind's bleQueue critical section, which must never sync-wait on the + /// engine (the engine sync-waits on bleQueue via `readLinkState`). 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 @@ -5092,7 +4917,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, @@ -5102,7 +4927,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, @@ -5125,7 +4950,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, @@ -5172,7 +4997,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, @@ -5208,47 +5033,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 @@ -5266,20 +5074,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 @@ -5295,11 +5106,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 @@ -5316,25 +5131,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() @@ -5351,6 +5150,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( @@ -5373,14 +5179,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 @@ -5395,29 +5200,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) } @@ -5433,22 +5228,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, @@ -5472,17 +5252,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() } } @@ -5502,7 +5277,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) } @@ -5524,21 +5299,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 } @@ -5681,18 +5445,14 @@ extension BLEService { 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 { + 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). @@ -5704,7 +5464,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, @@ -5723,7 +5483,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 } @@ -5801,7 +5561,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 { @@ -5821,7 +5581,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) @@ -5851,7 +5611,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 @@ -5961,7 +5721,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 { @@ -6029,7 +5789,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 { @@ -6074,7 +5834,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, @@ -6088,7 +5848,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 @@ -6161,7 +5921,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) @@ -6209,22 +5969,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 } } } @@ -6261,14 +6019,12 @@ extension BLEService { 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) @@ -6293,11 +6049,7 @@ extension BLEService { 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 @@ -6314,9 +6066,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) } } } @@ -6328,7 +6078,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) @@ -6437,9 +6187,7 @@ extension BLEService { 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.pendingPeripheralWrites.discardAll(for: peripheralID) self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) @@ -6493,7 +6241,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) } @@ -6515,7 +6263,7 @@ extension BLEService { ) else { return } - messageQueue.async(flags: .barrier) { + messageQueue.async { [weak self, weak service] in guard let self, let service, @@ -6557,7 +6305,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 { @@ -6584,7 +6332,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) } @@ -6627,7 +6375,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) @@ -6659,12 +6407,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( @@ -6729,7 +6477,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 @@ -6764,7 +6512,7 @@ extension BLEService { let transferIdentifier: String? if let id = reservedTransferId { - let activated = collectionsQueue.sync(flags: .barrier) { + let activated = onEngine { self.outboundFragmentTransfers.activateReservedTransfer( id: id, totalFragments: plan.totalFragments, @@ -6823,7 +6571,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 { @@ -6840,14 +6588,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 } @@ -6855,7 +6603,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) { @@ -6875,7 +6623,7 @@ extension BLEService { } private func startNextPendingTransferIfNeeded() { - let results = collectionsQueue.sync(flags: .barrier) { + let results = onEngine { outboundFragmentTransfers.reservePendingStarts(maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers) } @@ -6890,7 +6638,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) } } @@ -6910,7 +6658,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) } }, @@ -6961,7 +6709,7 @@ extension BLEService { capturePanicLifecycleGeneration() else { return } - messageQueue.async(flags: .barrier) { [weak self] in + messageQueue.async { [weak self] in guard let self, self.isCurrentPanicLifecycleGeneration( lifecycleGeneration @@ -6996,7 +6744,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) @@ -7101,9 +6849,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) } } @@ -7112,7 +6860,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, @@ -7124,7 +6872,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 @@ -7132,10 +6880,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) { @@ -7219,7 +6967,7 @@ extension BLEService { /// sender owns the link it arrived on, so rebind the link to the new ID /// and retire the old identity. private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) { - guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return } + guard let link = ingressLinks.link(for: packet) else { return } bleQueue.async { [weak self] in guard let self else { return } let linkUUID: String @@ -7235,7 +6983,7 @@ extension BLEService { guard let previousPeerID else { return } guard previousPeerID != peerID else { self.refreshNoiseSessionForVerifiedDirectLink( - packet, + link: link, peerID: peerID ) return @@ -7277,7 +7025,7 @@ extension BLEService { // section. No observer may see the new binding while a cached // peer-level sender is still considered established. self.refreshNoiseSessionForVerifiedDirectLink( - packet, + link: link, peerID: peerID ) SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) @@ -7325,7 +7073,7 @@ 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) } + let ingressLink = ingressLinks.link(for: packet) bleQueue.async { [weak self] in guard let self else { return } let now = Date() @@ -7367,9 +7115,7 @@ extension BLEService { ) for uuid in retiring { guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue } - collectionsQueue.sync(flags: .barrier) { - pendingPeripheralWrites.discardAll(for: uuid) - } + pendingPeripheralWrites.discardAll(for: uuid) noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid)) _ = linkStateStore.removePeripheral(uuid) @@ -7400,15 +7146,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)) } } @@ -7417,15 +7161,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)) } @@ -7442,7 +7184,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) } @@ -7474,7 +7216,7 @@ 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 } + guard let link = self.ingressLinks.link(for: packet) else { return false } let boundPeerID: PeerID? = self.readLinkState { store in switch link { case .peripheral(let peripheralUUID): @@ -7487,28 +7229,30 @@ extension BLEService { 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. @@ -7547,7 +7291,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)) } @@ -7559,7 +7303,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) } } @@ -7638,7 +7382,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) @@ -7672,7 +7416,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 @@ -7742,7 +7486,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) @@ -7837,15 +7581,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( @@ -7888,7 +7633,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 } @@ -7906,7 +7651,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, @@ -7970,10 +7715,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 @@ -7990,9 +7732,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) } @@ -8006,12 +7746,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, @@ -8082,7 +7820,7 @@ extension BLEService { private func checkPeerConnectivity() { 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) @@ -8092,8 +7830,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) @@ -8106,7 +7844,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)) @@ -8137,18 +7875,20 @@ 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) @@ -8156,14 +7896,14 @@ extension BLEService { connectionScheduler.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 { @@ -8176,7 +7916,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) @@ -8193,12 +7933,10 @@ extension BLEService { 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 hasRecentTraffic = recentTrafficTracker.hasTraffic( + within: TransportConfig.bleRecentTrafficForceScanSeconds, + now: Date() + ) let scanPlan = BLEScanDutyPolicy.plan( dutyEnabled: dutyEnabled, appIsActive: active, 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/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/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..879dc866 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -279,7 +279,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/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..1ea87a41 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()) } } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index bad3a7fb..1d663044 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) { 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..d794ea3f 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1069,7 +1069,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 +1564,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 +1700,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 +1746,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/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 3c856e19..f0a573cd 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() @@ -908,6 +960,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() @@ -1499,7 +1562,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 +1573,8 @@ private func makeService( idBridge: idBridge, identityManager: identityManager, initializeBluetoothManagers: false, - noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout + noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout, + engineScheduler: engineScheduler ) } diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index 53de683b..6ce23355 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -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/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/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/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/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/docs/BLE-ARCHITECTURE-V3.md b/docs/BLE-ARCHITECTURE-V3.md new file mode 100644 index 00000000..60400ceb --- /dev/null +++ b/docs/BLE-ARCHITECTURE-V3.md @@ -0,0 +1,175 @@ +# 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. +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. + +## 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. From d39467f7d3cba5365eaeb5b82b690cf4e446a995 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:09:09 +0100 Subject: [PATCH 04/35] Defer alert-binding dismissal writes out of the view update (#1537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI invokes an alert Binding's setter inside the current view update when the alert dismisses because its get re-evaluated (a scenePhase change while the Bluetooth-off or voice-error alert is up). Both root alert bindings wrote their @Published backing state synchronously from that setter — the 'Publishing changes from within view updates is not allowed' undefined-behavior warning, reproduced on device by launching with Bluetooth off and backgrounding. Defer the write one main-actor hop. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Views/ContentView.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index be2bdeec..4efb0d34 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -182,7 +182,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 +211,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 + } } ) } From 2c22b117b201a3ed941591058844857808040b95 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:45:49 +0100 Subject: [PATCH 05/35] Extract the central-role radio policy into BLERadioController (#1539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the link layer: discovery admission, the connection budget and queue, connect timeouts, wake-on-proximity background connects, scan duty-cycling, RSSI adaptation, and the advertising payload move out of BLEService into a bleQueue-confined controller (~400 lines). It makes no peer decisions and owns no bindings or security state: it shares the bleQueue-confined link-state store for admission reads, and when a connect attempt dies it asks its delegate to retire the transport bookkeeping — which also factors the four-times-repeated teardown sequence (write backpressure, link-auth proof, reconnect epoch, link-state entry) into one tearDownPeripheralLink helper. The three-method delegate (panic suspended, app active, tear down) is the radio's entire dependency on the transport; the CoreBluetooth delegate methods in BLEService shrink toward pure event forwarding ahead of the LinkEvent/LinkCommand port. candidateCount joins the Periphery baseline like the rest of the status-capture path: its only callers are iOS-gated, invisible to the macOS scheme scan. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .periphery.baseline.json | 2 +- bitchat/Services/BLE/BLERadioController.swift | 412 ++++++++++++++++ bitchat/Services/BLE/BLEService.swift | 441 +++--------------- 3 files changed, 484 insertions(+), 371 deletions(-) create mode 100644 bitchat/Services/BLE/BLERadioController.swift diff --git a/.periphery.baseline.json b/.periphery.baseline.json index 0b826869..af3885fb 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: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/bitchat/Services/BLE/BLERadioController.swift b/bitchat/Services/BLE/BLERadioController.swift new file mode 100644 index 00000000..8c7813e1 --- /dev/null +++ b/bitchat/Services/BLE/BLERadioController.swift @@ -0,0 +1,412 @@ +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, + peerID: 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/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 488fb098..6b0dc932 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -459,18 +459,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) + private lazy var radio = BLERadioController( + queue: bleQueue, + linkStateStore: linkStateStore, + recentTraffic: recentTrafficTracker + ) // Debounced publish to coalesce rapid changes private var peerPublishCoalescer = BLEPeerPublishCoalescer() @@ -522,6 +517,8 @@ 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) @@ -645,6 +642,7 @@ final class BLEService: NSObject { centralManager = CBCentralManager(delegate: self, queue: bleQueue) peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) #endif + radio.central = centralManager } private func restartGossipManager() { @@ -698,8 +696,7 @@ final class BLEService: NSObject { deinit { maintenanceTimer?.cancel() - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() #if os(iOS) @@ -786,7 +783,7 @@ final class BLEService: NSObject { pendingWriteBuffers.removeAll() noiseAuthenticatedLinkOwners.removeAll() noiseReconnectPolicy.removeAll() - connectionScheduler.reset() + radio.reset() } disconnectNotifyDebouncer.removeAll() @@ -1008,8 +1005,7 @@ final class BLEService: NSObject { // Stop timer maintenanceTimer?.cancel() maintenanceTimer = nil - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() @@ -1031,8 +1027,7 @@ final class BLEService: NSObject { maintenanceTimer?.cancel() maintenanceTimer = nil - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() @@ -1080,7 +1075,7 @@ final class BLEService: NSObject { linkStateStore.clearAll() noiseAuthenticatedLinkOwners.removeAll() noiseReconnectPolicy.removeAll() - connectionScheduler.reset() + radio.reset() subscriptionAnnounceLimiter.removeAll() } meshTopology.reset() @@ -2988,7 +2983,7 @@ extension BLEService: CBCentralManagerDelegate { // 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()) + radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date()) } // Via the sampler (not a direct capture): it refreshes the cached @@ -2997,7 +2992,7 @@ extension BLEService: CBCentralManagerDelegate { logBluetoothStatus("central-restore") if central.state == .poweredOn { - startScanning() + radio.startScanning() } } #endif @@ -3023,7 +3018,7 @@ extension BLEService: CBCentralManagerDelegate { } // Start scanning - use allow duplicates for faster discovery when active - startScanning() + radio.startScanning() case .poweredOff: // CoreBluetooth has already transitioned out of poweredOn. Do @@ -3070,70 +3065,11 @@ extension BLEService: CBCentralManagerDelegate { } } - 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 - #if os(iOS) - let allowDuplicates = isAppActive // Use our tracked state (thread-safe) - #else - let allowDuplicates = true // macOS doesn't have background restrictions - #endif - - central.scanForPeripherals( - withServices: [BLEService.serviceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] - ) - - // 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") - } + radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI) } - + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { guard !isPanicSuspended else { central.cancelPeripheralConnection(peripheral) @@ -3153,7 +3089,7 @@ extension BLEService: CBCentralManagerDelegate { linkStateStore.markConnected(peripheral) // Reset backoff state on success - connectionScheduler.recordConnectionSuccess(peripheralID: peripheralID) + radio.recordConnectionSuccess(peripheralID: peripheralID) SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session) @@ -3171,12 +3107,12 @@ extension BLEService: CBCentralManagerDelegate { // If disconnect carried an error (often timeout), apply short backoff to avoid thrash if error != nil { - connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date()) + 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. - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date()) + radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date()) #if os(iOS) // Link lost while backgrounded (peer walked away): re-arm a pending @@ -3188,16 +3124,13 @@ extension BLEService: CBCentralManagerDelegate { 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) + self.radio.armPendingBackgroundConnects(slotReserve: 0) } } #endif // Clean up references and peer mappings - pendingPeripheralWrites.discardAll(for: peripheralID) - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = linkStateStore.removePeripheral(peripheralID) + tearDownPeripheralLink(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 @@ -3220,11 +3153,11 @@ extension BLEService: CBCentralManagerDelegate { // Stop and restart scanning to ensure we get fresh discovery events centralManager?.stopScan() bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in - self?.startScanning() + self?.radio.startScanning() } } // Attempt to fill freed slot from queue - bleQueue.async { [weak self] in self?.tryConnectFromQueue() } + bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() } // Notify delegate about disconnection on main thread (direct link dropped) notifyUI { [weak self] in @@ -3245,119 +3178,46 @@ extension BLEService: CBCentralManagerDelegate { let peripheralID = peripheral.identifier.uuidString // Clean up the references - pendingPeripheralWrites.discardAll(for: peripheralID) - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = linkStateStore.removePeripheral(peripheralID) + tearDownPeripheralLink(peripheralID) SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) - connectionScheduler.recordConnectionFailure(peripheralID: peripheralID) + radio.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 - ) - } -} - -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 - } + bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() } } } 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() - ) +// MARK: - Radio controller integration - 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") - } +extension BLEService: BLERadioControllerDelegate { + func radioIsPanicSuspended() -> Bool { + isPanicSuspended } - 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) + func radioIsAppActive() -> Bool { + #if os(iOS) + return isAppActive + #else + return true + #endif + } - 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 } + func radioTearDownPeripheralLink(_ peripheralID: String) { + tearDownPeripheralLink(peripheralID) + } - 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.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() - } + /// Retires one peripheral link's transport bookkeeping: its write + /// backpressure, its Noise link proof and reconnect epoch, and the + /// link-state entry (which repairs the peer's reverse mapping onto a + /// surviving duplicate link). bleQueue-confined. + func tearDownPeripheralLink(_ peripheralID: String) { + pendingPeripheralWrites.discardAll(for: peripheralID) + noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) + _ = linkStateStore.removePeripheral(peripheralID) } } @@ -4042,7 +3902,7 @@ extension BLEService: CBPeripheralManagerDelegate { logBluetoothStatus("peripheral-restore") if peripheral.state == .poweredOn && !peripheral.isAdvertising { - peripheral.startAdvertising(buildAdvertisementData()) + peripheral.startAdvertising(BLERadioController.advertisementData()) } } #endif @@ -4060,7 +3920,7 @@ extension BLEService: CBPeripheralManagerDelegate { SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session) // Start advertising after service is confirmed added - let adData = buildAdvertisementData() + 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) @@ -4110,7 +3970,7 @@ extension BLEService: CBPeripheralManagerDelegate { // 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()) + peripheral.startAdvertising(BLERadioController.advertisementData()) } // Find and disconnect the peer associated with this central @@ -4297,15 +4157,7 @@ extension BLEService: CBPeripheralManagerDelegate { // 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 @@ -4542,11 +4394,12 @@ extension BLEService { let peripheralState = peripheralManager?.state ?? .unknown let isAdvertising = peripheralManager?.isAdvertising ?? false + let candidateCount = radio.candidateCount let peerSummary = peerRegistry.read { ( connected: $0.connectedCount, known: $0.count, - candidates: connectionScheduler.candidateCount + candidates: candidateCount ) } @@ -6094,9 +5947,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 @@ -6108,9 +5961,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() @@ -6118,87 +5971,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.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 @@ -6505,7 +6277,7 @@ 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() } } } @@ -7115,10 +6887,7 @@ extension BLEService { ) for uuid in retiring { guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue } - pendingPeripheralWrites.discardAll(for: uuid) - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid)) - _ = linkStateStore.removePeripheral(uuid) + tearDownPeripheralLink(uuid) SecureLogger.info( "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")", category: .session @@ -7765,20 +7534,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() @@ -7893,7 +7662,7 @@ extension BLEService { // 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) messageQueue.async { [weak self] in @@ -7924,72 +7693,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 = 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() - ) - } } From a0b7985cbee6a8d0797f3a018a280dd14839cf95 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:19:13 +0100 Subject: [PATCH 06/35] Link layer slice 2: cohere link-auth state and split bindings from the physical store (#1540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cohere per-link Noise auth and rebind containment into BLELinkAuthState The authenticated-link owners, the reconnect revalidation policy, and the two rebind-containment cooldowns were four loose bleQueue-owned maps whose invariants lived in call-site discipline: every teardown path had to remember to retire the proof AND close the revalidation epoch (the pair appeared seven times), and both cooldowns hand-rolled the same prune-check-record dance. BLELinkAuthState owns them as whole transitions — retireLink, retireLinks(ownedBy:), permitRebind, permitRedundantRetirement — with the ownership question (bleQueue today, engine after the option-B flip) answered in one place. No behavior change; the one call-site reordering (redundant retirement computes the survivor before the cooldown check instead of after) is outcome-equivalent since the cooldown only ever recorded when a survivor existed. Co-Authored-By: Claude Fable 5 * Split identity-link bindings out of the physical link store BLELinkStateStore owned two different kinds of truth: what physical links exist (CB handles, connect lifecycles, characteristics, stream assemblers) and who each link belongs to (peer bindings in both roles plus the preferred-peripheral reverse map for directed sends and fanout collapse). The bindings now live on BLELinkBindings — same bleQueue ownership, whole-transition methods, direct tests for the rotation reverse-map cleanup and the preferred-link survivor repair that were previously only exercised end to end. Composed operations that need both truths (remove-with-repair, direct link state, the subscribed- central snapshot, bind-only-live-links) live on the transport as explicitly bleQueue-confined helpers. This is the structural half of the option-B boundary flip (docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move to the engine without touching what-links-exist. An audit of every physical clear/remove found three sites (emergency clear, both unauthorized branches) that needed explicit binding-clear pairing under the split — each now clears both. Co-Authored-By: Claude Fable 5 * Fix iOS-gated constructors and preserve containment cooldowns on reset CI caught what the macOS SwiftPM build cannot see: two #if os(iOS) sites still passed the peerID field that slice B1 removed from BLEPeripheralLinkState (willRestoreState in BLEService and armPendingBackgroundConnects in BLERadioController). Both fixed and verified with a local iOS simulator xcodebuild. Codex also caught a real regression: BLELinkAuthState.removeAll() cleared the rebind/retirement cooldown maps, which the original panic and emergency reset paths deliberately left alive. A stable CoreBluetooth UUID must not earn a fresh rebind allowance just because the session state around it was wiped. removeAll() now clears only the proofs and revalidation epochs, and BLELinkAuthStateTests pins the survival invariant along with the other auth-state transitions. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Services/BLE/BLELinkAuthState.swift | 115 ++++++++ bitchat/Services/BLE/BLELinkBindings.swift | 149 ++++++++++ bitchat/Services/BLE/BLELinkStateStore.swift | 135 ++------- bitchat/Services/BLE/BLERadioController.swift | 1 - bitchat/Services/BLE/BLEService.swift | 265 ++++++++++-------- .../Services/BLELinkAuthStateTests.swift | 75 +++++ .../Services/BLELinkBindingsTests.swift | 111 ++++++++ .../Services/BLELinkStateStoreTests.swift | 46 --- 8 files changed, 607 insertions(+), 290 deletions(-) create mode 100644 bitchat/Services/BLE/BLELinkAuthState.swift create mode 100644 bitchat/Services/BLE/BLELinkBindings.swift create mode 100644 bitchatTests/Services/BLELinkAuthStateTests.swift create mode 100644 bitchatTests/Services/BLELinkBindingsTests.swift delete mode 100644 bitchatTests/Services/BLELinkStateStoreTests.swift diff --git a/bitchat/Services/BLE/BLELinkAuthState.swift b/bitchat/Services/BLE/BLELinkAuthState.swift new file mode 100644 index 00000000..ce31e350 --- /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. +/// +/// bleQueue-confined today, alongside the link bindings it qualifies; +/// both move to the engine together in the option-B boundary flip +/// (docs/BLE-ARCHITECTURE-V3.md). +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..d4c5342c --- /dev/null +++ b/bitchat/Services/BLE/BLELinkBindings.swift @@ -0,0 +1,149 @@ +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. +/// +/// Split from the physical link-state store so the option-B boundary flip +/// (docs/BLE-ARCHITECTURE-V3.md) can move ownership of *who owns a link* +/// to the engine without touching *what links exist*. bleQueue-confined +/// today, alongside the physical store and `BLELinkAuthState`. +/// +/// Lifecycle contract: bindings are only created for live physical links +/// (callers guard existence) and are retired through +/// `peripheralRemoved`/`centralRemoved`/`clear*` when the physical link +/// goes, so binding queries never see departed links. +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/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift index 31914092..ebf39114 100644 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ b/bitchat/Services/BLE/BLELinkStateStore.swift @@ -5,7 +5,6 @@ import Foundation struct BLEPeripheralLinkState { let peripheral: CBPeripheral var characteristic: CBCharacteristic? - var peerID: PeerID? var isConnecting: Bool var isConnected: Bool var lastConnectionAttempt: Date? @@ -26,17 +25,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 +66,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 +103,6 @@ final class BLELinkStateStore { BLEPeripheralLinkState( peripheral: peripheral, characteristic: nil, - peerID: nil, isConnecting: true, isConnected: false, lastConnectionAttempt: date, @@ -129,7 +122,6 @@ final class BLELinkStateStore { BLEPeripheralLinkState( peripheral: peripheral, characteristic: nil, - peerID: nil, isConnecting: false, isConnected: true, lastConnectionAttempt: nil, @@ -146,130 +138,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/BLERadioController.swift b/bitchat/Services/BLE/BLERadioController.swift index 8c7813e1..cf9d6c2d 100644 --- a/bitchat/Services/BLE/BLERadioController.swift +++ b/bitchat/Services/BLE/BLERadioController.swift @@ -337,7 +337,6 @@ final class BLERadioController { BLEPeripheralLinkState( peripheral: target.peripheral, characteristic: nil, - peerID: nil, isConnecting: true, isConnected: false, lastConnectionAttempt: nil, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 6b0dc932..5ad02cf9 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -206,20 +206,14 @@ final class BLEService: NSObject { // 1. Consolidated BLE link tracking for both central and peripheral roles. private 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] = [:] + // Per-link Noise authentication and rebind containment (bleQueue-owned, + // like the link store — 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). + private var linkAuth = BLELinkAuthState() + // Identity↔link bindings, split from the physical link store so the + // option-B flip can move ownership to the engine (bleQueue-owned). + private var linkBindings = BLELinkBindings() // BCH-01-004: Rate-limiting for subscription-triggered announces. private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() @@ -781,8 +775,7 @@ final class BLEService: NSObject { pendingPeripheralWrites.removeAll() pendingNotifications.removeAll() pendingWriteBuffers.removeAll() - noiseAuthenticatedLinkOwners.removeAll() - noiseReconnectPolicy.removeAll() + linkAuth.removeAll() radio.reset() } disconnectNotifyDebouncer.removeAll() @@ -1073,8 +1066,8 @@ final class BLEService: NSObject { // Clear peripheral references (synchronized access to avoid races with BLE callbacks) bleQueue.sync { linkStateStore.clearAll() - noiseAuthenticatedLinkOwners.removeAll() - noiseReconnectPolicy.removeAll() + linkBindings.removeAll() + linkAuth.removeAll() radio.reset() subscriptionAnnounceLimiter.removeAll() } @@ -2207,8 +2200,8 @@ final class BLEService: NSObject { 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 + return linkAuth.isAuthenticated(link, for: peerID) + && linkBindings.peer(forCentralUUID: central.identifier.uuidString) == peerID } } else { eligible = centrals @@ -2265,8 +2258,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 readLinkState { _ in linkBindings.peer(forPeripheralID: uuid) }.map { (uuid, $0) } }) let plan = BLEOutboundLinkPlanner.plan( packet: packet, @@ -2282,7 +2276,7 @@ final class BLEService: NSObject { // 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: readLinkState { _ in linkBindings.preferredPeripheralBindings }, directAnnounceTTL: messageTTL, directedOnlyPeer: directedOnlyPeer, requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink @@ -2703,13 +2697,7 @@ final class BLEService: NSObject { // 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) - } + _ = linkAuth.retireLinks(ownedBy: peerID) } // Remove the peer when they leave peerRegistry.mutate { _ = $0.remove(peerID) } @@ -2962,14 +2950,12 @@ extension BLEService: CBCentralManagerDelegate { 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, @@ -3026,16 +3012,13 @@ extension BLEService: CBCentralManagerDelegate { // 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 pendingPeripheralWrites.discardAll(for: peripheralID) - noiseAuthenticatedLinkOwners.removeValue( - forKey: .peripheral(peripheralID) - ) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) + linkAuth.retireLink(.peripheral(peripheralID)) } - _ = linkStateStore.clearPeripherals() + linkStateStore.clearPeripherals() + let peerIDs = linkBindings.clearPeripherals() // Notify UI of disconnections for peerID in peerIDs { notifyUI { [weak self] in @@ -3046,7 +3029,8 @@ extension BLEService: CBCentralManagerDelegate { case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) - _ = linkStateStore.clearPeripherals() + linkStateStore.clearPeripherals() + _ = linkBindings.clearPeripherals() case .unsupported: // Device doesn't support BLE @@ -3101,7 +3085,7 @@ extension BLEService: CBCentralManagerDelegate { let peripheralID = peripheral.identifier.uuidString // Find the peer ID if we have it - let peerID = linkStateStore.peerID(forPeripheralID: peripheralID) + let peerID = linkBindings.peer(forPeripheralID: peripheralID) SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session) @@ -3139,7 +3123,7 @@ extension BLEService: CBCentralManagerDelegate { // 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 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 @@ -3211,13 +3195,62 @@ extension BLEService: BLERadioControllerDelegate { /// Retires one peripheral link's transport bookkeeping: its write /// backpressure, its Noise link proof and reconnect epoch, and the - /// link-state entry (which repairs the peer's reverse mapping onto a - /// surviving duplicate link). bleQueue-confined. + /// link-state entry plus binding (which repairs the peer's reverse + /// mapping onto a surviving duplicate link). bleQueue-confined. func tearDownPeripheralLink(_ peripheralID: String) { pendingPeripheralWrites.discardAll(for: peripheralID) - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = linkStateStore.removePeripheral(peripheralID) + linkAuth.retireLink(.peripheral(peripheralID)) + removePeripheralLink(peripheralID) + } + + /// Physical removal plus binding retirement, one unit: the preferred + /// link repairs onto a connected survivor, preferring a writable one + /// (a link mid-service-rediscovery would strand directed sends until + /// its characteristic comes back). bleQueue-confined. + @discardableResult + func removePeripheralLink(_ peripheralID: String) -> PeerID? { + linkStateStore.removePeripheral(peripheralID) + return linkBindings.peripheralRemoved(peripheralID) { remaining in + let alive = remaining.compactMap { uuid -> (uuid: String, writable: Bool)? in + guard let state = linkStateStore.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 outlive (or precede) its link. bleQueue-confined. + func bindPeripheralLink(_ peripheralUUID: String, to peerID: PeerID) { + guard linkStateStore.state(forPeripheralID: peripheralUUID) != nil else { return } + linkBindings.bindPeripheral(peripheralUUID, to: peerID) + } + + /// Whether the peer holds a live direct link in either role. + /// bleQueue-confined (physical liveness + bindings in one view). + func directLinkState(for peerID: PeerID) -> BLEDirectLinkState { + let hasPeripheral = linkBindings.preferredPeripheralUUID(for: peerID) + .flatMap { linkStateStore.state(forPeripheralID: $0)?.isConnected } ?? false + return BLEDirectLinkState( + hasPeripheral: hasPeripheral, + hasCentral: linkBindings.hasCentral(boundTo: peerID) + ) + } + + /// The peer's preferred peripheral link state, when physically present. + /// bleQueue-confined. + func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { + linkBindings.preferredPeripheralUUID(for: peerID) + .flatMap { linkStateStore.state(forPeripheralID: $0) } + } + + /// Subscribed centrals with their bindings, one view. bleQueue-confined. + func subscribedCentralSnapshot() -> BLESubscribedCentralSnapshot { + BLESubscribedCentralSnapshot( + centrals: linkStateStore.subscribedCentrals, + peerIDsByCentralUUID: linkBindings.centralPeersByUUID + ) } } @@ -3343,23 +3376,23 @@ extension BLEService { } func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) { - bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) } + bleQueue.sync { linkBindings.bindCentral(centralUUID, to: peerID) } } func _test_centralBinding(_ centralUUID: String) -> PeerID? { - bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) } + bleQueue.sync { linkBindings.peer(forCentralUUID: centralUUID) } } func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) { bleQueue.sync { - guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return } - noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID + 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 + linkAuth.isAuthenticated(.central(centralUUID), for: peerID) } } @@ -3650,7 +3683,6 @@ extension BLEService: CBPeripheralDelegate { var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState( peripheral: peripheral, characteristic: nil, - peerID: nil, isConnecting: false, isConnected: peripheral.state == .connected, lastConnectionAttempt: nil, @@ -3675,7 +3707,7 @@ extension BLEService: CBPeripheralDelegate { // 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 + var boundPeerID: PeerID? = linkBindings.peer(forPeripheralID: peripheralUUID) for frame in result.frames { guard let packet = BinaryProtocol.decode(frame) else { @@ -3699,8 +3731,7 @@ extension BLEService: CBPeripheralDelegate { packet.type == MessageType.announce.rawValue, packet.ttl == messageTTL { boundPeerID = claimedSenderID - state.peerID = claimedSenderID - linkStateStore.bindPeripheral(peripheralUUID, to: claimedSenderID) + bindPeripheralLink(peripheralUUID, to: claimedSenderID) } if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) { @@ -3728,9 +3759,9 @@ extension BLEService: CBPeripheralDelegate { // 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) + let boundPeerID = linkBindings.peer(forPeripheralID: peripheralUUID) if boundPeerID == nil || boundPeerID == senderID { - linkStateStore.bindPeripheral(peripheralUUID, to: senderID) + bindPeripheralLink(peripheralUUID, to: senderID) refreshLocalTopology() } } @@ -3831,17 +3862,15 @@ extension BLEService: CBPeripheralManagerDelegate { // 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 + let centralSnapshot = subscribedCentralSnapshot() for central in centralSnapshot.centrals { let centralID = central.identifier.uuidString - noiseAuthenticatedLinkOwners.removeValue( - forKey: .central(centralID) - ) - noiseReconnectPolicy.endLinkEpoch(.central(centralID)) + linkAuth.retireLink(.central(centralID)) } pendingNotifications.removeAll() pendingWriteBuffers.removeAll() - let centralPeerIDs = linkStateStore.clearCentrals() + linkStateStore.clearCentrals() + let centralPeerIDs = linkBindings.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil // Notify UI of disconnections @@ -3854,7 +3883,8 @@ extension BLEService: CBPeripheralManagerDelegate { case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) - _ = linkStateStore.clearCentrals() + linkStateStore.clearCentrals() + _ = linkBindings.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil @@ -3963,9 +3993,9 @@ extension BLEService: CBPeripheralManagerDelegate { let centralID = central.identifier.uuidString SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session) pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } - noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) - noiseReconnectPolicy.endLinkEpoch(.central(centralID)) - let removedPeerID = linkStateStore.removeSubscribedCentral(central) + linkAuth.retireLink(.central(centralID)) + linkStateStore.removeSubscribedCentral(central) + let removedPeerID = linkBindings.centralRemoved(centralID) // Ensure we're still advertising for other devices to find us if !isPanicSuspended, peripheral.isAdvertising == false { @@ -3981,7 +4011,7 @@ extension BLEService: CBPeripheralManagerDelegate { // 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 } + guard linkBindings.links(to: peerID).isEmpty else { return } // Mark peer as not connected; retain for reachability peerRegistry.mutate { $0.markDisconnected(peerID) } @@ -4124,7 +4154,7 @@ extension BLEService: CBPeripheralManagerDelegate { let context = acceptedIngressContext( for: packet, claimedSenderID: claimedSenderID, - boundPeerID: linkStateStore.peerID(forCentralUUID: centralUUID), + boundPeerID: linkBindings.peer(forCentralUUID: centralUUID), linkDescription: "Central \(centralUUID.prefix(8))…" ) guard let context else { return } @@ -4139,9 +4169,9 @@ extension BLEService: CBPeripheralManagerDelegate { 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) + let boundPeerID = linkBindings.peer(forCentralUUID: centralUUID) if boundPeerID == nil || boundPeerID == claimedSenderID { - linkStateStore.bindCentral(centralUUID, to: claimedSenderID) + linkBindings.bindCentral(centralUUID, to: claimedSenderID) refreshLocalTopology() } } @@ -4683,22 +4713,15 @@ extension BLEService { /// Safely fetch the current direct-link state for a peer using the BLE queue. private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) { - let state = readLinkState { $0.directLinkState(for: peerID) } + let state = readLinkState { _ in directLinkState(for: peerID) } return (state.hasPeripheral, state.hasCentral) } private func links(to peerID: PeerID?) -> Set { - readLinkState { $0.links(to: peerID) } + readLinkState { _ in 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 @@ -4706,15 +4729,15 @@ extension BLEService { private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) { guard let link = ingressLinks.link(for: packet) else { return } readLinkState { store in - guard boundPeerID(for: link, in: store) == peerID else { return } - noiseAuthenticatedLinkOwners[link] = peerID + 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 = ingressLinks.link(for: packet) else { return false } return readLinkState { store in - noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID + linkAuth.isAuthenticated(link, for: peerID) && linkBindings.boundPeer(for: link) == peerID } } @@ -4724,8 +4747,8 @@ 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 }) } } @@ -4744,13 +4767,13 @@ extension BLEService { let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID) let shouldRevalidate = readLinkState { store in - guard boundPeerID(for: link, in: store) == peerID else { + guard linkBindings.boundPeer(for: link) == peerID else { return false } - return noiseReconnectPolicy.shouldRevalidate( + return linkAuth.shouldRevalidate( on: link, + for: peerID, hasEstablishedSession: hasEstablishedSession, - isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID, hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty, now: Date() ) @@ -5801,7 +5824,7 @@ extension BLEService { } private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - readLinkState { $0.directPeripheralState(for: peerID) } + readLinkState { _ in directPeripheralState(for: peerID) } } private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] { @@ -5809,7 +5832,7 @@ extension BLEService { } private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot { - readLinkState(\.subscribedCentralSnapshot) + readLinkState { _ in subscribedCentralSnapshot() } } // MARK: Helpers: IDs, selection, and write backpressure @@ -5861,8 +5884,8 @@ extension BLEService { } if let peerID = requiredAuthenticatedPeer { let link = BLEIngressLinkID.peripheral(uuid) - guard state.peerID == peerID, - noiseAuthenticatedLinkOwners[link] == peerID else { + guard linkBindings.peer(forPeripheralID: uuid) == peerID, + linkAuth.isAuthenticated(link, for: peerID) else { return false } } @@ -6747,10 +6770,10 @@ extension BLEService { switch link { case .peripheral(let peripheralUUID): linkUUID = peripheralUUID - previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID) + previousPeerID = self.linkBindings.peer(forPeripheralID: peripheralUUID) case .central(let centralUUID): linkUUID = centralUUID - previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID) + previousPeerID = self.linkBindings.peer(forCentralUUID: centralUUID) } guard let previousPeerID else { return } guard previousPeerID != peerID else { @@ -6768,30 +6791,29 @@ extension BLEService { // 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 { + guard self.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() - self.lastLinkRebindAt = self.lastLinkRebindAt.filter { - now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds - } - guard self.lastLinkRebindAt[linkUUID] == nil else { + guard self.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 } - 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) + self.linkAuth.retireLink(link) switch link { case .peripheral(let peripheralUUID): - self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID) + self.bindPeripheralLink(peripheralUUID, to: peerID) case .central(let centralUUID): - self.linkStateStore.bindCentral(centralUUID, to: peerID) + self.linkBindings.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 @@ -6820,7 +6842,7 @@ extension BLEService { 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 } + guard self.linkBindings.links(to: previousPeerID).isEmpty else { return } self.messageQueue.async { [weak self] in self?.retireRotatedPeer(previousPeerID) } @@ -6849,26 +6871,25 @@ extension BLEService { 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], + mostRecentlyBoundUUID: self.linkBindings.preferredPeripheralUUID(for: peerID), links: self.peripheralLinkPolicySnapshot(), peerID: peerID ) else { return } - self.lastRedundantLinkRetirementAt[peerID] = now + guard self.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. - self.linkStateStore.bindPeripheral(keptUUID, to: peerID) + self.bindPeripheralLink(keptUUID, to: peerID) self.cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID) self.refreshLocalTopology() } @@ -6899,9 +6920,10 @@ extension BLEService { /// bleQueue only (reads the link store). private func peripheralLinkPolicySnapshot() -> [BLERedundantLinkPolicy.PeripheralLink] { linkStateStore.peripheralStates.map { - BLERedundantLinkPolicy.PeripheralLink( - uuid: $0.peripheral.identifier.uuidString, - peerID: $0.peerID, + let uuid = $0.peripheral.identifier.uuidString + return BLERedundantLinkPolicy.PeripheralLink( + uuid: uuid, + peerID: linkBindings.peer(forPeripheralID: uuid), isConnected: $0.isConnected, hasCharacteristic: $0.characteristic != nil ) @@ -6986,13 +7008,8 @@ extension BLEService { // residual forged-presence window this leaves is accepted. guard let self else { return false } guard let link = 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) - } + let boundPeerID: PeerID? = self.readLinkState { _ in + self.linkBindings.boundPeer(for: link) } guard let boundPeerID else { return false } return boundPeerID != peerID 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) - } -} From 2f5b56ce5774c18c78c3594d35683c42b7fb863b Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:23:32 +0100 Subject: [PATCH 07/35] Link layer slice 3: bindings and link-auth become engine-owned (the option-B domain flip) (#1547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cohere per-link Noise auth and rebind containment into BLELinkAuthState The authenticated-link owners, the reconnect revalidation policy, and the two rebind-containment cooldowns were four loose bleQueue-owned maps whose invariants lived in call-site discipline: every teardown path had to remember to retire the proof AND close the revalidation epoch (the pair appeared seven times), and both cooldowns hand-rolled the same prune-check-record dance. BLELinkAuthState owns them as whole transitions — retireLink, retireLinks(ownedBy:), permitRebind, permitRedundantRetirement — with the ownership question (bleQueue today, engine after the option-B flip) answered in one place. No behavior change; the one call-site reordering (redundant retirement computes the survivor before the cooldown check instead of after) is outcome-equivalent since the cooldown only ever recorded when a survivor existed. Co-Authored-By: Claude Fable 5 * Split identity-link bindings out of the physical link store BLELinkStateStore owned two different kinds of truth: what physical links exist (CB handles, connect lifecycles, characteristics, stream assemblers) and who each link belongs to (peer bindings in both roles plus the preferred-peripheral reverse map for directed sends and fanout collapse). The bindings now live on BLELinkBindings — same bleQueue ownership, whole-transition methods, direct tests for the rotation reverse-map cleanup and the preferred-link survivor repair that were previously only exercised end to end. Composed operations that need both truths (remove-with-repair, direct link state, the subscribed- central snapshot, bind-only-live-links) live on the transport as explicitly bleQueue-confined helpers. This is the structural half of the option-B boundary flip (docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move to the engine without touching what-links-exist. An audit of every physical clear/remove found three sites (emergency clear, both unauthorized branches) that needed explicit binding-clear pairing under the split — each now clears both. Co-Authored-By: Claude Fable 5 * Fix iOS-gated constructors and preserve containment cooldowns on reset CI caught what the macOS SwiftPM build cannot see: two #if os(iOS) sites still passed the peerID field that slice B1 removed from BLEPeripheralLinkState (willRestoreState in BLEService and armPendingBackgroundConnects in BLERadioController). Both fixed and verified with a local iOS simulator xcodebuild. Codex also caught a real regression: BLELinkAuthState.removeAll() cleared the rebind/retirement cooldown maps, which the original panic and emergency reset paths deliberately left alive. A stable CoreBluetooth UUID must not earn a fresh rebind allowance just because the session state around it was wiped. removeAll() now clears only the proofs and revalidation epochs, and BLELinkAuthStateTests pins the survival invariant along with the other auth-state transitions. Co-Authored-By: Claude Fable 5 * Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine The identity domain (BLELinkBindings + BLELinkAuthState) is now owned by the engine queue, with a DEBUG dispatchPrecondition trapping any access from another queue. bleQueue keeps only physical link state. What changed shape: - Receive path is sans-I/O: bleQueue decodes frames and hands (packet, linkID) up through ingestDecodedPacket (panic lifecycle captured at the handoff); attributeAndHandlePacket resolves the sender binding, rejects spoofed senders, applies raw-announce binding, and records ingress on the engine. Per-link frame order is preserved end to end (both queues serial), which supersedes the old batch-local TOCTOU binding in the notification path. - The rotation rebind is one engine slot: containment checks, proof retirement, binding flip, reconnect decision, and rotated-identity retirement run straight-line; only CoreBluetooth cancels hop to bleQueue. The engine->bleQueue->engine ping-pong is gone, along with the _test_afterVerifiedDirectRebindEnqueued pause hook — the test that used it now asserts the atomicity directly (a paused engine wedged the old gate design into a three-queue deadlock). - Authenticated-send eligibility (notifyOrEnqueueIfAccepted, writeOrEnqueueIfAccepted) is checked on the engine, serialized against rebinds by construction; only physical admission (updateValue/write/backpressure) runs on bleQueue. - Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline in the delegates) + retirePeripheralLinkIdentity (engine hop with survivor repair reading liveness via readLinkState). A binding can briefly outlive its physical link; liveness queries join against the physical store and the queued retirement converges the two. - Gossip delegate sends enter the engine via onEngine — safe because mesh.sync sits above the engine in the sync order (production engine code only async-dispatches into the manager). - checkPeerConnectivity rides an engine slot from the bleQueue maintenance tick. No wire changes. 1,974 tests green (parallel and serial), iOS simulator build clean, Periphery clean. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Services/BLE/BLELinkAuthState.swift | 6 +- bitchat/Services/BLE/BLELinkBindings.swift | 17 +- bitchat/Services/BLE/BLEService.swift | 882 +++++++++++--------- bitchatTests/BLEServiceCoreTests.swift | 52 +- docs/BLE-ARCHITECTURE-V3.md | 33 + 5 files changed, 538 insertions(+), 452 deletions(-) diff --git a/bitchat/Services/BLE/BLELinkAuthState.swift b/bitchat/Services/BLE/BLELinkAuthState.swift index ce31e350..3989c3c4 100644 --- a/bitchat/Services/BLE/BLELinkAuthState.swift +++ b/bitchat/Services/BLE/BLELinkAuthState.swift @@ -11,9 +11,9 @@ import Foundation /// stop a replayed announce from flip-flopping bindings or survivor /// selection. /// -/// bleQueue-confined today, alongside the link bindings it qualifies; -/// both move to the engine together in the option-B boundary flip -/// (docs/BLE-ARCHITECTURE-V3.md). +/// 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() diff --git a/bitchat/Services/BLE/BLELinkBindings.swift b/bitchat/Services/BLE/BLELinkBindings.swift index d4c5342c..840acdfa 100644 --- a/bitchat/Services/BLE/BLELinkBindings.swift +++ b/bitchat/Services/BLE/BLELinkBindings.swift @@ -5,15 +5,18 @@ import Foundation /// belongs to, in both roles, plus each peer's preferred peripheral link /// for directed sends and fanout collapse. /// -/// Split from the physical link-state store so the option-B boundary flip -/// (docs/BLE-ARCHITECTURE-V3.md) can move ownership of *who owns a link* -/// to the engine without touching *what links exist*. bleQueue-confined -/// today, alongside the physical store and `BLELinkAuthState`. +/// 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 guard existence) and are retired through -/// `peripheralRemoved`/`centralRemoved`/`clear*` when the physical link -/// goes, so binding queries never see departed 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] = [:] diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5ad02cf9..cd366608 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -206,14 +206,35 @@ final class BLEService: NSObject { // 1. Consolidated BLE link tracking for both central and peripheral roles. private var linkStateStore = BLELinkStateStore() - // Per-link Noise authentication and rebind containment (bleQueue-owned, - // like the link store — courier handover needs the stronger fact that a + // 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). - private var linkAuth = BLELinkAuthState() - // Identity↔link bindings, split from the physical link store so the - // option-B flip can move ownership to the engine (bleQueue-owned). - private var linkBindings = BLELinkBindings() + // 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() @@ -279,9 +300,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. @@ -367,12 +385,14 @@ final class BLEService: NSObject { /// 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 and test threads 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. + /// 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)) @@ -758,6 +778,10 @@ final class BLEService: NSObject { 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() // These callbacks belong to pre-panic transfer state. Invoking // them would let queued UI work recreate or resend wiped media. privateMediaSessions.panicReset() @@ -775,7 +799,6 @@ final class BLEService: NSObject { pendingPeripheralWrites.removeAll() pendingNotifications.removeAll() pendingWriteBuffers.removeAll() - linkAuth.removeAll() radio.reset() } disconnectNotifyDebouncer.removeAll() @@ -1051,6 +1074,10 @@ final class BLEService: NSObject { // 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) } @@ -1066,8 +1093,6 @@ final class BLEService: NSObject { // Clear peripheral references (synchronized access to avoid races with BLE callbacks) bleQueue.sync { linkStateStore.clearAll() - linkBindings.removeAll() - linkAuth.removeAll() radio.reset() subscriptionAnnounceLimiter.removeAll() } @@ -2185,9 +2210,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], @@ -2195,18 +2223,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 linkAuth.isAuthenticated(link, for: peerID) - && linkBindings.peer(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 } @@ -2216,10 +2245,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) } @@ -2260,7 +2286,7 @@ final class BLEService: NSObject { let centralIDs = subscribedCentrals.map { $0.identifier.uuidString } let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state -> (String, PeerID)? in let uuid = state.peripheral.identifier.uuidString - return readLinkState { _ in linkBindings.peer(forPeripheralID: uuid) }.map { (uuid, $0) } + return linkBindings.peer(forPeripheralID: uuid).map { (uuid, $0) } }) let plan = BLEOutboundLinkPlanner.plan( packet: packet, @@ -2273,10 +2299,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 { _ in linkBindings.preferredPeripheralBindings }, + preferredPeripheralPerPeer: linkBindings.preferredPeripheralBindings, directAnnounceTTL: messageTTL, directedOnlyPeer: directedOnlyPeer, requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink @@ -2696,9 +2719,7 @@ 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 - _ = linkAuth.retireLinks(ownedBy: peerID) - } + _ = linkAuth.retireLinks(ownedBy: peerID) // Remove the peer when they leave peerRegistry.mutate { _ = $0.remove(peerID) } // Remove any stored announcement for sync purposes @@ -2903,12 +2924,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 { @@ -3011,18 +3042,22 @@ extension BLEService: CBCentralManagerDelegate { // 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 - for state in peripheralStates { - let peripheralID = state.peripheral.identifier.uuidString + let peripheralIDs = linkStateStore.peripheralStates.map { $0.peripheral.identifier.uuidString } + for peripheralID in peripheralIDs { pendingPeripheralWrites.discardAll(for: peripheralID) - linkAuth.retireLink(.peripheral(peripheralID)) } linkStateStore.clearPeripherals() - let peerIDs = linkBindings.clearPeripherals() - // Notify UI of disconnections - for peerID in peerIDs { - notifyUI { [weak self] in - self?.notifyPeerDisconnectedDebounced(peerID) + messageQueue.async { [weak self] in + guard let self else { return } + for peripheralID in peripheralIDs { + self.linkAuth.retireLink(.peripheral(peripheralID)) + } + let peerIDs = self.linkBindings.clearPeripherals() + // Notify UI of disconnections + for peerID in peerIDs { + self.notifyUI { [weak self] in + self?.notifyPeerDisconnectedDebounced(peerID) + } } } @@ -3030,7 +3065,9 @@ extension BLEService: CBCentralManagerDelegate { // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) linkStateStore.clearPeripherals() - _ = linkBindings.clearPeripherals() + messageQueue.async { [weak self] in + _ = self?.linkBindings.clearPeripherals() + } case .unsupported: // Device doesn't support BLE @@ -3083,11 +3120,8 @@ extension BLEService: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { let peripheralID = peripheral.identifier.uuidString - - // Find the peer ID if we have it - let peerID = linkBindings.peer(forPeripheralID: peripheralID) - - SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session) + + 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 { @@ -3113,24 +3147,46 @@ extension BLEService: CBCentralManagerDelegate { } #endif - // Clean up references and peer mappings - tearDownPeripheralLink(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 { 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() - } + // Physical teardown now; identity retirement and peer-disconnect + // bookkeeping on the engine, which owns the bindings. The scan + // restart and connect-slot refill below stay on bleQueue — they + // respond to the physical drop regardless of remaining logical + // links. + discardPeripheralLinkPhysical(peripheralID) + messageQueue.async { [weak self] in + guard let self else { return } + // 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 peerID = self.retirePeripheralLinkIdentity(peripheralID) + if let peerID { + SecureLogger.debug("📱 Disconnected link was bound to \(peerID.id.prefix(8))…", category: .session) + } + let remainingLinks = peerID.map { self.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 + self.peerRegistry.mutate { $0.markDisconnected(peerID) } + self.refreshLocalTopology() + } + // Notify delegate about disconnection on main thread (direct link dropped) + self.notifyUI { [weak self] in + guard let self = self else { return } + + // Get current peer list (after removal) + let currentPeerIDs = self.peerRegistry.peerIDs + + if let peerID, !peerStillLinked { + self.notifyPeerDisconnectedDebounced(peerID) + } + self.requestPeerDataPublish() + self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) + } + } // Restart scanning with allow duplicates for faster rediscovery if centralManager?.state == .poweredOn { @@ -3142,27 +3198,16 @@ extension BLEService: CBCentralManagerDelegate { } // Attempt to fill freed slot from queue bleQueue.async { [weak self] in self?.radio.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.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 - tearDownPeripheralLink(peripheralID) + + // Clean up the references: physical now, identity on the engine. + discardPeripheralLinkPhysical(peripheralID) + messageQueue.async { [weak self] in + self?.retirePeripheralLinkIdentity(peripheralID) + } SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) radio.recordConnectionFailure(peripheralID: peripheralID) @@ -3190,48 +3235,59 @@ extension BLEService: BLERadioControllerDelegate { } func radioTearDownPeripheralLink(_ peripheralID: String) { - tearDownPeripheralLink(peripheralID) + // bleQueue (the controller's queue): physical discard now, identity + // retirement on the engine. + discardPeripheralLinkPhysical(peripheralID) + messageQueue.async { [weak self] in + self?.retirePeripheralLinkIdentity(peripheralID) + } } - /// Retires one peripheral link's transport bookkeeping: its write - /// backpressure, its Noise link proof and reconnect epoch, and the - /// link-state entry plus binding (which repairs the peer's reverse - /// mapping onto a surviving duplicate link). bleQueue-confined. - func tearDownPeripheralLink(_ peripheralID: String) { + /// 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) - linkAuth.retireLink(.peripheral(peripheralID)) - removePeripheralLink(peripheralID) + linkStateStore.removePeripheral(peripheralID) } - /// Physical removal plus binding retirement, one unit: the preferred - /// link repairs onto a connected survivor, preferring a writable one + /// 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). bleQueue-confined. + /// its characteristic comes back). Returns the peer that owned the + /// binding. Engine-confined. @discardableResult - func removePeripheralLink(_ peripheralID: String) -> PeerID? { - linkStateStore.removePeripheral(peripheralID) + func retirePeripheralLinkIdentity(_ peripheralID: String) -> PeerID? { + linkAuth.retireLink(.peripheral(peripheralID)) return linkBindings.peripheralRemoved(peripheralID) { remaining in - let alive = remaining.compactMap { uuid -> (uuid: String, writable: Bool)? in - guard let state = linkStateStore.state(forPeripheralID: uuid), - state.isConnected else { return nil } - return (uuid, state.characteristic != nil) + 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 outlive (or precede) its link. bleQueue-confined. + /// 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 linkStateStore.state(forPeripheralID: peripheralUUID) != nil else { return } + 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. - /// bleQueue-confined (physical liveness + bindings in one view). + /// 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 { linkStateStore.state(forPeripheralID: $0)?.isConnected } ?? false + .flatMap { uuid in readLinkState { $0.state(forPeripheralID: uuid)?.isConnected } } ?? false return BLEDirectLinkState( hasPeripheral: hasPeripheral, hasCentral: linkBindings.hasCentral(boundTo: peerID) @@ -3239,16 +3295,16 @@ extension BLEService: BLERadioControllerDelegate { } /// The peer's preferred peripheral link state, when physically present. - /// bleQueue-confined. + /// Engine-confined. func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { linkBindings.preferredPeripheralUUID(for: peerID) - .flatMap { linkStateStore.state(forPeripheralID: $0) } + .flatMap { uuid in readLinkState { $0.state(forPeripheralID: uuid) } } } - /// Subscribed centrals with their bindings, one view. bleQueue-confined. + /// Subscribed centrals with their bindings, one view. Engine-confined. func subscribedCentralSnapshot() -> BLESubscribedCentralSnapshot { BLESubscribedCentralSnapshot( - centrals: linkStateStore.subscribedCentrals, + centrals: readLinkState(\.subscribedCentrals), peerIDsByCentralUUID: linkBindings.centralPeersByUUID ) } @@ -3376,22 +3432,22 @@ extension BLEService { } func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) { - bleQueue.sync { linkBindings.bindCentral(centralUUID, to: peerID) } + onEngine { linkBindings.bindCentral(centralUUID, to: peerID) } } func _test_centralBinding(_ centralUUID: String) -> PeerID? { - bleQueue.sync { linkBindings.peer(forCentralUUID: centralUUID) } + onEngine { linkBindings.peer(forCentralUUID: centralUUID) } } func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) { - bleQueue.sync { + 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 { + onEngine { linkAuth.isAuthenticated(.central(centralUUID), for: peerID) } } @@ -3702,72 +3758,25 @@ extension BLEService: CBPeripheralDelegate { 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? = linkBindings.peer(forPeripheralID: peripheralUUID) - + // 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 } - - let claimedSenderID = PeerID(hexData: packet.senderID) - let context = acceptedIngressContext( - for: packet, - claimedSenderID: claimedSenderID, - boundPeerID: boundPeerID, + ingestDecodedPacket( + packet, + link: .peripheral(peripheralUUID), 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 - bindPeripheralLink(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 = linkBindings.peer(forPeripheralID: peripheralUUID) - if boundPeerID == nil || boundPeerID == senderID { - bindPeripheralLink(peripheralUUID, to: senderID) - refreshLocalTopology() - } - } - - handleReceivedPacket(packet, from: peerID) - } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { @@ -3862,21 +3871,23 @@ extension BLEService: CBPeripheralManagerDelegate { // 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 = subscribedCentralSnapshot() - for central in centralSnapshot.centrals { - let centralID = central.identifier.uuidString - linkAuth.retireLink(.central(centralID)) - } + let centralIDs = linkStateStore.subscribedCentrals.map { $0.identifier.uuidString } pendingNotifications.removeAll() pendingWriteBuffers.removeAll() linkStateStore.clearCentrals() - let centralPeerIDs = linkBindings.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil - // Notify UI of disconnections - for peerID in centralPeerIDs { - notifyUI { [weak self] in - self?.notifyPeerDisconnectedDebounced(peerID) + messageQueue.async { [weak self] in + guard let self else { return } + for centralID in centralIDs { + self.linkAuth.retireLink(.central(centralID)) + } + let centralPeerIDs = self.linkBindings.clearCentrals() + // Notify UI of disconnections + for peerID in centralPeerIDs { + self.notifyUI { [weak self] in + self?.notifyPeerDisconnectedDebounced(peerID) + } } } @@ -3884,9 +3895,11 @@ extension BLEService: CBPeripheralManagerDelegate { // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) linkStateStore.clearCentrals() - _ = linkBindings.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil + messageQueue.async { [weak self] in + _ = self?.linkBindings.clearCentrals() + } case .unsupported: // Device doesn't support BLE peripheral role @@ -3992,38 +4005,41 @@ extension BLEService: CBPeripheralManagerDelegate { 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 } - linkAuth.retireLink(.central(centralID)) linkStateStore.removeSubscribedCentral(central) - let removedPeerID = linkBindings.centralRemoved(centralID) - + // 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()) } - - // Find and disconnect the peer associated with this central - if let peerID = removedPeerID { + + // Identity retirement and peer-disconnect bookkeeping on the + // engine, which owns the bindings. + messageQueue.async { [weak self] in + guard let self else { return } + self.linkAuth.retireLink(.central(centralID)) + guard let peerID = self.linkBindings.centralRemoved(centralID) else { return } // 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 linkBindings.links(to: peerID).isEmpty else { return } + guard self.linkBindings.links(to: peerID).isEmpty else { return } // Mark peer as not connected; retain for reachability - peerRegistry.mutate { $0.markDisconnected(peerID) } - - refreshLocalTopology() - + self.peerRegistry.mutate { $0.markDisconnected(peerID) } + + self.refreshLocalTopology() + // Update UI immediately - notifyUI { [weak self] in + self.notifyUI { [weak self] in guard let self = self else { return } - + // Get current peer list (after removal) let currentPeerIDs = self.peerRegistry.peerIDs - + self.notifyPeerDisconnectedDebounced(peerID) // Publish snapshots so UnifiedPeerService can refresh icons promptly self.requestPeerDataPublish() @@ -4150,37 +4166,16 @@ extension BLEService: CBPeripheralManagerDelegate { } private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) { - let claimedSenderID = PeerID(hexData: packet.senderID) - let context = acceptedIngressContext( - for: packet, - claimedSenderID: claimedSenderID, - boundPeerID: linkBindings.peer(forCentralUUID: centralUUID), + // 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). + ingestDecodedPacket( + packet, + link: .central(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 = linkBindings.peer(forCentralUUID: centralUUID) - if boundPeerID == nil || boundPeerID == claimedSenderID { - linkBindings.bindCentral(centralUUID, to: claimedSenderID) - refreshLocalTopology() - } - } - - guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else { - return - } - - handleReceivedPacket(packet, from: context.receivedFromPeerID) } } @@ -4711,14 +4706,15 @@ 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 { _ in directLinkState(for: peerID) } + let state = directLinkState(for: peerID) return (state.hasPeripheral, state.hasCentral) } private func links(to peerID: PeerID?) -> Set { - readLinkState { _ in linkBindings.links(to: peerID) } + linkBindings.links(to: peerID) } @@ -4726,19 +4722,16 @@ extension BLEService { /// 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 = ingressLinks.link(for: packet) else { return } - readLinkState { store in - guard linkBindings.boundPeer(for: link) == peerID else { return } - linkAuth.markAuthenticated(link, owner: peerID) - } + 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 = ingressLinks.link(for: packet) else { return false } - return readLinkState { store in - linkAuth.isAuthenticated(link, for: peerID) && linkBindings.boundPeer(for: link) == peerID - } + return linkAuth.isAuthenticated(link, for: peerID) && linkBindings.boundPeer(for: link) == peerID } private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool { @@ -4746,37 +4739,36 @@ extension BLEService { } private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set { - readLinkState { store in - Set(linkAuth.links(ownedBy: peerID).filter { link in - linkBindings.boundPeer(for: link) == peerID - }) - } + 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: both callers run inside the - /// rebind's bleQueue critical section, which must never sync-wait on the - /// engine (the engine sync-waits on bleQueue via `readLinkState`). + /// 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( link: BLEIngressLinkID, peerID: PeerID ) { let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID) let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID) - let shouldRevalidate = readLinkState { store in - guard linkBindings.boundPeer(for: link) == peerID else { - return false - } - return linkAuth.shouldRevalidate( + let shouldRevalidate: Bool + if linkBindings.boundPeer(for: link) == peerID { + shouldRevalidate = linkAuth.shouldRevalidate( on: link, for: peerID, hasEstablishedSession: hasEstablishedSession, hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty, now: Date() ) + } else { + shouldRevalidate = false } guard shouldRevalidate else { return } @@ -5318,11 +5310,13 @@ 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) return onEngine { - sendPacketDirected( + // 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, @@ -5813,7 +5807,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 { @@ -5824,7 +5821,7 @@ extension BLEService { } private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - readLinkState { _ in directPeripheralState(for: peerID) } + directPeripheralState(for: peerID) } private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] { @@ -5832,7 +5829,7 @@ extension BLEService { } private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot { - readLinkState { _ in subscribedCentralSnapshot() } + subscribedCentralSnapshot() } // MARK: Helpers: IDs, selection, and write backpressure @@ -5868,6 +5865,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, @@ -5875,20 +5877,20 @@ 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 linkBindings.peer(forPeripheralID: uuid) == peerID, - linkAuth.isAuthenticated(link, for: peerID) else { - return false - } - } if peripheral.canSendWriteWithoutResponse { peripheral.writeValue(data, for: characteristic, type: .withoutResponse) @@ -6467,7 +6469,81 @@ extension BLEService { } // 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 @@ -6708,9 +6784,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) } @@ -6761,92 +6834,90 @@ 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 = 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.linkBindings.peer(forPeripheralID: peripheralUUID) - case .central(let centralUUID): - linkUUID = centralUUID - previousPeerID = self.linkBindings.peer(forCentralUUID: centralUUID) - } - guard let previousPeerID else { return } - guard previousPeerID != peerID else { - self.refreshNoiseSessionForVerifiedDirectLink( - link: link, - 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.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 self.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. - self.linkAuth.retireLink(link) - switch link { - case .peripheral(let peripheralUUID): - self.bindPeripheralLink(peripheralUUID, to: peerID) - case .central(let centralUUID): - self.linkBindings.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( + 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.linkBindings.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) + // Retire the rotated-away ID only once its last link is gone; a + // remaining stale link heals the same way or ages out. + guard linkBindings.links(to: previousPeerID).isEmpty else { return } + retireRotatedPeer(previousPeerID) } /// After a restore relaunch the same phone can reappear under a fresh @@ -6868,38 +6939,37 @@ extension BLEService { /// link either way. private func retireRedundantPeripheralLinks(_ packet: BitchatPacket, to peerID: PeerID) { let ingressLink = ingressLinks.link(for: packet) - bleQueue.async { [weak self] in - guard let self else { return } - let now = Date() - var ingressPeripheralUUID: String? - if case .peripheral(let uuid) = ingressLink { - ingressPeripheralUUID = uuid - } - guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID( - ingressPeripheralUUID: ingressPeripheralUUID, - mostRecentlyBoundUUID: self.linkBindings.preferredPeripheralUUID(for: peerID), - links: self.peripheralLinkPolicySnapshot(), - peerID: peerID - ) else { return } - - guard self.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. - self.bindPeripheralLink(keptUUID, to: peerID) - self.cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID) - self.refreshLocalTopology() + 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(), @@ -6907,25 +6977,36 @@ extension BLEService { keeping: keptUUID ?? "" ) for uuid in retiring { - guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue } - tearDownPeripheralLink(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 uuid = $0.peripheral.identifier.uuidString - return BLERedundantLinkPolicy.PeripheralLink( - uuid: uuid, - peerID: linkBindings.peer(forPeripheralID: uuid), + let physical = readLinkState { store in + store.peripheralStates.map { + (uuid: $0.peripheral.identifier.uuidString, + isConnected: $0.isConnected, + hasCharacteristic: $0.characteristic != nil) + } + } + return physical.map { + BLERedundantLinkPolicy.PeripheralLink( + uuid: $0.uuid, + peerID: linkBindings.peer(forPeripheralID: $0.uuid), isConnected: $0.isConnected, - hasCharacteristic: $0.characteristic != nil + hasCharacteristic: $0.hasCharacteristic ) } } @@ -7008,10 +7089,7 @@ extension BLEService { // residual forged-presence window this leaves is accepted. guard let self else { return false } guard let link = self.ingressLinks.link(for: packet) else { return false } - let boundPeerID: PeerID? = self.readLinkState { _ in - self.linkBindings.boundPeer(for: link) - } - guard let boundPeerID else { return false } + guard let boundPeerID = self.linkBindings.boundPeer(for: link) else { return false } return boundPeerID != peerID }, withRegistryBarrier: { [weak self] body in @@ -7605,6 +7683,14 @@ 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] = peerRegistry.peerIDs var cachedLinkStates: [PeerID: BLEPeerLinkPresence] = [:] diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index f0a573cd..13f2bb25 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -554,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) } @@ -1469,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 diff --git a/docs/BLE-ARCHITECTURE-V3.md b/docs/BLE-ARCHITECTURE-V3.md index 60400ceb..d72df1bf 100644 --- a/docs/BLE-ARCHITECTURE-V3.md +++ b/docs/BLE-ARCHITECTURE-V3.md @@ -158,6 +158,39 @@ throughput is nowhere near what one serial queue sustains. (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 From cdebdd9347f133d463a2f86e41810766391c44e1 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:14:19 +0100 Subject: [PATCH 08/35] Link layer slice 4: deterministic multi-node mesh simulation (and the panic-announce bug it caught) (#1548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cohere per-link Noise auth and rebind containment into BLELinkAuthState The authenticated-link owners, the reconnect revalidation policy, and the two rebind-containment cooldowns were four loose bleQueue-owned maps whose invariants lived in call-site discipline: every teardown path had to remember to retire the proof AND close the revalidation epoch (the pair appeared seven times), and both cooldowns hand-rolled the same prune-check-record dance. BLELinkAuthState owns them as whole transitions — retireLink, retireLinks(ownedBy:), permitRebind, permitRedundantRetirement — with the ownership question (bleQueue today, engine after the option-B flip) answered in one place. No behavior change; the one call-site reordering (redundant retirement computes the survivor before the cooldown check instead of after) is outcome-equivalent since the cooldown only ever recorded when a survivor existed. Co-Authored-By: Claude Fable 5 * Split identity-link bindings out of the physical link store BLELinkStateStore owned two different kinds of truth: what physical links exist (CB handles, connect lifecycles, characteristics, stream assemblers) and who each link belongs to (peer bindings in both roles plus the preferred-peripheral reverse map for directed sends and fanout collapse). The bindings now live on BLELinkBindings — same bleQueue ownership, whole-transition methods, direct tests for the rotation reverse-map cleanup and the preferred-link survivor repair that were previously only exercised end to end. Composed operations that need both truths (remove-with-repair, direct link state, the subscribed- central snapshot, bind-only-live-links) live on the transport as explicitly bleQueue-confined helpers. This is the structural half of the option-B boundary flip (docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move to the engine without touching what-links-exist. An audit of every physical clear/remove found three sites (emergency clear, both unauthorized branches) that needed explicit binding-clear pairing under the split — each now clears both. Co-Authored-By: Claude Fable 5 * Fix iOS-gated constructors and preserve containment cooldowns on reset CI caught what the macOS SwiftPM build cannot see: two #if os(iOS) sites still passed the peerID field that slice B1 removed from BLEPeripheralLinkState (willRestoreState in BLEService and armPendingBackgroundConnects in BLERadioController). Both fixed and verified with a local iOS simulator xcodebuild. Codex also caught a real regression: BLELinkAuthState.removeAll() cleared the rebind/retirement cooldown maps, which the original panic and emergency reset paths deliberately left alive. A stable CoreBluetooth UUID must not earn a fresh rebind allowance just because the session state around it was wiped. removeAll() now clears only the proofs and revalidation epochs, and BLELinkAuthStateTests pins the survival invariant along with the other auth-state transitions. Co-Authored-By: Claude Fable 5 * Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine The identity domain (BLELinkBindings + BLELinkAuthState) is now owned by the engine queue, with a DEBUG dispatchPrecondition trapping any access from another queue. bleQueue keeps only physical link state. What changed shape: - Receive path is sans-I/O: bleQueue decodes frames and hands (packet, linkID) up through ingestDecodedPacket (panic lifecycle captured at the handoff); attributeAndHandlePacket resolves the sender binding, rejects spoofed senders, applies raw-announce binding, and records ingress on the engine. Per-link frame order is preserved end to end (both queues serial), which supersedes the old batch-local TOCTOU binding in the notification path. - The rotation rebind is one engine slot: containment checks, proof retirement, binding flip, reconnect decision, and rotated-identity retirement run straight-line; only CoreBluetooth cancels hop to bleQueue. The engine->bleQueue->engine ping-pong is gone, along with the _test_afterVerifiedDirectRebindEnqueued pause hook — the test that used it now asserts the atomicity directly (a paused engine wedged the old gate design into a three-queue deadlock). - Authenticated-send eligibility (notifyOrEnqueueIfAccepted, writeOrEnqueueIfAccepted) is checked on the engine, serialized against rebinds by construction; only physical admission (updateValue/write/backpressure) runs on bleQueue. - Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline in the delegates) + retirePeripheralLinkIdentity (engine hop with survivor repair reading liveness via readLinkState). A binding can briefly outlive its physical link; liveness queries join against the physical store and the queued retirement converges the two. - Gossip delegate sends enter the engine via onEngine — safe because mesh.sync sits above the engine in the sync order (production engine code only async-dispatches into the manager). - checkPeerConnectivity rides an engine slot from the bleQueue maintenance tick. No wire changes. 1,974 tests green (parallel and serial), iOS simulator build clean, Periphery clean. Co-Authored-By: Claude Fable 5 * Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught SimulatedMesh wires real CoreBluetooth-free BLEService engines edge-to-edge through the outbound packet tap and _test_ingestFrame (the production attribution path the B2 flip created), with per-edge synthetic link IDs and manual-scheduler time. Five multi-node tests run in ~40ms with no wall-clock waits: - announce exchange binds simulated links and connects peers - Noise sessions establish end-to-end (real crypto, both directions) - a public message relays across a line topology inside a TTL/frame budget (storm bound asserted) - an 8x duplicate flood delivers exactly once - a panic rotation rebinds the survivor's link exactly once and stays — the scenario that previously needed two phones and log archaeology Fidelity boundary (documented in the harness): no physical links, so fanout planning and backpressure are not exercised; attribution, binding, dedup, TTL, relay decisions, and sessions are the real engine code. The simulator found a real bug on its first run: the forced-announce throttle's lastSent survived a panic, so a rotation within bleForceAnnounceMinIntervalSeconds of the last announce silently swallowed the new identity's announce — leaving it invisible to the mesh until the next maintenance cycle. Today's device test only passed because the previous announce happened to be minutes old. BLEAnnounceThrottle gains reset(), called from the panic slot so the rotated identity owes no throttle debt; pinned by a unit test and the mesh rotation test. New DEBUG seams: _test_ingestFrame (production ingress attribution), _test_forceAnnounce, _test_fenceEngine. 1,980 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat.xcodeproj/project.pbxproj | 13 +- .../Services/BLE/BLEAnnounceThrottle.swift | 9 + bitchat/Services/BLE/BLEService.swift | 26 +++ .../Services/BLEAnnounceThrottleTests.swift | 15 ++ bitchatTests/Simulation/SimulatedMesh.swift | 129 +++++++++++++ .../Simulation/SimulatedMeshTests.swift | 172 ++++++++++++++++++ docs/BLE-ARCHITECTURE-V3.md | 21 +++ 7 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 bitchatTests/Simulation/SimulatedMesh.swift create mode 100644 bitchatTests/Simulation/SimulatedMeshTests.swift 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/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/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index cd366608..75775e51 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -782,6 +782,11 @@ final class BLEService: NSObject { // 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. privateMediaSessions.panicReset() @@ -3353,6 +3358,27 @@ 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) { + ingestDecodedPacket(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) } + } + + /// 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, 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/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift new file mode 100644 index 00000000..47491d34 --- /dev/null +++ b/bitchatTests/Simulation/SimulatedMesh.swift @@ -0,0 +1,129 @@ +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] = [] + + @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) + nodes.append(node) + neighbors.append([]) + service.setNickname(nickname) + 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.lock.unlock() + } + return node + } + + func connect(_ a: Int, _ b: Int) { + neighbors[a].insert(b) + neighbors[b].insert(a) + } + + /// The synthetic 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)" + } + + 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.. 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/docs/BLE-ARCHITECTURE-V3.md b/docs/BLE-ARCHITECTURE-V3.md index d72df1bf..5dd2fbd8 100644 --- a/docs/BLE-ARCHITECTURE-V3.md +++ b/docs/BLE-ARCHITECTURE-V3.md @@ -201,6 +201,27 @@ throughput is nowhere near what one serial queue sustains. 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). + Remaining from the original slice-C scope: the mechanical delegate + extraction behind explicit LinkEvent/LinkCommand types, and the + formal `handle(event) -> [Effect]` engine shape. + ## What this is not No wire changes: packet formats, signing (padding is signed), the From 4226f01503a14816bcaf45bd4161288034461ce3 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:27:34 +0100 Subject: [PATCH 09/35] =?UTF-8?q?Link=20layer=20slice=205:=20BLELinkEvent?= =?UTF-8?q?=20=E2=80=94=20the=20port=20has=20a=20name,=20the=20delegates?= =?UTF-8?q?=20have=20their=20own=20files=20(#1551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cohere per-link Noise auth and rebind containment into BLELinkAuthState The authenticated-link owners, the reconnect revalidation policy, and the two rebind-containment cooldowns were four loose bleQueue-owned maps whose invariants lived in call-site discipline: every teardown path had to remember to retire the proof AND close the revalidation epoch (the pair appeared seven times), and both cooldowns hand-rolled the same prune-check-record dance. BLELinkAuthState owns them as whole transitions — retireLink, retireLinks(ownedBy:), permitRebind, permitRedundantRetirement — with the ownership question (bleQueue today, engine after the option-B flip) answered in one place. No behavior change; the one call-site reordering (redundant retirement computes the survivor before the cooldown check instead of after) is outcome-equivalent since the cooldown only ever recorded when a survivor existed. Co-Authored-By: Claude Fable 5 * Split identity-link bindings out of the physical link store BLELinkStateStore owned two different kinds of truth: what physical links exist (CB handles, connect lifecycles, characteristics, stream assemblers) and who each link belongs to (peer bindings in both roles plus the preferred-peripheral reverse map for directed sends and fanout collapse). The bindings now live on BLELinkBindings — same bleQueue ownership, whole-transition methods, direct tests for the rotation reverse-map cleanup and the preferred-link survivor repair that were previously only exercised end to end. Composed operations that need both truths (remove-with-repair, direct link state, the subscribed- central snapshot, bind-only-live-links) live on the transport as explicitly bleQueue-confined helpers. This is the structural half of the option-B boundary flip (docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move to the engine without touching what-links-exist. An audit of every physical clear/remove found three sites (emergency clear, both unauthorized branches) that needed explicit binding-clear pairing under the split — each now clears both. Co-Authored-By: Claude Fable 5 * Fix iOS-gated constructors and preserve containment cooldowns on reset CI caught what the macOS SwiftPM build cannot see: two #if os(iOS) sites still passed the peerID field that slice B1 removed from BLEPeripheralLinkState (willRestoreState in BLEService and armPendingBackgroundConnects in BLERadioController). Both fixed and verified with a local iOS simulator xcodebuild. Codex also caught a real regression: BLELinkAuthState.removeAll() cleared the rebind/retirement cooldown maps, which the original panic and emergency reset paths deliberately left alive. A stable CoreBluetooth UUID must not earn a fresh rebind allowance just because the session state around it was wiped. removeAll() now clears only the proofs and revalidation epochs, and BLELinkAuthStateTests pins the survival invariant along with the other auth-state transitions. Co-Authored-By: Claude Fable 5 * Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine The identity domain (BLELinkBindings + BLELinkAuthState) is now owned by the engine queue, with a DEBUG dispatchPrecondition trapping any access from another queue. bleQueue keeps only physical link state. What changed shape: - Receive path is sans-I/O: bleQueue decodes frames and hands (packet, linkID) up through ingestDecodedPacket (panic lifecycle captured at the handoff); attributeAndHandlePacket resolves the sender binding, rejects spoofed senders, applies raw-announce binding, and records ingress on the engine. Per-link frame order is preserved end to end (both queues serial), which supersedes the old batch-local TOCTOU binding in the notification path. - The rotation rebind is one engine slot: containment checks, proof retirement, binding flip, reconnect decision, and rotated-identity retirement run straight-line; only CoreBluetooth cancels hop to bleQueue. The engine->bleQueue->engine ping-pong is gone, along with the _test_afterVerifiedDirectRebindEnqueued pause hook — the test that used it now asserts the atomicity directly (a paused engine wedged the old gate design into a three-queue deadlock). - Authenticated-send eligibility (notifyOrEnqueueIfAccepted, writeOrEnqueueIfAccepted) is checked on the engine, serialized against rebinds by construction; only physical admission (updateValue/write/backpressure) runs on bleQueue. - Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline in the delegates) + retirePeripheralLinkIdentity (engine hop with survivor repair reading liveness via readLinkState). A binding can briefly outlive its physical link; liveness queries join against the physical store and the queued retirement converges the two. - Gossip delegate sends enter the engine via onEngine — safe because mesh.sync sits above the engine in the sync order (production engine code only async-dispatches into the manager). - checkPeerConnectivity rides an engine slot from the bleQueue maintenance tick. No wire changes. 1,974 tests green (parallel and serial), iOS simulator build clean, Periphery clean. Co-Authored-By: Claude Fable 5 * Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught SimulatedMesh wires real CoreBluetooth-free BLEService engines edge-to-edge through the outbound packet tap and _test_ingestFrame (the production attribution path the B2 flip created), with per-edge synthetic link IDs and manual-scheduler time. Five multi-node tests run in ~40ms with no wall-clock waits: - announce exchange binds simulated links and connects peers - Noise sessions establish end-to-end (real crypto, both directions) - a public message relays across a line topology inside a TTL/frame budget (storm bound asserted) - an 8x duplicate flood delivers exactly once - a panic rotation rebinds the survivor's link exactly once and stays — the scenario that previously needed two phones and log archaeology Fidelity boundary (documented in the harness): no physical links, so fanout planning and backpressure are not exercised; attribution, binding, dedup, TTL, relay decisions, and sessions are the real engine code. The simulator found a real bug on its first run: the forced-announce throttle's lastSent survived a panic, so a rotation within bleForceAnnounceMinIntervalSeconds of the last announce silently swallowed the new identity's announce — leaving it invisible to the mesh until the next maintenance cycle. Today's device test only passed because the previous announce happened to be minutes old. BLEAnnounceThrottle gains reset(), called from the panic slot so the rotated identity owes no throttle debt; pinned by a unit test and the mesh rotation test. New DEBUG seams: _test_ingestFrame (production ingress attribution), _test_forceAnnounce, _test_fenceEngine. 1,980 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 * Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files The upward half of the link-layer port is now a type. BLELinkEvent enumerates everything the bleQueue link layer tells the engine: frameDecoded plus the four physical lifecycle transitions (peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded, allCentralLinksEnded). Every bleQueue→engine crossing goes through emitLinkEvent into one engine consumer (handleLinkEvent) — the scattered messageQueue.async identity hops in the delegates collapse into event emission, and the engine-side retirement/bookkeeping logic now lives in one switch. The CoreBluetooth delegate extensions move to their own files as physical bookkeeping plus event emission: - BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate + CBPeripheralDelegate) - BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate + write accumulation) BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain members the role files share flip private→internal; the queue contract is enforced by the existing DEBUG traps and grep guards, not access control. (Two of the flips — isAppActive, logBluetoothStatus — only surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code. Verified with a local iOS simulator build.) The simulated mesh now drives lifecycle events through the identical enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers drop → identity retirement → last-link peer bookkeeping → re-announce heal, entirely through the port. New seam _test_resetAnnounceThrottle models elapsed wall-clock for the throttle (deliberately separate from _test_forceAnnounce so the panic-rotation test keeps its regression value: the production panic path must do its own reset). The panic test's containment re-announces reset throttles explicitly so those assertions exercise real delivered announces instead of silently throttled ones. noiseSessionEstablishesEndToEnd gains a bounded scheduler-time settle loop after a one-in-many parallel-suite flake (no wall-clock waits). Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a formal handle(event)->[Effect] system and further engine-domain file splits — both would flip the engine's private state to internal for cosmetic file counts; the effect formalization rides future feature- module extractions instead. 1,981 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 * Baseline logBluetoothStatus for the macOS Periphery scan Its callers are all inside #if os(iOS) (willRestoreState in both role files plus the app-state handlers), so the macOS-scheme scan sees the now-internal declaration with zero callers — the same class as the baselined candidateCount. Verified 1-USR diff; the previously private mangled variant was already baselined, which is why the pre-split scan never flagged it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .periphery.baseline.json | 2 +- bitchat/Services/BLE/BLELinkEvent.swift | 40 + .../BLE/BLEService+LinkLayerCentralRole.swift | 433 ++++++++ .../BLEService+LinkLayerPeripheralRole.swift | 320 ++++++ bitchat/Services/BLE/BLEService.swift | 973 +++--------------- bitchatTests/Simulation/SimulatedMesh.swift | 14 + .../Simulation/SimulatedMeshTests.swift | 44 +- docs/BLE-ARCHITECTURE-V3.md | 25 +- 8 files changed, 1009 insertions(+), 842 deletions(-) create mode 100644 bitchat/Services/BLE/BLELinkEvent.swift create mode 100644 bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift create mode 100644 bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift diff --git a/.periphery.baseline.json b/.periphery.baseline.json index af3885fb..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:7bitchat18BLERadioControllerC14candidateCountSivp","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/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/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 75775e51..1fb33389 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -204,7 +204,7 @@ 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() // The engine-owned identity domain: per-link Noise authentication + // rebind containment (courier handover needs the stronger fact that a @@ -237,7 +237,7 @@ final class BLEService: NSObject { } // BCH-01-004: Rate-limiting for subscription-triggered announces. - private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() + var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() // 3. Peer Information (single source of truth). Lock-backed so the main // actor reads it directly instead of blocking on the engine queue. @@ -330,7 +330,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). @@ -344,9 +344,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 @@ -377,8 +377,8 @@ final class BLEService: NSObject { private let messageQueueKey = DispatchSpecificKey() /// The only source of deferred engine work (see BLEEngineScheduling); /// injectable so tests drive protocol deadlines with a manual clock. - private let engineScheduler: BLEEngineScheduling - private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) + 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. @@ -409,7 +409,7 @@ final class BLEService: NSObject { private var pendingNoiseSessionQueues = BLENoiseSessionQueues() // Queue for notifications that failed due to full queue (bleQueue-owned, // like the link state store: every producer and drain runs there). - private var pendingNotifications = BLEOutboundNotificationBuffer() + var pendingNotifications = BLEOutboundNotificationBuffer() // Backpressure logging fires per fragment during media transfers // (hundreds of lines per image); sampled via this counter, which is // only touched on bleQueue (no sync needed). @@ -417,7 +417,7 @@ final class BLEService: NSObject { // Accumulate long write chunks per central until a full frame decodes // (bleQueue-owned) - private var pendingWriteBuffers = BLEInboundWriteBuffer() + 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 @@ -435,10 +435,10 @@ final class BLEService: NSObject { // delivery so a duplicate costs one decrypt instead of a delivery + ack // + handshake each. Engine-confined. private var openedCourierMessageIDs = BoundedIDSet(capacity: TransportConfig.courierOpenedMessageIDCap) - private let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5) + let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5) // Per-peripheral write backpressure (bleQueue-owned) - private var pendingPeripheralWrites = BLEOutboundWriteBuffer() + var pendingPeripheralWrites = BLEOutboundWriteBuffer() // Debounce duplicate disconnect notifies private var disconnectNotifyDebouncer = BLEPeerEventDebouncer() // Store-and-forward for directed messages when we have no links @@ -475,7 +475,7 @@ final class BLEService: NSObject { // MARK: - Radio (central-role policy: discovery admission, connection // budget, connect timeouts, background connects, scan duty, advertising) - private lazy var radio = BLERadioController( + lazy var radio = BLERadioController( queue: bleQueue, linkStateStore: linkStateStore, recentTraffic: recentTrafficTracker @@ -596,7 +596,7 @@ final class BLEService: NSObject { } } - private var isPanicSuspended: Bool { + var isPanicSuspended: Bool { panicLifecycleLock.lock() defer { panicLifecycleLock.unlock() } return _isPanicSuspended @@ -2415,7 +2415,7 @@ final class BLEService: NSObject { } } - private func flushDirectedSpool() { + func flushDirectedSpool() { guard !isPanicSuspended else { return } // Runs from bleQueue maintenance: hop to the engine asynchronously // (bleQueue must never sync-wait on the engine). Move items out and @@ -2741,7 +2741,7 @@ final class BLEService: NSObject { } 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 @@ -2958,272 +2958,6 @@ extension BLEService: GossipSyncManager.Delegate { } } -// 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() - messageQueue.async { [weak self] in - guard let self else { return } - for peripheralID in peripheralIDs { - self.linkAuth.retireLink(.peripheral(peripheralID)) - } - let peerIDs = self.linkBindings.clearPeripherals() - // Notify UI of disconnections - for peerID in peerIDs { - self.notifyUI { [weak self] in - self?.notifyPeerDisconnectedDebounced(peerID) - } - } - } - - case .unauthorized: - // User denied Bluetooth permission - SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) - linkStateStore.clearPeripherals() - messageQueue.async { [weak self] in - _ = self?.linkBindings.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) - } - } - - - 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 on the engine, which owns the bindings. The scan - // restart and connect-slot refill below stay on bleQueue — they - // respond to the physical drop regardless of remaining logical - // links. - discardPeripheralLinkPhysical(peripheralID) - messageQueue.async { [weak self] in - guard let self else { return } - // 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 peerID = self.retirePeripheralLinkIdentity(peripheralID) - if let peerID { - SecureLogger.debug("📱 Disconnected link was bound to \(peerID.id.prefix(8))…", category: .session) - } - let remainingLinks = peerID.map { self.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 - self.peerRegistry.mutate { $0.markDisconnected(peerID) } - self.refreshLocalTopology() - } - - // Notify delegate about disconnection on main thread (direct link dropped) - self.notifyUI { [weak self] in - guard let self = self else { return } - - // Get current peer list (after removal) - let currentPeerIDs = self.peerRegistry.peerIDs - - if let peerID, !peerStillLinked { - self.notifyPeerDisconnectedDebounced(peerID) - } - self.requestPeerDataPublish() - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) - } - } - - // 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 on the engine. - discardPeripheralLinkPhysical(peripheralID) - messageQueue.async { [weak self] in - self?.retirePeripheralLinkIdentity(peripheralID) - } - - 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() } - } -} - -extension BLEService { -} - // MARK: - Radio controller integration extension BLEService: BLERadioControllerDelegate { @@ -3241,11 +2975,9 @@ extension BLEService: BLERadioControllerDelegate { func radioTearDownPeripheralLink(_ peripheralID: String) { // bleQueue (the controller's queue): physical discard now, identity - // retirement on the engine. + // retirement via the port. discardPeripheralLinkPhysical(peripheralID) - messageQueue.async { [weak self] in - self?.retirePeripheralLinkIdentity(peripheralID) - } + emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false)) } /// bleQueue half of a peripheral-link teardown: the link's write @@ -3315,14 +3047,6 @@ extension BLEService: BLERadioControllerDelegate { } } -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 @@ -3364,7 +3088,7 @@ extension BLEService { /// 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) { - ingestDecodedPacket(packet, link: link, linkDescription: "Simulated \(link)") + emitLinkEvent(.frameDecoded(packet, link: link, linkDescription: "Simulated \(link)")) } /// Sends an unthrottled announce, exactly like the maintenance forced @@ -3373,6 +3097,16 @@ extension BLEService { 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() { @@ -3666,544 +3400,6 @@ 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) - 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 - } - ingestDecodedPacket( - 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) - } - } - } - -} - -// 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 - messageQueue.async { [weak self] in - guard let self else { return } - for centralID in centralIDs { - self.linkAuth.retireLink(.central(centralID)) - } - let centralPeerIDs = self.linkBindings.clearCentrals() - // Notify UI of disconnections - for peerID in centralPeerIDs { - self.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 - messageQueue.async { [weak self] in - _ = self?.linkBindings.clearCentrals() - } - - 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 on the - // engine, which owns the bindings. - messageQueue.async { [weak self] in - guard let self else { return } - self.linkAuth.retireLink(.central(centralID)) - guard let peerID = self.linkBindings.centralRemoved(centralID) else { return } - // 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 self.linkBindings.links(to: peerID).isEmpty else { return } - // Mark peer as not connected; retain for reachability - self.peerRegistry.mutate { $0.markDisconnected(peerID) } - - self.refreshLocalTopology() - - // Update UI immediately - self.notifyUI { [weak self] in - guard let self = self else { return } - - // Get current peer list (after removal) - let currentPeerIDs = 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) { - 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). - ingestDecodedPacket( - packet, - link: .central(centralUUID), - linkDescription: "Central \(centralUUID.prefix(8))…" - ) - } -} // MARK: - Advertising Builders & Alias Rotation @@ -4320,7 +3516,7 @@ extension BLEService { } } - private func emitTransportEvent( + func emitTransportEvent( _ event: TransportEvent, shouldDeliver: (() -> Bool)? = nil, completion: (() -> Void)? = nil, @@ -4405,7 +3601,7 @@ extension BLEService { } } - private func logBluetoothStatus(_ context: String) { + func logBluetoothStatus(_ context: String) { scheduleBluetoothStatusSample(after: 0, context: context) } @@ -5946,7 +5142,7 @@ 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 } @@ -6494,6 +5690,111 @@ 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 diff --git a/bitchatTests/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift index 47491d34..d9f418fb 100644 --- a/bitchatTests/Simulation/SimulatedMesh.swift +++ b/bitchatTests/Simulation/SimulatedMesh.swift @@ -126,4 +126,18 @@ final class SimulatedMesh { } pump() } + + /// Advances scheduler time one second per round until `condition` + /// holds (or the round budget runs out — the caller's assertion then + /// reports the real failure). Protocol exchanges normally settle in + /// one or two rounds; under a heavily loaded parallel suite, engine + /// slots can interleave with wall-clock-windowed crypto decisions and + /// need a retry cycle or two more. Deterministic: rounds are scheduler + /// time, never sleeps. + func settleUntil(maxRounds: Int = 20, _ condition: () -> Bool) { + for _ in 0.. [Effect]` engine shape. + **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 From e2b409e466f3a20ef99deb120f0dca362102fb53 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:21:59 +0100 Subject: [PATCH 10/35] Fix #1538: release stale bindings on rotation instead of leaving a ghost identity (#1554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cohere per-link Noise auth and rebind containment into BLELinkAuthState The authenticated-link owners, the reconnect revalidation policy, and the two rebind-containment cooldowns were four loose bleQueue-owned maps whose invariants lived in call-site discipline: every teardown path had to remember to retire the proof AND close the revalidation epoch (the pair appeared seven times), and both cooldowns hand-rolled the same prune-check-record dance. BLELinkAuthState owns them as whole transitions — retireLink, retireLinks(ownedBy:), permitRebind, permitRedundantRetirement — with the ownership question (bleQueue today, engine after the option-B flip) answered in one place. No behavior change; the one call-site reordering (redundant retirement computes the survivor before the cooldown check instead of after) is outcome-equivalent since the cooldown only ever recorded when a survivor existed. Co-Authored-By: Claude Fable 5 * Split identity-link bindings out of the physical link store BLELinkStateStore owned two different kinds of truth: what physical links exist (CB handles, connect lifecycles, characteristics, stream assemblers) and who each link belongs to (peer bindings in both roles plus the preferred-peripheral reverse map for directed sends and fanout collapse). The bindings now live on BLELinkBindings — same bleQueue ownership, whole-transition methods, direct tests for the rotation reverse-map cleanup and the preferred-link survivor repair that were previously only exercised end to end. Composed operations that need both truths (remove-with-repair, direct link state, the subscribed- central snapshot, bind-only-live-links) live on the transport as explicitly bleQueue-confined helpers. This is the structural half of the option-B boundary flip (docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move to the engine without touching what-links-exist. An audit of every physical clear/remove found three sites (emergency clear, both unauthorized branches) that needed explicit binding-clear pairing under the split — each now clears both. Co-Authored-By: Claude Fable 5 * Fix iOS-gated constructors and preserve containment cooldowns on reset CI caught what the macOS SwiftPM build cannot see: two #if os(iOS) sites still passed the peerID field that slice B1 removed from BLEPeripheralLinkState (willRestoreState in BLEService and armPendingBackgroundConnects in BLERadioController). Both fixed and verified with a local iOS simulator xcodebuild. Codex also caught a real regression: BLELinkAuthState.removeAll() cleared the rebind/retirement cooldown maps, which the original panic and emergency reset paths deliberately left alive. A stable CoreBluetooth UUID must not earn a fresh rebind allowance just because the session state around it was wiped. removeAll() now clears only the proofs and revalidation epochs, and BLELinkAuthStateTests pins the survival invariant along with the other auth-state transitions. Co-Authored-By: Claude Fable 5 * Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine The identity domain (BLELinkBindings + BLELinkAuthState) is now owned by the engine queue, with a DEBUG dispatchPrecondition trapping any access from another queue. bleQueue keeps only physical link state. What changed shape: - Receive path is sans-I/O: bleQueue decodes frames and hands (packet, linkID) up through ingestDecodedPacket (panic lifecycle captured at the handoff); attributeAndHandlePacket resolves the sender binding, rejects spoofed senders, applies raw-announce binding, and records ingress on the engine. Per-link frame order is preserved end to end (both queues serial), which supersedes the old batch-local TOCTOU binding in the notification path. - The rotation rebind is one engine slot: containment checks, proof retirement, binding flip, reconnect decision, and rotated-identity retirement run straight-line; only CoreBluetooth cancels hop to bleQueue. The engine->bleQueue->engine ping-pong is gone, along with the _test_afterVerifiedDirectRebindEnqueued pause hook — the test that used it now asserts the atomicity directly (a paused engine wedged the old gate design into a three-queue deadlock). - Authenticated-send eligibility (notifyOrEnqueueIfAccepted, writeOrEnqueueIfAccepted) is checked on the engine, serialized against rebinds by construction; only physical admission (updateValue/write/backpressure) runs on bleQueue. - Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline in the delegates) + retirePeripheralLinkIdentity (engine hop with survivor repair reading liveness via readLinkState). A binding can briefly outlive its physical link; liveness queries join against the physical store and the queued retirement converges the two. - Gossip delegate sends enter the engine via onEngine — safe because mesh.sync sits above the engine in the sync order (production engine code only async-dispatches into the manager). - checkPeerConnectivity rides an engine slot from the bleQueue maintenance tick. No wire changes. 1,974 tests green (parallel and serial), iOS simulator build clean, Periphery clean. Co-Authored-By: Claude Fable 5 * Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught SimulatedMesh wires real CoreBluetooth-free BLEService engines edge-to-edge through the outbound packet tap and _test_ingestFrame (the production attribution path the B2 flip created), with per-edge synthetic link IDs and manual-scheduler time. Five multi-node tests run in ~40ms with no wall-clock waits: - announce exchange binds simulated links and connects peers - Noise sessions establish end-to-end (real crypto, both directions) - a public message relays across a line topology inside a TTL/frame budget (storm bound asserted) - an 8x duplicate flood delivers exactly once - a panic rotation rebinds the survivor's link exactly once and stays — the scenario that previously needed two phones and log archaeology Fidelity boundary (documented in the harness): no physical links, so fanout planning and backpressure are not exercised; attribution, binding, dedup, TTL, relay decisions, and sessions are the real engine code. The simulator found a real bug on its first run: the forced-announce throttle's lastSent survived a panic, so a rotation within bleForceAnnounceMinIntervalSeconds of the last announce silently swallowed the new identity's announce — leaving it invisible to the mesh until the next maintenance cycle. Today's device test only passed because the previous announce happened to be minutes old. BLEAnnounceThrottle gains reset(), called from the panic slot so the rotated identity owes no throttle debt; pinned by a unit test and the mesh rotation test. New DEBUG seams: _test_ingestFrame (production ingress attribution), _test_forceAnnounce, _test_fenceEngine. 1,980 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 * Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files The upward half of the link-layer port is now a type. BLELinkEvent enumerates everything the bleQueue link layer tells the engine: frameDecoded plus the four physical lifecycle transitions (peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded, allCentralLinksEnded). Every bleQueue→engine crossing goes through emitLinkEvent into one engine consumer (handleLinkEvent) — the scattered messageQueue.async identity hops in the delegates collapse into event emission, and the engine-side retirement/bookkeeping logic now lives in one switch. The CoreBluetooth delegate extensions move to their own files as physical bookkeeping plus event emission: - BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate + CBPeripheralDelegate) - BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate + write accumulation) BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain members the role files share flip private→internal; the queue contract is enforced by the existing DEBUG traps and grep guards, not access control. (Two of the flips — isAppActive, logBluetoothStatus — only surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code. Verified with a local iOS simulator build.) The simulated mesh now drives lifecycle events through the identical enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers drop → identity retirement → last-link peer bookkeeping → re-announce heal, entirely through the port. New seam _test_resetAnnounceThrottle models elapsed wall-clock for the throttle (deliberately separate from _test_forceAnnounce so the panic-rotation test keeps its regression value: the production panic path must do its own reset). The panic test's containment re-announces reset throttles explicitly so those assertions exercise real delivered announces instead of silently throttled ones. noiseSessionEstablishesEndToEnd gains a bounded scheduler-time settle loop after a one-in-many parallel-suite flake (no wall-clock waits). Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a formal handle(event)->[Effect] system and further engine-domain file splits — both would flip the engine's private state to internal for cosmetic file counts; the effect formalization rides future feature- module extractions instead. 1,981 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 * Baseline logBluetoothStatus for the macOS Periphery scan Its callers are all inside #if os(iOS) (willRestoreState in both role files plus the app-state handlers), so the macOS-scheme scan sees the now-internal declaration with zero callers — the same class as the baselined candidateCount. Verified 1-USR diff; the previously private mangled variant was already baselined, which is why the pre-split scan never flagged it. Co-Authored-By: Claude Fable 5 * Fix #1538: release stale bindings on rotation instead of leaving a ghost With two live links to one phone, a panic rotation healed only the link the verified announce arrived on. The second link kept its binding to the retired identity, so that dead ID stayed in the peer list — and was kept alive by the NEW identity's own traffic, since a bound link attributes non-announce frames to its bound peer. It only healed when the stale link physically dropped. The issue proposed exempting the containment rule via retiredBy[X] = Y so the second link could rebind. Two problems: the exemption's stated precondition (X removed by retireRotatedPeer) can never hold in this scenario — the retire is gated on X having no remaining links, which is false precisely because the stale link exists — and it would loosen a security rule to fix a liveness bug. Instead the rotation now RELEASES every link still bound to the rotated-away identity (unbind + retire that link's Noise proof) and retires the identity. No containment rule changes: unbinding is strictly less trusting than any binding, and it is correct under both readings of a second link bound to the retired ID — same physical device (the field case), or one link is a spoofer holding a forged binding, since a peer ID is a Noise-key fingerprint and two devices cannot both legitimately own it. Released links reconverge through the ordinary unbound-link path: the next raw direct announce binds them to whoever they actually carry. Reproduced and fixed under the slice-4 simulator, which is why this lands as tests rather than another two-phone session: - duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks fails without the fix (ghost in both knownPeers and getConnectedPeers, duplicate link still bound to the dead ID) - replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim pins the #1401 containment rule against exactly the attack this fix had to avoid re-opening, with a positive control proving the refusal is the containment check and not duplicate suppression Harness gains connectDuplicateLinks (two links to one peer, modelled in the central role — the links we cannot cancel, and the only role whose bindings a CB-free harness can form), silence (range loss without a link event, so a packet can be captured that the far side never saw), and emittedPackets (the attacker's capture buffer). Residual, documented at the fix: an attacker who binds their own link to X by replaying X's raw announce can drive a rebind there and so evict X's registry entry; X's next announce restores it, and the per-link rebind cooldown bounds the rate. This is the same class of capability the containment already accepts, not a new one. 1,983 tests green, Periphery clean, iOS simulator build clean. Closes #1538 Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Services/BLE/BLEService.swift | 57 ++++++++- bitchatTests/Simulation/SimulatedMesh.swift | 69 ++++++++-- .../Simulation/SimulatedMeshTests.swift | 118 ++++++++++++++++++ 3 files changed, 234 insertions(+), 10 deletions(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 1fb33389..5f29ade3 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3199,6 +3199,14 @@ extension BLEService { 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) { onEngine { guard linkBindings.peer(forCentralUUID: centralUUID) == peerID else { return } @@ -6241,12 +6249,55 @@ extension BLEService { // them now instead of leaving ghost links that spray duplicate // traffic until the inactivity timeout. 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 linkBindings.links(to: previousPeerID).isEmpty else { return } + // 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) + } + } + } + /// After a restore relaunch the same phone can reappear under a fresh /// peripheral UUID while its restored connection lives on, leaving /// several live central-role connections to one peer that each carry diff --git a/bitchatTests/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift index d9f418fb..1d5c5094 100644 --- a/bitchatTests/Simulation/SimulatedMesh.swift +++ b/bitchatTests/Simulation/SimulatedMesh.swift @@ -32,6 +32,16 @@ final class SimulatedMesh { 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 { @@ -50,6 +60,7 @@ final class SimulatedMesh { let node = Node(service: service, scheduler: scheduler) nodes.append(node) neighbors.append([]) + emitted.append([]) service.setNickname(nickname) service._test_onOutboundPacket = { [weak self] packet in // Runs on the sender's engine; only buffer here — delivering @@ -57,6 +68,7 @@ final class SimulatedMesh { guard let self else { return } self.lock.lock() self.pendingDeliveries.append((from: index, packet: packet)) + self.emitted[index].append(packet) self.lock.unlock() } return node @@ -67,12 +79,56 @@ final class SimulatedMesh { neighbors[b].insert(a) } - /// The synthetic link a frame from `sender` arrives on at `receiver`. - /// Stable per directed edge, like a CoreBluetooth central UUID. + /// 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() @@ -100,11 +156,10 @@ final class SimulatedMesh { for (from, packet) in batch { for receiver in neighbors[from] { - deliveredFrameCount += 1 - nodes[receiver].service._test_ingestFrame( - packet, - link: .central(linkUUID(from: from, at: receiver)) - ) + for link in links(from: from, at: receiver) { + deliveredFrameCount += 1 + nodes[receiver].service._test_ingestFrame(packet, link: link) + } } } nodes.forEach { $0.service._test_fenceEngine() } diff --git a/bitchatTests/Simulation/SimulatedMeshTests.swift b/bitchatTests/Simulation/SimulatedMeshTests.swift index 58ac11fe..c03ed21f 100644 --- a/bitchatTests/Simulation/SimulatedMeshTests.swift +++ b/bitchatTests/Simulation/SimulatedMeshTests.swift @@ -140,6 +140,124 @@ struct SimulatedMeshTests { #expect(a.service.getConnectedPeers().contains(b.service.myPeerID)) } + /// Issue #1538: with two live links to the same phone, a panic + /// rotation used to heal only the link the verified announce arrived + /// on. The second link kept its binding to + /// the retired identity, which therefore stayed in the peer list as a + /// ghost — and, worse, kept being refreshed by the *new* identity's + /// traffic (a bound link attributes non-announce frames to its bound + /// peer, so the dead ID looked alive for as long as the link lived). + @Test + func duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks() { + let mesh = SimulatedMesh() + let a = mesh.addNode(nickname: "alice") + let b = mesh.addNode(nickname: "bob") + mesh.connectDuplicateLinks(0, 1) + mesh.announceAll() + + let centralLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0)) + let duplicateLink = BLEIngressLinkID.central(mesh.duplicateLinkUUID(from: 1, at: 0)) + let oldBobID = b.service.myPeerID + // Both links bind to bob: raw direct announces bind unbound links, + // and that happens before duplicate suppression. + #expect(a.service._test_linkBinding(centralLink) == oldBobID) + #expect(a.service._test_linkBinding(duplicateLink) == oldBobID) + + b.service.suspendForPanicReset() + b.service.resetIdentityForPanic(currentNickname: "anon", restartServices: false) + b.service.completePanicReset(restartServices: false) + mesh.pump() + let newBobID = b.service.myPeerID + #expect(newBobID != oldBobID) + + // One verified direct announce must retire the old identity + // outright — no ghost survives on the link it did not arrive on. + mesh.forceAnnounce(from: 1) + mesh.settleUntil { !a.service._test_knownPeerIDs().contains(oldBobID) } + #expect(!a.service._test_knownPeerIDs().contains(oldBobID)) + #expect(a.service._test_linkBinding(centralLink) != oldBobID) + #expect(a.service._test_linkBinding(duplicateLink) != oldBobID) + + // Both links converge onto the new identity as its announces land + // (the released link binds through the ordinary unbound-link path, + // so no containment rule has to be relaxed). + for _ in 0..<4 { + b.service._test_resetAnnounceThrottle() + mesh.forceAnnounce(from: 1) + mesh.advanceTime(by: 1) + } + #expect(a.service._test_linkBinding(centralLink) == newBobID) + #expect(a.service._test_linkBinding(duplicateLink) == newBobID) + #expect(a.service.getConnectedPeers() == [newBobID]) + } + + /// The #1401 containment rule, pinned against the attack the #1538 fix + /// had to avoid re-opening: a captured verified direct announce replayed + /// onto a link the attacker controls must NOT bind that link to the + /// victim while the victim holds a live link of its own — and must not + /// evict the victim either (the rotation release only runs after a + /// rebind the containment actually permitted). + @Test + func replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim() { + let mesh = SimulatedMesh() + let alice = mesh.addNode(nickname: "alice") + let bob = mesh.addNode(nickname: "bob") + let mallory = mesh.addNode(nickname: "mallory") + mesh.connect(0, 1) + mesh.connect(0, 2) + mesh.announceAll() + + let bobLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0)) + let malloryLink = BLEIngressLinkID.central(mesh.linkUUID(from: 2, at: 0)) + #expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID) + #expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID) + + // Mallory captures a signed direct announce alice has NOT seen, so + // duplicate suppression cannot mask the containment check: bob + // announces while out of alice's range, and mallory replays it on + // her own link. Directness is forgeable; the signature is real. + mesh.silence(0, 1) + bob.service._test_resetAnnounceThrottle() + mesh.forceAnnounce(from: 1) + let replay = mesh.emittedPackets(from: 1).last { + $0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault + } + guard let replay else { + Issue.record("bob emitted no direct announce to capture") + return + } + alice.service._test_ingestFrame(replay, link: malloryLink) + mesh.pump() + mesh.advanceTime(by: 1) + + // The link is not stolen, and bob keeps both his binding and his + // place in the peer list. + #expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID) + #expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID) + #expect(alice.service._test_knownPeerIDs().contains(bob.service.myPeerID)) + #expect(alice.service.getConnectedPeers().contains(bob.service.myPeerID)) + + // Positive control — proves the refusal above was the containment + // rule and not duplicate suppression: once bob holds no live link, + // the very same replayed announce on the very same link does take + // effect. (Long-standing accepted residual: a stolen link carries + // only Noise ciphertext, and the rebind retires the link's proof.) + alice.service.emitLinkEvent(.centralLinkEnded(centralUUID: mesh.linkUUID(from: 1, at: 0))) + alice.service._test_fenceEngine() + bob.service._test_resetAnnounceThrottle() + mesh.forceAnnounce(from: 1) + let secondReplay = mesh.emittedPackets(from: 1).last { + $0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault + } + #expect(secondReplay?.timestamp != replay.timestamp) + if let secondReplay { + alice.service._test_ingestFrame(secondReplay, link: malloryLink) + mesh.pump() + mesh.advanceTime(by: 1) + } + #expect(alice.service._test_linkBinding(malloryLink) == bob.service.myPeerID) + } + @Test func panicRotationRebindsSurvivorExactlyOnceAndStays() { let mesh = SimulatedMesh() From b49400ff0cbdc8b4f7ea8e1e26b3c5b216edb7b1 Mon Sep 17 00:00:00 2001 From: Jozef Koval Date: Thu, 30 Jul 2026 18:56:32 +0200 Subject: [PATCH 11/35] Fix $$ escaping that broke every Xcode just recipe (#1525) --- Justfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From e8f95e9a88864ddad7d5a557ac4722b5a44a5ec1 Mon Sep 17 00:00:00 2001 From: Kudala Bharani Kumar Reddy Date: Thu, 30 Jul 2026 12:56:35 -0400 Subject: [PATCH 12/35] Fix built-in relay actor isolation (#1528) --- bitchat/Nostr/NostrRelayManager.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 From ab835e58c9dd5e984ce1bf3b3acdb72c6b4ebc52 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Thu, 30 Jul 2026 22:26:39 +0530 Subject: [PATCH 13/35] Don't suggest blocked people in @-mentions (#1543) * Keep blocked peers out of @-mention suggestions Blocked mesh nicknames and blocked geohash pubkeys no longer show up in the composer autocomplete list. Co-authored-by: Cursor * Fix blocked-mention test resetting private(set) state Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../ViewModels/ChatComposerCoordinator.swift | 20 ++++++++-- .../ChatComposerCoordinatorContextTests.swift | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) 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/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() From 81837d7202663762d016eca0f6471da7b067b0e8 Mon Sep 17 00:00:00 2001 From: Vidit Kulshrestha <91754462+viditkulsh@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:26:42 +0530 Subject: [PATCH 14/35] Make DeliveryStatus non-optional with an explicit .notSentYet state (#1503) BitchatMessage.deliveryStatus was Optional, with nil implicitly meaning 'no tracking' for public messages. Every consumer had to branch on the absent case, ranking needed an optional-aware helper, and the UI treated nil as an invisible state (#644). Model delivery as a total state machine instead: - New DeliveryStatus.notSentYet: created but not yet handed to any transport. Public messages initialize to it; private messages keep their historical .sending default. - BitchatMessage.deliveryStatus becomes non-optional. Archives written while the field was optional decode with the absent key mapped to .notSentYet. The wire format is untouched (toBinaryPayload never carried the field). - deliveryStatusRank drops its optional parameter; .notSentYet ranks below .failed, preserving the existing dedup preference order. - Conversation.shouldSkipStatusUpdate treats a write back to .notSentYet as a downgrade and skips it. - The status indicator renders exactly as before: .notSentYet draws nothing in message rows (the state nil used to represent), and DeliveryStatusView gains a glyph and description for it only so the view stays total. Tests: initialization defaults, legacy-archive decoding, round-trip, the extended rank order, and the new downgrade rule. Fixes #644 --- bitchat/App/ConversationStore.swift | 7 ++- bitchat/Services/PrivateChatManager.swift | 14 ++--- .../ViewModels/ChatLifecycleCoordinator.swift | 4 +- .../Views/Components/DeliveryStatusView.swift | 10 ++++ .../Views/Components/TextMessageView.swift | 14 ++--- bitchat/Views/Media/MediaMessageView.swift | 36 ++++++----- bitchat/Views/MessageListView.swift | 2 +- .../ChatViewModelDeliveryStatusTests.swift | 22 ++++--- .../BLEFileTransferHandlerTests.swift | 2 +- .../BitFoundation/BitchatMessage.swift | 8 ++- .../BitFoundation/DeliveryStatus.swift | 3 + .../DeliveryStatusNotSentYetTests.swift | 59 +++++++++++++++++++ 12 files changed, 130 insertions(+), 51 deletions(-) create mode 100644 localPackages/BitFoundation/Tests/BitFoundationTests/DeliveryStatusNotSentYetTests.swift 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/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/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 1ea87a41..a2d8e8ea 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -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/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/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/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/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/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))) + } +} From 4ef5558d7bd275ffb9e54ee2969e639e3a5c71e8 Mon Sep 17 00:00:00 2001 From: Vidit Kulshrestha <91754462+viditkulsh@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:26:45 +0530 Subject: [PATCH 15/35] Replace try! regex construction with a non-trapping SafeRegex helper (#1501) MessageFormattingEngine and MessageDeduplicationService compiled eight bundled regex literals with try!, so a bad pattern would crash the app at startup - in the middle of the message-render path (#645). Add SafeRegex.compile: it compiles the pattern normally, and on failure logs through SecureLogger and returns a never-matching regex ('(?!)'), so a broken pattern degrades that one formatting feature instead of trapping. Pattern properties stay non-optional, so no call-site churn across ChatMessageFormatter, MessageTextHelpers, and ChatComposerCoordinator. The compile-time guarantee try! provided moves into tests: each production pattern is asserted to compile and match a known-good sample, so a typo in a pattern now fails CI instead of crashing users. Part of #645 (the remaining try! sites; NoiseSessionManager's force-unwrap is addressed separately in #1456). --- .../MessageDeduplicationService.swift | 10 ++- .../Services/MessageFormattingEngine.swift | 28 ++------ bitchat/Utils/SafeRegex.swift | 36 +++++++++++ bitchatTests/Services/SafeRegexTests.swift | 64 +++++++++++++++++++ 4 files changed, 111 insertions(+), 27 deletions(-) create mode 100644 bitchat/Utils/SafeRegex.swift create mode 100644 bitchatTests/Services/SafeRegexTests.swift 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..8f77bfa5 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 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/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) + } +} From e7f4ef091277af19f0873ab8dc7016d52ff1622f Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Thu, 30 Jul 2026 22:26:48 +0530 Subject: [PATCH 16/35] fix: show verified seal next to sender names in chat (#1506) Surface fingerprint verification in the message timeline so a verified contact is distinguishable from an impersonator without opening the fingerprint sheet. Co-authored-by: Cursor --- bitchat/ViewModels/ChatMessageFormatter.swift | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 62723d7a..a2259b08 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 @@ -335,7 +340,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 +361,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 +387,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 +436,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 { From e2bd13a7f2deaca6b73fd2ea5ceb688b31548ef1 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Thu, 30 Jul 2026 22:26:52 +0530 Subject: [PATCH 17/35] chore: fix receive typo and refresh relay count in README (#1510) Correct a confirmation label typo in the public-chat E2E suite and bump the README relay-network claim to match the current GPS relay list. Co-authored-by: Cursor --- README.md | 2 +- bitchatTests/EndToEnd/PublicChatE2ETests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b9b6e000..36f50be2 100644 --- a/README.md +++ b/README.md @@ -51,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/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 { From 6c8499a603b3961d8a3ace2db7607d9ec127f0de Mon Sep 17 00:00:00 2001 From: Vidit Kulshrestha <91754462+viditkulsh@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:44:53 +0530 Subject: [PATCH 18/35] Normalize nicknames to Unicode NFC at storage and comparison boundaries (#1502) * Replace try! regex construction with a non-trapping SafeRegex helper MessageFormattingEngine and MessageDeduplicationService compiled eight bundled regex literals with try!, so a bad pattern would crash the app at startup - in the middle of the message-render path (#645). Add SafeRegex.compile: it compiles the pattern normally, and on failure logs through SecureLogger and returns a never-matching regex ('(?!)'), so a broken pattern degrades that one formatting feature instead of trapping. Pattern properties stay non-optional, so no call-site churn across ChatMessageFormatter, MessageTextHelpers, and ChatComposerCoordinator. The compile-time guarantee try! provided moves into tests: each production pattern is asserted to compile and match a known-good sample, so a typo in a pattern now fails CI instead of crashing users. Part of #645 (the remaining try! sites; NoiseSessionManager's force-unwrap is addressed separately in #1456). * Normalize nicknames to Unicode NFC at storage and comparison boundaries A nickname containing an accent can arrive in two canonically equivalent but bytewise different forms: precomposed (U+00E9) or decomposed (e + U+0301), depending on the keyboard and platform that produced it. Nicknames were stored and compared without normalization, so visually identical names silently failed to match: mentions of your own name did not highlight or notify, /msg and /block could not resolve the peer, autocomplete skipped candidates, and geohash DM resolution failed (#214). Fix by canonicalizing to NFC (String.normalizedNickname) at every boundary where a nickname enters storage - own nickname (ChatViewModel didSet, alongside the existing trim), verified announce ingest (BLEPeerRegistry), geohash presence (LocationPresenceStore), and InputValidator.validateNickname - and by normalizing both sides at comparison sites that can still see pre-normalization data (persisted favorites, message-content mentions): peer resolution in UnifiedPeerService and ChatPeerIdentityCoordinator, the three mention checks, and autocomplete prefix matching. The wire codec (AnnouncementPacket) is deliberately untouched: announces are signature-verified against raw bytes, so canonicalization happens at the storage layer, never during parsing. Fixes #214 --- bitchat/App/LocationPresenceStore.swift | 3 +- bitchat/Services/AutocompleteService.swift | 6 +-- bitchat/Services/BLE/BLEPeerRegistry.swift | 2 +- .../Services/MessageFormattingEngine.swift | 3 +- bitchat/Services/UnifiedPeerService.swift | 5 +- bitchat/Utils/InputValidator.swift | 5 +- bitchat/Utils/String+Nickname.swift | 8 +++ bitchat/ViewModels/ChatMessageFormatter.swift | 3 +- .../ChatPeerIdentityCoordinator.swift | 3 ++ .../ChatPublicConversationCoordinator.swift | 9 ++-- bitchat/ViewModels/ChatViewModel.swift | 10 ++-- bitchatTests/NicknameNormalizationTests.swift | 53 +++++++++++++++++++ 12 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 bitchatTests/NicknameNormalizationTests.swift 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/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/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/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 8f77bfa5..88a32e3c 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -110,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/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 879dc866..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 } } 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/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/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index a2259b08..ae663f84 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -188,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 } 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/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index d794ea3f..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 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") + } +} From 6414a59851a1be266a9a637e2d5019fd8704f211 Mon Sep 17 00:00:00 2001 From: krish rathi <148011352+krishrathi1@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:44:57 +0530 Subject: [PATCH 19/35] fix(ble): don't spend the fragment scheduler's slot budget on blocked requests (#1530) reservePendingStarts() decremented availableSlots for every dequeued pending transfer before checking whether it would actually be admitted. A request blocked because its transferId is already active (a resend of in-flight content sitting at the front of the queue) still consumed a slot even though it was deferred back into the queue rather than started -- so a single blocked front-of-queue item could zero out the budget and end the loop before ever reaching a later, unrelated, genuinely startable pending transfer. That transfer then sat starved until some other transfer happened to complete and trigger another pass, rather than starting immediately when real capacity was already available. Move the decrement to the point where a transfer is actually admitted into activeTransfers, so only genuine starts spend the budget. --- ...BLEOutboundFragmentTransferScheduler.swift | 8 +++- ...tboundFragmentTransferSchedulerTests.swift | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) 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/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift index 8ceb1ac9..27670408 100644 --- a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift +++ b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift @@ -260,6 +260,48 @@ struct BLEOutboundFragmentTransferSchedulerTests { } } + @Test + func blockedDuplicateAtFrontOfQueueDoesNotStarveALaterUnrelatedPendingTransfer() { + // Bug: reservePendingStarts spent the slot budget on a pending + // request the moment it was dequeued, before checking whether that + // request would actually be admitted. A resend of still-active + // content sitting at the front of the queue therefore consumed a + // slot even though it was deferred back to the queue rather than + // started -- starving an unrelated, genuinely startable transfer + // right behind it until some other transfer happened to complete. + var scheduler = BLEOutboundFragmentTransferScheduler() + let t1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a") + let t2 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t2", payload: "file-b") + let dupT1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a") + let unrelated = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t3", payload: "file-c") + + _ = scheduler.submit(t1, maxConcurrentTransfers: 2) + _ = scheduler.submit(t2, maxConcurrentTransfers: 2) + #expect(scheduler.activeCount == 2) + + // Both slots are full, so a resend of "t1" (still active) and an + // unrelated transfer both land in the pending queue, in that order. + _ = scheduler.submit(dupT1, maxConcurrentTransfers: 2) + _ = scheduler.submit(unrelated, maxConcurrentTransfers: 2) + #expect(scheduler.pendingCount == 2) + + // "t2" finishes; "t1" stays active, so the queued "t1" resend at the + // front of the queue is still blocked when we reserve pending starts. + let didActivate = scheduler.activateReservedTransfer(id: "t2", totalFragments: 1, workItems: []) + #expect(didActivate) + #expect(scheduler.markFragmentSent(transferId: "t2") == .complete(sentFragments: 1, totalFragments: 1)) + + let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 2) + + let startedTransferIds: [String] = starts.compactMap { + if case let .start(_, reservedTransferId) = $0 { return reservedTransferId } + return nil + } + #expect(startedTransferIds == ["t3"], "the unrelated pending transfer must start in the same pass despite the blocked front item") + #expect(scheduler.activeCount == 2, "t1 (still running) and the newly-started t3") + #expect(scheduler.pendingCount == 1, "only the blocked t1 resend remains queued") + } + @Test func removeAllReturnsActiveWorkItemsAndDropsPendingTransfers() { var scheduler = BLEOutboundFragmentTransferScheduler() From 1d0dc5822112c74a9ec30d8f31f84075956e57e5 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:21:46 +0100 Subject: [PATCH 20/35] Make the completion-grace restart test deterministic (#1563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit immediateLegacyRestartDuringCompletionGrace injected a 0.03s initiator completion grace period and needed the restart initiation to arrive inside it. Constructing the restarted service (keypair generation) sits between starting that clock and processing the message, so on a starved CI runner the window expired first, the initiation was processed as a legitimate fresh handshake, and the nil-expectations cascaded — the most-sighted flake in CI (7 runs across #1502, #1477, #883, #1364, and main). The test now injects a grace period no test run can outlive, so the in-grace suppression and the duplicate-initiation coalescing are decided deterministically, and fires the deferred recovery through a DEBUG hook on NoiseSessionManager instead of waiting out the real timer. The hook cancels the scheduled work item before requesting recovery, so the converged-once assertion cannot double-fire either. Verified (count-checked via xcresulttool): the full 30-test NoiseEncryptionServiceTests suite green on the iOS simulator, and 30/30 x 5 consecutive runs under 16x CPU oversubscription (a 6th run was lost to a simulator app-launch refusal under load — no tests executed). The old test did not reproduce locally in 2 suite runs under the same load; the starvation needs the slow 2-core CI runner, so the diagnosis rests on the mechanism plus the identical assertion signature in all seven CI sightings. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Noise/NoiseSessionManager.swift | 21 +++++++++++++++++++ bitchat/Services/NoiseEncryptionService.swift | 4 ++++ .../NoiseEncryptionServiceTests.swift | 10 +++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) 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/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/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index a2031983..c0cd293f 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -962,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()) @@ -1035,6 +1040,7 @@ struct NoiseEncryptionServiceTests { ) #expect(lower.hasEstablishedSession(with: higherPeerID)) + lower._test_fireSuppressedInitiationRecovery(for: higherPeerID) let requested = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, timeout: TestConstants.longTimeout From 5780405dce58810f6f33dc98c907f12ab523fbfb Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:21:49 +0100 Subject: [PATCH 21/35] Fix the SimulatedMesh announce-loss flake (#1564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SimulatedMesh.addNode installed the outbound tap one statement after setNickname, but setNickname force-announces asynchronously on the engine. When a starved runner let that slot run inside the gap, the announce was emitted invisibly while still stamping the wall-clock announce throttle, and announceAll's forced announce — arriving well inside the 0.15s forced minimum interval — was swallowed. No discovery traffic ever reached the mesh, so bindings stayed nil and peer lists empty: the exact 4-issue signature that failed three main runs and one PR run on July 30. Reproduced deterministically by forcing the ordering with a 5ms sleep after setNickname: all 8 SimulatedMesh tests fail on the old harness and pass on the fixed one. Fixes: install the tap before setNickname so an early nickname announce is captured instead of lost; reset each node's throttle in announceAll so wall-clock throttle debt can never swallow the discovery round (forceAnnounce(from:) deliberately keeps no-reset — the panic-rotation tests pin the production reset behavior through it); and take the lock around addNode's array appends, which could race the tap reading `emitted` on an earlier node's engine. Verified: suite green normally, 8/8 tests x 6 runs under 16x CPU oversubscription, and 8/8 under the adversarial forced ordering — all count-verified via xcresulttool (an earlier single-test -only-testing filter silently matched zero tests, so every result here was re-checked against reported test counts). Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchatTests/Simulation/SimulatedMesh.swift | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/bitchatTests/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift index 1d5c5094..6caf379d 100644 --- a/bitchatTests/Simulation/SimulatedMesh.swift +++ b/bitchatTests/Simulation/SimulatedMesh.swift @@ -58,10 +58,19 @@ final class SimulatedMesh { ) 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([]) - service.setNickname(nickname) + 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. @@ -71,6 +80,7 @@ final class SimulatedMesh { self.emitted[index].append(packet) self.lock.unlock() } + service.setNickname(nickname) return node } @@ -175,8 +185,16 @@ final class SimulatedMesh { } /// Full discovery round: every node announces, traffic settles. + /// + /// Resets each node's announce throttle first: the throttle window is + /// wall-clock, so any announce that already ran (setNickname's, in + /// `addNode`) would otherwise swallow this forced one whenever the two + /// land within the forced minimum interval — which is always, on any + /// runner. `forceAnnounce(from:)` deliberately does NOT reset — the + /// panic-rotation tests pin the production reset behavior through it. func announceAll() { for node in nodes { + node.service._test_resetAnnounceThrottle() node.service._test_forceAnnounce() } pump() From f269617004b981f0b4f12263663d66537188fcfe Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:30:51 +0100 Subject: [PATCH 22/35] =?UTF-8?q?Fix=20the=20retire=E2=86=94reconnect=20os?= =?UTF-8?q?cillation:=20redundant-link=20survivor=20is=20the=20newest=20co?= =?UTF-8?q?nnection=20(#1566)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cohere per-link Noise auth and rebind containment into BLELinkAuthState The authenticated-link owners, the reconnect revalidation policy, and the two rebind-containment cooldowns were four loose bleQueue-owned maps whose invariants lived in call-site discipline: every teardown path had to remember to retire the proof AND close the revalidation epoch (the pair appeared seven times), and both cooldowns hand-rolled the same prune-check-record dance. BLELinkAuthState owns them as whole transitions — retireLink, retireLinks(ownedBy:), permitRebind, permitRedundantRetirement — with the ownership question (bleQueue today, engine after the option-B flip) answered in one place. No behavior change; the one call-site reordering (redundant retirement computes the survivor before the cooldown check instead of after) is outcome-equivalent since the cooldown only ever recorded when a survivor existed. Co-Authored-By: Claude Fable 5 * Split identity-link bindings out of the physical link store BLELinkStateStore owned two different kinds of truth: what physical links exist (CB handles, connect lifecycles, characteristics, stream assemblers) and who each link belongs to (peer bindings in both roles plus the preferred-peripheral reverse map for directed sends and fanout collapse). The bindings now live on BLELinkBindings — same bleQueue ownership, whole-transition methods, direct tests for the rotation reverse-map cleanup and the preferred-link survivor repair that were previously only exercised end to end. Composed operations that need both truths (remove-with-repair, direct link state, the subscribed- central snapshot, bind-only-live-links) live on the transport as explicitly bleQueue-confined helpers. This is the structural half of the option-B boundary flip (docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move to the engine without touching what-links-exist. An audit of every physical clear/remove found three sites (emergency clear, both unauthorized branches) that needed explicit binding-clear pairing under the split — each now clears both. Co-Authored-By: Claude Fable 5 * Fix iOS-gated constructors and preserve containment cooldowns on reset CI caught what the macOS SwiftPM build cannot see: two #if os(iOS) sites still passed the peerID field that slice B1 removed from BLEPeripheralLinkState (willRestoreState in BLEService and armPendingBackgroundConnects in BLERadioController). Both fixed and verified with a local iOS simulator xcodebuild. Codex also caught a real regression: BLELinkAuthState.removeAll() cleared the rebind/retirement cooldown maps, which the original panic and emergency reset paths deliberately left alive. A stable CoreBluetooth UUID must not earn a fresh rebind allowance just because the session state around it was wiped. removeAll() now clears only the proofs and revalidation epochs, and BLELinkAuthStateTests pins the survival invariant along with the other auth-state transitions. Co-Authored-By: Claude Fable 5 * Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine The identity domain (BLELinkBindings + BLELinkAuthState) is now owned by the engine queue, with a DEBUG dispatchPrecondition trapping any access from another queue. bleQueue keeps only physical link state. What changed shape: - Receive path is sans-I/O: bleQueue decodes frames and hands (packet, linkID) up through ingestDecodedPacket (panic lifecycle captured at the handoff); attributeAndHandlePacket resolves the sender binding, rejects spoofed senders, applies raw-announce binding, and records ingress on the engine. Per-link frame order is preserved end to end (both queues serial), which supersedes the old batch-local TOCTOU binding in the notification path. - The rotation rebind is one engine slot: containment checks, proof retirement, binding flip, reconnect decision, and rotated-identity retirement run straight-line; only CoreBluetooth cancels hop to bleQueue. The engine->bleQueue->engine ping-pong is gone, along with the _test_afterVerifiedDirectRebindEnqueued pause hook — the test that used it now asserts the atomicity directly (a paused engine wedged the old gate design into a three-queue deadlock). - Authenticated-send eligibility (notifyOrEnqueueIfAccepted, writeOrEnqueueIfAccepted) is checked on the engine, serialized against rebinds by construction; only physical admission (updateValue/write/backpressure) runs on bleQueue. - Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline in the delegates) + retirePeripheralLinkIdentity (engine hop with survivor repair reading liveness via readLinkState). A binding can briefly outlive its physical link; liveness queries join against the physical store and the queued retirement converges the two. - Gossip delegate sends enter the engine via onEngine — safe because mesh.sync sits above the engine in the sync order (production engine code only async-dispatches into the manager). - checkPeerConnectivity rides an engine slot from the bleQueue maintenance tick. No wire changes. 1,974 tests green (parallel and serial), iOS simulator build clean, Periphery clean. Co-Authored-By: Claude Fable 5 * Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught SimulatedMesh wires real CoreBluetooth-free BLEService engines edge-to-edge through the outbound packet tap and _test_ingestFrame (the production attribution path the B2 flip created), with per-edge synthetic link IDs and manual-scheduler time. Five multi-node tests run in ~40ms with no wall-clock waits: - announce exchange binds simulated links and connects peers - Noise sessions establish end-to-end (real crypto, both directions) - a public message relays across a line topology inside a TTL/frame budget (storm bound asserted) - an 8x duplicate flood delivers exactly once - a panic rotation rebinds the survivor's link exactly once and stays — the scenario that previously needed two phones and log archaeology Fidelity boundary (documented in the harness): no physical links, so fanout planning and backpressure are not exercised; attribution, binding, dedup, TTL, relay decisions, and sessions are the real engine code. The simulator found a real bug on its first run: the forced-announce throttle's lastSent survived a panic, so a rotation within bleForceAnnounceMinIntervalSeconds of the last announce silently swallowed the new identity's announce — leaving it invisible to the mesh until the next maintenance cycle. Today's device test only passed because the previous announce happened to be minutes old. BLEAnnounceThrottle gains reset(), called from the panic slot so the rotated identity owes no throttle debt; pinned by a unit test and the mesh rotation test. New DEBUG seams: _test_ingestFrame (production ingress attribution), _test_forceAnnounce, _test_fenceEngine. 1,980 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 * Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files The upward half of the link-layer port is now a type. BLELinkEvent enumerates everything the bleQueue link layer tells the engine: frameDecoded plus the four physical lifecycle transitions (peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded, allCentralLinksEnded). Every bleQueue→engine crossing goes through emitLinkEvent into one engine consumer (handleLinkEvent) — the scattered messageQueue.async identity hops in the delegates collapse into event emission, and the engine-side retirement/bookkeeping logic now lives in one switch. The CoreBluetooth delegate extensions move to their own files as physical bookkeeping plus event emission: - BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate + CBPeripheralDelegate) - BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate + write accumulation) BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain members the role files share flip private→internal; the queue contract is enforced by the existing DEBUG traps and grep guards, not access control. (Two of the flips — isAppActive, logBluetoothStatus — only surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code. Verified with a local iOS simulator build.) The simulated mesh now drives lifecycle events through the identical enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers drop → identity retirement → last-link peer bookkeeping → re-announce heal, entirely through the port. New seam _test_resetAnnounceThrottle models elapsed wall-clock for the throttle (deliberately separate from _test_forceAnnounce so the panic-rotation test keeps its regression value: the production panic path must do its own reset). The panic test's containment re-announces reset throttles explicitly so those assertions exercise real delivered announces instead of silently throttled ones. noiseSessionEstablishesEndToEnd gains a bounded scheduler-time settle loop after a one-in-many parallel-suite flake (no wall-clock waits). Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a formal handle(event)->[Effect] system and further engine-domain file splits — both would flip the engine's private state to internal for cosmetic file counts; the effect formalization rides future feature- module extractions instead. 1,981 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 * Baseline logBluetoothStatus for the macOS Periphery scan Its callers are all inside #if os(iOS) (willRestoreState in both role files plus the app-state handlers), so the macOS-scheme scan sees the now-internal declaration with zero callers — the same class as the baselined candidateCount. Verified 1-USR diff; the previously private mangled variant was already baselined, which is why the pre-split scan never flagged it. Co-Authored-By: Claude Fable 5 * Fix #1538: release stale bindings on rotation instead of leaving a ghost With two live links to one phone, a panic rotation healed only the link the verified announce arrived on. The second link kept its binding to the retired identity, so that dead ID stayed in the peer list — and was kept alive by the NEW identity's own traffic, since a bound link attributes non-announce frames to its bound peer. It only healed when the stale link physically dropped. The issue proposed exempting the containment rule via retiredBy[X] = Y so the second link could rebind. Two problems: the exemption's stated precondition (X removed by retireRotatedPeer) can never hold in this scenario — the retire is gated on X having no remaining links, which is false precisely because the stale link exists — and it would loosen a security rule to fix a liveness bug. Instead the rotation now RELEASES every link still bound to the rotated-away identity (unbind + retire that link's Noise proof) and retires the identity. No containment rule changes: unbinding is strictly less trusting than any binding, and it is correct under both readings of a second link bound to the retired ID — same physical device (the field case), or one link is a spoofer holding a forged binding, since a peer ID is a Noise-key fingerprint and two devices cannot both legitimately own it. Released links reconverge through the ordinary unbound-link path: the next raw direct announce binds them to whoever they actually carry. Reproduced and fixed under the slice-4 simulator, which is why this lands as tests rather than another two-phone session: - duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks fails without the fix (ghost in both knownPeers and getConnectedPeers, duplicate link still bound to the dead ID) - replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim pins the #1401 containment rule against exactly the attack this fix had to avoid re-opening, with a positive control proving the refusal is the containment check and not duplicate suppression Harness gains connectDuplicateLinks (two links to one peer, modelled in the central role — the links we cannot cancel, and the only role whose bindings a CB-free harness can form), silence (range loss without a link event, so a packet can be captured that the far side never saw), and emittedPackets (the attacker's capture buffer). Residual, documented at the fix: an attacker who binds their own link to X by replaying X's raw announce can drive a rebind there and so evict X's registry entry; X's next announce restores it, and the per-link rebind cooldown bounds the rate. This is the same class of capability the containment already accepts, not a new one. 1,983 tests green, Periphery clean, iOS simulator build clean. Closes #1538 Co-Authored-By: Claude Fable 5 * Fix the retire↔reconnect oscillation: redundant-link survivor is the newest connection Field-observed July 31 on main: with a restored old-address link and a fresh-address duplicate to the same phone, redundant-link consolidation kept choosing the restored link as survivor (it carried the announce ingress and the binding) and cancelling the fresh one — which the radio promptly rediscovered and reconnected, because the fresh link sits on the BLE address the peer still advertises. Retire, reconnect, repeat at the retirement cooldown (~1/min) until the ingress happened to flip. Battery and airtime noise on every restore-with-duplicates. BLERedundantLinkPolicy now prefers the most recently CONNECTED candidate. Only the newest connection lives on the currently advertised address; the older-address link cannot return once cancelled, so consolidation converges on the first pass. BLEPeripheralLinkState gains lastConnectedAt (set by markConnected; nil for restored links, whose connect predates the process — exactly the 'stale address' signal). Security note: physical connect recency is a signal an announce replay cannot nominate, unlike the previous ingress-link preference — the announce anchors (ingress, then most recently bound) are demoted to tie-breakers and the fallback for all-restored links. Writability still trumps everything: a newest link mid-service-rediscovery is never kept over a writable duplicate. Containment (bound-links-only, one retirement per peer per cooldown, peer keeps a live link) unchanged. Six policy tests pin the new order, including the field scenario (restored link holding both announce anchors loses to the fresh connection) and the legacy fallback. 1,988 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 * Defer consolidation while the newest connection is still mid-discovery Codex P2 on #1566: a fresh duplicate that has connected but not yet finished service discovery was excluded from the writable candidate set, so the policy kept the older writable (restored) link and cancelled the freshly advertised connection — recreating the retire↔reconnect oscillation inside the discovery window. Now, when the physically newest connection is not writable yet while a writable duplicate exists, consolidation defers to a later announce instead of guessing. Also documents that RSSI is deliberately not a policy input (Chessing234's rule-pinning ask) and pins the defer window, the restored-anchor variant, the co-newest writable tie, and the all-unwritable recency path with tests. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Services/BLE/BLELinkStateStore.swift | 10 +- .../Services/BLE/BLERedundantLinkPolicy.swift | 81 +++++++++- bitchat/Services/BLE/BLEService.swift | 6 +- .../BLERedundantLinkPolicyTests.swift | 144 +++++++++++++++++- 4 files changed, 228 insertions(+), 13 deletions(-) diff --git a/bitchat/Services/BLE/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift index ebf39114..79440a64 100644 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ b/bitchat/Services/BLE/BLELinkStateStore.swift @@ -8,6 +8,12 @@ struct BLEPeripheralLinkState { 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 } @@ -112,11 +118,12 @@ 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( @@ -125,6 +132,7 @@ final class BLELinkStateStore { isConnecting: false, isConnected: true, lastConnectionAttempt: nil, + lastConnectedAt: now, assembler: NotificationStreamAssembler() ), for: peripheralID 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.swift b/bitchat/Services/BLE/BLEService.swift index 5f29ade3..8891b40e 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -6376,7 +6376,8 @@ extension BLEService { store.peripheralStates.map { (uuid: $0.peripheral.identifier.uuidString, isConnected: $0.isConnected, - hasCharacteristic: $0.characteristic != nil) + hasCharacteristic: $0.characteristic != nil, + lastConnectedAt: $0.lastConnectedAt) } } return physical.map { @@ -6384,7 +6385,8 @@ extension BLEService { uuid: $0.uuid, peerID: linkBindings.peer(forPeripheralID: $0.uuid), isConnected: $0.isConnected, - hasCharacteristic: $0.hasCharacteristic + hasCharacteristic: $0.hasCharacteristic, + lastConnectedAt: $0.lastConnectedAt ) } } 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") + } } From 3a75567f5c15d3cf70d4bd48175a68666bb17ff4 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Fri, 31 Jul 2026 15:34:55 +0530 Subject: [PATCH 23/35] fix: stop EnvironmentObject crash in the people sheet (#1567) * fix: re-inject environment objects into the people sheet Sheets hosting a NavigationStack can drop inherited EnvironmentObjects on some iOS versions, crashing ContentPeopleListView / MessageListView (#1558). Co-authored-by: Cursor * test: note people-sheet environment contract in smoke mount Make the #1558 regression visible next to the ContentView / people-sheet smoke mounts so a future env-object trim is harder to miss. Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> --- bitchat/Views/ContentView.swift | 22 ++++++++++++++++++++++ bitchatTests/ViewSmokeTests.swift | 3 +++ 2 files changed, 25 insertions(+) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 4efb0d34..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 = "" @@ -297,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, @@ -314,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/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)) From 7b39d72beca06350f2f9534fb135e3aa7e327162 Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:40:02 -0500 Subject: [PATCH 24/35] Give media the explicit file-protection class other stores use (#1552) Media payload writes used .atomic alone and inherited the container default; the courier store, outbox, gossip archive, and receipt index all state their protection class at the write site. Media now follows the same convention: until-first-user-authentication on payload writes and on every site that creates a media directory (the store's helpers, live captures, the outgoing writers, and the files/ root creators), so recordings that save as they go inherit it. A best-effort launch migration stamps files written by older builds, applying only to items at the container default or weaker so it can never downgrade, running detached after the retention sweep from #1484. On stock devices the container default already yields this class, so behavior does not change; the protection is now stated in the code instead of inherited. Full iOS suite green; macOS builds; swiftlint adds no violations. Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> --- bitchat/App/AppRuntime.swift | 19 +-- bitchat/Features/media/ImageUtils.swift | 2 +- .../Features/voice/VoiceCaptureSession.swift | 2 +- bitchat/Features/voice/VoiceRecorder.swift | 2 +- bitchat/Models/BitchatMessage+Media.swift | 2 +- .../Services/BLE/BLEIncomingFileStore.swift | 109 +++++++++++++++++- .../ViewModels/ChatLiveVoiceCoordinator.swift | 6 +- .../ChatMediaTransferCoordinator.swift | 2 +- .../Services/MediaRetentionTests.swift | 79 +++++++++++++ 9 files changed, 205 insertions(+), 18 deletions(-) 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/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/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/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/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 1d663044..84295135 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -1899,7 +1899,7 @@ private extension ChatMediaTransferCoordinator { try FileManager.default.createDirectory( at: filesDirectory, withIntermediateDirectories: true, - attributes: nil + attributes: BLEIncomingFileStore.mediaProtectionAttributes ) return filesDirectory } 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 } From 59a9f628dfdb0d1c34dd947516577eaab97c348e Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Fri, 31 Jul 2026 12:50:21 +0200 Subject: [PATCH 25/35] test: pin that unknown file TLVs are skipped, not fatal (#1550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BitchatFilePacket.decode` skips tags it does not recognise (`case nil: continue`), which is what keeps the TLV list a floor rather than a ceiling: a field the sender considered optional costs the receiver that field, not the whole file. Nothing pinned it. The behaviour is load-bearing for any peer, version or third-party client that adds a field this build has not seen, and it is also where the two implementations diverge — the Android decoder returns null on an unknown tag, which is why `PrivateMediaMessageIdentity` has to derive its receipt key from fields already on the wire instead of adding one. Worth a test on the side that gets it right so it cannot quietly drift into the strict behaviour. Two cases, both hand-built so they do not depend on our own encoder: an unknown TLV between MIME_TYPE and CONTENT (where an encoder appending content last would put it), and one trailing CONTENT. Changing `case nil: continue` to `return nil` fails both. Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> --- .../Protocols/BitchatFilePacketTests.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) 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) From 6f323637745488604f540657cdb2f46a924ffb24 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:50:24 +0100 Subject: [PATCH 26/35] Deflake VoiceRecorderTests: replace timed semaphores with async events (#1572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitUntilActivationBegan hardcoded a 5-second DispatchSemaphore timeout — below the 10s house floor and invisible to TestTimingHygieneTests (it's a semaphore wait, not a helper-timeout parameter). On a starved runner the window expired before the recorder's session-acquire task was scheduled, failing cancelWhileSessionAcquireIsInFlightNeverCreates- ARecorder — 7 sightings, including three in the last two days (#1506 and #1528 merge runs, #1550's PR run). The fix is extracted verbatim from #1107 (mmalmi), which carries it but is blocked on a V3 rebase: both test gates (activation and padding) drop their DispatchSemaphore + timeout for an untimed async-event wait (VoiceRecorderAsyncEvent), so there is no timing constant left to starve — the test framework's own timeout is the backstop. Extracting it unblocks CI now; #1107's rebase will see this file already matching its branch. Verified (count-checked via xcresulttool): 7/7 VoiceRecorderTests on the iOS simulator, and 7/7 x 5 consecutive runs under 16x CPU oversubscription. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchatTests/VoiceRecorderTests.swift | 71 ++++++++++++++++----------- 1 file changed, 41 insertions(+), 30 deletions(-) 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() From 9edb7c26ef7bdcf3bb29e7907b38997f8d5cd0fa Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:29:47 +0100 Subject: [PATCH 27/35] Silence the four release-build warnings (#1583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four surfaced in the 1.7.1 RC window and are behavior-neutral: - sendPacket(to:) discarded sendPacketDirected's Bool through generic onEngine, tripping unused-result (from #1547's engine-domain flip). - Both _test_drain*Pipeline helpers captured non-Sendable self in @Sendable dispatch closures; they only need the queue, which is Sendable — capture that instead. - removeEphemeralSession returned removeValue's result out of the barrier closure, tripping unused-result on sync(flags:execute:). Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Identity/SecureIdentityStateManager.swift | 2 +- bitchat/Services/BLE/BLEService.swift | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) 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/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 8891b40e..752d9758 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2943,7 +2943,7 @@ extension BLEService: GossipSyncManager.Delegate { func sendPacket(to peerID: PeerID, packet: BitchatPacket) { onEngine { - sendPacketDirected(packet, to: peerID) + _ = sendPacketDirected(packet, to: peerID) } } @@ -3336,9 +3336,12 @@ extension BLEService { } func _test_drainPrivateMediaSendPipeline() async { + // Capture only the (Sendable) queue, not self, so the @Sendable + // dispatch closures carry no non-Sendable state. + let queue = messageQueue await withCheckedContinuation { continuation in - self.messageQueue.async { [weak self] in - self?.messageQueue.async { + queue.async { + queue.async { continuation.resume() } } @@ -3357,9 +3360,10 @@ extension BLEService { } func _test_drainNoiseMessagePipeline() async { + let queue = messageQueue await withCheckedContinuation { continuation in - self.messageQueue.async { - self.messageQueue.async { + queue.async { + queue.async { continuation.resume() } } From 948d6a85b9f254760c475a64f02fe8899e4f3d7f Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:41:15 +0100 Subject: [PATCH 28/35] Peer ID rotation: working primitives + spec for iOS/Android review (#1487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: specify peer ID rotation for cross-platform review Draft protocol spec for review by both iOS and Android before any implementation. Nothing here is implemented; this is the artifact to agree on, since the change is a wire revision neither platform can ship alone. The headline correction, because it is easy to get wrong: rotating the peer ID alone accomplishes nothing. The announce carries the Noise static key, the Ed25519 signing key and the nickname in cleartext, so a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together. The second thing an implementer needs to know up front is that peerID == SHA-256(noiseStaticKey)[0..8] is not a convention, it is the mechanism that makes peer IDs unforgeable, enforced in the announce preflight and again at handshake completion. Making IDs independent of the key fails both checks for every peer, so a replacement binding has to ship in the same change. The spec proposes one: an Ed25519 proof over (context, epoch, rotating ID, static key) carried inside the completed Noise session via the existing AuthenticatedPeerStatePacket, checked against a pinned signing key — strictly stronger than today's self-signed announce. Design summary: hour-epoch IDs derived from private key material via HKDF+HMAC so no observer can predict or link them; pairwise recognition tags from the X25519 shared secret so mutual favourites still recognise each other with no handshake, padded to fixed slots so the tag count does not leak how many favourites someone has; strangers discovered by handshake-first-identify-second over Noise XX, whose static keys are already encrypted on the wire. Nickname moves inside the session and the neighbour list is dropped rather than rotated. Includes a verified impact inventory separating what breaks hard (the handshake check, the announce preflight, the disk outbox keyed by peer ID, private-media stable IDs and their deletion tombstones, the initiator tie-break, fingerprint-prefix lookups) from what degrades gracefully and what is already safe because it keys on fingerprints or Noise keys. Rollout uses the two mechanisms already proven in this repo: a PeerCapabilities bit (11 is next; 10 is burned) with capabilitiesWereExplicitlyAdvertised to tell an old client from a new one with the bit off, and observed-version gating as used for source routing. Two findings surfaced while writing this and are recorded in the spec. CourierEnvelope.recipientTag is HMAC keyed on the recipient's *public* static key, and since that key is broadcast in cleartext today, any observer in radio range can compute a peer's courier tags for any day — so the whitepaper's "cannot link it across days" does not currently hold, and the pattern must not be copied. And NoiseEncryptionService's buildAnnounceSignature/verifyAnnounceSignature/canonicalAnnounceBytes are present but production-dead, called only from tests; the binding above deliberately uses a different context string so the two can never be confused. Eight open questions are left explicitly unresolved, including the rotation period, whether unsigned v2 announces are an acceptable posture, and whether Android's decoder tolerates trailing bytes the way iOS's does (which decides whether padding coverage can ship ungated). Co-Authored-By: Claude Opus 5 * Implement the peer ID rotation primitives Code is a better thing to argue with than prose, so the spec now has a working, tested base under it. Every number and context string is a concrete proposal you can reject by changing one function and watching a test vector move. What is implemented: - PeerIDRotation: hour epochs with a ±1 matching window, the rotation secret from the Noise static *private* key, per-epoch peer IDs, pairwise recognition keys and tags from an X25519 shared secret, the fixed-width tag block with CSPRNG padding and constant-time matching, and the canonical bytes for the identity binding. - AnnounceV2Packet (announceV2 = 0x05): TLV wire format carrying an epoch, a 64-byte tag block, capabilities and an optional bridge cell — and nothing else. No nickname, no public keys, no neighbour list. Rejects a wrong-width tag block on both encode and decode, since a short block would disclose how many mutual favourites someone has, and rejects non-canonical capability encodings the way AuthenticatedPeerStatePacket does. Unknown TLVs are skipped for forward compatibility. - 37 tests, three of which are hex vectors cross-checked against an independent implementation written from the spec alone (Python hmac/hashlib, HKDF extract-then-expand, empty salt) and matching byte for byte. That is the property Android needs: the document is sufficient to reproduce the numbers without reading this code. What is deliberately NOT implemented: nothing emits a v2 announce, and BLEService parses the type and explicitly ignores it. Consuming presence needs both the replacement identity binding and a decision on how unverified presence appears in the peer list, and accepting it now would put unauthenticated entries in front of people. Adding the message type forced three policy decisions, all reviewable: - Not gossip-synced. Syncing presence would defeat the point — a device never in radio range could collect tag blocks, turning a local beacon into a network-wide one. - Not padded. At ~75 bytes the smallest bucket would triple the airtime of the most frequent packet in the protocol; the format is already near-constant width, and fixing the capability and geohash field widths would be cheaper than padding. - Parsed but ignored on receive, as above. Notably the v2 announce is *smaller* than v1 (~75 vs ~229 bytes): dropping two 32-byte keys, the neighbour list and the signature more than pays for 64 bytes of tags, so unlinkability here costs less airtime rather than more. Co-Authored-By: Claude Opus 5 * Mark the rotation primitives periphery:ignore The dead-code scan correctly flagged both new types as unused, which they intentionally are: they exist to be reviewed and argued with before the protocol change they belong to can ship. Annotated in place rather than added to .periphery.baseline.json so the reason sits next to the code and disappears with it, following the existing convention in MessageRouter. Both notes say to delete the annotation once the mesh starts using the type. `periphery scan --strict` locally: no unused code detected. Co-Authored-By: Claude Opus 5 * Fix two P1 flaws in the recognition tag design (Codex #1487) Both findings are correct and both were real. This is the argument for shipping code next to the prose: neither was obvious in the design text. **Tags were symmetric, which leaked the social graph.** `HMAC(K_AB, epoch)` produces the same 8 bytes for both parties, so an observer who saw one value in two different announces would learn those two devices are mutual favourites, and could link their two rotating IDs to each other — handing over exactly the graph the design exists to hide, plus a cross-epoch correlation handle. Tags are now directional: the MAC covers the ordered sender and recipient static public keys, so A→B and B→A differ. Both parties can still compute both directions because both hold both keys. **Tags were replayable under any ID.** A tag depending only on (pair, epoch) could be lifted from a recorded announce and replayed in a fresh announce under an attacker-chosen ID; the recipient would match and treat that ID as the favourite, and since epoch-1 is accepted it would keep working into the next period. The MAC now covers the announced peer ID, which reduces this to replaying the victim's own presence. That residual is unfixable while announces are unsigned, so the spec now states plainly that recognition is a hint only: presence may be populated, but routing a DM or showing a verified badge must wait for a handshake whose static key equals the favourite that produced the match. O4 is rewritten around that, with the two alternatives named (per-epoch ephemeral signing key, or a freshness nonce echoed by the recipient). Tests: two regression cases named for the findings, plus a wrong-direction-does-not-match case so the directional fix cannot silently become cosmetic. The vector table now gives both directions, because their difference is the security property — an implementation that produces one value for both has reintroduced the flaw. Recomputed independently in Python from the spec and matched byte for byte. Co-Authored-By: Claude Opus 5 * docs: record that padding changes are cross-platform coordinated O7 began as a question about whether Android tolerates trailing bytes. The firmer answer, found while attempting the padding fix unilaterally: toBinaryDataForSigning encodes with padding enabled, so the padding bytes are inside the signed material for every signed packet. Changing the algorithm changes the signed byte stream and breaks verification against any peer that has not made the identical change. So both outstanding padding fixes — coverage beyond Noise frames, and the gap where a frame needing over 255 bytes of padding ships unpadded — are wire changes requiring both platforms, not local cleanups. O7 now says so, and names the two things to settle. Also updates the related-work section: dropping the neighbour list and randomizing origin TTL did turn out to be unilateral and have landed separately. Co-Authored-By: Claude Opus 5 * Close the review findings on the rotation spec **P1 — the binding proof was replayable onto another session.** The §4.5 verifier checklist omitted the check that the proof's noiseStaticPublicKey equals the remote static key the Noise session actually established. The proof is a self-contained signed blob with nothing tying it to the session it arrives on, so a peer M that had seen A's proof could replay it verbatim inside M's own session with B; B would verify A's signature, see a well-formed binding, and on first contact TOFU-pin A's signing key against M's fingerprint. Added as the first item in the checklist, with the attack written out, because "signed" and "bound to this conversation" are different properties and the difference is easy to lose in a bullet list. **announceV2 is 0x2C, not 0x05.** 0x05 only looks free. It has been recycled twice — announce, then bulkTransferResponse, then fragmentStart until #446 — so an old peer could still map it to a fragment header and misparse presence as a partial message. Values above voiceFrame = 0x29 have only ever been allocated forward, and 0x2A/0x2B belong to the courier spray-ack work, leaving 0x2C. Confirmed never used anywhere in this repository's history. **Outbound priority is now stated, not inherited.** announceV2 fell through to `default: .high`. High is the right answer — presence is small, time-bounded to its epoch and useless once stale — but for a type nothing emits yet, a fall-through means the choice gets made without anyone seeing it. **Reverted unexplained pbxproj churn.** Xcode had rewritten resource-phase ordering and dropped a share-extension entitlements membership exception; none of it belongs in this PR. The file now matches main byte for byte. **O7 said "payloads" where the arithmetic is over encoded frames.** The 241-256 / 497-768 / 1009-1792 ranges are what `pad` receives, which is the whole encoded packet, not the payload alone. **Added O9: a seized device recomputes every past peer ID.** K_rot is long-lived, so peerID_e is computable for any epoch by whoever holds it — someone who seizes a phone, or pulls the static key from a backup, can go back over historical radio captures and identify which were this device. Rotation defends against the passive observer, not against later key compromise. A hash ratchet would give forward secrecy for the ID stream at the cost of state that must survive restarts, tolerate clock jumps, and resynchronise after a gap — a real trade rather than an obvious win, so it is written down as a question rather than silently adopted. Co-Authored-By: Claude Opus 5 * docs: rotation capability bit is 14 now — 11-13 claimed by in-flight work Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Opus 5 --- .../BLE/BLEOutboundPacketPolicy.swift | 17 +- bitchat/Services/BLE/BLEService.swift | 11 +- bitchat/Sync/SyncTypeFlags.swift | 5 + docs/PEER-ID-ROTATION.md | 342 +++++++++++++++ .../BitFoundation/AnnounceV2Packet.swift | 158 +++++++ .../Sources/BitFoundation/MessageType.swift | 18 + .../BitFoundation/PeerIDRotation.swift | 315 ++++++++++++++ .../AnnounceV2PacketTests.swift | 159 +++++++ .../PeerIDRotationTests.swift | 408 ++++++++++++++++++ 9 files changed, 1431 insertions(+), 2 deletions(-) create mode 100644 docs/PEER-ID-ROTATION.md create mode 100644 localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift create mode 100644 localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift create mode 100644 localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift create mode 100644 localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index ddcc4abc..fa0853d6 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -15,7 +15,15 @@ enum BLEOutboundPacketPolicy { // voiceFrame is deliberately unpadded: padding to the 512 block would // push every ~490-byte signed voice packet over the MTU into the // fragment path. - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame: + // + // announceV2 is unpadded too, but for a different reason and it is worth + // revisiting: it is ~75 bytes, so the smallest bucket would triple the + // airtime of the most frequently sent packet in the protocol. Its length + // is already near-constant by construction (the tag block is fixed + // width); the residual variation is the capability width and whether a + // bridge geohash is present. Making those fixed-width would be cheaper + // than padding. See docs/PEER-ID-ROTATION.md. + case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame: return false } } @@ -27,6 +35,13 @@ enum BLEOutboundPacketPolicy { return .fragment(totalFragments: fragmentTotalCount(from: packet.payload)) case .fileTransfer: return .fileTransfer + case .announceV2: + // Stated rather than inherited from `default`. Presence is small, + // time-bounded to its epoch, and useless once stale, so it belongs + // with the other control traffic at high priority — but that should + // be a decision on the record, not a fall-through, since this type + // is not emitted yet and nobody would notice the choice being made. + return .high default: return .high } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 752d9758..0c4ecfb1 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -5983,7 +5983,16 @@ extension BLEService { switch context.messageType { case .announce: handleAnnounce(packet, from: senderID) - + + case .announceV2: + // Parsed and ignored on purpose. The wire format and derivations are + // implemented and tested (see PeerIDRotation, AnnounceV2Packet), but + // consuming presence from it needs the replacement identity binding + // and the peer-list policy for unverified presence, both of which are + // still open questions in docs/PEER-ID-ROTATION.md. Accepting it now + // would add unauthenticated entries to the peer list. + break + case .message: handleMessage(packet, from: senderID) diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index c2c96a1c..4dbbb53a 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -57,6 +57,11 @@ struct SyncTypeFlags: OptionSet { // Live voice is only useful now; replaying stale audio frames via // sync would waste airtime (receivers drop them as stale anyway). case .voiceFrame: return nil + // Rotating-ID presence is valid only inside its epoch, and gossiping it + // would defeat the point: a synced announce would let a device that was + // never in radio range collect tag blocks, turning a local presence + // beacon into a network-wide one. + case .announceV2: return nil // Prekey bundles gossip like board posts. The bitfield is a // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits // ignored by `type(forBit:)`), so bits 8+ need no format change: old diff --git a/docs/PEER-ID-ROTATION.md b/docs/PEER-ID-ROTATION.md new file mode 100644 index 00000000..b71de1ed --- /dev/null +++ b/docs/PEER-ID-ROTATION.md @@ -0,0 +1,342 @@ +# Peer ID Rotation Specification + +**Status:** Draft for cross-platform review. The derivations and the wire format **are implemented and tested**; nothing is wired into the shipping mesh. +**Audience:** bitchat iOS and bitchat Android maintainers. +**Requires agreement before going further.** This changes the wire protocol, so neither platform can ship it alone. + +**Where the code is:** + +| Piece | File | +|---|---| +| Epochs, ID derivation, recognition tags, tag block, binding message | `localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift` | +| `announceV2 = 0x2C` wire format | `localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift` | +| Executable test vectors | `localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift` | +| Wire-format tests | `localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift` | + +The code is deliberately an **opinionated working base, not a finished feature**. Every number and context string in it is a concrete proposal you can disagree with by changing one function and watching a test vector move. What is *not* implemented is the part that carries risk: nothing emits a v2 announce, and `BLEService` parses the type and explicitly ignores it, because consuming it needs both the replacement identity binding (§4.5) and a decision on how unverified presence appears in the peer list (O4). + +Three policy decisions were forced by the compiler when the new message type was added, and are worth reviewing as part of this: + +- **Not gossip-synced** (`SyncTypeFlags`). Syncing presence would defeat the purpose: a device never in radio range could collect tag blocks, turning a local beacon into a network-wide one. +- **Not padded** (`BLEOutboundPacketPolicy`). At ~75 bytes the smallest bucket would triple the airtime of the most frequent packet in the protocol. The format is already near-constant width; making the capability and geohash fields fixed-width would be cheaper than padding. Open for argument. +- **Parsed but ignored** on receive (`BLEService`), as above. + +--- + +## 1. The problem + +Today a passive listener with a BLE dongle, standing in a crowd, can do the following with no cryptographic attack and no active participation: + +1. **Detect that a phone is running bitchat.** The service UUID is a fixed constant. +2. **Assign that phone a permanent identifier.** The 8-byte sender ID in every packet header is `SHA-256(noiseStaticPublicKey)[0..8]`, and the Noise static key is generated once and kept in the keychain. It does not rotate. Same phone, same bytes, next week, next city. +3. **Learn the phone's long-term public keys and its self-chosen nickname.** The announce carries the 32-byte Noise static key, the 32-byte Ed25519 signing key, and the nickname, all in cleartext, re-broadcast every 4–30 seconds and on demand to anything that connects and subscribes. +4. **Reconstruct who was standing near whom.** The announce also carries up to ten neighbour IDs, so one receiver gets the local adjacency graph without needing several receivers or signal-strength trilateration. + +For the people this app is explicitly built for, (2) and (4) are the dangerous ones. A protest attendee's phone announces a stable pseudonym and its social graph to anyone within radio range. + +iOS BLE address randomization does not help. It randomizes the link-layer address underneath an application layer that publishes a stable identifier above it. + +**The correction that matters most:** rotating the peer ID *alone* accomplishes nothing. As long as the announce carries the static keys in cleartext, a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together or not at all. + +## 2. Goals and non-goals + +**Goals** + +- **G1.** A passive listener cannot link two observations of the same device across rotation periods. +- **G2.** A passive listener cannot learn a device's long-term identity keys or nickname. +- **G3.** Peers who already know each other (mutual favourites) still recognise each other automatically, without an interactive handshake, so existing UX does not regress. +- **G4.** Strangers can still discover and handshake, so the mesh still forms among people who have never met. +- **G5.** Old and new clients interoperate. A mixed mesh keeps working, in both directions, with no flag day. +- **G6.** Rotation does not make identity spoofing easier than it is today. + +**Non-goals, explicitly out of scope here** + +- Hiding *that* bitchat is in use. The service UUID is a separate problem; BLE requires something discoverable. Tracked separately. +- Traffic-analysis resistance in general: padding coverage, send-time jitter, TTL randomization, and the neighbour-list leak each need their own change. Rotation does not fix them and they do not fix rotation. +- Resistance to an active attacker who connects and completes a handshake. Anyone you handshake with learns your identity; that is what a handshake is for. + +## 3. What currently binds an identity, and why rotation breaks it + +This is the part most likely to be underestimated, so it is stated precisely. + +`peerID == SHA-256(noiseStaticPublicKey)[0..8]` is not merely a convention. It is **the mechanism that makes peer IDs unforgeable**, and it is enforced in two places: + +**Announce preflight** — `BLEAnnounceHandlingPolicy.swift:32-35`: + +```swift +let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey) +guard derivedPeerID == peerID else { return .reject(.senderMismatch(derivedPeerID: derivedPeerID)) } +``` + +**Handshake completion** — `NoiseSessionManager.swift:1106-1122`: + +```swift +private func authenticatedRemoteKey(_ remoteKey: Curve25519.KeyAgreement.PublicKey, + matches claimedPeerID: PeerID) -> Bool { + let rawKey = remoteKey.rawRepresentation + if claimedPeerID.isShort { return PeerID(publicKey: rawKey) == claimedPeerID } + … +} +``` + +Failure throws `NoiseSessionError.peerIdentityMismatch`. + +If the peer ID becomes independent of the key, **both checks fail for every peer** and there is nothing left proving that a sender ID belongs to the sender. Any rotation design must therefore ship a *replacement* binding in the same change. This is why the work is a protocol revision and not a patch. + +Note also what the existing announce signature does and does not prove. The packet signature covers the sender ID (`BitchatPacket.toBinaryDataForSigning()` zeroes only TTL and the RSR flag), but it is verified against the Ed25519 key carried *inside the same announce* — a self-signature. The code says so plainly (`BLEAnnounceHandlingPolicy.swift:94-103`): an attacker can replay a victim's peer ID and Noise key with their own signing key and a valid self-signature, and only trust-on-first-use pinning of the signing key stops it. So today's binding is "derived ID + TOFU", and a replacement must be at least that strong. + +## 4. Design + +### 4.1 Epochs + +Rotation is on a wall-clock schedule so that two devices that have never met agree on the current period without negotiation. + +``` +epoch = floor(unixTimeSeconds / ROTATION_PERIOD) +ROTATION_PERIOD = 3600 (1 hour, proposed — see open question O1) +``` + +`epoch` is a `UInt32`, big-endian wherever it is hashed. Implementations MUST accept `epoch-1`, `epoch`, and `epoch+1` when matching (the ±1 window absorbs clock skew and boundary crossings), following the precedent already set by courier recipient tags (`CourierEnvelope.candidateTags`). + +### 4.2 The rotating peer ID + +``` +K_rot = HKDF-SHA256(ikm: noiseStaticPrivateKey, + salt: "", + info: "bitchat-peer-rotation-v1", + length: 32) + +peerID_e = HMAC-SHA256(key: K_rot, + message: "bitchat-peer-id-v2" || uint32be(epoch))[0..8] +``` + +Properties: + +- Derived from the **private** key, so no observer can compute or predict it, and two epochs' IDs are unlinkable. +- Deterministic, so the device recomputes the same ID after a restart within the same epoch. +- Still 8 bytes, so the packet header layout is unchanged. + +**It must be derived from private key material.** Deriving from the *public* key would let anyone who has ever seen that key compute every past and future ID, which is worse than doing nothing because it would look like protection. This mistake already exists in the codebase: `CourierEnvelope.recipientTag` is `HMAC(key: recipient's **public** static key, epochDay)`, and since that public key is broadcast in cleartext today, any observer in radio range can compute a peer's courier tags for any day. The whitepaper's claim that couriers "cannot link it across days" does not currently hold. Fixing that is out of scope here but should be tracked; do not copy the pattern. + +### 4.3 Recognising peers you already know + +With the static keys off the air, mutual favourites need another way to spot each other. Each announce carries a set of **pairwise recognition tags**. For a device A announcing under `peerID_e` to mutual favourite B: + +``` +S_AB = X25519(A_noiseStaticPrivate, B_noiseStaticPublic) // == X25519(B_priv, A_pub) +K_AB = HKDF-SHA256(ikm: S_AB, salt: "", info: "bitchat-recognition-v1", length: 32) + +tag_A→B = HMAC-SHA256(key: K_AB, + message: uint32be(epoch) + || A_noiseStaticPublic (32) + || B_noiseStaticPublic (32) + || peerID_e (8))[0..8] +``` + +A includes `tag_A→B` in its announce. B computes the same value independently — it holds the same shared secret and both public keys — and matches it against inbound announces. Only A and B can compute it, because it needs one of the two private keys. + +Two properties of that MAC input are load-bearing, and an earlier draft of this document got both wrong. They were caught in review of #1487, which is the argument for shipping the code alongside the prose. + +**Ordered keys make the tag directional.** The earlier form was `HMAC(K_AB, epoch)`, which is symmetric: A and B would broadcast the *identical* 8 bytes. An observer who saw one value appear in two different announces would learn that those two devices are mutual favourites, and could link their two rotating IDs to each other — handing over precisely the social graph this design exists to hide, and providing a cross-epoch correlation handle. Ordering the keys yields distinct A→B and B→A values, and both parties can still compute both directions because both hold both public keys. + +**`peerID_e` binds the tag to the announce carrying it.** Without it a tag depends only on (pair, epoch), so an attacker could lift A's tag out of a recorded announce and replay it in a fresh announce under an ID of their own choosing; B would match and treat that ID as A. Because `epoch-1` is also accepted, the spoof would stay usable into the following period. Binding to the ID reduces this from impersonation-as-any-ID to replaying A's own presence. + +**Residual risk, unfixable while announces are unsigned:** an attacker can rebroadcast A's exact announce within the epoch window, making A appear present when absent. Recognition is therefore a **hint only**. A match may populate presence, but anything consequential — routing a DM, showing a verified badge — MUST wait for a completed handshake whose static key equals the favourite that produced the match. See O4. + +Rules: + +- Tags are **unordered**. Implementations MUST NOT infer anything from position. +- The tag list MUST be padded with uniform random 8-byte values to a fixed count `TAG_SLOTS = 8`, so the number of tags does not disclose how many mutual favourites a device has. Random padding is indistinguishable from a real tag to anyone who cannot compute it. +- With more than `TAG_SLOTS` mutual favourites, a device MUST rotate which favourites occupy the slots across successive announces so all of them eventually see a tag. (Selection strategy is an implementation detail; convergence is not — see O2.) +- A device MUST NOT include a tag for a one-directional favourite, since that would disclose interest to someone who has not reciprocated. + +### 4.4 Strangers + +Nothing identifying is broadcast for strangers. Discovery still works: + +1. A hears an announce from unknown `peerID_e` advertising the rotation capability. +2. A initiates Noise **XX** to that ID. +3. In XX, the responder's static key is sent in message 2 *after* `ee`, and the initiator's in message 3 — both encrypted. A passive observer learns neither. +4. On completion, both sides learn the peer's real static key and fingerprint, exactly as they do today (`handleSessionEstablished`), and the existing `AuthenticatedPeerStatePacket` (Noise payload `0x21`) carries the Ed25519 signing key and capability claims *inside* the session, where they are proven rather than asserted. + +So the model becomes **handshake first, identify second**, for anyone who is not already a mutual favourite. + +### 4.5 The replacement binding + +Inside the completed handshake, each side proves that the rotating ID it was using belongs to its static key: + +``` +proof = Ed25519-Sign(signingPrivateKey, + "bitchat-peerid-binding-v1" + || uint32be(epoch) + || peerID_e (8 bytes) + || noiseStaticPublicKey (32 bytes)) +``` + +Sent as a new TLV in `AuthenticatedPeerStatePacket`, whose existing structure already carries a version byte, a canonicality-checked capability TLV, and the 32-byte signing key. The receiver verifies: + +- **that the `noiseStaticPublicKey` inside the proof is byte-equal to the remote static key the Noise session actually established** — see below, this one is load-bearing, and +- the signature against the signing key in the same packet, **and** +- that the signing key matches whatever it has already pinned for this fingerprint, using the existing trust ladder (authenticated key, then TOFU pin), and +- that `peerID_e` equals the ID the session was actually conducted under, and +- that `epoch` is within the ±1 window. + +An earlier draft of this list omitted the first check, which left a hole worth spelling out because it is the kind that survives review. The proof is a self-contained signed blob: nothing in the signature ties it to *the session it arrives on*. So a peer M who has observed A's proof — it travels inside a session, but M can be a peer A legitimately talked to — could replay A's proof verbatim inside M's own session with B. Without the static-key check, B verifies A's signature successfully, sees a well-formed binding, and on **first contact** TOFU-pins A's signing key against M's fingerprint. From then on B attributes M's identity to A's key. Comparing the proof's static key against the key the handshake actually produced closes it: M cannot substitute A's key without also being A. + +This replaces `authenticatedRemoteKey`'s derivation check with an explicit signed statement. With the static-key check present it is strictly stronger than today's self-signed announce, because the signing key is checked against a pin rather than taken from the same message. Without it, it is weaker — a reminder that "signed" and "bound to this conversation" are different properties. + +Note the canonical-bytes helper for this already half-exists: `NoiseEncryptionService.buildAnnounceSignature` / `verifyAnnounceSignature` / `canonicalAnnounceBytes`, with context `"bitchat-announce-v1"`, are present but unreferenced in production (only tests call them). They sign `context‖peerID(8)‖noiseKey(32)‖ed25519Key(32)‖nickname‖timestampMs`. The binding above is deliberately a **different context string** and a different field set, so the two can never be confused; the dead code should be deleted or repurposed explicitly rather than silently reused. + +### 4.6 The announce, before and after + +**Today** (`AnnouncementPacket`, TLVs in `Packets.swift:33-40`), all cleartext: + +| T | Field | Width | +|---|---|---| +| `0x01` | nickname | var | +| `0x02` | Noise static public key | 32 | +| `0x03` | Ed25519 signing public key | 32 | +| `0x04` | direct neighbours | N × 8, max 10 | +| `0x05` | capabilities | 1–8 | +| `0x06` | bridge geohash | var | + +`0x01`, `0x02`, `0x03` are **required** by the decoder (`Packets.swift:147`). + +**Proposed v2 announce.** Because the existing decoder hard-requires the three identity TLVs, a v2 announce cannot simply omit them — that is a parse failure, not a graceful degrade. It therefore needs a distinct message type: **`announceV2 = 0x2C`**. + +An earlier draft proposed `0x05` on the grounds that it is unassigned today and sits next to `announce = 0x01`. That was wrong. `0x05` has already been recycled twice — `announce`, then `bulkTransferResponse`, then `fragmentStart` until #446 — so a sufficiently old peer may still map it to a fragment header and misparse presence as a partial message. Values above `voiceFrame = 0x29` have only ever been allocated forward, which is the safe direction; `0x2A`/`0x2B` are spoken for by the courier spray-ack work, leaving `0x2C`. Verified never used anywhere in this repository's history (see O3). + +TLVs, all cleartext but none identifying: + +| T | Field | Width | Notes | +|---|---|---|---| +| `0x01` | epoch | 4 | `uint32be`; lets a receiver match without guessing | +| `0x02` | recognition tags | `TAG_SLOTS` × 8 = 64 | unordered, random-padded | +| `0x03` | capabilities | 1–8 | same minimal-LE encoding as today | +| `0x04` | bridge geohash | ≤12 | unchanged semantics | + +Deliberately absent: nickname, both public keys, neighbour list. + +Worth noting because it is counter-intuitive: **the v2 announce is smaller than the v1 announce**, despite carrying 64 bytes of tags. A v1 announce with a 10-byte nickname and a full neighbour list is roughly 165 payload bytes plus a 64-byte signature; a v2 announce is roughly 75 bytes and unsigned. Dropping two 32-byte keys, the neighbour list, and the signature more than pays for the tag block, so this reduces airtime rather than adding to it. + +- **Nickname** moves inside the session (`AuthenticatedPeerStatePacket`). A nickname is a self-chosen, often reused human label; broadcasting it in cleartext is a linkage vector on its own. +- **Neighbour list** is dropped entirely. It exists to seed source routing, and its documented fallback is flooding. Publishing the adjacency graph of a crowd is not a reasonable price for routing efficiency. (Dropping it is independently backward compatible — the TLV is optional on decode — and can ship ahead of this spec.) + +**The v2 announce is unsigned.** This is a real trade-off and needs review (O4). There is no key to verify a signature against without disclosing one, so a v2 announce asserts nothing except "somebody is here, and here are some tags". Consequences: + +- An attacker can emit v2 announces with arbitrary IDs and random tags — cheap peer-list noise. This is bounded by the existing announce rate limiting, per-central subscription limiting, and connection rate limits, but it is weaker than today. +- An attacker **cannot** impersonate a specific known peer, because it cannot compute that peer's recognition tags without one of the two private keys. +- An attacker cannot get a Noise session, so it cannot send messages, only occupy a peer-list slot. + +Mitigation for review: treat a v2 announce as *unverified presence* only, and do not surface it in the peer list until either a recognition tag matches or a handshake completes. That preserves today's property that the peer list reflects authenticated peers. + +## 5. Compatibility and rollout + +The repo already has the two mechanisms this needs, both proven in production. + +**Capability bit.** `PeerCapabilities` is a `UInt64` `OptionSet` with minimal little-endian wire encoding, at least one byte, so "no TLV" and "empty set" stay distinguishable. Crucially `BLEPeerRegistry.capabilitiesWereExplicitlyAdvertised(for:)` distinguishes *old client that sent no TLV* from *new client with the bit off*. Add `peerIDRotation` at the next free bit — **bit 14** at the time of writing: bit 10 is burned and MUST NOT be reused, bit 11 is claimed by the Nostr double-ratchet work (#1107), bit 12 by courier spray receipts (#1438), and bit 13 is reserved for stickers (#1544). Re-check the claim table in `PeerCapabilities.swift` before assigning; whichever platform implements first pins the number in a shared test vector. + +**Observed-version gating.** `MeshTopologyTracker.recordObservedVersion(_:for:)` records the highest protocol version seen from each node, and `computeRoute(…, requiringVersion:)` refuses paths through nodes not observed at that version. `docs/SOURCE_ROUTING.md` records this as the shipped pattern for a compatible rollout. The same shape applies here. + +**Phased plan.** + +| Phase | Behaviour | +|---|---| +| 1 | Both platforms ship the ability to **parse** v2 announces and advertise the capability, while still sending v1. Purely additive; a v2 announce from a test build is understood rather than dropped. | +| 2 | Send v1 **and** v2 announces, alternating. New clients prefer v2 and ignore the v1 from a peer they have recognised via v2; old clients see only the v1. Costs airtime, buys a no-flag-day transition. | +| 3 | Once telemetry-free judgement says adoption is sufficient, a setting (default on) suppresses v1 announces. A device that suppresses v1 becomes invisible to old clients — that is the intended cost of unlinkability, and it must be stated in the UI, not buried. | + +During phases 2–3 a device runs **both** a stable v1 ID and a rotating v2 ID. They must never appear as two peers; a peer recognised by both paths has to collapse to one entry. The repo has the beginnings of this in `MessageRouter.peerIDAliases` and `ChatPeerIdentityCoordinator.migrateChatState`, but they were built for panic-reset rotation, not steady-state rotation. + +## 6. Impact inventory + +This is what an implementer must handle. Every item below was verified against the iOS source; Android should expect its own equivalents. + +### 6.1 Must be fixed or the feature is broken + +| Area | Why | iOS reference | +|---|---|---| +| **Handshake identity check** | `authenticatedRemoteKey` re-derives the ID from the static key and fails for every peer once IDs are independent. Replace with §4.5. | `NoiseSessionManager.swift:1106-1122`, enforced `:714-718` | +| **Announce preflight** | Same derivation check rejects any announce whose ID is not the key's hash. | `BLEAnnounceHandlingPolicy.swift:32-35` | +| **Sealed message outbox** | Queued DM plaintext is keyed by peer ID on disk and survives app kill. A recipient's rotation orphans their queue. Needs re-keying by **fingerprint** (stable) with the peer ID as a lookup hint. This is the single worst offender. | `MessageOutboxStore.swift:66`, `:704-707`, `:746` | +| **Private-media durable IDs** | `stableID` hashes sender and recipient short IDs, and the durable receipt ledger keys accept/tombstone records on it. Rotation silently breaks dedup **and user deletion tombstones**, so deleted media could be re-accepted. | `BitchatFilePacket.swift:183-231`, `BLEPrivateMediaReceiptStore.swift` | +| **Initiator tie-break** | Crossed-initiation resolution compares `localPeerID < peerID`. Both sides must reach the same verdict; a rotation mid-negotiation flips it asymmetrically. Needs a rotation-stable comparison key (fingerprint). | `NoiseSessionManager.swift:83`, `:569`, `:582`, `:603` | +| **Fingerprint-prefix lookups** | Several paths recover a peer from `fingerprint.hasPrefix(peerID)`. These silently return empty, and one of them is what lets a public message from a not-yet-registered peer be accepted at all. | `SecureIdentityStateManager.swift:437-444`; `ChatGroupCoordinator.swift:98-102`, `:432`; `FavoritesPersistenceService.swift:188-195`; `BLEService.swift:2552`, `:2823` | +| **`PeerID.routingData`** | Falls back to `toShort()`, i.e. fingerprint-derived routing bytes. | `PeerID.swift:190-202` | + +### 6.2 Degrades gracefully but needs handling + +| Area | Effect | iOS reference | +|---|---|---| +| **Noise sessions** | A rotation mid-session leaves an established session under the old ID. Rotation should either be deferred while sessions are live or migrate them explicitly. | `NoiseEncryptionService.swift:1010-1019` | +| **Fragment reassembly** | The reassembly key mixes the 8-byte sender ID, so a rotation mid-transfer strands every in-flight assembly until the 30 s timeout. Defer rotation while fragments are in flight. | `BLEFragmentAssemblyBuffer.swift:4-47` | +| **Dedup LRU** | Keys embed the sender ID, so the same packet crossing a rotation boundary can be reprocessed once. Bounded and probably acceptable. | `BLEReceivePipeline.swift:21` | +| **Source routes / topology** | A remote rotation invalidates cached adjacency, and a rotated relay no longer finds itself in an in-flight v2 route, falling back to flooding. Already the documented fallback. | `MeshTopologyTracker.swift`, `BLERouteForwardingPolicy.swift:62` | +| **Gossip archive** | Archived raw packets keep the old sender ID forever, and packet IDs are sender-derived, so attribution and purge-by-peer break for pre-rotation history. | `GossipMessageArchive.swift`, `PacketIdUtil.swift:8-17` | +| **Read receipts** | The wire receipt carries an 8-byte `readerID`; one sent before and matched after a rotation will not correlate. | `ReadReceipt.swift:47-64` | + +### 6.3 Already safe — no work needed + +Keyed by fingerprint, Noise key, or Ed25519 key rather than peer ID: the identity cache and every map in it (social identities, verified fingerprints, vouches, blocks), favourites (keyed by Noise static key), courier envelopes and recipient tags, prekey bundles, board posts, bridge drop dedup, group rosters, vouch attestations, and all geohash/location state (keyed by Nostr pubkey). Peer registry, link state, and all Noise session maps are in-memory and session-scoped. + +## 7. Test vectors + +These live as assertions in `PeerIDRotationTests.swift`, so they run on every build rather than rotting in a table. + +All three were **cross-checked against an independent implementation written from this document alone** — Python `hmac`/`hashlib`, HKDF as extract-then-expand with an empty salt — and matched byte for byte. That is the property that matters: the spec text is sufficient to reproduce the numbers without reading the Swift. + +With `noiseStaticPrivateKey = 0102…20` (bytes 1 through 32): + +``` +rotationSecret = HKDF-SHA256(ikm: 0102…20, salt: , + info: "bitchat-peer-rotation-v1", len: 32) + = fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09 + +peerID(epoch=100) = HMAC-SHA256(rotationSecret, + "bitchat-peer-id-v2" || uint32be(100))[0..8] + = f7c08c528506a374 +``` + +With a recognition key derived from a shared secret of 32 × `0x42`, sender key +32 × `0x0A`, recipient key 32 × `0x0B`, and announced ID 8 × `0xA1`: + +``` +recognitionKey = HKDF-SHA256(ikm: 42×32, salt: , + info: "bitchat-recognition-v1", len: 32) + +tag_A→B(epoch=100) = HMAC-SHA256(recognitionKey, + uint32be(100) || 0A×32 || 0B×32 || A1×8)[0..8] + = 4568f61d61d6cbfb + +tag_B→A(epoch=100) (same key, keys swapped) + = 5313c7731f629959 +``` + +Both directions are given because their *difference* is the security property: if +an implementation produces the same value for both, it has reintroduced the +symmetric-tag flaw. + +Also asserted, and worth reproducing on Android because they are the properties rather than the numbers: both sides of a real X25519 pair derive the identical tag from opposite key halves; consecutive epochs produce unrelated IDs; the ±1 epoch window matches across a boundary but two epochs out does not; the tag block is always 64 bytes regardless of how many tags it carries; a match is found regardless of slot position; and the binding message is fixed-width so a short input cannot shift a later field into an earlier field's position. + +Still to be written jointly: a full `announceV2` packet as a hex blob, and the §4.5 signature over a fixed key. Whichever platform writes a vector, the other MUST reproduce it from this document rather than from the first platform's code. + +## 8. Open questions for review + +- **O1 — Rotation period.** One hour is a guess balancing unlinkability against churn. Shorter means less linkable and more session/route disruption; longer the reverse. Is there a period that is clearly right, or should it be a build constant both platforms pin? +- **O2 — More than `TAG_SLOTS` favourites.** What is the required convergence guarantee — "every mutual favourite sees a tag within N announces"? Should the slot rotation be deterministic from the epoch so it is testable? +- **O3 — New message type vs. announce version byte.** A distinct `MessageType` is cleanest given the decoder's required TLVs, but it consumes a type value and means two announce paths. Would a version TLV inside the existing type, with the identity TLVs made optional on both platforms first, be preferable? +- **O4 — Unsigned v2 announces.** Binding tags to the announced peer ID removes impersonation-as-any-ID, but a recorded announce can still be rebroadcast verbatim within the epoch window, so a peer can be made to look present when absent. Is "presence is a hint; nothing consequential until a handshake whose static key matches the favourite that produced the match" acceptable? The alternatives are an ephemeral per-epoch signing key with a proof-of-continuity, or a freshness nonce echoed by the recipient — both more machinery and more bytes. +- **O5 — Rotation while a session is live.** Defer rotation until sessions are idle, or rotate and migrate? Deferring is simpler and safer, but a long-lived session pins the ID for its lifetime, which weakens G1 for exactly the people who talk most. +- **O6 — Nickname timing.** Moving the nickname into the session means a stranger's name appears only after a handshake. Is that acceptable UX on both platforms, or does the peer list need a "someone nearby" placeholder state? +- **O7 — Padding is a coordinated change, not a local one.** This started as a question about decoder tolerance and turned into something firmer. `BitchatPacket.toBinaryDataForSigning()` encodes with padding enabled, so **the padding bytes are inside the signed material for every signed packet**. Changing the padding algorithm therefore changes the signed byte stream, and signatures stop verifying against any peer that has not made the identical change. Both outstanding padding fixes are affected: extending coverage beyond `noiseEncrypted`/`noiseHandshake`, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (encoded *frames* of 241–256, 497–768 and 1009–1792 bytes ship at exact length today — the arithmetic is over the whole encoded packet that `pad` receives, not the payload alone). Two things to settle: whether Android's decoder also tolerates trailing bytes the way iOS's does (`guard offset <= buf.count`, plus an unpad retry), and whether padding changes ride this protocol revision or get their own capability-gated one. + +- **O9 — A seized device recomputes every past peer ID.** `K_rot` is a long-lived secret, so `peerID_e = HMAC(K_rot, epoch)` is computable for *any* epoch by whoever holds it. Someone who seizes a phone, or extracts the Noise static key from a backup, can therefore take historical radio captures and identify which of them were this device — retroactively defeating the unlinkability for every past epoch. Rotation protects against the passive observer, not against later key compromise. A hash ratchet (`K_{e+1} = HKDF(K_e)`, discarding `K_e`) would give forward secrecy for the ID stream, at the cost of state that must survive restarts, tolerate clock jumps, and resynchronise after a gap — none of which is free, and all of which interacts with the ±1 window. Worth deciding deliberately rather than inheriting. + +## 9. Relationship to other work + +Rotation is the largest item in the radio-layer metadata cluster but not the only one, and the others are cheaper: + +- **Drop the neighbour list** and **randomize origin TTL** — both landed separately, since neither needs agreement: see the radio-metadata PR. +- **Extend padding beyond Noise frames, and fix the length-marker gap** — only `noiseEncrypted` and `noiseHandshake` are padded, and `pad` silently declines when the required padding exceeds the single-byte marker, so frames well below their bucket ship unpadded. **Not unilateral**: padding is inside the signed bytes, so this needs both platforms. See O7. + +None of these substitute for rotation, and rotation does not substitute for them: a device with a rotating ID that still publishes its neighbour list, or that still marks its own originated packets by TTL, remains linkable. diff --git a/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift new file mode 100644 index 00000000..0bee3e6f --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift @@ -0,0 +1,158 @@ +// +// AnnounceV2Packet.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Identity-free presence announcement for rotating peer IDs. +/// +/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the +/// nickname, the 32-byte Noise static public key, the 32-byte Ed25519 signing +/// key, and up to ten neighbour IDs. That is a permanent device fingerprint plus +/// the local social graph, free to anyone in radio range. This carries none of +/// it — only an epoch, a fixed-size block of pairwise recognition tags, and +/// capability bits. +/// +/// Deliberately absent, with reasons: +/// - **Public keys**: they are the linkage. Peers learn them inside the Noise XX +/// handshake, where they are already encrypted on the wire. +/// - **Nickname**: a self-chosen, frequently reused human label. It moves into +/// the session (`AuthenticatedPeerStatePacket`). +/// - **Neighbour list**: it seeds source routing, whose documented fallback is +/// flooding. Publishing a crowd's adjacency graph is not a reasonable price +/// for routing efficiency. +/// +/// **Unsigned, on purpose and not without cost.** There is no key to verify a +/// signature against without disclosing one, so this asserts only "somebody is +/// here, and here are some tags". An attacker can therefore emit noise — bounded +/// by existing announce and connection rate limits — but cannot impersonate a +/// specific peer, because forging a recognition tag needs one of the two private +/// keys, and cannot send anything without completing a handshake. The intended +/// posture is to treat a v2 announce as *unverified presence* and not surface it +/// until a tag matches or a handshake completes. See open question O4 in +/// `docs/PEER-ID-ROTATION.md`. +/// +/// Not emitted or consumed by the shipping mesh yet. +// periphery:ignore - intentionally unreferenced by production code; nothing +// emits or consumes this type yet, and BLEService parses it only to ignore it. +// Delete this annotation when the mesh starts using it. +public struct AnnounceV2Packet: Equatable, Sendable { + /// Rotation epoch this announce was built for. Carried explicitly so a + /// receiver matches against a stated epoch instead of guessing. + public let epoch: UInt32 + /// Exactly `PeerIDRotation.tagSlots * PeerIDRotation.idLength` bytes. + public let tagBlock: Data + public let capabilities: PeerCapabilities? + /// Coarse rendezvous cell, when bridging. Same semantics as v1. + public let bridgeGeohash: String? + + public init( + epoch: UInt32, + tagBlock: Data, + capabilities: PeerCapabilities? = nil, + bridgeGeohash: String? = nil + ) { + self.epoch = epoch + self.tagBlock = tagBlock + self.capabilities = capabilities + self.bridgeGeohash = bridgeGeohash + } + + private enum TLVType: UInt8 { + case epoch = 0x01 + case tagBlock = 0x02 + case capabilities = 0x03 + case bridgeGeohash = 0x04 + } + + /// Expected tag-block width. A fixed size is load-bearing: it hides how many + /// mutual favourites a device has. + public static var tagBlockLength: Int { + PeerIDRotation.tagSlots * PeerIDRotation.idLength + } + + public func encode() -> Data? { + guard tagBlock.count == Self.tagBlockLength else { return nil } + + var data = Data() + + data.append(TLVType.epoch.rawValue) + data.append(UInt8(4)) + withUnsafeBytes(of: epoch.bigEndian) { data.append(contentsOf: $0) } + + data.append(TLVType.tagBlock.rawValue) + data.append(UInt8(tagBlock.count)) + data.append(tagBlock) + + if let capabilities { + let bytes = capabilities.encoded() + guard bytes.count <= 255 else { return nil } + data.append(TLVType.capabilities.rawValue) + data.append(UInt8(bytes.count)) + data.append(bytes) + } + + if let bridgeGeohash, !bridgeGeohash.isEmpty { + let bytes = Data(bridgeGeohash.utf8) + guard bytes.count <= 12 else { return nil } + data.append(TLVType.bridgeGeohash.rawValue) + data.append(UInt8(bytes.count)) + data.append(bytes) + } + + return data + } + + public static func decode(from data: Data) -> AnnounceV2Packet? { + var epoch: UInt32? + var tagBlock: Data? + var capabilities: PeerCapabilities? + var bridgeGeohash: String? + + var offset = data.startIndex + while offset < data.endIndex { + guard data.distance(from: offset, to: data.endIndex) >= 2 else { return nil } + let rawType = data[offset] + let length = Int(data[data.index(after: offset)]) + let valueStart = data.index(offset, offsetBy: 2) + guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil } + let value = data.subdata(in: valueStart.. +// + +import Foundation +private import CryptoKit + +/// Derivations for rotating peer IDs and pairwise recognition tags. +/// +/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the +/// open questions. This type is the executable half of that document: it is +/// deliberately pure (no I/O, no clock of its own, no dependency on the BLE +/// stack) so both platforms can agree on the numbers before anyone wires it +/// into a transport. +/// +/// Nothing here is used by the shipping mesh yet. +/// +/// ## Why the derivations look like this +/// +/// The rotating ID comes from **private** key material. Deriving it from the +/// public key would let anyone who has ever seen that key compute every past +/// and future ID, which is worse than not rotating because it would look like +/// protection. The same mistake is live in `CourierEnvelope.recipientTag`, +/// which is keyed on the recipient's *public* static key — and since that key +/// is broadcast in cleartext in every announce today, any observer in radio +/// range can compute a peer's courier tags for any day. +/// +/// Recognition tags come from the X25519 shared secret between two static +/// keys, so exactly two parties can compute a given tag and an observer can +/// compute none of them. +// periphery:ignore - intentionally unreferenced by production code. These are +// the reviewable primitives for a protocol change that cannot ship until both +// platforms agree on it; wiring them into the transport is the next step, not +// this one. Delete this annotation when the mesh starts using them. +public enum PeerIDRotation { + // MARK: - Parameters + + /// Seconds per rotation epoch. One hour is a starting position, not a + /// settled one: shorter is less linkable but churns sessions, routes, and + /// in-flight fragment reassembly more often. See open question O1. + public static let rotationPeriod: TimeInterval = 3600 + + /// Bytes of an ID or tag placed on the wire. Matches the existing 8-byte + /// header sender ID, so the packet layout is unchanged. + public static let idLength = 8 + + /// Fixed number of tag slots in an announce. Padding to a constant hides + /// how many mutual favourites a device has, which is itself identifying. + public static let tagSlots = 8 + + // MARK: - Context strings + // + // Distinct per use so a value derived for one purpose can never be + // substituted for another. `bitchat-announce-v1` is deliberately NOT reused: + // it belongs to the production-dead announce-signature helpers in + // NoiseEncryptionService, and confusing the two would be a real bug. + + private static let rotationInfo = Data("bitchat-peer-rotation-v1".utf8) + private static let peerIDContext = Data("bitchat-peer-id-v2".utf8) + private static let recognitionInfo = Data("bitchat-recognition-v1".utf8) + private static let bindingContext = Data("bitchat-peerid-binding-v1".utf8) + + // MARK: - Epochs + + /// Epoch number for a point in time. Wall-clock derived so two devices that + /// have never met agree on the current epoch without negotiating. + public static func epoch(at date: Date) -> UInt32 { + let seconds = max(0, date.timeIntervalSince1970) + return UInt32(truncatingIfNeeded: Int(seconds / rotationPeriod)) + } + + /// Epochs to test when matching, oldest first. + /// + /// The ±1 window absorbs clock skew and the moment either side crosses a + /// boundary, mirroring `CourierEnvelope.candidateTags`. Without it, two + /// devices a few seconds apart across a boundary would fail to recognise + /// each other for no reason a person could understand. + public static func candidateEpochs(around date: Date) -> [UInt32] { + let current = epoch(at: date) + return current == 0 ? [0, 1] : [current - 1, current, current + 1] + } + + // MARK: - Rotating peer ID + + /// Long-lived rotation secret for this device. Derived from the Noise + /// static **private** key, so it never leaves the device and no observer + /// can predict any ID it produces. + public static func rotationSecret(noiseStaticPrivateKey: Data) -> Data { + let derived = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: noiseStaticPrivateKey), + info: rotationInfo, + outputByteCount: 32 + ) + return derived.withUnsafeBytes { Data($0) } + } + + /// This device's peer ID for a given epoch. + public static func peerID(rotationSecret: Data, epoch: UInt32) -> Data { + var message = peerIDContext + message.append(bigEndianBytes(epoch)) + let mac = HMAC.authenticationCode( + for: message, + using: SymmetricKey(data: rotationSecret) + ) + return Data(mac).prefix(idLength) + } + + /// Convenience: the ID this device should be using at `date`. + public static func currentPeerID(noiseStaticPrivateKey: Data, at date: Date) -> Data { + peerID( + rotationSecret: rotationSecret(noiseStaticPrivateKey: noiseStaticPrivateKey), + epoch: epoch(at: date) + ) + } + + // MARK: - Pairwise recognition tags + + /// Symmetric recognition key for a pair, from their X25519 shared secret. + /// + /// Both sides compute the identical value from opposite key halves, which + /// is the whole point: recognition needs no round trip, and no third party + /// can derive it. + public static func recognitionKey(sharedSecret: Data) -> Data { + let derived = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: sharedSecret), + info: recognitionInfo, + outputByteCount: 32 + ) + return derived.withUnsafeBytes { Data($0) } + } + + /// The tag `sender` puts in its announce for `recipient` this epoch. + /// + /// Three inputs beyond the epoch, each load-bearing: + /// + /// - **Ordered keys make the tag directional.** An earlier draft used + /// `HMAC(K_AB, epoch)`, which is symmetric — so A and B broadcast the + /// *same* 8 bytes, and an observer who spots one value in two different + /// announces learns those two devices are mutual favourites and can link + /// their rotating IDs to each other. That hands over exactly the social + /// graph this design exists to hide. Ordering the keys gives A→B and B→A + /// distinct values; both parties can still compute both directions, + /// because both hold both public keys. + /// - **`peerID` binds the tag to the announce carrying it.** Without it the + /// tag depends only on (pair, epoch), so an attacker could lift a tag out + /// of A's announce and replay it under an ID of their choosing; the + /// recipient would match and believe that ID is A. Binding means a lifted + /// tag is only valid alongside A's own ID, which reduces the attack from + /// impersonation-as-any-ID to replaying A's presence. + /// + /// Replaying A's own announce within the epoch window remains possible — + /// unsigned announces cannot prevent it. Recognition is therefore a hint + /// only, and anything consequential must wait for a completed handshake. + /// See open question O4. + public static func recognitionTag( + recognitionKey: Data, + epoch: UInt32, + senderStaticPublicKey: Data, + recipientStaticPublicKey: Data, + peerID: Data + ) -> Data { + var message = bigEndianBytes(epoch) + message.append(fixedWidth(senderStaticPublicKey, 32)) + message.append(fixedWidth(recipientStaticPublicKey, 32)) + message.append(fixedWidth(peerID, idLength)) + let mac = HMAC.authenticationCode( + for: message, + using: SymmetricKey(data: recognitionKey) + ) + return Data(mac).prefix(idLength) + } + + // MARK: - Tag block + + /// Packs tags into the fixed-size announce block, padding with uniform + /// random bytes. + /// + /// Random padding is indistinguishable from a real tag to anyone who cannot + /// compute the real ones, so the block discloses neither how many mutual + /// favourites a device has nor which slot belongs to whom. Tags beyond + /// `tagSlots` are dropped here; choosing *which* to carry across successive + /// announces is the caller's problem (open question O2). + public static func tagBlock( + tags: [Data], + randomBytes: (Int) -> Data = Self.secureRandomBytes + ) -> Data { + var slots = tags.prefix(tagSlots).map { $0.prefix(idLength) } + // Order must carry no information, so shuffle rather than appending + // real tags at the front. + slots.shuffle() + var block = Data() + for slot in slots { + block.append(slot) + if slot.count < idLength { + block.append(Data(repeating: 0, count: idLength - slot.count)) + } + } + let padding = (tagSlots - slots.count) * idLength + if padding > 0 { + block.append(randomBytes(padding)) + } + return block + } + + /// Splits a received block back into candidate tags. + /// + /// Returns nil for a block that is not exactly `tagSlots * idLength`, so a + /// malformed announce is rejected rather than partially interpreted. + public static func tags(fromBlock block: Data) -> [Data]? { + guard block.count == tagSlots * idLength else { return nil } + return stride(from: 0, to: block.count, by: idLength).map { + block.subdata(in: (block.startIndex + $0)..<(block.startIndex + $0 + idLength)) + } + } + + /// Whether any slot in `block` holds the tag we expect a specific peer to + /// have put there, for an announce carrying `peerID`. + /// + /// `senderStaticPublicKey` is the peer we hope sent this (so we compute the + /// direction they would use) and `recipientStaticPublicKey` is our own. + /// Passing them the other way round tests the opposite direction and will + /// not match, which is the point of making tags directional. + /// + /// Comparison is constant-time per candidate, and every slot is examined + /// even after a match, so neither the presence of a match nor its slot + /// index is observable through timing. + public static func blockMatches( + _ block: Data, + recognitionKey: Data, + senderStaticPublicKey: Data, + recipientStaticPublicKey: Data, + peerID: Data, + at date: Date + ) -> Bool { + guard let slots = tags(fromBlock: block) else { return false } + let expected = candidateEpochs(around: date).map { + recognitionTag( + recognitionKey: recognitionKey, + epoch: $0, + senderStaticPublicKey: senderStaticPublicKey, + recipientStaticPublicKey: recipientStaticPublicKey, + peerID: peerID + ) + } + var matched = false + for slot in slots { + for candidate in expected where constantTimeEquals(slot, candidate) { + matched = true + } + } + return matched + } + + // MARK: - Identity binding + + /// Canonical bytes proving a rotating ID belongs to a static key. + /// + /// Signed with the Ed25519 identity key and exchanged **inside** a + /// completed Noise session, this replaces the derivation check that today + /// makes peer IDs unforgeable (`peerID == SHA-256(staticKey)[0..8]`, checked + /// in the announce preflight and again at handshake completion). Once IDs + /// are independent of the key, those checks fail for every peer, so a + /// replacement has to exist before rotation can ship. + /// + /// Fixed-width fields throughout: no length prefixes are needed and no two + /// distinct inputs can produce the same bytes. + public static func bindingMessage( + epoch: UInt32, + peerID: Data, + noiseStaticPublicKey: Data + ) -> Data { + var out = bindingContext + out.append(bigEndianBytes(epoch)) + out.append(fixedWidth(peerID, idLength)) + out.append(fixedWidth(noiseStaticPublicKey, 32)) + return out + } + + // MARK: - Helpers + + /// Padding must be indistinguishable from a real tag, so it comes from the + /// system CSPRNG via key generation rather than a general-purpose RNG. + public static func secureRandomBytes(_ count: Int) -> Data { + guard count > 0 else { return Data() } + let key = SymmetricKey(size: SymmetricKeySize(bitCount: count * 8)) + return key.withUnsafeBytes { Data($0) } + } + + private static func bigEndianBytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } + + private static func fixedWidth(_ data: Data, _ width: Int) -> Data { + var out = data.prefix(width) + if out.count < width { + out.append(Data(repeating: 0, count: width - out.count)) + } + return Data(out) + } + + /// Length-independent comparison, so a match cannot be found byte by byte + /// through timing. + private static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var difference: UInt8 = 0 + for (left, right) in zip(lhs, rhs) { + difference |= left ^ right + } + return difference == 0 + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift new file mode 100644 index 00000000..e7653463 --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift @@ -0,0 +1,159 @@ +import Foundation +import Testing +@testable import BitFoundation + +/// Wire-format tests for the identity-free announce. These are the second half +/// of the cross-platform contract: Android must encode and decode byte-identical +/// packets, so anything asserted here is a promise, not an implementation detail. +struct AnnounceV2PacketTests { + private var block: Data { + Data(repeating: 0xAB, count: AnnounceV2Packet.tagBlockLength) + } + + @Test func typeValueIsStable() { + // Changing this breaks every deployed decoder. + // + // Deliberately NOT 0x05, which merely looks free: it has been recycled + // twice already (announce, then bulkTransferResponse, then fragmentStart + // until #446), so an old peer could still map it to a fragment header + // and misparse presence as a partial message. Values above + // voiceFrame = 0x29 have only ever been allocated forward; 0x2A/0x2B + // belong to the courier spray-ack work. + #expect(MessageType.announceV2.rawValue == 0x2C) + #expect(MessageType(rawValue: 0x2C) == .announceV2) + #expect(MessageType.announceV2.description == "announceV2") + } + + @Test func tagBlockIsSixtyFourBytes() { + #expect(AnnounceV2Packet.tagBlockLength == 64) + } + + @Test func roundTripsWithEveryField() throws { + let packet = AnnounceV2Packet( + epoch: 495_555, + tagBlock: block, + capabilities: [.bridge, .prekeys], + bridgeGeohash: "u4pruy" + ) + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnounceV2Packet.decode(from: encoded)) + #expect(decoded == packet) + } + + @Test func roundTripsWithOnlyRequiredFields() throws { + let packet = AnnounceV2Packet(epoch: 0, tagBlock: block) + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnounceV2Packet.decode(from: encoded)) + #expect(decoded == packet) + #expect(decoded.capabilities == nil) + #expect(decoded.bridgeGeohash == nil) + } + + @Test func epochIsBigEndianOnTheWire() throws { + let encoded = try #require(AnnounceV2Packet(epoch: 0x0102_0304, tagBlock: block).encode()) + // TLV 0x01, length 4, then the epoch most-significant byte first. + #expect(Array(encoded.prefix(6)) == [0x01, 0x04, 0x01, 0x02, 0x03, 0x04]) + } + + /// The whole point of the format: none of the identifying v1 fields appear. + @Test func encodingCarriesNoIdentity() throws { + let noiseKey = Data(repeating: 0x11, count: 32) + let signingKey = Data(repeating: 0x22, count: 32) + let nickname = Data("alice".utf8) + + let encoded = try #require( + AnnounceV2Packet( + epoch: 100, + tagBlock: block, + capabilities: [.bridge], + bridgeGeohash: "u4pruy" + ).encode() + ) + + #expect(!encoded.contains(noiseKey)) + #expect(!encoded.contains(signingKey)) + #expect(encoded.range(of: nickname) == nil) + } + + @Test func encodingIsSmallerThanAV1Announce() throws { + let v2 = try #require( + AnnounceV2Packet(epoch: 100, tagBlock: block, capabilities: [.bridge]).encode() + ) + // v1 with a 10-byte nickname and a full neighbour list, before its + // 64-byte signature: nickname 12 + noise 34 + signing 34 + neighbours 82 + // + capabilities 3. + let v1PayloadEstimate = 12 + 34 + 34 + 82 + 3 + #expect(v2.count < v1PayloadEstimate) + } + + // MARK: - Rejection + + @Test func encodeRejectsAWrongWidthTagBlock() { + // A short block would disclose the favourite count, so it must never go + // on the wire. + #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 63)).encode() == nil) + #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 65)).encode() == nil) + #expect(AnnounceV2Packet(epoch: 1, tagBlock: Data()).encode() == nil) + } + + @Test func encodeRejectsAnOversizedGeohash() { + #expect(AnnounceV2Packet( + epoch: 1, + tagBlock: block, + bridgeGeohash: String(repeating: "u", count: 13) + ).encode() == nil) + } + + @Test func decodeRequiresEpochAndTagBlock() throws { + // Capabilities alone is not a valid announce. + var onlyCapabilities = Data([0x03, 0x01]) + onlyCapabilities.append(PeerCapabilities([.bridge]).encoded()) + #expect(AnnounceV2Packet.decode(from: onlyCapabilities) == nil) + + // Epoch without a tag block is not either. + let onlyEpoch = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) + #expect(AnnounceV2Packet.decode(from: onlyEpoch) == nil) + } + + @Test func decodeRejectsTruncatedAndMalformedInput() { + #expect(AnnounceV2Packet.decode(from: Data()) == nil) + // Declares 4 bytes, supplies 2. + #expect(AnnounceV2Packet.decode(from: Data([0x01, 0x04, 0x00, 0x00])) == nil) + // Dangling type byte with no length. + #expect(AnnounceV2Packet.decode(from: Data([0x01])) == nil) + // Wrong epoch width. + #expect(AnnounceV2Packet.decode(from: Data([0x01, 0x02, 0x00, 0x64])) == nil) + } + + @Test func decodeRejectsAWrongWidthTagBlock() { + var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) + data.append(0x02) + data.append(UInt8(63)) + data.append(Data(repeating: 0xAB, count: 63)) + #expect(AnnounceV2Packet.decode(from: data) == nil) + } + + @Test func decodeRejectsNonCanonicalCapabilities() throws { + // Same capability set, non-minimal encoding: it must not be accepted, or + // one set could travel as several distinct byte strings. + var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64]) + data.append(0x02) + data.append(UInt8(AnnounceV2Packet.tagBlockLength)) + data.append(block) + data.append(0x03) + data.append(UInt8(3)) + data.append(Data([0x80, 0x00, 0x00])) // trailing zero bytes are non-minimal + #expect(AnnounceV2Packet.decode(from: data) == nil) + } + + @Test func unknownTLVsAreSkippedForForwardCompatibility() throws { + var data = try #require(AnnounceV2Packet(epoch: 100, tagBlock: block).encode()) + data.append(0x7F) // a type this build has never heard of + data.append(UInt8(3)) + data.append(Data([0x01, 0x02, 0x03])) + + let decoded = try #require(AnnounceV2Packet.decode(from: data)) + #expect(decoded.epoch == 100) + #expect(decoded.tagBlock == block) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift new file mode 100644 index 00000000..434d08f3 --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift @@ -0,0 +1,408 @@ +import Foundation +import Testing +import CryptoKit +@testable import BitFoundation + +/// Executable test vectors for peer ID rotation. +/// +/// These are the numbers the Android implementation must reproduce. Two rules +/// for keeping them useful: +/// +/// 1. **Reproduce them from `docs/PEER-ID-ROTATION.md`, not from this code.** +/// Deriving the expected values by reading the other platform's +/// implementation proves only that both share a bug. +/// 2. **If a derivation changes, the hex here changes too, deliberately.** A +/// vector that gets "fixed" to match new behavior has stopped being a vector. +/// +/// The three `VECTOR:` values below were cross-checked against an independent +/// HKDF/HMAC implementation written from the specification alone (Python +/// `hmac`/`hashlib`, empty salt, extract-then-expand) and matched byte for byte. +/// So the spec text is sufficient to reproduce them without reading this code — +/// which is the property Android needs. +struct PeerIDRotationTests { + // A fixed, obviously-fake private key so the vectors are stable. + private let staticPrivateA = Data((0..<32).map { UInt8($0 + 1) }) // 01..20 + private let staticPrivateB = Data((0..<32).map { UInt8(0xA0 &+ $0) }) // a0..bf + + private func hex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Epochs + + @Test func epochIsWallClockDivision() { + #expect(PeerIDRotation.rotationPeriod == 3600) + #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 0)) == 0) + #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3599)) == 0) + #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3600)) == 1) + // 2026-07-26T00:00:00Z + #expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 1_784_000_000)) == 495_555) + } + + @Test func candidateEpochsCoverTheBoundaryBothWays() { + // Two devices seconds apart across a boundary must still recognise each + // other, so the window spans the neighbouring epochs. + let date = Date(timeIntervalSince1970: 3600 * 100) + #expect(PeerIDRotation.candidateEpochs(around: date) == [99, 100, 101]) + } + + @Test func candidateEpochsDoNotUnderflowAtTheOrigin() { + // UInt32 underflow here would produce 4294967295 and break matching. + #expect(PeerIDRotation.candidateEpochs(around: Date(timeIntervalSince1970: 0)) == [0, 1]) + } + + // MARK: - Rotating peer ID + + @Test func rotationSecretIsStableForAKey() { + let first = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) + let second = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) + #expect(first == second) + #expect(first.count == 32) + // VECTOR: HKDF-SHA256(ikm: 01..20, salt: empty, info: "bitchat-peer-rotation-v1", 32) + #expect(hex(first) == "fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09") + } + + @Test func peerIDIsEightBytesAndEpochDependent() { + let secret = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) + let a = PeerIDRotation.peerID(rotationSecret: secret, epoch: 100) + let b = PeerIDRotation.peerID(rotationSecret: secret, epoch: 101) + + #expect(a.count == PeerIDRotation.idLength) + #expect(b.count == PeerIDRotation.idLength) + // VECTOR: HMAC-SHA256(rotationSecret, "bitchat-peer-id-v2" || uint32be(100))[0..8] + #expect(hex(a) == "f7c08c528506a374") + // The whole point: consecutive epochs are unrelated to an observer. + #expect(a != b) + // Deterministic within an epoch, so a restart keeps the same ID. + #expect(a == PeerIDRotation.peerID(rotationSecret: secret, epoch: 100)) + } + + @Test func peerIDDiffersBetweenDevices() { + let secretA = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA) + let secretB = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateB) + #expect(PeerIDRotation.peerID(rotationSecret: secretA, epoch: 100) + != PeerIDRotation.peerID(rotationSecret: secretB, epoch: 100)) + } + + @Test func currentPeerIDMatchesTheExplicitEpochForm() { + let date = Date(timeIntervalSince1970: 3600 * 100 + 17) + let viaConvenience = PeerIDRotation.currentPeerID( + noiseStaticPrivateKey: staticPrivateA, + at: date + ) + let viaParts = PeerIDRotation.peerID( + rotationSecret: PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA), + epoch: 100 + ) + #expect(viaConvenience == viaParts) + } + + // MARK: - Recognition tags + + private var pubA: Data { Data(repeating: 0x0A, count: 32) } + private var pubB: Data { Data(repeating: 0x0B, count: 32) } + private var idA: Data { Data(repeating: 0xA1, count: 8) } + + /// The property that makes handshake-free recognition possible: both sides + /// reach the same tag from opposite halves of the key pair. + @Test func bothSidesDeriveTheSameRecognitionTag() throws { + let privA = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateA) + let privB = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateB) + + let sharedFromA = try privA.sharedSecretFromKeyAgreement(with: privB.publicKey) + let sharedFromB = try privB.sharedSecretFromKeyAgreement(with: privA.publicKey) + let rawA = sharedFromA.withUnsafeBytes { Data($0) } + let rawB = sharedFromB.withUnsafeBytes { Data($0) } + #expect(rawA == rawB) + + let keyA = PeerIDRotation.recognitionKey(sharedSecret: rawA) + let keyB = PeerIDRotation.recognitionKey(sharedSecret: rawB) + #expect(keyA == keyB) + + // A emits its A->B tag; B computes the same value to look for it. + let emitted = PeerIDRotation.recognitionTag( + recognitionKey: keyA, epoch: 100, + senderStaticPublicKey: privA.publicKey.rawRepresentation, + recipientStaticPublicKey: privB.publicKey.rawRepresentation, + peerID: idA + ) + let expected = PeerIDRotation.recognitionTag( + recognitionKey: keyB, epoch: 100, + senderStaticPublicKey: privA.publicKey.rawRepresentation, + recipientStaticPublicKey: privB.publicKey.rawRepresentation, + peerID: idA + ) + #expect(emitted == expected) + #expect(emitted.count == PeerIDRotation.idLength) + } + + /// Regression, Codex #1487 P1: a symmetric tag means A and B broadcast the + /// identical 8 bytes, so an observer who sees one value in two announces + /// learns those two are mutual favourites and can link their rotating IDs. + /// Tags must therefore differ by direction. + @Test func recognitionTagsAreDirectional() { + let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32)) + let aToB = PeerIDRotation.recognitionTag( + recognitionKey: key, epoch: 100, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA + ) + let bToA = PeerIDRotation.recognitionTag( + recognitionKey: key, epoch: 100, + senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, peerID: idA + ) + #expect(aToB != bToA) + } + + /// Regression, Codex #1487 P1: without the peer ID in the MAC, a tag lifted + /// from someone's announce could be replayed under an attacker-chosen ID and + /// the recipient would accept that ID as the favourite. + @Test func recognitionTagIsBoundToTheAnnouncedPeerID() { + let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32)) + let real = PeerIDRotation.recognitionTag( + recognitionKey: key, epoch: 100, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA + ) + let underAttackerID = PeerIDRotation.recognitionTag( + recognitionKey: key, epoch: 100, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, + peerID: Data(repeating: 0xFF, count: 8) + ) + #expect(real != underAttackerID) + + // And the lifted tag must not verify against the attacker's ID. + let block = PeerIDRotation.tagBlock(tags: [real]) + #expect(!PeerIDRotation.blockMatches( + block, recognitionKey: key, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, + peerID: Data(repeating: 0xFF, count: 8), + at: Date(timeIntervalSince1970: 3600 * 100) + )) + } + + @Test func recognitionTagRotatesWithTheEpoch() { + let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32)) + let now = PeerIDRotation.recognitionTag( + recognitionKey: key, epoch: 100, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA + ) + let next = PeerIDRotation.recognitionTag( + recognitionKey: key, epoch: 101, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA + ) + #expect(now != next) + // VECTOR: HMAC-SHA256(HKDF(ikm: 0x42*32, info: "bitchat-recognition-v1"), + // uint32be(100) || 0x0A*32 || 0x0B*32 || 0xA1*8)[0..8] + #expect(hex(now) == "4568f61d61d6cbfb") + } + + @Test func aThirdPartyCannotDeriveAPairsTag() { + // An observer holding a *different* shared secret gets a different tag, + // which is what stops it from tracking the pair. + let pair = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x01, count: 32)) + let other = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x02, count: 32)) + #expect(PeerIDRotation.recognitionTag( + recognitionKey: pair, epoch: 7, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA + ) != PeerIDRotation.recognitionTag( + recognitionKey: other, epoch: 7, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA + )) + } + + // MARK: - Tag block + + @Test func tagBlockIsAlwaysFullWidth() { + let expected = PeerIDRotation.tagSlots * PeerIDRotation.idLength + for count in 0...PeerIDRotation.tagSlots { + let tags = (0.. (key: Data, tag: Data, date: Date) { + let date = Date(timeIntervalSince1970: 3600 * 100) + let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x77, count: 32)) + let tag = PeerIDRotation.recognitionTag( + recognitionKey: key, + epoch: PeerIDRotation.epoch(at: date), + senderStaticPublicKey: pubA, + recipientStaticPublicKey: pubB, + peerID: idA + ) + return (key, tag, date) + } + + @Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() { + let (key, tag, date) = matchFixture() + // Slot order must not matter, so assert across many shuffles. + for _ in 0..<20 { + let block = PeerIDRotation.tagBlock(tags: [tag]) + #expect(PeerIDRotation.blockMatches( + block, recognitionKey: key, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, + peerID: idA, at: date + )) + } + } + + /// Testing the wrong direction must fail, or the directional fix would be + /// cosmetic. + @Test func blockDoesNotMatchTheOppositeDirection() { + let (key, tag, date) = matchFixture() + let block = PeerIDRotation.tagBlock(tags: [tag]) + #expect(!PeerIDRotation.blockMatches( + block, recognitionKey: key, + senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, + peerID: idA, at: date + )) + } + + @Test func blockMatchesToleratesTheEpochBoundary() { + let date = Date(timeIntervalSince1970: 3600 * 100) + let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x11, count: 32)) + + func tag(epoch: UInt32) -> Data { + PeerIDRotation.recognitionTag( + recognitionKey: key, epoch: epoch, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA + ) + } + func matches(_ candidate: Data) -> Bool { + PeerIDRotation.blockMatches( + PeerIDRotation.tagBlock(tags: [candidate]), recognitionKey: key, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, + peerID: idA, at: date + ) + } + + // A peer whose clock has already ticked over still matches. + #expect(matches(tag(epoch: 101))) + // Two epochs out is outside the window and must not. + #expect(!matches(tag(epoch: 98))) + } + + @Test func randomBlockDoesNotMatch() { + let (key, _, date) = matchFixture() + #expect(!PeerIDRotation.blockMatches( + PeerIDRotation.tagBlock(tags: []), recognitionKey: key, + senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, + peerID: idA, at: date + )) + } + + // MARK: - Identity binding + + @Test func bindingMessageIsFixedWidthAndContextSeparated() { + let message = PeerIDRotation.bindingMessage( + epoch: 100, + peerID: Data(repeating: 0xAB, count: 8), + noiseStaticPublicKey: Data(repeating: 0xCD, count: 32) + ) + let context = Data("bitchat-peerid-binding-v1".utf8) + #expect(message.count == context.count + 4 + 8 + 32) + #expect(message.starts(with: context)) + // Must not collide with the production-dead announce-signature helpers, + // which use "bitchat-announce-v1". + #expect(!message.starts(with: Data("bitchat-announce-v1".utf8))) + } + + @Test func bindingMessagePadsShortInputsRatherThanShifting() { + // Fixed-width fields mean a short ID cannot shift the key into the ID's + // position and produce a message that verifies for the wrong pairing. + let short = PeerIDRotation.bindingMessage( + epoch: 1, + peerID: Data([0x01]), + noiseStaticPublicKey: Data([0x02]) + ) + let padded = PeerIDRotation.bindingMessage( + epoch: 1, + peerID: Data([0x01]) + Data(repeating: 0, count: 7), + noiseStaticPublicKey: Data([0x02]) + Data(repeating: 0, count: 31) + ) + #expect(short == padded) + } + + @Test func bindingMessageChangesWithEveryField() { + let base = PeerIDRotation.bindingMessage( + epoch: 1, + peerID: Data(repeating: 0x01, count: 8), + noiseStaticPublicKey: Data(repeating: 0x02, count: 32) + ) + #expect(base != PeerIDRotation.bindingMessage( + epoch: 2, + peerID: Data(repeating: 0x01, count: 8), + noiseStaticPublicKey: Data(repeating: 0x02, count: 32) + )) + #expect(base != PeerIDRotation.bindingMessage( + epoch: 1, + peerID: Data(repeating: 0x03, count: 8), + noiseStaticPublicKey: Data(repeating: 0x02, count: 32) + )) + #expect(base != PeerIDRotation.bindingMessage( + epoch: 1, + peerID: Data(repeating: 0x01, count: 8), + noiseStaticPublicKey: Data(repeating: 0x04, count: 32) + )) + } + + @Test func bindingMessageVerifiesUnderTheIdentityKey() throws { + let signing = Curve25519.Signing.PrivateKey() + let message = PeerIDRotation.bindingMessage( + epoch: 100, + peerID: Data(repeating: 0xAB, count: 8), + noiseStaticPublicKey: Data(repeating: 0xCD, count: 32) + ) + let signature = try signing.signature(for: message) + #expect(signing.publicKey.isValidSignature(signature, for: message)) + + // A different epoch must not verify: replaying a binding into a later + // epoch is exactly what this prevents. + let other = PeerIDRotation.bindingMessage( + epoch: 101, + peerID: Data(repeating: 0xAB, count: 8), + noiseStaticPublicKey: Data(repeating: 0xCD, count: 32) + ) + #expect(!signing.publicKey.isValidSignature(signature, for: other)) + } +} From 8f9489790dc91284dc0066c2a27c91f17c3a5e6d Mon Sep 17 00:00:00 2001 From: Felix-Ayush <67006255+Ayush7614@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:33:48 +0530 Subject: [PATCH 29/35] Add macOS camera QR scanning for peer verification (#1477) * Add macOS camera QR scanning for peer verification. Mac verification previously only supported paste/validate. Reuse the same AVCapture metadata pipeline as iOS, keep paste as a fallback, and extend the scanner smoke test to macOS. * Grant sandboxed macOS camera access for QR scanning. NSCameraUsageDescription alone is not enough under App Sandbox. Add com.apple.security.device.camera so the new macOS AVCapture QR path can open the camera after user permission. * Check camera auth before capture input for QR scanning. Consult AVCaptureDevice.authorizationStatus before creating AVCaptureDeviceInput so smoke tests and cold launches do not trigger TCC prompts, gate the smoke test on prior authorization, and show a one-line hint when the camera is unavailable. --- bitchat/Localizable.xcstrings | 186 +++++++++++++++ bitchat/Views/VerificationViews.swift | 329 +++++++++++++++++--------- bitchat/bitchat-macOS.entitlements | 2 + bitchatTests/ViewSmokeTests.swift | 21 +- 4 files changed, 425 insertions(+), 113 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f34ed351..8abd2d9a 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -74196,6 +74196,192 @@ } } }, + "verification.scan.camera_unavailable" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الكاميرا غير متاحة — الصق رمز QR أدناه." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera nicht verfügbar — QR unten einfügen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cámara no disponible — pega un QR abajo." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caméra indisponible — collez un QR ci-dessous." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המצלמה אינה זמינה — הדבק QR למטה." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कैमरा उपलब्ध नहीं — नीचे QR पेस्ट करें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera tidak tersedia — tempel QR di bawah." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fotocamera non disponibile — incolla un QR sotto." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カメラを利用できません — 下にQRを貼り付けてください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카메라를 사용할 수 없습니다 — 아래에 QR을 붙여넣으세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera niet beschikbaar — plak hieronder een QR." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aparat niedostępny — wklej QR poniżej." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câmara indisponível — cole um QR abaixo." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câmera indisponível — cole um QR abaixo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Камера недоступна — вставьте QR ниже." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera otillgänglig — klistra in en QR nedan." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera kullanılamıyor — aşağıya bir QR yapıştırın." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Камера недоступна — вставте QR нижче." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera unavailable — paste a QR below." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không dùng được camera — dán mã QR bên dưới." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法使用相机 — 请在下方粘贴二维码。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法使用相機 — 請在下方貼上 QR。" + } + } + } + }, + "verification.scan.paste_prompt" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 94aaf211..a3f9d82b 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -1,6 +1,7 @@ import SwiftUI import CoreImage import CoreImage.CIFilterBuiltins +import AVFoundation #if os(iOS) import UIKit #else @@ -109,19 +110,27 @@ struct ImageWrapper: View { } } -/// Placeholder scanner UI; real camera scanning will be added later. +/// Peer verification QR scanner. Uses the camera on iOS and macOS; macOS also +/// keeps a paste/validate fallback for machines without a usable camera. struct QRScanView: View { @EnvironmentObject private var verificationModel: VerificationModel @ThemedPalette private var palette var isActive: Bool = true var onSuccess: (() -> Void)? = nil // Called when verification succeeds @State private var input = "" - @State private var result: String = "" // not shown for iOS scanner + @State private var result: String = "" @State private var lastValid: String = "" + @State private var cameraUnavailable = false + private enum Strings { static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt" static let validate: LocalizedStringKey = "verification.scan.validate" + static let cameraUnavailable = String( + localized: "verification.scan.camera_unavailable", + defaultValue: "Camera unavailable — paste a QR below.", + comment: "Shown over the scanner preview when no camera is available or permission was denied" + ) static func requested(_ nickname: String) -> String { String( format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"), @@ -135,69 +144,83 @@ struct QRScanView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - #if os(iOS) - CameraScannerView(isActive: isActive) { code in - // Deduplicate: ignore if we just processed this exact QR code - guard code != lastValid else { return } - - switch verificationModel.verifyScannedPayload(code) { - case .requested: - // Successfully initiated verification; remember this QR to prevent re-scanning - lastValid = code - // Close scanner and return to "My QR" view - onSuccess?() - case .notFound, .invalid: - // Ignore invalid/no-match reads and keep scanning - break + ZStack { + CameraScannerView(isActive: isActive, onUnavailable: { cameraUnavailable = true }) { code in + handleScannedCode(code, announceResult: false) + } + if cameraUnavailable { + Text(Strings.cameraUnavailable) + .bitchatFont(size: 13, weight: .medium) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + .padding(16) } } .frame(height: 260) .clipShape(RoundedRectangle(cornerRadius: 8)) - #else + + #if os(macOS) Text(Strings.pastePrompt) .bitchatFont(size: 14, weight: .medium) TextEditor(text: $input) .frame(height: 100) .border(palette.secondary.opacity(0.4)) Button(Strings.validate) { - // Deduplicate: ignore if we just processed this exact QR - guard input != lastValid else { - result = Strings.requested("") // Already processed - return - } - - switch verificationModel.verifyScannedPayload(input) { - case .requested(let nickname): - result = Strings.requested(nickname) - lastValid = input - // Close scanner and return to "My QR" view - onSuccess?() - case .notFound: - result = Strings.notFound - case .invalid: - result = Strings.invalid - } + handleScannedCode(input, announceResult: true) } .buttonStyle(.bordered) + if !result.isEmpty { + Text(result) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + } #endif - // No status text under camera per design Spacer() } .padding() } + + private func handleScannedCode(_ code: String, announceResult: Bool) { + guard code != lastValid else { + if announceResult { + result = Strings.requested("") + } + return + } + + switch verificationModel.verifyScannedPayload(code) { + case .requested(let nickname): + lastValid = code + if announceResult { + result = Strings.requested(nickname) + } + onSuccess?() + case .notFound: + if announceResult { + result = Strings.notFound + } + case .invalid: + if announceResult { + result = Strings.invalid + } + } + } } #if os(iOS) -import AVFoundation - struct CameraScannerView: UIViewRepresentable { typealias UIViewType = PreviewView var isActive: Bool + var onUnavailable: (() -> Void)? = nil var onCode: (String) -> Void func makeUIView(context: Context) -> PreviewView { let view = PreviewView() - context.coordinator.setup(sessionOwner: view, onCode: onCode) + context.coordinator.setup( + previewLayer: view.videoPreviewLayer, + onCode: onCode, + onUnavailable: onUnavailable + ) context.coordinator.setActive(isActive) return view } @@ -206,68 +229,7 @@ struct CameraScannerView: UIViewRepresentable { context.coordinator.setActive(isActive) } - func makeCoordinator() -> Coordinator { Coordinator() } - - final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { - private var onCode: ((String) -> Void)? - private weak var owner: PreviewView? - private let session = AVCaptureSession() - private var isRunning = false - private var permissionGranted = false - private var desiredActive = false - - func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) { - self.owner = sessionOwner - self.onCode = onCode - session.beginConfiguration() - session.sessionPreset = .high - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } - session.addInput(input) - let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { return } - session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) - if output.availableMetadataObjectTypes.contains(.qr) { - output.metadataObjectTypes = [.qr] - } - session.commitConfiguration() - sessionOwner.videoPreviewLayer.session = session - // Request permission and start - AVCaptureDevice.requestAccess(for: .video) { granted in - self.permissionGranted = granted - if granted && self.desiredActive && !self.isRunning { - self.setActive(true) - } - } - } - - func setActive(_ active: Bool) { - desiredActive = active - guard permissionGranted else { return } - if active && !isRunning { - isRunning = true - DispatchQueue.global(qos: .userInitiated).async { - if !self.session.isRunning { self.session.startRunning() } - } - } else if !active && isRunning { - isRunning = false - DispatchQueue.global(qos: .userInitiated).async { - if self.session.isRunning { self.session.stopRunning() } - } - } - } - - func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { - for obj in metadataObjects { - guard let m = obj as? AVMetadataMachineReadableCodeObject, - m.type == .qr, - let str = m.stringValue else { continue } - onCode?(str) - } - } - } + func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() } final class PreviewView: UIView { override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } @@ -279,8 +241,166 @@ struct CameraScannerView: UIViewRepresentable { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } } +#elseif os(macOS) +struct CameraScannerView: NSViewRepresentable { + typealias NSViewType = PreviewView + var isActive: Bool + var onUnavailable: (() -> Void)? = nil + var onCode: (String) -> Void + + func makeNSView(context: Context) -> PreviewView { + let view = PreviewView() + context.coordinator.setup( + previewLayer: view.videoPreviewLayer, + onCode: onCode, + onUnavailable: onUnavailable + ) + context.coordinator.setActive(isActive) + return view + } + + func updateNSView(_ nsView: PreviewView, context: Context) { + context.coordinator.setActive(isActive) + } + + func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() } + + final class PreviewView: NSView { + let videoPreviewLayer = AVCaptureVideoPreviewLayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + videoPreviewLayer.videoGravity = .resizeAspectFill + layer = CALayer() + layer?.addSublayer(videoPreviewLayer) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layout() { + super.layout() + videoPreviewLayer.frame = bounds + } + } +} #endif +final class CameraScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + private var onCode: ((String) -> Void)? + private var onUnavailable: (() -> Void)? + private let session = AVCaptureSession() + private var isRunning = false + private var permissionGranted = false + private var desiredActive = false + private var didConfigureSession = false + private weak var previewLayer: AVCaptureVideoPreviewLayer? + + func setup( + previewLayer: AVCaptureVideoPreviewLayer, + onCode: @escaping (String) -> Void, + onUnavailable: (() -> Void)? = nil + ) { + self.onCode = onCode + self.onUnavailable = onUnavailable + self.previewLayer = previewLayer + previewLayer.session = session + + // Check authorization before creating AVCaptureDeviceInput so tests and + // cold launches do not trigger a TCC prompt just by constructing input. + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + permissionGranted = true + if !configureSessionIfNeeded() { + reportUnavailable() + } + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + self.permissionGranted = granted + if granted { + if !self.configureSessionIfNeeded() { + self.reportUnavailable() + return + } + if self.desiredActive && !self.isRunning { + self.setActive(true) + } + } else { + self.reportUnavailable() + } + } + } + default: + permissionGranted = false + reportUnavailable() + } + } + + @discardableResult + private func configureSessionIfNeeded() -> Bool { + guard !didConfigureSession else { return true } + session.beginConfiguration() + session.sessionPreset = .high + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { + session.commitConfiguration() + return false + } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { + session.commitConfiguration() + return false + } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + if output.availableMetadataObjectTypes.contains(.qr) { + output.metadataObjectTypes = [.qr] + } + session.commitConfiguration() + previewLayer?.session = session + didConfigureSession = true + return true + } + + private func reportUnavailable() { + DispatchQueue.main.async { + self.onUnavailable?() + } + } + + func setActive(_ active: Bool) { + desiredActive = active + guard permissionGranted, didConfigureSession else { return } + if active && !isRunning { + isRunning = true + DispatchQueue.global(qos: .userInitiated).async { + if !self.session.isRunning { self.session.startRunning() } + } + } else if !active && isRunning { + isRunning = false + DispatchQueue.global(qos: .userInitiated).async { + if self.session.isRunning { self.session.stopRunning() } + } + } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + for obj in metadataObjects { + guard let m = obj as? AVMetadataMachineReadableCodeObject, + m.type == .qr, + let str = m.stringValue else { continue } + onCode?(str) + } + } +} + // Combined sheet: shows my QR by default with a button to scan instead struct VerificationSheetView: View { @EnvironmentObject private var verificationModel: VerificationModel @@ -320,19 +440,12 @@ struct VerificationSheetView: View { .frame(maxWidth: .infinity) .multilineTextAlignment(.center) .foregroundColor(accentColor) - #if os(iOS) QRScanView(isActive: showingScanner, onSuccess: { showingScanner = false }) .environmentObject(verificationModel) - .frame(height: 280) + .frame(minHeight: 280) .clipShape(RoundedRectangle(cornerRadius: 10)) - #else - QRScanView(onSuccess: { - showingScanner = false - }) - .environmentObject(verificationModel) - #endif } .padding() .frame(maxWidth: .infinity) diff --git a/bitchat/bitchat-macOS.entitlements b/bitchat/bitchat-macOS.entitlements index 2369c18b..f3310f75 100644 --- a/bitchat/bitchat-macOS.entitlements +++ b/bitchat/bitchat-macOS.entitlements @@ -10,6 +10,8 @@ com.apple.security.device.bluetooth + com.apple.security.device.camera + com.apple.security.device.microphone com.apple.security.personal-information.location diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index eab6d989..6ab5290e 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -803,18 +803,29 @@ struct ViewSmokeTests { #expect(deliveryStatusSnapshot(of: mediaRow) == read) } - #if os(iOS) @Test func cameraScannerView_previewAndCoordinatorSmoke() { + #if os(iOS) || os(macOS) + // Avoid constructing AVCaptureDeviceInput (and the TCC prompt it can + // trigger) unless the host process already has camera authorization — + // same class of isolation as keeping tests off the login keychain. + let status = AVCaptureDevice.authorizationStatus(for: .video) let preview = CameraScannerView.PreviewView(frame: .zero) - let coordinator = CameraScannerView.Coordinator() + let coordinator = CameraScannerCoordinator() + #if os(iOS) _ = CameraScannerView.PreviewView.layerClass + #elseif os(macOS) + preview.layout() + #endif _ = preview.videoPreviewLayer - coordinator.setup(sessionOwner: preview) { _ in } - coordinator.setActive(false) + + if status == .authorized { + coordinator.setup(previewLayer: preview.videoPreviewLayer) { _ in } + coordinator.setActive(false) + } #expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill) + #endif } - #endif } From 2c9a4e07c6c014797cd8191d3c0b832eb5ab6358 Mon Sep 17 00:00:00 2001 From: Felix-Ayush <67006255+Ayush7614@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:33:52 +0530 Subject: [PATCH 30/35] Localize hardcoded macOS image picker copy. (#1478) MacImagePickerView used English string literals, so it skipped the string catalog and localization coverage. Move the user-facing strings into Localizable.xcstrings for all supported locales. Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> --- bitchat/Localizable.xcstrings | 744 ++++++++++++++++++ .../Image Viewers/MacImagePickerView.swift | 15 +- 2 files changed, 755 insertions(+), 4 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 8abd2d9a..ff2548c2 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -56015,6 +56015,750 @@ } } }, + "mac.image_picker.cancel" : { + "comment" : "Cancel button for the macOS image picker sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إلغاء" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বাতিল" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "لغو" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ביטול" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रद्द करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annulla" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "취소" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रद्द गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuleren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anuluj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ரத்துசெய்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยกเลิก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İptal" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скасувати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "منسوخ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + } + } + }, + "mac.image_picker.panel_message" : { + "comment" : "Message shown in the macOS NSOpenPanel when picking an image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اختر صورة لإرسالها" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পাঠানোর জন্য একটি ছবি বেছে নিন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein Bild zum Senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose an image to send" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige una imagen para enviar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصویری برای ارسال انتخاب کنید" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumili ng larawang ipapadala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez une image à envoyer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בחר תמונה לשליחה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "भेजने के लिए एक छवि चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih gambar untuk dikirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un'immagine da inviare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "送信する画像を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보낼 이미지 선택" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih imej untuk dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पठाउनका लागि एउटा तस्बिर छान्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies een afbeelding om te versturen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz obraz do wysłania" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher uma imagem para enviar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha uma imagem para enviar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите изображение для отправки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj en bild att skicka" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அனுப்ப ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือกภาพที่จะส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Göndermek için bir görsel seçin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виберіть зображення для надсилання" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بھیجنے کے لیے ایک تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một hình ảnh để gửi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择要发送的图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇要傳送的圖片" + } + } + } + }, + "mac.image_picker.select" : { + "comment" : "Button that opens the macOS open-panel to pick an image", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تحديد صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ছবি নির্বাচন করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccionar imagen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "انتخاب تصویر" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Piliin ang Larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionner une image" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בחר תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "छवि चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih Gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleziona immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지 선택" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih Imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तस्बिर चयन गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afbeelding selecteren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecionar imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecionar imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படத்தைத் தேர்ந்தெடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือกภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Görsel Seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вибрати зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇圖片" + } + } + } + }, + "mac.image_picker.title" : { + "comment" : "Title shown in the macOS image picker sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اختر صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "একটি ছবি বেছে নিন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose an image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige una imagen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "یک تصویر انتخاب کنید" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumili ng larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une image" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בחר תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एक छवि चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un'immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지 선택" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एउटा तस्बिर छान्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies een afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher uma imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha uma imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj en bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือกภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir görsel seçin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виберіть зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایک تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇圖片" + } + } + } + }, "media.accessibility.cancel_send" : { "comment" : "Accessibility label for the cancel button on an in-flight media send", "extractionState" : "manual", diff --git a/bitchat/Views/Image Viewers/MacImagePickerView.swift b/bitchat/Views/Image Viewers/MacImagePickerView.swift index 805d7495..1b88c7f2 100644 --- a/bitchat/Views/Image Viewers/MacImagePickerView.swift +++ b/bitchat/Views/Image Viewers/MacImagePickerView.swift @@ -14,18 +14,25 @@ struct MacImagePickerView: View { let completion: (URL?) -> Void @Environment(\.dismiss) private var dismiss + private enum Strings { + static let title: LocalizedStringKey = "mac.image_picker.title" + static let select = String(localized: "mac.image_picker.select", comment: "Button that opens the macOS open-panel to pick an image") + static let panelMessage = String(localized: "mac.image_picker.panel_message", comment: "Message shown in the macOS NSOpenPanel when picking an image") + static let cancel = String(localized: "mac.image_picker.cancel", comment: "Cancel button for the macOS image picker sheet") + } + var body: some View { VStack(spacing: 16) { - Text("Choose an image") + Text(Strings.title) .font(.headline) - Button("Select Image") { + Button(Strings.select) { let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowedContentTypes = [.image, .png, .jpeg, .heic] - panel.message = "Choose an image to send" + panel.message = Strings.panelMessage if panel.runModal() == .OK { completion(panel.url) @@ -35,7 +42,7 @@ struct MacImagePickerView: View { } .buttonStyle(.borderedProminent) - Button("Cancel") { + Button(Strings.cancel) { completion(nil) } .buttonStyle(.bordered) From 68edb34469d0c604b934cf56b4546a286391761a Mon Sep 17 00:00:00 2001 From: Rod Bahmanyari Date: Sat, 1 Aug 2026 02:22:26 -0700 Subject: [PATCH 31/35] Location channels: locale-derived one-tap quick join (#1444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channels sheet grows a "quick join" row: one tap into the region-level geohash channel around the device region's main population center — the same path as typing the geohash and teleporting, no location access used. Review of the first cut (a curated "heavily censored countries" roster) called out that a hand-picked list invites inclusion disputes, goes stale with politics, and is App Store / geopolitical exposure. The suggestion now derives from the device locale under one neutral rule for every country: ISO region → the 2-char geohash cell of the main population center (the largest metro, not always the capital: US → New York, TR → Istanbul). 219 regions covered; the twelve cells the earlier roster shipped were independently verified in review and reproduce unchanged. The caption is deliberately blunt, per the same review: the cell belongs to the main population center — it is not "your country's channel" and not your location; the channel is public and well-known, so assume it is watched; quick join is discovery — it hides nothing and bypasses nothing. Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 558 ++++++++++++++++++++++ bitchat/Utils/QuickJoinRegions.swift | 113 +++++ bitchat/Views/LocationChannelsSheet.swift | 62 +++ 3 files changed, 733 insertions(+) create mode 100644 bitchat/Utils/QuickJoinRegions.swift diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index ff2548c2..efda8dc1 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -77457,6 +77457,564 @@ "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "用于草稿" } }, "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "用於草稿" } } } + }, + "location_channels.quick_join.title" : { + "comment" : "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0627\u0646\u0636\u0645\u0627\u0645 \u0633\u0631\u064a\u0639" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u09a6\u09cd\u09b0\u09c1\u09a4 \u09af\u09cb\u0997" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "schnellbeitritt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "quick join" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "acceso r\u00e1pido" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u067e\u06cc\u0648\u0633\u062a\u0646 \u0633\u0631\u06cc\u0639" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mabilis na pagsali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "acc\u00e8s rapide" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u05d4\u05e6\u05d8\u05e8\u05e4\u05d5\u05ea \u05de\u05d4\u05d9\u05e8\u05d4" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u091d\u091f\u092a\u091f \u091c\u0941\u0921\u093c\u0947\u0902" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "gabung cepat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "accesso rapido" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u30af\u30a4\u30c3\u30af\u53c2\u52a0" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "\ube60\ub978 \ucc38\uc5ec" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sertai pantas" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u091b\u093f\u091f\u094b \u0938\u093e\u092e\u0947\u0932" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "snel deelnemen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "szybkie do\u0142\u0105czenie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "entrada r\u00e1pida" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "entrada r\u00e1pida" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0431\u044b\u0441\u0442\u0440\u044b\u0439 \u0432\u0445\u043e\u0434" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "snabbanslutning" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0bb5\u0bbf\u0bb0\u0bc8\u0bb5\u0bc1 \u0b9a\u0bc7\u0bb0\u0bcd\u0b95\u0bcd\u0b95\u0bc8" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0e40\u0e02\u0e49\u0e32\u0e23\u0e48\u0e27\u0e21\u0e14\u0e48\u0e27\u0e19" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "h\u0131zl\u0131 kat\u0131l\u0131m" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0448\u0432\u0438\u0434\u043a\u0438\u0439 \u0432\u0445\u0456\u0434" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0641\u0648\u0631\u06cc \u0634\u0645\u0648\u0644\u06cc\u062a" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tham gia nhanh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u5feb\u901f\u52a0\u5165" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u5feb\u901f\u52a0\u5165" + } + } + } + }, + "location_channels.quick_join.description" : { + "comment" : "Caption under the quick join row; %@ is the localized country/region name. States plainly that the cell is the main population center's (not the person's location), that the channel must be assumed watched, and that quick join is discovery, not circumvention", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0642\u0646\u0627\u0629 \u0627\u0644\u0645\u0646\u0637\u0642\u0629 \u0627\u0644\u062a\u064a \u064a\u062a\u062c\u0645\u0639 \u0641\u064a\u0647\u0627 \u0639\u0627\u062f\u0629\u064b \u0627\u0644\u0646\u0627\u0633 \u0645\u0646 %@ \u2014 \u0627\u0644\u062e\u0644\u064a\u0629 \u0627\u0644\u0648\u0627\u0633\u0639\u0629 \u062d\u0648\u0644 \u0623\u0643\u0628\u0631 \u0645\u0631\u0643\u0632 \u0633\u0643\u0627\u0646\u064a\u060c \u0648\u0644\u064a\u0633\u062a \u0645\u0648\u0642\u0639\u0643. \u0625\u0646\u0647\u0627 \u0639\u0627\u0645\u0629 \u0648\u0645\u0639\u0631\u0648\u0641\u0629 \u0644\u0644\u062c\u0645\u064a\u0639\u060c \u0641\u0627\u0641\u062a\u0631\u0636 \u0623\u0646\u0647\u0627 \u0645\u0631\u0627\u0642\u0628\u0629: \u0627\u0644\u0627\u0646\u0636\u0645\u0627\u0645 \u0627\u0644\u0633\u0631\u064a\u0639 \u064a\u0648\u0641\u0651\u0631 \u0639\u0644\u064a\u0643 \u0643\u062a\u0627\u0628\u0629 \u0627\u0644\u062c\u064a\u0648\u0647\u0627\u0634 \u0641\u0642\u0637\u061b \u0644\u0627 \u064a\u062e\u0641\u064a\u0643 \u0648\u0644\u0627 \u064a\u062a\u062c\u0627\u0648\u0632 \u0627\u0644\u062d\u062c\u0628." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u09af\u09c7 \u0985\u099e\u09cd\u099a\u09b2-\u099a\u09cd\u09af\u09be\u09a8\u09c7\u09b2\u09c7 %@-\u098f\u09b0 \u09ae\u09be\u09a8\u09c1\u09b7 \u09b8\u09be\u09a7\u09be\u09b0\u09a3\u09a4 \u099c\u09a1\u09bc\u09cb \u09b9\u09af\u09bc \u2014 \u09b8\u09ac\u099a\u09c7\u09af\u09bc\u09c7 \u09ac\u09a1\u09bc \u099c\u09a8\u0995\u09c7\u09a8\u09cd\u09a6\u09cd\u09b0\u09c7\u09b0 \u099a\u09be\u09b0\u09aa\u09be\u09b6\u09c7\u09b0 \u09ac\u09bf\u09b8\u09cd\u09a4\u09c3\u09a4 \u09b8\u09c7\u09b2, \u0986\u09aa\u09a8\u09be\u09b0 \u0985\u09ac\u09b8\u09cd\u09a5\u09be\u09a8 \u09a8\u09af\u09bc\u0964 \u098f\u099f\u09bf \u09aa\u09cd\u09b0\u0995\u09be\u09b6\u09cd\u09af \u0993 \u09b8\u09c1\u09aa\u09b0\u09bf\u099a\u09bf\u09a4, \u09a4\u09be\u0987 \u09a7\u09b0\u09c7 \u09a8\u09bf\u09a8 \u098f\u099f\u09bf \u09a8\u099c\u09b0\u09a6\u09be\u09b0\u09bf\u09a4\u09c7 \u0986\u099b\u09c7: \u09a6\u09cd\u09b0\u09c1\u09a4 \u09af\u09cb\u0997 \u09b6\u09c1\u09a7\u09c1 \u099c\u09bf\u0993\u09b9\u09cd\u09af\u09be\u09b6 \u099f\u09be\u0987\u09aa \u0995\u09b0\u09be \u09ac\u09be\u0981\u099a\u09be\u09af\u09bc; \u098f\u099f\u09bf \u0986\u09aa\u09a8\u09be\u0995\u09c7 \u09b2\u09c1\u0995\u09be\u09af\u09bc \u09a8\u09be, \u09ac\u09cd\u09b2\u0995\u0993 \u098f\u09a1\u09bc\u09be\u09af\u09bc \u09a8\u09be\u0964" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "der regionskanal, in dem sich leute aus %@ meist sammeln \u2014 die weite zelle um das gr\u00f6\u00dfte bev\u00f6lkerungszentrum, nicht dein standort. er ist \u00f6ffentlich und allgemein bekannt, geh also davon aus, dass er beobachtet wird: schnellbeitritt spart nur das eintippen des geohash; er versteckt dich nicht und umgeht keine sperren." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "the region channel where people from %@ tend to gather \u2014 the wide cell around the main population center, not your location. it's public and well-known, so assume it's watched: quick join saves typing a geohash; it doesn't hide you or bypass blocks." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "el canal de regi\u00f3n donde suele reunirse la gente de %@ \u2014 la celda amplia alrededor del mayor centro de poblaci\u00f3n, no tu ubicaci\u00f3n. es p\u00fablico y conocido, as\u00ed que asume que est\u00e1 vigilado: el acceso r\u00e1pido solo te ahorra escribir el geohash; no te oculta ni evita bloqueos." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u06a9\u0627\u0646\u0627\u0644 \u0645\u0646\u0637\u0642\u0647\u200c\u0627\u06cc \u06a9\u0647 \u0645\u0631\u062f\u0645 %@ \u0645\u0639\u0645\u0648\u0644\u0627\u064b \u062f\u0631 \u0622\u0646 \u062c\u0645\u0639 \u0645\u06cc\u200c\u0634\u0648\u0646\u062f \u2014 \u0633\u0644\u0648\u0644 \u067e\u0647\u0646\u0627\u0648\u0631 \u062f\u0648\u0631 \u0628\u0632\u0631\u06af\u200c\u062a\u0631\u06cc\u0646 \u0645\u0631\u06a9\u0632 \u062c\u0645\u0639\u06cc\u062a\u060c \u0646\u0647 \u0645\u0648\u0642\u0639\u06cc\u062a \u0634\u0645\u0627. \u0639\u0645\u0648\u0645\u06cc \u0648 \u0634\u0646\u0627\u062e\u062a\u0647\u200c\u0634\u062f\u0647 \u0627\u0633\u062a\u060c \u067e\u0633 \u0641\u0631\u0636 \u06a9\u0646\u06cc\u062f \u0632\u06cc\u0631 \u0646\u0638\u0631 \u0627\u0633\u062a: \u067e\u06cc\u0648\u0633\u062a\u0646 \u0633\u0631\u06cc\u0639 \u0641\u0642\u0637 \u062a\u0627\u06cc\u067e \u0698\u0626\u0648\u0647\u0634 \u0631\u0627 \u06a9\u0645 \u0645\u06cc\u200c\u06a9\u0646\u062f\u061b \u0634\u0645\u0627 \u0631\u0627 \u067e\u0646\u0647\u0627\u0646 \u0646\u0645\u06cc\u200c\u06a9\u0646\u062f \u0648 \u0641\u06cc\u0644\u062a\u0631\u0647\u0627 \u0631\u0627 \u062f\u0648\u0631 \u0646\u0645\u06cc\u200c\u0632\u0646\u062f." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "ang region channel kung saan karaniwang nagtitipon ang mga tao mula sa %@ \u2014 ang malawak na cell sa paligid ng pinakamalaking sentro ng populasyon, hindi ang iyong lokasyon. pampubliko ito at kilala, kaya ipagpalagay na binabantayan: ang mabilis na pagsali ay nagtitipid lang ng pag-type ng geohash; hindi ka nito itinatago at hindi nito nilalampasan ang mga block." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "le canal de r\u00e9gion o\u00f9 les gens de %@ se retrouvent le plus souvent \u2014 la large cellule autour du principal centre de population, pas votre position. il est public et bien connu : partez du principe qu'il est surveill\u00e9. l'acc\u00e8s rapide \u00e9vite seulement de taper le geohash ; il ne vous cache pas et ne contourne aucun blocage." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u05e2\u05e8\u05d5\u05e5 \u05d4\u05d0\u05d6\u05d5\u05e8 \u05e9\u05d1\u05d5 \u05d0\u05e0\u05e9\u05d9\u05dd \u05de%@ \u05d1\u05d3\u05e8\u05da \u05db\u05dc\u05dc \u05de\u05ea\u05e7\u05d1\u05e6\u05d9\u05dd \u2014 \u05d4\u05ea\u05d0 \u05d4\u05e8\u05d7\u05d1 \u05e1\u05d1\u05d9\u05d1 \u05de\u05e8\u05db\u05d6 \u05d4\u05d0\u05d5\u05db\u05dc\u05d5\u05e1\u05d9\u05d9\u05d4 \u05d4\u05d2\u05d3\u05d5\u05dc \u05d1\u05d9\u05d5\u05ea\u05e8, \u05dc\u05d0 \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05e9\u05dc\u05da. \u05d4\u05d5\u05d0 \u05e6\u05d9\u05d1\u05d5\u05e8\u05d9 \u05d5\u05de\u05d5\u05db\u05e8, \u05d0\u05d6 \u05d9\u05e9 \u05dc\u05d4\u05e0\u05d9\u05d7 \u05e9\u05d4\u05d5\u05d0 \u05de\u05e0\u05d5\u05d8\u05e8: \u05d4\u05e6\u05d8\u05e8\u05e4\u05d5\u05ea \u05de\u05d4\u05d9\u05e8\u05d4 \u05e8\u05e7 \u05d7\u05d5\u05e1\u05db\u05ea \u05d4\u05e7\u05dc\u05d3\u05ea geohash; \u05d4\u05d9\u05d0 \u05dc\u05d0 \u05de\u05e1\u05ea\u05d9\u05e8\u05d4 \u05d0\u05d5\u05ea\u05da \u05d5\u05dc\u05d0 \u05e2\u05d5\u05e7\u05e4\u05ea \u05d7\u05e1\u05d9\u05de\u05d5\u05ea." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0935\u0939 \u0915\u094d\u0937\u0947\u0924\u094d\u0930 \u091a\u0948\u0928\u0932 \u091c\u0939\u093e\u0901 %@ \u0915\u0947 \u0932\u094b\u0917 \u0906\u092e\u0924\u094c\u0930 \u092a\u0930 \u091c\u0941\u091f\u0924\u0947 \u0939\u0948\u0902 \u2014 \u0938\u092c\u0938\u0947 \u092c\u0921\u093c\u0947 \u0906\u092c\u093e\u0926\u0940 \u0915\u0947\u0902\u0926\u094d\u0930 \u0915\u0947 \u091a\u093e\u0930\u094b\u0902 \u0913\u0930 \u0915\u0940 \u091a\u094c\u0921\u093c\u0940 \u0938\u0947\u0932, \u0906\u092a\u0915\u0940 \u0932\u094b\u0915\u0947\u0936\u0928 \u0928\u0939\u0940\u0902\u0964 \u092f\u0939 \u0938\u093e\u0930\u094d\u0935\u091c\u0928\u093f\u0915 \u0914\u0930 \u091c\u093e\u0928\u093e-\u092a\u0939\u091a\u093e\u0928\u093e \u0939\u0948, \u0907\u0938\u0932\u093f\u090f \u092e\u093e\u0928 \u0932\u0947\u0902 \u0915\u093f \u0907\u0938 \u092a\u0930 \u0928\u091c\u093c\u0930 \u0939\u0948: \u091d\u091f\u092a\u091f \u091c\u0941\u0921\u093c\u0928\u093e \u0938\u093f\u0930\u094d\u092b\u093c geohash \u091f\u093e\u0907\u092a \u0915\u0930\u0928\u0947 \u0938\u0947 \u092c\u091a\u093e\u0924\u093e \u0939\u0948; \u092f\u0939 \u0906\u092a\u0915\u094b \u091b\u093f\u092a\u093e\u0924\u093e \u0928\u0939\u0940\u0902 \u0914\u0930 \u0928 \u0939\u0940 \u092c\u094d\u0932\u0949\u0915 \u0939\u091f\u093e\u0924\u093e \u0939\u0948\u0964" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "channel wilayah tempat orang-orang dari %@ biasanya berkumpul \u2014 sel luas di sekitar pusat populasi terbesar, bukan lokasimu. channel ini publik dan dikenal luas, jadi anggap saja dipantau: gabung cepat hanya menghemat pengetikan geohash; tidak menyembunyikanmu dan tidak menembus blokir." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "il canale di regione dove di solito si ritrovano le persone di %@ \u2014 la cella ampia attorno al principale centro abitato, non la tua posizione. \u00e8 pubblico e ben noto, quindi dai per scontato che sia sorvegliato: l'accesso rapido ti evita solo di digitare il geohash; non ti nasconde e non aggira i blocchi." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@\u306e\u4eba\u3005\u304c\u96c6\u307e\u308a\u3084\u3059\u3044\u5730\u57df\u30c1\u30e3\u30f3\u30cd\u30eb \u2014 \u6700\u5927\u306e\u4eba\u53e3\u96c6\u4e2d\u5730\u3092\u56f2\u3080\u5e83\u3044\u30bb\u30eb\u3067\u3001\u3042\u306a\u305f\u306e\u4f4d\u7f6e\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002\u516c\u958b\u3055\u308c\u3066\u3044\u3066\u8ab0\u3082\u304c\u77e5\u308b\u5834\u6240\u306a\u306e\u3067\u3001\u76e3\u8996\u3055\u308c\u3066\u3044\u308b\u524d\u63d0\u3067\u3002\u30af\u30a4\u30c3\u30af\u53c2\u52a0\u306f\u30b8\u30aa\u30cf\u30c3\u30b7\u30e5\u5165\u529b\u3092\u7701\u304f\u3060\u3051\u3067\u3001\u3042\u306a\u305f\u3092\u96a0\u3057\u305f\u308a\u30d6\u30ed\u30c3\u30af\u3092\u56de\u907f\u3057\u305f\u308a\u306f\u3057\u307e\u305b\u3093\u3002" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ \uc0ac\ub78c\ub4e4\uc774 \uc8fc\ub85c \ubaa8\uc774\ub294 \uc9c0\uc5ed \ucc44\ub110 \u2014 \ucd5c\ub300 \uc778\uad6c \ubc00\uc9d1\uc9c0 \uc8fc\ubcc0\uc758 \ub113\uc740 \uc140\uc774\uba70, \ub2f9\uc2e0\uc758 \uc704\uce58\uac00 \uc544\ub2d9\ub2c8\ub2e4. \uacf5\uac1c\ub418\uc5b4 \uc788\uace0 \ub110\ub9ac \uc54c\ub824\uc838 \uc788\uc73c\ub2c8 \uac10\uc2dc\ub41c\ub2e4\uace0 \uac00\uc815\ud558\uc138\uc694: \ube60\ub978 \ucc38\uc5ec\ub294 \uc9c0\uc624\ud574\uc2dc \uc785\ub825\ub9cc \uc904\uc5ec\uc904 \ubfd0, \ub2f9\uc2e0\uc744 \uc228\uae30\uac70\ub098 \ucc28\ub2e8\uc744 \uc6b0\ud68c\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "saluran wilayah tempat orang dari %@ biasanya berkumpul \u2014 sel luas di sekitar pusat penduduk terbesar, bukan lokasi anda. ia terbuka dan dikenali ramai, jadi anggap ia dipantau: sertai pantas cuma menjimatkan menaip geohash; ia tidak menyembunyikan anda dan tidak memintas sekatan." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ \u0915\u093e \u092e\u093e\u0928\u093f\u0938\u0939\u0930\u0942 \u092a\u094d\u0930\u093e\u092f\u0903 \u092d\u0947\u0932\u093e \u0939\u0941\u0928\u0947 \u0915\u094d\u0937\u0947\u0924\u094d\u0930 \u091a\u094d\u092f\u093e\u0928\u0932 \u2014 \u0938\u092c\u0948\u092d\u0928\u094d\u0926\u093e \u0920\u0942\u0932\u094b \u091c\u0928\u0938\u0902\u0916\u094d\u092f\u093e \u0915\u0947\u0928\u094d\u0926\u094d\u0930 \u0935\u0930\u093f\u092a\u0930\u093f\u0915\u094b \u092b\u0930\u093e\u0915\u093f\u0932\u094b \u0938\u0947\u0932, \u0924\u092a\u093e\u0908\u0902\u0915\u094b \u0938\u094d\u0925\u093e\u0928 \u0939\u094b\u0907\u0928\u0964 \u092f\u094b \u0938\u093e\u0930\u094d\u0935\u091c\u0928\u093f\u0915 \u0930 \u0938\u092c\u0948\u0932\u093e\u0908 \u0925\u093e\u0939\u093e \u092d\u090f\u0915\u094b \u0939\u0941\u0928\u093e\u0932\u0947 \u0928\u093f\u0917\u0930\u093e\u0928\u0940\u092e\u093e \u091b \u092d\u0928\u0940 \u092e\u093e\u0928\u094d\u0928\u0941\u0939\u094b\u0938\u094d: \u091b\u093f\u091f\u094b \u0938\u093e\u092e\u0947\u0932\u0932\u0947 geohash \u091f\u093e\u0907\u092a \u0917\u0930\u094d\u0928\u092c\u093e\u091f \u092e\u093e\u0924\u094d\u0930 \u091c\u094b\u0917\u093e\u0909\u0901\u091b; \u092f\u0938\u0932\u0947 \u0924\u092a\u093e\u0908\u0902\u0932\u093e\u0908 \u0932\u0941\u0915\u093e\u0909\u0901\u0926\u0948\u0928 \u0930 \u092c\u094d\u0932\u0915 \u091b\u0932\u094d\u0926\u0948\u0928\u0964" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "het regiokanaal waar mensen uit %@ meestal samenkomen \u2014 de brede cel rond het grootste bevolkingscentrum, niet jouw locatie. het is openbaar en algemeen bekend, ga er dus van uit dat er wordt meegekeken: snel deelnemen bespaart alleen het intypen van de geohash; het verbergt je niet en omzeilt geen blokkades." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kana\u0142 regionu, w kt\u00f3rym zwykle zbieraj\u0105 si\u0119 ludzie z %@ \u2014 szeroka kom\u00f3rka wok\u00f3\u0142 najwi\u0119kszego skupiska ludno\u015bci, nie twoja lokalizacja. jest publiczny i powszechnie znany, wi\u0119c za\u0142\u00f3\u017c, \u017ce jest obserwowany: szybkie do\u0142\u0105czenie oszcz\u0119dza tylko wpisywania geohasha; nie ukrywa ci\u0119 i nie omija blokad." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o canal de regi\u00e3o onde as pessoas de %@ costumam reunir-se \u2014 a c\u00e9lula ampla em torno do maior centro populacional, n\u00e3o a sua localiza\u00e7\u00e3o. \u00e9 p\u00fablico e conhecido, por isso presuma que est\u00e1 vigiado: a entrada r\u00e1pida apenas evita escrever o geohash; n\u00e3o o esconde nem contorna bloqueios." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o canal de regi\u00e3o onde as pessoas de %@ costumam se reunir \u2014 a c\u00e9lula ampla em torno do maior centro populacional, n\u00e3o a sua localiza\u00e7\u00e3o. \u00e9 p\u00fablico e conhecido, ent\u00e3o presuma que est\u00e1 vigiado: a entrada r\u00e1pida s\u00f3 poupa digitar o geohash; n\u00e3o esconde voc\u00ea nem contorna bloqueios." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0440\u0435\u0433\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u043a\u0430\u043d\u0430\u043b, \u0433\u0434\u0435 \u043e\u0431\u044b\u0447\u043d\u043e \u0441\u043e\u0431\u0438\u0440\u0430\u044e\u0442\u0441\u044f \u043b\u044e\u0434\u0438 \u0438\u0437 %@ \u2014 \u0448\u0438\u0440\u043e\u043a\u0430\u044f \u044f\u0447\u0435\u0439\u043a\u0430 \u0432\u043e\u043a\u0440\u0443\u0433 \u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448\u0435\u0433\u043e \u0446\u0435\u043d\u0442\u0440\u0430 \u043d\u0430\u0441\u0435\u043b\u0435\u043d\u0438\u044f, \u0430 \u043d\u0435 \u0432\u0430\u0448\u0435 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435. \u043e\u043d \u043f\u0443\u0431\u043b\u0438\u0447\u043d\u044b\u0439 \u0438 \u0432\u0441\u0435\u043c \u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0441\u0447\u0438\u0442\u0430\u0439\u0442\u0435, \u0447\u0442\u043e \u0437\u0430 \u043d\u0438\u043c \u043d\u0430\u0431\u043b\u044e\u0434\u0430\u044e\u0442: \u0431\u044b\u0441\u0442\u0440\u044b\u0439 \u0432\u0445\u043e\u0434 \u043b\u0438\u0448\u044c \u0438\u0437\u0431\u0430\u0432\u043b\u044f\u0435\u0442 \u043e\u0442 \u0432\u0432\u043e\u0434\u0430 \u0433\u0435\u043e\u0445\u044d\u0448\u0430; \u043e\u043d \u043d\u0435 \u0441\u043a\u0440\u044b\u0432\u0430\u0435\u0442 \u0432\u0430\u0441 \u0438 \u043d\u0435 \u043e\u0431\u0445\u043e\u0434\u0438\u0442 \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "regionkanalen d\u00e4r folk fr\u00e5n %@ oftast samlas \u2014 den vida cellen kring det st\u00f6rsta befolkningscentrumet, inte din plats. den \u00e4r offentlig och v\u00e4lk\u00e4nd, s\u00e5 utg\u00e5 fr\u00e5n att den bevakas: snabbanslutning sparar bara geohash-skrivandet; den d\u00f6ljer dig inte och kringg\u00e5r inga blockeringar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ \u0bae\u0b95\u0bcd\u0b95\u0bb3\u0bcd \u0baa\u0bca\u0ba4\u0bc1\u0bb5\u0bbe\u0b95\u0b95\u0bcd \u0b95\u0bc2\u0b9f\u0bc1\u0bae\u0bcd \u0bae\u0ba3\u0bcd\u0b9f\u0bb2 \u0b9a\u0bc7\u0ba9\u0bb2\u0bcd \u2014 \u0bae\u0bbf\u0b95\u0baa\u0bcd\u0baa\u0bc6\u0bb0\u0bbf\u0baf \u0bae\u0b95\u0bcd\u0b95\u0bb3\u0bcd\u0ba4\u0bca\u0b95\u0bc8 \u0bae\u0bc8\u0baf\u0ba4\u0bcd\u0ba4\u0bc8\u0b9a\u0bcd \u0b9a\u0bc1\u0bb1\u0bcd\u0bb1\u0bbf\u0baf\u0bc1\u0bb3\u0bcd\u0bb3 \u0b85\u0b95\u0ba9\u0bcd\u0bb1 \u0b9a\u0bc6\u0bb2\u0bcd, \u0b89\u0b99\u0bcd\u0b95\u0bb3\u0bcd \u0b87\u0bb0\u0bc1\u0baa\u0bcd\u0baa\u0bbf\u0b9f\u0bae\u0bcd \u0b85\u0bb2\u0bcd\u0bb2. \u0b87\u0ba4\u0bc1 \u0baa\u0bca\u0ba4\u0bc1\u0bb5\u0bbe\u0ba9\u0ba4\u0bc1, \u0ba8\u0ba9\u0bcd\u0b95\u0bc1 \u0b85\u0bb1\u0bbf\u0baf\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1; \u0b8e\u0ba9\u0bb5\u0bc7 \u0b95\u0ba3\u0bcd\u0b95\u0bbe\u0ba3\u0bbf\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bc1\u0b95\u0bbf\u0bb1\u0ba4\u0bc1 \u0b8e\u0ba9\u0bcd\u0bb1\u0bc7 \u0b95\u0bb0\u0bc1\u0ba4\u0bc1\u0b99\u0bcd\u0b95\u0bb3\u0bcd: \u0bb5\u0bbf\u0bb0\u0bc8\u0bb5\u0bc1 \u0b9a\u0bc7\u0bb0\u0bcd\u0b95\u0bcd\u0b95\u0bc8 geohash \u0ba4\u0b9f\u0bcd\u0b9f\u0b9a\u0bcd\u0b9a\u0bc8 \u0bae\u0b9f\u0bcd\u0b9f\u0bc1\u0bae\u0bc7 \u0ba4\u0bb5\u0bbf\u0bb0\u0bcd\u0b95\u0bcd\u0b95\u0bbf\u0bb1\u0ba4\u0bc1; \u0b89\u0b99\u0bcd\u0b95\u0bb3\u0bc8 \u0bae\u0bb1\u0bc8\u0b95\u0bcd\u0b95\u0bbe\u0ba4\u0bc1, \u0ba4\u0b9f\u0bc8\u0b95\u0bb3\u0bc8\u0baf\u0bc1\u0bae\u0bcd \u0ba4\u0bbe\u0ba3\u0bcd\u0b9f\u0bbe\u0ba4\u0bc1." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0e41\u0e0a\u0e19\u0e41\u0e19\u0e25\u0e20\u0e39\u0e21\u0e34\u0e20\u0e32\u0e04\u0e17\u0e35\u0e48\u0e1c\u0e39\u0e49\u0e04\u0e19\u0e08\u0e32\u0e01 %@ \u0e21\u0e31\u0e01\u0e21\u0e32\u0e23\u0e27\u0e21\u0e01\u0e31\u0e19 \u2014 \u0e40\u0e0b\u0e25\u0e25\u0e4c\u0e01\u0e27\u0e49\u0e32\u0e07\u0e23\u0e2d\u0e1a\u0e28\u0e39\u0e19\u0e22\u0e4c\u0e01\u0e25\u0e32\u0e07\u0e1b\u0e23\u0e30\u0e0a\u0e32\u0e01\u0e23\u0e17\u0e35\u0e48\u0e43\u0e2b\u0e0d\u0e48\u0e17\u0e35\u0e48\u0e2a\u0e38\u0e14 \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e48\u0e15\u0e33\u0e41\u0e2b\u0e19\u0e48\u0e07\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13 \u0e21\u0e31\u0e19\u0e40\u0e1b\u0e34\u0e14\u0e2a\u0e32\u0e18\u0e32\u0e23\u0e13\u0e30\u0e41\u0e25\u0e30\u0e40\u0e1b\u0e47\u0e19\u0e17\u0e35\u0e48\u0e23\u0e39\u0e49\u0e08\u0e31\u0e01 \u0e43\u0e2b\u0e49\u0e16\u0e37\u0e2d\u0e27\u0e48\u0e32\u0e16\u0e39\u0e01\u0e08\u0e31\u0e1a\u0e15\u0e32\u0e21\u0e2d\u0e07: \u0e40\u0e02\u0e49\u0e32\u0e23\u0e48\u0e27\u0e21\u0e14\u0e48\u0e27\u0e19\u0e41\u0e04\u0e48\u0e0a\u0e48\u0e27\u0e22\u0e43\u0e2b\u0e49\u0e44\u0e21\u0e48\u0e15\u0e49\u0e2d\u0e07\u0e1e\u0e34\u0e21\u0e1e\u0e4c geohash \u0e44\u0e21\u0e48\u0e44\u0e14\u0e49\u0e0b\u0e48\u0e2d\u0e19\u0e15\u0e31\u0e27\u0e04\u0e38\u0e13\u0e41\u0e25\u0e30\u0e44\u0e21\u0e48\u0e44\u0e14\u0e49\u0e02\u0e49\u0e32\u0e21\u0e01\u0e32\u0e23\u0e1a\u0e25\u0e47\u0e2d\u0e01" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ insanlar\u0131n\u0131n genellikle topland\u0131\u011f\u0131 b\u00f6lge kanal\u0131 \u2014 en b\u00fcy\u00fck n\u00fcfus merkezinin \u00e7evresindeki geni\u015f h\u00fccre, sizin konumunuz de\u011fil. herkese a\u00e7\u0131k ve iyi bilinir, bu y\u00fczden izlendi\u011fini varsay\u0131n: h\u0131zl\u0131 kat\u0131l\u0131m yaln\u0131zca geohash yazmaktan kurtar\u0131r; sizi gizlemez ve engelleri a\u015fmaz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0440\u0435\u0433\u0456\u043e\u043d\u0430\u043b\u044c\u043d\u0438\u0439 \u043a\u0430\u043d\u0430\u043b, \u0434\u0435 \u0437\u0430\u0437\u0432\u0438\u0447\u0430\u0439 \u0437\u0431\u0438\u0440\u0430\u044e\u0442\u044c\u0441\u044f \u043b\u044e\u0434\u0438 \u0437 %@ \u2014 \u0448\u0438\u0440\u043e\u043a\u0430 \u043a\u043e\u043c\u0456\u0440\u043a\u0430 \u043d\u0430\u0432\u043a\u043e\u043b\u043e \u043d\u0430\u0439\u0431\u0456\u043b\u044c\u0448\u043e\u0433\u043e \u0446\u0435\u043d\u0442\u0440\u0443 \u043d\u0430\u0441\u0435\u043b\u0435\u043d\u043d\u044f, \u0430 \u043d\u0435 \u0442\u0432\u043e\u0454 \u043c\u0456\u0441\u0446\u0435\u0437\u043d\u0430\u0445\u043e\u0434\u0436\u0435\u043d\u043d\u044f. \u0432\u0456\u043d \u043f\u0443\u0431\u043b\u0456\u0447\u043d\u0438\u0439 \u0456 \u0432\u0441\u0456\u043c \u0432\u0456\u0434\u043e\u043c\u0438\u0439, \u0442\u043e\u0436 \u0432\u0432\u0430\u0436\u0430\u0439, \u0449\u043e \u0437\u0430 \u043d\u0438\u043c \u0441\u0442\u0435\u0436\u0430\u0442\u044c: \u0448\u0432\u0438\u0434\u043a\u0438\u0439 \u0432\u0445\u0456\u0434 \u043b\u0438\u0448\u0435 \u043f\u043e\u0437\u0431\u0430\u0432\u043b\u044f\u0454 \u0432\u0432\u0435\u0434\u0435\u043d\u043d\u044f \u0433\u0435\u043e\u0445\u0435\u0448\u0443; \u0432\u0456\u043d \u043d\u0435 \u0445\u043e\u0432\u0430\u0454 \u0442\u0435\u0431\u0435 \u0439 \u043d\u0435 \u043e\u0431\u0445\u043e\u0434\u0438\u0442\u044c \u0431\u043b\u043e\u043a\u0443\u0432\u0430\u043d\u043d\u044f." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0648\u06c1 \u0639\u0644\u0627\u0642\u0627\u0626\u06cc \u0686\u06cc\u0646\u0644 \u062c\u06c1\u0627\u06ba %@ \u06a9\u06d2 \u0644\u0648\u06af \u0639\u0645\u0648\u0645\u0627\u064b \u062c\u0645\u0639 \u06c1\u0648\u062a\u06d2 \u06c1\u06cc\u06ba \u2014 \u0633\u0628 \u0633\u06d2 \u0628\u0691\u06d2 \u0622\u0628\u0627\u062f\u06cc \u06a9\u06d2 \u0645\u0631\u06a9\u0632 \u06a9\u06d2 \u06af\u0631\u062f \u0648\u0633\u06cc\u0639 \u0633\u06cc\u0644\u060c \u0622\u067e \u06a9\u0627 \u0645\u0642\u0627\u0645 \u0646\u06c1\u06cc\u06ba\u06d4 \u06cc\u06c1 \u0639\u0648\u0627\u0645\u06cc \u0627\u0648\u0631 \u0645\u0639\u0631\u0648\u0641 \u06c1\u06d2\u060c \u0627\u0633 \u0644\u06cc\u06d2 \u0641\u0631\u0636 \u06a9\u0631\u06cc\u06ba \u06a9\u06c1 \u0627\u0633 \u067e\u0631 \u0646\u0638\u0631 \u06c1\u06d2: \u0641\u0648\u0631\u06cc \u0634\u0645\u0648\u0644\u06cc\u062a \u0635\u0631\u0641 geohash \u0679\u0627\u0626\u067e \u06a9\u0631\u0646\u06d2 \u0633\u06d2 \u0628\u0686\u0627\u062a\u06cc \u06c1\u06d2\u061b \u06cc\u06c1 \u0622\u067e \u06a9\u0648 \u0686\u06be\u067e\u0627\u062a\u06cc \u0646\u06c1\u06cc\u06ba \u0627\u0648\u0631 \u0646\u06c1 \u0628\u0646\u062f\u0634\u06cc\u06ba \u0639\u0628\u0648\u0631 \u06a9\u0631\u062a\u06cc \u06c1\u06d2\u06d4" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "k\u00eanh khu v\u1ef1c n\u01a1i m\u1ecdi ng\u01b0\u1eddi t\u1eeb %@ th\u01b0\u1eddng t\u1ee5 h\u1ecdp \u2014 \u00f4 r\u1ed9ng quanh trung t\u00e2m d\u00e2n c\u01b0 l\u1edbn nh\u1ea5t, kh\u00f4ng ph\u1ea3i v\u1ecb tr\u00ed c\u1ee7a b\u1ea1n. k\u00eanh n\u00e0y c\u00f4ng khai v\u00e0 ai c\u0169ng bi\u1ebft, n\u00ean h\u00e3y m\u1eb7c \u0111\u1ecbnh l\u00e0 n\u00f3 b\u1ecb theo d\u00f5i: tham gia nhanh ch\u1ec9 gi\u00fap kh\u1ecfi g\u00f5 geohash; n\u00f3 kh\u00f4ng che gi\u1ea5u b\u1ea1n v\u00e0 kh\u00f4ng v\u01b0\u1ee3t ch\u1eb7n." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@\u7684\u4eba\u4eec\u901a\u5e38\u805a\u96c6\u7684\u533a\u57df\u9891\u9053\u2014\u2014\u56f4\u7ed5\u6700\u5927\u4eba\u53e3\u4e2d\u5fc3\u7684\u5bbd\u9614\u5355\u5143\u683c\uff0c\u800c\u4e0d\u662f\u4f60\u7684\u4f4d\u7f6e\u3002\u5b83\u516c\u5f00\u4e14\u4f17\u6240\u5468\u77e5\uff0c\u8bf7\u9ed8\u8ba4\u5b83\u88ab\u76d1\u89c6\uff1a\u5feb\u901f\u52a0\u5165\u53ea\u662f\u7701\u53bb\u8f93\u5165 geohash\uff1b\u5b83\u4e0d\u4f1a\u9690\u85cf\u4f60\uff0c\u4e5f\u4e0d\u4f1a\u7ed5\u8fc7\u5c01\u9501\u3002" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@\u7684\u4eba\u5011\u901a\u5e38\u805a\u96c6\u7684\u5340\u57df\u983b\u9053\u2014\u2014\u570d\u7e5e\u6700\u5927\u4eba\u53e3\u4e2d\u5fc3\u7684\u5bec\u95ca\u55ae\u5143\u683c\uff0c\u800c\u4e0d\u662f\u4f60\u7684\u4f4d\u7f6e\u3002\u5b83\u516c\u958b\u4e14\u773e\u6240\u5468\u77e5\uff0c\u8acb\u9810\u8a2d\u5b83\u53d7\u5230\u76e3\u8996\uff1a\u5feb\u901f\u52a0\u5165\u53ea\u662f\u7701\u53bb\u8f38\u5165 geohash\uff1b\u5b83\u4e0d\u6703\u96b1\u85cf\u4f60\uff0c\u4e5f\u4e0d\u6703\u7e5e\u904e\u5c01\u9396\u3002" + } + } + } + }, + "location_channels.quick_join.join_label" : { + "comment" : "Accessibility label for the quick join row; %@ is the localized country/region name", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0627\u0644\u0627\u0646\u0636\u0645\u0627\u0645 \u0625\u0644\u0649 \u0642\u0646\u0627\u0629 \u0645\u0646\u0637\u0642\u0629 %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-\u098f\u09b0 \u0985\u099e\u09cd\u099a\u09b2-\u099a\u09cd\u09af\u09be\u09a8\u09c7\u09b2\u09c7 \u09af\u09cb\u0997 \u09a6\u09bf\u09a8" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dem regionskanal von %@ beitreten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "join the %@ region channel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "unirse al canal de regi\u00f3n de %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u067e\u06cc\u0648\u0633\u062a\u0646 \u0628\u0647 \u06a9\u0627\u0646\u0627\u0644 \u0645\u0646\u0637\u0642\u0647\u200c\u0627\u06cc %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "sumali sa region channel ng %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "rejoindre le canal de r\u00e9gion de %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u05d4\u05e6\u05d8\u05e8\u05e4\u05d5\u05ea \u05dc\u05e2\u05e8\u05d5\u05e5 \u05d4\u05d0\u05d6\u05d5\u05e8 \u05e9\u05dc %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ \u0915\u0947 \u0915\u094d\u0937\u0947\u0924\u094d\u0930 \u091a\u0948\u0928\u0932 \u0938\u0947 \u091c\u0941\u0921\u093c\u0947\u0902" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "gabung ke channel wilayah %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "entra nel canale di regione di %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@\u306e\u5730\u57df\u30c1\u30e3\u30f3\u30cd\u30eb\u306b\u53c2\u52a0" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ \uc9c0\uc5ed \ucc44\ub110 \ucc38\uc5ec" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sertai saluran wilayah %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ \u0915\u094b \u0915\u094d\u0937\u0947\u0924\u094d\u0930 \u091a\u094d\u092f\u093e\u0928\u0932\u092e\u093e \u0938\u093e\u092e\u0947\u0932 \u0939\u0941\u0928\u0941\u0939\u094b\u0938\u094d" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "deelnemen aan het regiokanaal van %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "do\u0142\u0105cz do kana\u0142u regionu %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "entrar no canal de regi\u00e3o de %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "entrar no canal de regi\u00e3o de %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0432\u043e\u0439\u0442\u0438 \u0432 \u0440\u0435\u0433\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u043a\u0430\u043d\u0430\u043b %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "g\u00e5 med i regionkanalen f\u00f6r %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ \u0bae\u0ba3\u0bcd\u0b9f\u0bb2 \u0b9a\u0bc7\u0ba9\u0bb2\u0bbf\u0bb2\u0bcd \u0b9a\u0bc7\u0bb0\u0bcd" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0e40\u0e02\u0e49\u0e32\u0e23\u0e48\u0e27\u0e21\u0e41\u0e0a\u0e19\u0e41\u0e19\u0e25\u0e20\u0e39\u0e21\u0e34\u0e20\u0e32\u0e04\u0e02\u0e2d\u0e07 %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ b\u00f6lge kanal\u0131na kat\u0131l" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u0443\u0432\u0456\u0439\u0442\u0438 \u0434\u043e \u0440\u0435\u0433\u0456\u043e\u043d\u0430\u043b\u044c\u043d\u043e\u0433\u043e \u043a\u0430\u043d\u0430\u043b\u0443 %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ \u06a9\u06d2 \u0639\u0644\u0627\u0642\u0627\u0626\u06cc \u0686\u06cc\u0646\u0644 \u0645\u06cc\u06ba \u0634\u0627\u0645\u0644 \u06c1\u0648\u06ba" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tham gia k\u00eanh khu v\u1ef1c c\u1ee7a %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u52a0\u5165%@\u7684\u533a\u57df\u9891\u9053" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "\u52a0\u5165%@\u7684\u5340\u57df\u983b\u9053" + } + } + } } }, "version" : "1.1" diff --git a/bitchat/Utils/QuickJoinRegions.swift b/bitchat/Utils/QuickJoinRegions.swift new file mode 100644 index 00000000..31a6fae6 --- /dev/null +++ b/bitchat/Utils/QuickJoinRegions.swift @@ -0,0 +1,113 @@ +import Foundation + +/// The one-tap "quick join" suggestion in the channels sheet: the +/// region-level geohash channel around the device region's main population +/// center. Derived from the device locale — no location access, no GPS; the +/// tap reuses the same path as typing the geohash and teleporting. +/// +/// This replaced an earlier curated list of heavily censored countries. A +/// hand-picked roster invites disputes over who is on it and goes stale +/// with every political shift; deriving the suggestion from the locale +/// gives every country the same treatment under one rule. +/// +/// Quick join is channel discovery, not protection: region cells are +/// public, well known, and trivially enumerable, so the suggested channel +/// must be assumed watched. Joining it hides nothing and bypasses nothing. +struct QuickJoinSuggestion { + let regionCode: String + let geohash: String + + /// Regional-indicator flag emoji derived from the ISO code. + var flag: String { + regionCode.unicodeScalars.reduce(into: "") { result, scalar in + if let indicator = Unicode.Scalar(127397 + scalar.value) { + result.unicodeScalars.append(indicator) + } + } + } + + var localizedName: String { + Locale.current.localizedString(forRegionCode: regionCode) ?? regionCode + } + + /// The suggestion for the device's region, or nil when the region is + /// unknown or unmapped (the section is hidden then). + static func current(for locale: Locale = .current) -> QuickJoinSuggestion? { + guard let code = locale.region?.identifier.uppercased(), + let geohash = regionCells[code] else { return nil } + return QuickJoinSuggestion(regionCode: code, geohash: geohash) + } + + /// ISO 3166-1 alpha-2 region → the 2-character geohash cell over the + /// country's main population center — the largest metro, not always the + /// capital (US → New York, TR → Istanbul, MM → Yangon), because that is + /// the cell where a country's channel actually forms. Someone elsewhere + /// in the country lands in this cell too; the caption in the sheet says + /// so rather than calling it "your country's channel". + /// + /// Coverage is every UN member state plus inhabited territories a + /// device locale plausibly reports; a missing code just hides the row. + /// Cells are 11.25° × 5.625°, so city-level coordinates are ample. The + /// table is generated by geohashing each center's coordinates; the + /// twelve entries the earlier roster shipped were independently + /// verified in review and reproduce unchanged, and entries near a cell + /// boundary were checked by hand. Distinct countries can legitimately + /// share a cell (Seoul and Pyongyang are both "wy") — cells are big. + private static let regionCells: [String: String] = [ + "AD": "sp", "AE": "th", "AF": "tw", "AG": "de", + "AL": "sr", "AM": "sz", "AO": "kq", "AR": "69", + "AT": "u2", "AU": "r3", "AW": "d6", "AZ": "tp", + "BA": "sr", "BB": "dd", "BD": "wh", "BE": "u1", + "BF": "ef", "BG": "sx", "BH": "th", "BI": "kx", + "BJ": "s1", "BM": "dt", "BN": "w8", "BO": "6s", + "BR": "6g", "BS": "dk", "BT": "tu", "BW": "ke", + "BY": "u9", "BZ": "d5", "CA": "dp", "CD": "kr", + "CF": "s2", "CG": "kr", "CH": "u0", "CI": "eb", + "CL": "66", "CM": "s0", "CN": "wx", "CO": "d2", + "CR": "d1", "CU": "dh", "CV": "e6", "CW": "d6", + "CY": "sw", "CZ": "u2", "DE": "u3", "DJ": "sf", + "DK": "u3", "DM": "dd", "DO": "d7", "DZ": "sn", + "EC": "6p", "EE": "ud", "EG": "st", "ER": "sf", + "ES": "ez", "ET": "sc", "FI": "ud", "FJ": "ru", + "FM": "x9", "FO": "gg", "FR": "u0", "GA": "s0", + "GB": "gc", "GD": "dd", "GE": "sz", "GF": "db", + "GG": "gb", "GH": "eb", "GI": "ey", "GL": "fg", + "GM": "ed", "GN": "e9", "GP": "dd", "GQ": "s0", + "GR": "sw", "GT": "9f", "GU": "x4", "GW": "ed", + "GY": "d9", "HK": "we", "HN": "d4", "HR": "u2", + "HT": "d7", "HU": "u2", "ID": "qq", "IE": "gc", + "IL": "sv", "IM": "gc", "IN": "tt", "IQ": "sv", + "IR": "tn", "IS": "ge", "IT": "sr", "JE": "gb", + "JM": "d7", "JO": "sv", "JP": "xn", "KE": "kz", + "KG": "tx", "KH": "w6", "KI": "xb", "KM": "kv", + "KN": "de", "KP": "wy", "KR": "wy", "KW": "tj", + "KY": "d5", "KZ": "tx", "LA": "w7", "LB": "sy", + "LC": "dd", "LI": "u0", "LK": "tc", "LR": "ec", + "LS": "kd", "LT": "u9", "LU": "u0", "LV": "ud", + "LY": "sm", "MA": "ev", "MC": "sp", "MD": "u8", + "ME": "sr", "MG": "mh", "MH": "xc", "MK": "sr", + "ML": "ef", "MM": "w4", "MN": "y2", "MO": "we", + "MQ": "dd", "MR": "ee", "MT": "sq", "MU": "mk", + "MV": "t8", "MW": "kv", "MX": "9g", "MY": "w2", + "MZ": "ke", "NA": "k7", "NC": "rs", "NE": "s4", + "NG": "s1", "NI": "d4", "NL": "u1", "NO": "u4", + "NP": "tu", "NR": "rx", "NZ": "rc", "OM": "tk", + "PA": "d1", "PE": "6m", "PF": "2s", "PG": "rq", + "PH": "wd", "PK": "tk", "PL": "u3", "PR": "de", + "PS": "sv", "PT": "ey", "PW": "wc", "PY": "6e", + "QA": "th", "RE": "mh", "RO": "sx", "RS": "sr", + "RU": "uc", "RW": "kx", "SA": "th", "SB": "rw", + "SC": "mp", "SD": "sd", "SE": "u6", "SG": "w2", + "SI": "u2", "SK": "u2", "SL": "e9", "SM": "sr", + "SN": "ed", "SO": "t0", "SR": "dc", "SS": "s8", + "ST": "s0", "SV": "d4", "SY": "sv", "SZ": "ke", + "TD": "s6", "TG": "s1", "TH": "w4", "TJ": "tw", + "TL": "qy", "TM": "tq", "TN": "sn", "TO": "2h", + "TR": "sx", "TT": "d9", "TV": "ry", "TW": "ws", + "TZ": "ky", "UA": "u8", "UG": "s8", "US": "dr", + "UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd", + "VE": "d9", "VI": "de", "VN": "w7", "VU": "rs", + "WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj", + "ZA": "ke", "ZM": "kt", "ZW": "ks", + ] +} diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index fe916cf2..286c84ef 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -24,6 +24,22 @@ struct LocationChannelsSheet: View { static let teleport: LocalizedStringKey = "location_channels.action.teleport" static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title" + static let quickJoinTitle = String(localized: "location_channels.quick_join.title", defaultValue: "quick join", comment: "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region") + static func quickJoinDescription(_ regionName: String) -> String { + String( + format: String(localized: "location_channels.quick_join.description", defaultValue: "the region channel where people from %@ tend to gather — the wide cell around the main population center, not your location. it's public and well-known, so assume it's watched: quick join saves typing a geohash; it doesn't hide you or bypass blocks.", comment: "Caption under the quick join row; %@ is the localized country/region name. States plainly that the cell is the main population center's (not the person's location), that the channel must be assumed watched, and that quick join is discovery, not circumvention"), + locale: .current, + regionName + ) + } + static func quickJoinLabel(_ regionName: String) -> String { + String( + format: String(localized: "location_channels.quick_join.join_label", defaultValue: "join the %@ region channel", comment: "Accessibility label for the quick join row; %@ is the localized country/region name"), + locale: .current, + regionName + ) + } + static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it") static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel") @@ -236,6 +252,12 @@ struct LocationChannelsSheet: View { customTeleportSection .padding(.vertical, 8) + if QuickJoinSuggestion.current() != nil { + sectionDivider + quickJoinSection + .padding(.vertical, 8) + } + let bookmarkedList = locationChannelsModel.bookmarks if !bookmarkedList.isEmpty { sectionDivider @@ -319,6 +341,46 @@ struct LocationChannelsSheet: View { } } + /// One tap into the region channel around the device region's main + /// population center — derived from the locale, no location access, no + /// roster (see QuickJoinSuggestion). The caption is deliberately blunt + /// that the cell is public and watched: discovery, not circumvention. + @ViewBuilder + private var quickJoinSection: some View { + if let suggestion = QuickJoinSuggestion.current() { + VStack(alignment: .leading, spacing: 8) { + Text(Strings.quickJoinTitle) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + + Button(action: { + locationChannelsModel.teleport(to: suggestion.geohash) + isPresented = false + }) { + HStack { + Text(verbatim: "\(suggestion.flag) \(suggestion.localizedName)") + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + Spacer() + Text(verbatim: "#\(suggestion.geohash)") + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.quickJoinLabel(suggestion.localizedName)) + .accessibilityHint(Strings.switchChannelHint) + + Text(Strings.quickJoinDescription(suggestion.localizedName)) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + private func bookmarkedSection(_ entries: [String]) -> some View { VStack(alignment: .leading, spacing: 8) { Text(Strings.bookmarked) From c66015d029bfa74133796eaddc97bf51864f4909 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Sat, 1 Aug 2026 14:53:14 +0530 Subject: [PATCH 32/35] feat: local alias field on the fingerprint sheet (#1507) * feat: let users set a local alias on the fingerprint sheet Wire the existing localPetname field to a write path so peers can be renamed locally; read paths already prefer the alias when present. Co-authored-by: Cursor * fix: make local aliases visible and localize the fingerprint field Give local petnames display precedence over announced nicknames across peer rows, DM headers, and resolveNickname; rebuild peer state after a save so lists update immediately. Load the alias draft when the fingerprint arrives, and add the three local-alias strings across all 30 locales. Co-authored-by: Cursor * fix: use macOS 13-compatible onChange for fingerprint alias sync The two-parameter onChange API requires macOS 14; match the rest of the views so release and Periphery builds stay green on the 13.0 deployment target. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- bitchat/App/PeerListModel.swift | 4 +- bitchat/App/PrivateConversationModels.swift | 35 +- bitchat/App/VerificationModel.swift | 63 +- bitchat/Localizable.xcstrings | 558 ++++++++++++++++++ bitchat/Models/BitchatPeer.swift | 13 +- bitchat/Services/UnifiedPeerService.swift | 21 +- .../ChatPeerIdentityCoordinator.swift | 12 +- bitchat/Views/FingerprintView.swift | 68 +++ ...tPeerIdentityCoordinatorContextTests.swift | 4 + 9 files changed, 752 insertions(+), 26 deletions(-) diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index b96c2e72..b365d09d 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -213,7 +213,7 @@ final class PeerListModel: ObservableObject { return MeshPeerRow( peerID: peer.peerID, - displayName: isMe ? chatViewModel.nickname : peer.nickname, + displayName: isMe ? chatViewModel.nickname : peer.displayName, isMe: isMe, hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID), isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID), @@ -248,7 +248,7 @@ final class PeerListModel: ObservableObject { self.groupRows = groupRows renderID = ( meshRows.map { - "\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" + "\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" } + geohashPeople.map { "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 57c93afd..a4e89149 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -294,6 +294,21 @@ final class PrivateConversationModel: ObservableObject { if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel { return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))" } + // Local alias wins over a live peer row's announced nickname. + if headerPeerID.id.count == 16 { + let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) + if let identity = candidates.first, + let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint), + let pet = social.localPetname, !pet.isEmpty { + return pet + } + } else if let noiseKey = headerPeerID.noiseKey { + let fingerprint = noiseKey.sha256Fingerprint() + if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint), + let pet = social.localPetname, !pet.isEmpty { + return pet + } + } if let displayName = peer?.displayName { return displayName } @@ -308,23 +323,15 @@ final class PrivateConversationModel: ObservableObject { if headerPeerID.id.count == 16 { let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) if let identity = candidates.first, - let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) { - if let pet = social.localPetname, !pet.isEmpty { - return pet - } - if !social.claimedNickname.isEmpty { - return social.claimedNickname - } + let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint), + !social.claimedNickname.isEmpty { + return social.claimedNickname } } else if let noiseKey = headerPeerID.noiseKey { let fingerprint = noiseKey.sha256Fingerprint() - if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) { - if let pet = social.localPetname, !pet.isEmpty { - return pet - } - if !social.claimedNickname.isEmpty { - return social.claimedNickname - } + if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint), + !social.claimedNickname.isEmpty { + return social.claimedNickname } } diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift index d9c2ce08..4e1de457 100644 --- a/bitchat/App/VerificationModel.swift +++ b/bitchat/App/VerificationModel.swift @@ -8,6 +8,9 @@ struct FingerprintPresentationState: Equatable { let theirFingerprint: String? let myFingerprint: String let isVerified: Bool + /// User-assigned local alias (petname), if any — distinct from the + /// peer-claimed nickname. + let localPetname: String? /// Number of currently-valid vouches from peers the user verified /// (0 when the peer is explicitly verified — the stronger badge wins). let voucherCount: Int @@ -20,6 +23,11 @@ struct FingerprintPresentationState: Equatable { var canToggleVerification: Bool { encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified } + + /// Alias field is editable once we know who we're looking at. + var canEditLocalAlias: Bool { + theirFingerprint != nil + } } enum VerificationScanOutcome: Equatable { @@ -75,6 +83,46 @@ final class VerificationModel: ObservableObject { chatViewModel.unverifyFingerprint(for: peerID) } + /// Persist a local alias for this peer. Empty/whitespace clears it so the + /// claimed nickname shows again. Display paths prefer `localPetname` + /// when set (#1439). + func setLocalPetname(_ petname: String?, for peerID: PeerID) { + let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID) + guard let fingerprint = chatViewModel.getFingerprint(for: statusPeerID) else { return } + + let trimmed = petname?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized: String? = (trimmed?.isEmpty == false) ? trimmed : nil + + let existing = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) + let claimed = existing?.claimedNickname + ?? chatViewModel.meshService.peerNickname(peerID: statusPeerID) + ?? chatViewModel.resolveNickname(for: statusPeerID) + var identity = existing ?? SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: claimed, + trustLevel: .unknown, + isFavorite: false, + isBlocked: false, + notes: nil + ) + identity.localPetname = normalized + // Prefer the mesh-announced name for claimedNickname so we don't + // persist a previous alias as the "claimed" identity. + if let announced = chatViewModel.meshService.peerNickname(peerID: statusPeerID), + !announced.isEmpty { + identity.claimedNickname = announced + } else if identity.claimedNickname.isEmpty { + identity.claimedNickname = claimed + } + chatViewModel.identityManager.updateSocialIdentity(identity) + // Rebuild peer rows so PeerList / DM header pick up the new display name + // without waiting for an unrelated mesh event. + chatViewModel.unifiedPeerService.refreshPeers() + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + objectWillChange.send() + } + func isVerified(peerID: PeerID) -> Bool { guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } return peerIdentityStore.isVerified(fingerprint) @@ -86,6 +134,8 @@ final class VerificationModel: ObservableObject { let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID) let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID) let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false + let localPetname = theirFingerprint + .flatMap { chatViewModel.identityManager.getSocialIdentity(for: $0)?.localPetname } // Vouch state is recomputed on read: only vouchers still in the // verified set count, so removing a verification silently retires the @@ -110,6 +160,7 @@ final class VerificationModel: ObservableObject { theirFingerprint: theirFingerprint, myFingerprint: chatViewModel.getMyFingerprint(), isVerified: isVerified, + localPetname: localPetname, voucherCount: vouchers.count, voucherNames: voucherNames ) @@ -158,6 +209,15 @@ final class VerificationModel: ObservableObject { } private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { + // Prefer an explicit local alias even when a live peer row exists — + // peer.displayName already does this once UnifiedPeerService rebuilds, + // but read social identity directly so the fingerprint sheet header + // updates before that rebuild lands. + if let fingerprint = chatViewModel.getFingerprint(for: statusPeerID), + let pet = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)?.localPetname, + !pet.isEmpty { + return pet + } if let peer = chatViewModel.getPeer(byID: statusPeerID) { return peer.displayName } @@ -171,9 +231,6 @@ final class VerificationModel: ObservableObject { } let fingerprint = data.sha256Fingerprint() if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) { - if let pet = social.localPetname, !pet.isEmpty { - return pet - } if !social.claimedNickname.isEmpty { return social.claimedNickname } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index efda8dc1..8ceebf02 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -44954,6 +44954,564 @@ } } }, + "fingerprint.local_alias.hint" : { + "comment" : "Explanation under the local alias field", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "على هذا الجهاز فقط. اتركه فارغًا لاستخدام اللقب الذي يدّعونه." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "শুধু এই ডিভাইসে। তাদের দাবিকৃত ডাকনাম ব্যবহার করতে খালি রাখুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nur auf diesem gerät. leer lassen, um den beanspruchten nickname zu verwenden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "only on this device. leave blank to use their claimed nickname." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "solo en este dispositivo. déjalo vacío para usar su apodo reclamado." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فقط روی این دستگاه. برای استفاده از نام مستعار اعلام‌شده خالی بگذارید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "sa device na ito lang. iwanang blangko para gamitin ang kanilang claimed nickname." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "uniquement sur cet appareil. laisse vide pour utiliser leur surnom annoncé." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "רק במכשיר הזה. השאר ריק כדי להשתמש בכינוי שהם מצהירים עליו." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "केवल इस डिवाइस पर। उनका दावा किया उपनाम इस्तेमाल करने के लिए खाली छोड़ें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hanya di perangkat ini. biarkan kosong untuk memakai nama panggilan yang mereka klaim." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "solo su questo dispositivo. lascia vuoto per usare il nickname dichiarato." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この端末のみ。空欄なら相手の名乗りニックネームを使います。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에만 저장됩니다. 비워 두면 상대가 주장한 닉네임을 씁니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hanya pada peranti ini. biarkan kosong untuk guna nama samaran yang mereka tuntut." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यस यन्त्रमा मात्र। उनीहरूको दाबी गरिएको उपनाम प्रयोग गर्न खाली छोड्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alleen op dit apparaat. laat leeg om hun geclaimde bijnaam te gebruiken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tylko na tym urządzeniu. zostaw puste, by użyć ich deklarowanego nicku." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "apenas neste dispositivo. deixa em branco para usar a alcunha reclamada." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "somente neste dispositivo. deixe em branco para usar o apelido declarado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "только на этом устройстве. оставь пустым, чтобы использовать заявленный ник." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "bara på den här enheten. lämna tomt för att använda deras angivna smeknamn." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த சாதனத்தில் மட்டும். அவர்கள் கூறும் புனைபெயரைப் பயன்படுத்த காலியாக விடவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เฉพาะบนอุปกรณ์นี้ เว้นว่างเพื่อใช้ชื่อเล่นที่พวกเขาอ้าง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yalnızca bu cihazda. iddia edilen takma adı kullanmak için boş bırak." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "лише на цьому пристрої. залиш порожнім, щоб використати їх заявлене ім’я." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "صرف اس ڈیوائس پر۔ ان کا دعویٰ کردہ عرفی نام استعمال کرنے کے لیے خالی چھوڑیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chỉ trên thiết bị này. để trống để dùng biệt danh họ tuyên bố." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅保存在此设备。留空则使用对方声称的昵称。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "僅保存在此裝置。留空則使用對方聲稱的暱稱。" + } + } + } + }, + "fingerprint.local_alias.label" : { + "comment" : "Label for the local-only alias field on the fingerprint sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اسم مستعار محلي" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "স্থানীয় উপনাম" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "lokaler alias" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "local alias" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias local" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نام مستعار محلی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "local na alias" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias local" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כינוי מקומי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थानीय उपनाम" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias lokal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias locale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ローカル別名" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로컬 별칭" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias tempatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थानीय उपनाम" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "lokale alias" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "lokalny alias" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "alcunha local" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "apelido local" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "локальный псевдоним" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lokalt alias" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "உள்ளூர் மாற்றுப்பெயர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ชื่อเล่นในเครื่อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yerel takma ad" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "локальний псевдонім" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مقامی عرفی نام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bí danh cục bộ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本地别名" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本地別名" + } + } + } + }, + "fingerprint.local_alias.placeholder" : { + "comment" : "Placeholder for the local alias field on the fingerprint sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اسم لهذا الشخص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই ব্যক্তির জন্য নাম" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "name für diese person" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "name for this person" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nombre para esta persona" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نام برای این فرد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "pangalan para sa taong ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "nom pour cette personne" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שם לאדם הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस व्यक्ति के लिए नाम" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "nama untuk orang ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nome per questa persona" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この人の名前" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 사람의 이름" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "nama untuk orang ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यस व्यक्तिको लागि नाम" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "naam voor deze persoon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nazwa dla tej osoby" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "nome para esta pessoa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nome para esta pessoa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "имя для этого человека" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "namn för den här personen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த நபருக்கான பெயர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ชื่อสำหรับคนนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu kişi için ad" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ім’я для цієї людини" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس شخص کا نام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tên cho người này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "给此人的名称" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "給此人的名稱" + } + } + } + }, "fingerprint.message.verified" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index fcccda49..61c0a5f8 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -15,6 +15,10 @@ struct BitchatPeer: Equatable { // Nostr identity (if known) var nostrPublicKey: String? + + /// Device-local alias (petname). Never sent over the wire; when set it + /// outranks the peer-claimed `nickname` for display only. + var localPetname: String? // Connection state enum ConnectionState { @@ -51,7 +55,10 @@ struct BitchatPeer: Equatable { // Display helpers var displayName: String { - nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname + if let localPetname, !localPetname.isEmpty { + return localPetname + } + return nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname } var statusIcon: String { @@ -78,13 +85,15 @@ struct BitchatPeer: Equatable { nickname: String, lastSeen _: Date = Date(), isConnected: Bool = false, - isReachable: Bool = false + isReachable: Bool = false, + localPetname: String? = nil ) { self.peerID = peerID self.noisePublicKey = noisePublicKey self.nickname = nickname self.isConnected = isConnected self.isReachable = isReachable + self.localPetname = localPetname // Load favorite status - will be set later by the manager self.favoriteStatus = nil diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index c22c7e43..f1e4f8fa 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -195,7 +195,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { nickname: peerInfo.nickname, lastSeen: peerInfo.lastSeen, isConnected: peerInfo.isConnected, - isReachable: isReachable + isReachable: isReachable, + localPetname: localPetname(forFingerprint: fingerprint) ) // Check for favorite status @@ -218,7 +219,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { nickname: favorite.peerNickname, lastSeen: favorite.lastUpdated, isConnected: false, - isReachable: false + isReachable: false, + localPetname: localPetname(forFingerprint: favorite.peerNoisePublicKey.sha256Fingerprint()) ) peer.favoriteStatus = favorite @@ -227,6 +229,21 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { return peer } + /// Rebuild peer rows after a social-identity write (local alias, etc.) so + /// display names update without waiting for a mesh event. + func refreshPeers() { + updatePeers() + } + + private func localPetname(forFingerprint fingerprint: String?) -> String? { + guard let fingerprint, + let petname = identityManager.getSocialIdentity(for: fingerprint)?.localPetname, + !petname.isEmpty else { + return nil + } + return petname + } + // MARK: - Public Methods /// Get peer by ID diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 65b30df9..a2bd6297 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -477,15 +477,21 @@ final class ChatPeerIdentityCoordinator { return peerID.id } + // Local aliases outrank announced nicknames so a saved petname is + // actually visible after the fingerprint sheet dismisses. + if let fingerprint = getFingerprint(for: peerID), + let identity = context.socialIdentity(forFingerprint: fingerprint), + let petname = identity.localPetname, + !petname.isEmpty { + return petname + } + if let nickname = context.meshPeerNicknames()[peerID] { return nickname } if let fingerprint = getFingerprint(for: peerID), let identity = context.socialIdentity(forFingerprint: fingerprint) { - if let petname = identity.localPetname { - return petname - } return identity.claimedNickname } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index a6c371ae..bce7e89c 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -14,6 +14,8 @@ struct FingerprintView: View { let peerID: PeerID @Environment(\.dismiss) var dismiss @ThemedPalette private var palette + @State private var aliasDraft: String = "" + @State private var didLoadAlias = false private var textColor: Color { palette.primary } @@ -26,6 +28,21 @@ struct FingerprintView: View { static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified" static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified" static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified" + static let localAlias = String( + localized: "fingerprint.local_alias.label", + defaultValue: "local alias", + comment: "Label for the local-only alias field on the fingerprint sheet" + ) + static let localAliasPlaceholder = String( + localized: "fingerprint.local_alias.placeholder", + defaultValue: "name for this person", + comment: "Placeholder for the local alias field on the fingerprint sheet" + ) + static let localAliasHint = String( + localized: "fingerprint.local_alias.hint", + defaultValue: "only on this device. leave blank to use their claimed nickname.", + comment: "Explanation under the local alias field" + ) static func verifyHint(_ nickname: String) -> String { String( format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"), @@ -85,6 +102,26 @@ struct FingerprintView: View { .padding() .background(palette.secondary.opacity(0.1)) .cornerRadius(8) + + if fingerprintState.canEditLocalAlias { + VStack(alignment: .leading, spacing: 8) { + Text(verbatim: Strings.localAlias) + .bitchatFont(size: 12, weight: .bold) + .foregroundColor(textColor.opacity(0.7)) + + TextField(Strings.localAliasPlaceholder, text: $aliasDraft) + .bitchatFont(size: 14) + .foregroundColor(textColor) + .padding(10) + .background(palette.secondary.opacity(0.1)) + .cornerRadius(8) + .onSubmit { commitAlias() } + + Text(verbatim: Strings.localAliasHint) + .bitchatFont(size: 11) + .foregroundColor(textColor.opacity(0.6)) + } + } // Their fingerprint VStack(alignment: .leading, spacing: 8) { @@ -248,6 +285,37 @@ struct FingerprintView: View { .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) .themedSheetBackground() + .onAppear { + syncAliasDraft(from: fingerprintState, force: true) + } + .onChange(of: fingerprintState.theirFingerprint) { _ in + // Fingerprint can arrive after the sheet opens; load (or reload) + // the saved alias then, otherwise an empty draft looks like a clear. + syncAliasDraft(from: fingerprintState, force: false) + } + .onDisappear { + commitAlias() + } + } + + /// Populate `aliasDraft` from the persisted petname once we know the + /// fingerprint. `force` reloads even if we already loaded (onAppear). + private func syncAliasDraft(from state: FingerprintPresentationState, force: Bool) { + guard state.canEditLocalAlias else { return } + if didLoadAlias && !force { return } + aliasDraft = state.localPetname ?? "" + didLoadAlias = true + } + + private func commitAlias() { + let fingerprintState = verificationModel.fingerprintPresentation(for: peerID) + guard fingerprintState.canEditLocalAlias else { return } + // Don't treat "never loaded a draft" as an intentional clear. + guard didLoadAlias else { return } + let current = fingerprintState.localPetname ?? "" + let draft = aliasDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard draft != current else { return } + verificationModel.setLocalPetname(draft.isEmpty ? nil : draft, for: peerID) } private func formatFingerprint(_ fingerprint: String) -> String { diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index bbdda609..982a6de3 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -383,6 +383,10 @@ struct ChatPeerIdentityCoordinatorContextTests { ) #expect(coordinator.resolveNickname(for: identityPeer) == "bob!") + // Local alias outranks a live mesh announce for the same peer. + context.nicknamesByPeerID[identityPeer] = "bob" + #expect(coordinator.resolveNickname(for: identityPeer) == "bob!") + #expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed") #expect(coordinator.getMyFingerprint() == "my-fingerprint") } From 681c1800602e8bafd6b76b6b3dcb4538e531d577 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Sat, 1 Aug 2026 14:53:41 +0530 Subject: [PATCH 33/35] feat: share location channel invites via the system share sheet (#1513) * feat: share location channel invites via the system share sheet Adds text-first geohash invites (deep link + App Store URL) from channel rows and the active-channel header, with an OpSec warning for neighborhood-or-finer cells. Closes #1497 Co-authored-by: Cursor * fix: add Localizable.xcstrings entries for channel share copy Cover all six new share/done keys across the 30-locale catalog so LocalizationCoverageTests and non-English builds stop falling back to English defaults. Co-authored-by: Cursor * chore: retrigger CI after unrelated VoiceRecorder flake Co-authored-by: Cursor --------- Co-authored-by: Cursor --- bitchat/Localizable.xcstrings | 1116 +++++++++++++++++++++ bitchat/Services/ChannelShare.swift | 49 + bitchat/Views/ContentHeaderView.swift | 43 + bitchat/Views/LocationChannelsSheet.swift | 68 +- bitchat/Views/ShareActivityView.swift | 58 ++ bitchatTests/ChannelShareTests.swift | 26 + 6 files changed, 1358 insertions(+), 2 deletions(-) create mode 100644 bitchat/Services/ChannelShare.swift create mode 100644 bitchat/Views/ShareActivityView.swift create mode 100644 bitchatTests/ChannelShareTests.swift diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 8ceebf02..c43da7a2 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -16925,6 +16925,936 @@ } } }, + "channel.share.action" : { + "comment" : "Context-menu / accessibility / button label that shares a location-channel invite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مشاركة القناة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "চ্যানেল শেয়ার করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "kanal teilen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "share channel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "compartir canal" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اشتراک‌گذاری کانال" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-share ang channel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "partager le canal" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שתף ערוץ" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "चैनल साझा करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bagikan kanal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "condividi canale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チャンネルを共有" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "채널 공유" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kongsi saluran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "च्यानल सेयर गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kanaal delen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "udostępnij kanał" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "partilhar canal" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "compartilhar canal" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "поделиться каналом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dela kanal" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "சேனலைப் பகிர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "แชร์ช่อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kanalı paylaş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "поділитися каналом" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "چینل شیئر کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chia sẻ kênh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享频道" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享頻道" + } + } + } + }, + "channel.share.payload" : { + "comment" : "Plain-text share payload for a location channel; %1$@ is the geohash, %2$@ is the App Store URL", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "انضم إلى قناة #%1$@ على bitchat: bitchat://geohash/%1$@ — جديد على bitchat؟ حمّله من %2$@ ثم اكتب #%1$@ ضمن قنوات الموقع." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat-এ #%1$@ চ্যানেলে যোগ দিন: bitchat://geohash/%1$@ — bitchat-এ নতুন? %2$@ থেকে নিন, তারপর লোকেশন চ্যানেলে #%1$@ টাইপ করুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tritt dem #%1$@-kanal auf bitchat bei: bitchat://geohash/%1$@ — neu bei bitchat? hole es unter %2$@ und tippe dann #%1$@ unter standort-kanäle." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "join the #%1$@ channel on bitchat: bitchat://geohash/%1$@ — new to bitchat? get it at %2$@ then type #%1$@ under location channels." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "únete al canal #%1$@ en bitchat: bitchat://geohash/%1$@ — ¿nuevo en bitchat? consíguelo en %2$@ y luego escribe #%1$@ en canales de ubicación." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "به کانال #%1$@ در bitchat بپیوندید: bitchat://geohash/%1$@ — تازه‌واردید؟ از %2$@ بگیرید و سپس #%1$@ را در کانال‌های موقعیت وارد کنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "sumali sa #%1$@ channel sa bitchat: bitchat://geohash/%1$@ — bago sa bitchat? kunin ito sa %2$@ tapos i-type ang #%1$@ sa mga channel ng lokasyon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "rejoins le canal #%1$@ sur bitchat : bitchat://geohash/%1$@ — nouveau sur bitchat ? télécharge-le sur %2$@ puis saisis #%1$@ dans les canaux localisation." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצטרף לערוץ #%1$@ ב-bitchat: bitchat://geohash/%1$@ — חדש ב-bitchat? הורד מ-%2$@ ואז הקלד #%1$@ תחת ערוצי מיקום." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat पर #%1$@ चैनल से जुड़ें: bitchat://geohash/%1$@ — bitchat नए हैं? %2$@ से पाएँ, फिर लोकेशन चैनल में #%1$@ टाइप करें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "gabung ke kanal #%1$@ di bitchat: bitchat://geohash/%1$@ — baru di bitchat? unduh di %2$@ lalu ketik #%1$@ di kanal lokasi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "entra nel canale #%1$@ su bitchat: bitchat://geohash/%1$@ — nuovo su bitchat? scaricalo da %2$@ e poi digita #%1$@ nei canali posizione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchatの#%1$@チャンネルに参加: bitchat://geohash/%1$@ — はじめてですか? %2$@ から入手し、ロケーションチャンネルで #%1$@ と入力してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat의 #%1$@ 채널에 참여하세요: bitchat://geohash/%1$@ — bitchat이 처음이신가요? %2$@에서 받은 뒤 위치 채널에서 #%1$@을(를) 입력하세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sertai saluran #%1$@ di bitchat: bitchat://geohash/%1$@ — baharu di bitchat? dapatkannya di %2$@ kemudian taip #%1$@ di bawah kanal lokasi." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat मा #%1$@ च्यानलमा सामेल हुनुहोस्: bitchat://geohash/%1$@ — bitchat मा नयाँ? %2$@ बाट लिनुहोस्, त्यसपछि स्थान च्यानलमा #%1$@ टाइप गर्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "doe mee met het #%1$@-kanaal op bitchat: bitchat://geohash/%1$@ — nieuw bij bitchat? haal het op %2$@ en typ daarna #%1$@ onder locatiekanalen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dołącz do kanału #%1$@ w bitchat: bitchat://geohash/%1$@ — nowość w bitchat? pobierz z %2$@, a potem wpisz #%1$@ w kanałach lokalizacji." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "junta-te ao canal #%1$@ no bitchat: bitchat://geohash/%1$@ — novo no bitchat? obtém-no em %2$@ e depois escreve #%1$@ em canais de localização." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "entre no canal #%1$@ no bitchat: bitchat://geohash/%1$@ — novo no bitchat? baixe em %2$@ e depois digite #%1$@ em canais de localização." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "присоединяйся к каналу #%1$@ в bitchat: bitchat://geohash/%1$@ — впервые в bitchat? скачай на %2$@, затем введи #%1$@ в каналах локации." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "gå med i #%1$@-kanalen på bitchat: bitchat://geohash/%1$@ — ny på bitchat? hämta den på %2$@ och skriv sedan #%1$@ under platskanaler." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat-இல் #%1$@ சேனலில் சேரவும்: bitchat://geohash/%1$@ — bitchat புதியவரா? %2$@ இல் பெற்று, பின்னர் இட சேனல்களில் #%1$@ என தட்டச்சு செய்யவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เข้าร่วมช่อง #%1$@ บน bitchat: bitchat://geohash/%1$@ — ใหม่กับ bitchat? ดาวน์โหลดที่ %2$@ แล้วพิมพ์ #%1$@ ในช่องตามตำแหน่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat'te #%1$@ kanalına katıl: bitchat://geohash/%1$@ — bitchat'te yeni misin? %2$@ adresinden indir, sonra konum kanallarında #%1$@ yaz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "приєднуйся до каналу #%1$@ у bitchat: bitchat://geohash/%1$@ — вперше в bitchat? завантаж на %2$@, потім введи #%1$@ у каналах локації." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitchat پر #%1$@ چینل میں شامل ہوں: bitchat://geohash/%1$@ — bitchat نئے ہیں؟ %2$@ سے حاصل کریں، پھر لوکیشن چینلز میں #%1$@ ٹائپ کریں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tham gia kênh #%1$@ trên bitchat: bitchat://geohash/%1$@ — mới dùng bitchat? tải tại %2$@ rồi nhập #%1$@ trong kênh vị trí." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入 bitchat 上的 #%1$@ 频道:bitchat://geohash/%1$@ — 初次使用 bitchat?前往 %2$@ 获取,然后在位置频道中输入 #%1$@。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入 bitchat 上的 #%1$@ 頻道:bitchat://geohash/%1$@ — 初次使用 bitchat?前往 %2$@ 取得,然後在位置頻道中輸入 #%1$@。" + } + } + } + }, + "channel.share.precision_warning.confirm" : { + "comment" : "Confirms sharing a fine-precision location channel after the OpSec warning", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المشاركة على أي حال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "তবুও শেয়ার করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "trotzdem teilen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "share anyway" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "compartir de todos modos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "باز هم اشتراک‌گذاری" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-share pa rin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "partager quand même" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שתף בכל זאת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "फिर भी साझा करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bagikan saja" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "condividi comunque" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "それでも共有" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그래도 공유" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kongsi juga" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तैपनि सेयर गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "toch delen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "udostępnij mimo to" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "partilhar na mesma" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "compartilhar mesmo assim" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "всё равно поделиться" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dela ändå" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இருந்தாலும் பகிர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "แชร์อยู่ดี" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yine de paylaş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "все одно поділитися" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پھر بھی شیئر کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "vẫn chia sẻ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仍然分享" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "仍然分享" + } + } + } + }, + "channel.share.precision_warning.message" : { + "comment" : "Body of the confirmation before sharing a fine-precision geohash invite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تغطي هذه القناة منطقة صغيرة. الدعوة عبر الرسائل أو imessage تظهر لمشغّل الشبكة والجهازين — وتكشف الاهتمام بذلك المكان، وليس فقط أن شخصًا يستخدم bitchat." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই চ্যানেলটি ছোট এলাকা জুড়ে। sms বা imessage-এ পাঠানো আমন্ত্রণ ক্যারিয়ার ও দুই হ্যান্ডসেটে দেখা যায় — এতে bitchat ব্যবহারের পাশাপাশি সেই জায়গায় আগ্রহও প্রকাশ পায়।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dieser kanal deckt ein kleines gebiet ab. eine einladung per sms oder imessage ist für den netzanbieter und beide geräte sichtbar — sie verrät interesse an diesem ort, nicht nur dass jemand bitchat nutzt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "este canal cubre un área pequeña. una invitación enviada por sms o imessage es visible para el operador y ambos teléfonos — revela interés en ese lugar, no solo que alguien usa bitchat." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این کانال ناحیه کوچکی را پوشش می‌دهد. دعوت‌نامهٔ sms یا imessage برای اپراتور و هر دو دستگاه دیده می‌شود — علاقه به آن مکان را فاش می‌کند، نه فقط اینکه کسی از bitchat استفاده می‌کند." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "maliit na lugar ang saklaw ng channel na ito. ang invite sa sms o imessage ay makikita ng carrier at ng parehong device — inilalantad nito ang interes sa lugar na iyon, hindi lang na may gumagamit ng bitchat." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ce canal couvre une petite zone. une invitation envoyée par sms ou imessage est visible par l’opérateur et les deux appareils — elle révèle un intérêt pour ce lieu, pas seulement que quelqu’un utilise bitchat." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הערוץ הזה מכסה אזור קטן. הזמנה ב-sms או imessage גלויה לספק ולשני המכשירים — היא חושפת עניין במקום, לא רק שמישהו משתמש ב-bitchat." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह चैनल छोटे क्षेत्र को कवर करता है। sms या imessage से भेजा निमंत्रण कैरियर और दोनों हैंडसेट को दिखता है — इससे bitchat इस्तेमाल करने के अलावा उस जगह में रुचि भी ज़ाहिर होती है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kanal ini mencakup area kecil. undangan lewat sms atau imessage terlihat oleh operator dan kedua perangkat — itu mengungkapkan minat pada tempat itu, bukan hanya bahwa seseorang memakai bitchat." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "questo canale copre un’area piccola. un invito inviato via sms o imessage è visibile all’operatore e a entrambi i telefoni — rivela interesse per quel luogo, non solo che qualcuno usa bitchat." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このチャンネルは狭い範囲をカバーします。smsやimessageで送った招待は通信事業者と双方の端末に見えます — bitchatを使っていることだけでなく、その場所への関心も伝わります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 채널은 좁은 영역을 다룹니다. sms나 imessage로 보낸 초대는 통신사와 양쪽 기기에 보입니다 — bitchat을 쓴다는 사실뿐 아니라 그 장소에 대한 관심도 드러납니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "saluran ini meliputi kawasan kecil. jemputan melalui sms atau imessage kelihatan kepada operator dan kedua-dua peranti — ia mendedahkan minat terhadap tempat itu, bukan hanya bahawa seseorang menggunakan bitchat." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो च्यानल सानो क्षेत्र समेट्छ। sms वा imessage मार्फत पठाइएको निमन्त्रणा क्यारियर र दुवै ह्यान्डसेटमा देखिन्छ — यसले bitchat प्रयोग मात्र होइन, त्यो ठाउँप्रति चासो पनि खुलाउँछ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dit kanaal dekt een klein gebied. een uitnodiging via sms of imessage is zichtbaar voor de provider en beide toestellen — het onthult interesse in die plek, niet alleen dat iemand bitchat gebruikt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ten kanał obejmuje mały obszar. zaproszenie wysłane sms-em lub imessage jest widoczne dla operatora i obu telefonów — ujawnia zainteresowanie tym miejscem, nie tylko to, że ktoś używa bitchat." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "este canal cobre uma área pequena. um convite enviado por sms ou imessage fica visível para a operadora e ambos os telemóveis — revela interesse nesse local, não só que alguém usa bitchat." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "este canal cobre uma área pequena. um convite enviado por sms ou imessage fica visível para a operadora e ambos os celulares — revela interesse naquele lugar, não só que alguém usa bitchat." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "этот канал покрывает небольшую область. приглашение по sms или imessage видно оператору и обоим устройствам — оно раскрывает интерес к этому месту, а не только то, что кто-то пользуется bitchat." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "denna kanal täcker ett litet område. en inbjudan via sms eller imessage syns för operatören och båda enheterna — den avslöjar intresse för platsen, inte bara att någon använder bitchat." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த சேனல் சிறிய பகுதியை உள்ளடக்குகிறது. sms அல்லது imessage அழைப்பு கேரியருக்கும் இரு சாதனங்களுக்கும் தெரியும் — bitchat பயன்படுத்துவது மட்டுமல்ல, அந்த இடத்தில் ஆர்வமும் வெளிப்படும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ช่องนี้ครอบคลุมพื้นที่เล็ก คำเชิญทาง sms หรือ imessage เปิดเผยต่อผู้ให้บริการและทั้งสองเครื่อง — มันเผยความสนใจในสถานที่นั้น ไม่ใช่แค่การใช้ bitchat" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu kanal küçük bir alanı kapsar. sms veya imessage ile gönderilen davet operatör ve her iki cihazda görünür — yalnızca birinin bitchat kullandığını değil, o yere ilgiyi de açığa çıkarır." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "цей канал покриває невелику ділянку. запрошення через sms або imessage бачать оператор і обидва пристрої — воно розкриває інтерес до цього місця, а не лише те, що хтось користується bitchat." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ چینل چھوٹے علاقے کا احاطہ کرتا ہے۔ sms یا imessage پر بھیجی گئی دعوت کیریئر اور دونوں ہینڈسیٹس کو نظر آتی ہے — اس سے صرف bitchat کا استعمال نہیں بلکہ اس جگہ میں دلچسپی بھی ظاہر ہوتی ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "kênh này phủ một khu vực nhỏ. lời mời gửi qua sms hoặc imessage hiện với nhà mạng và cả hai máy — nó tiết lộ sự quan tâm đến nơi đó, không chỉ việc ai đó dùng bitchat." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此频道覆盖范围很小。通过短信或 iMessage 发送的邀请对运营商和双方设备可见——它会暴露对该地点的兴趣,而不仅是有人在使用 bitchat。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此頻道覆蓋範圍很小。透過簡訊或 iMessage 傳送的邀請對電信商與雙方裝置可見——它會揭露對該地點的興趣,而不只是有人在使用 bitchat。" + } + } + } + }, + "channel.share.precision_warning.title" : { + "comment" : "Title of the confirmation before sharing a neighborhood-or-finer geohash invite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مشاركة قناة موقع دقيقة؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সঠিক লোকেশন চ্যানেল শেয়ার করবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "präzisen standort-kanal teilen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "share a precise location channel?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿compartir un canal de ubicación precisa?" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کانال موقعیت دقیق به اشتراک گذاشته شود؟" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-share ang isang precise na location channel?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "partager un canal de localisation précis ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לשתף ערוץ מיקום מדויק?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सटीक लोकेशन चैनल साझा करें?" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bagikan kanal lokasi yang tepat?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "condividere un canale di posizione precisa?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "精密なロケーションチャンネルを共有しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정밀한 위치 채널을 공유할까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kongsi saluran lokasi yang tepat?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सटीक स्थान च्यानल सेयर गर्ने?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "een precies locatiekanaal delen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "udostępnić precyzyjny kanał lokalizacji?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "partilhar um canal de localização precisa?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "compartilhar um canal de localização precisa?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "поделиться точным каналом локации?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dela en precis platskanal?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "துல்லியமான இட சேனலைப் பகிரவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "แชร์ช่องตำแหน่งที่แม่นยำ?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "hassas bir konum kanalı paylaşılsın mı?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "поділитися точним каналом локації?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "درست لوکیشن چینل شیئر کریں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chia sẻ kênh vị trí chính xác?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享精确的位置频道?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享精確的位置頻道?" + } + } + } + }, "close" : { "comment" : "Button to dismiss fullscreen media viewer", "localizations" : { @@ -17665,6 +18595,192 @@ } } }, + "common.done" : { + "comment" : "Dismisses a sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সম্পন্ন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "fertig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "done" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "listo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمام" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "tapos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "terminé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בוצע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "हो गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "selesai" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "fatto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "selesai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सम्पन्न" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "klaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gotowe" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "concluído" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "concluído" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "готово" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "klart" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "முடிந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เสร็จสิ้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bitti" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "готово" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مکمل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xong" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + } + }, "common.ok" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Services/ChannelShare.swift b/bitchat/Services/ChannelShare.swift new file mode 100644 index 00000000..eec82fe8 --- /dev/null +++ b/bitchat/Services/ChannelShare.swift @@ -0,0 +1,49 @@ +// +// ChannelShare.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Builds plain-text location-channel invites for the system share sheet (#1497). +/// +/// Text-first on purpose: a `bitchat://` deep link is dead weight for people +/// who have not installed yet, and SMS does not reliably linkify custom +/// schemes. The payload always includes the App Store URL and the geohash a +/// person can type under location channels after installing. +enum ChannelShare { + /// App Store listing used in out-of-app invites. + static let appStoreURL = "https://apps.apple.com/us/app/bitchat-mesh/id6748219622" + + /// Neighborhood (6) and finer imply a small cell — sharing that over SMS + /// discloses location interest to the carrier and both handsets. + static let precisionWarningMinimumLength = 6 + + static func shouldWarn(forGeohash geohash: String) -> Bool { + geohash.count >= precisionWarningMinimumLength + } + + /// Channel-not-presence framing: "join #x", never "I'm in #x". + static func payload(forGeohash geohash: String) -> String { + let gh = geohash.lowercased() + return String( + format: String( + localized: "channel.share.payload", + defaultValue: "join the #%1$@ channel on bitchat: bitchat://geohash/%1$@ — new to bitchat? get it at %2$@ then type #%1$@ under location channels.", + comment: "Plain-text share payload for a location channel; %1$@ is the geohash, %2$@ is the App Store URL" + ), + locale: .current, + gh, + appStoreURL + ) + } +} + +/// Identifiable wrapper so `.sheet(item:)` can present the system share UI. +struct ChannelSharePayload: Identifiable { + let id = UUID() + let text: String +} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 96b548fb..bd3af7f2 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -30,6 +30,10 @@ struct ContentHeaderView: View { /// timeline is showing) — they should light the pin too. @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared + @State private var pendingShareGeohash: String? + @State private var showSharePrecisionWarning = false + @State private var activeSharePayload: ChannelSharePayload? + /// The bridged-people count belongs to the mesh channel only. private var showBridgedPeerCount: Bool { if case .location = locationChannelsModel.selectedChannel { return false } @@ -213,6 +217,16 @@ struct ContentHeaderView: View { channel.geohash ) ) + + Button(action: { requestHeaderShare(forGeohash: channel.geohash) }) { + Image(systemName: "square.and.arrow.up") + .font(.bitchatSystem(size: 12)) + .headerTapTarget() + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "channel.share.action", defaultValue: "share channel", comment: "Accessibility label for sharing the active location channel") + ) } Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) { @@ -336,8 +350,37 @@ struct ContentHeaderView: View { } message: { Text("content.alert.screenshot.message") } + .confirmationDialog( + String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite"), + isPresented: $showSharePrecisionWarning, + titleVisibility: .visible + ) { + Button(String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")) { + if let gh = pendingShareGeohash { + activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: gh)) + } + pendingShareGeohash = nil + } + Button("common.cancel", role: .cancel) { + pendingShareGeohash = nil + } + } message: { + Text(String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite")) + } + .sheet(item: $activeSharePayload) { payload in + ShareActivityView(text: payload.text) + } .themedChromePanel(edge: .top) } + + private func requestHeaderShare(forGeohash geohash: String) { + if ChannelShare.shouldWarn(forGeohash: geohash) { + pendingShareGeohash = geohash + showSharePrecisionWarning = true + } else { + activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash)) + } + } } private extension View { diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 286c84ef..788d66c4 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -12,6 +12,10 @@ struct LocationChannelsSheet: View { @ThemedPalette private var palette @State private var customGeohash: String = "" @State private var customError: String? = nil + /// Geohash waiting on the fine-precision OpSec confirmation before share. + @State private var pendingShareGeohash: String? + @State private var showSharePrecisionWarning = false + @State private var activeSharePayload: ChannelSharePayload? private enum Strings { static let title: LocalizedStringKey = "location_channels.title" @@ -44,6 +48,10 @@ struct LocationChannelsSheet: View { static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it") static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel") static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark") + static let shareChannel = String(localized: "channel.share.action", defaultValue: "share channel", comment: "Context-menu / accessibility action that shares a location-channel invite") + static let sharePrecisionTitle = String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite") + static let sharePrecisionMessage = String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite") + static let shareAnyway = String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning") static func meshTitle(_ count: Int) -> String { let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") @@ -179,6 +187,39 @@ struct LocationChannelsSheet: View { } } .onChange(of: locationChannelsModel.availableChannels) { _ in } + .confirmationDialog( + Strings.sharePrecisionTitle, + isPresented: $showSharePrecisionWarning, + titleVisibility: .visible + ) { + Button(Strings.shareAnyway) { + if let gh = pendingShareGeohash { + presentShare(forGeohash: gh) + } + pendingShareGeohash = nil + } + Button("common.cancel", role: .cancel) { + pendingShareGeohash = nil + } + } message: { + Text(Strings.sharePrecisionMessage) + } + .sheet(item: $activeSharePayload) { payload in + ShareActivityView(text: payload.text) + } + } + + private func requestShare(forGeohash geohash: String) { + if ChannelShare.shouldWarn(forGeohash: geohash) { + pendingShareGeohash = geohash + showSharePrecisionWarning = true + } else { + presentShare(forGeohash: geohash) + } + } + + private func presentShare(forGeohash geohash: String) { + activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash)) } private var closeButton: some View { @@ -220,12 +261,21 @@ struct LocationChannelsSheet: View { .accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark) }, accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark, - accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) } + accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }, + shareGeohash: channel.geohash, + onShare: { requestShare(forGeohash: channel.geohash) } ) { locationChannelsModel.markTeleported(for: channel.geohash, false) locationChannelsModel.select(ChannelID.location(channel)) isPresented = false } + .contextMenu { + Button { + requestShare(forGeohash: channel.geohash) + } label: { + Label(Strings.shareChannel, systemImage: "square.and.arrow.up") + } + } .padding(.vertical, 6) } } else if locationChannelsModel.permissionState == .authorized { @@ -409,7 +459,9 @@ struct LocationChannelsSheet: View { .accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark) }, accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark, - accessoryAction: { locationChannelsModel.toggleBookmark(gh) } + accessoryAction: { locationChannelsModel.toggleBookmark(gh) }, + shareGeohash: gh, + onShare: { requestShare(forGeohash: gh) } ) { let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh } if !inRegional && !locationChannelsModel.availableChannels.isEmpty { @@ -420,6 +472,13 @@ struct LocationChannelsSheet: View { locationChannelsModel.select(ChannelID.location(channel)) isPresented = false } + .contextMenu { + Button { + requestShare(forGeohash: gh) + } label: { + Label(Strings.shareChannel, systemImage: "square.and.arrow.up") + } + } .padding(.vertical, 6) .onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) } @@ -453,6 +512,8 @@ struct LocationChannelsSheet: View { @ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, accessoryActionTitle: String? = nil, accessoryAction: (() -> Void)? = nil, + shareGeohash: String? = nil, + onShare: (() -> Void)? = nil, action: @escaping () -> Void ) -> some View { HStack(alignment: .center, spacing: 8) { @@ -500,6 +561,9 @@ struct LocationChannelsSheet: View { if let accessoryActionTitle, let accessoryAction { Button(accessoryActionTitle, action: accessoryAction) } + if shareGeohash != nil, let onShare { + Button(Strings.shareChannel, action: onShare) + } } } diff --git a/bitchat/Views/ShareActivityView.swift b/bitchat/Views/ShareActivityView.swift new file mode 100644 index 00000000..7b5ea947 --- /dev/null +++ b/bitchat/Views/ShareActivityView.swift @@ -0,0 +1,58 @@ +// +// ShareActivityView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Hosts the system share UI after an optional OpSec confirmation (#1497). +struct ShareActivityView: View { + let text: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + #if os(iOS) + ShareActivityController(items: [text]) + .ignoresSafeArea() + #elseif os(macOS) + VStack(alignment: .leading, spacing: 16) { + Text(text) + .font(.body) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + HStack { + Spacer() + ShareLink(item: text) { + Label( + String(localized: "channel.share.action", defaultValue: "share channel", comment: "Button that opens the system share sheet for a location channel invite"), + systemImage: "square.and.arrow.up" + ) + } + Button(String(localized: "common.done", defaultValue: "done", comment: "Dismisses a sheet")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + } + .padding() + .frame(minWidth: 360) + #endif + } +} + +#if os(iOS) +import UIKit + +private struct ShareActivityController: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} +#endif diff --git a/bitchatTests/ChannelShareTests.swift b/bitchatTests/ChannelShareTests.swift new file mode 100644 index 00000000..ee5d13ad --- /dev/null +++ b/bitchatTests/ChannelShareTests.swift @@ -0,0 +1,26 @@ +// +// ChannelShareTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +@testable import bitchat + +struct ChannelShareTests { + @Test func payloadIncludesGeohashDeepLinkAndStoreURL() { + let text = ChannelShare.payload(forGeohash: "u4pru") + #expect(text.contains("#u4pru")) + #expect(text.contains("bitchat://geohash/u4pru")) + #expect(text.contains(ChannelShare.appStoreURL)) + #expect(!text.lowercased().contains("i'm in")) + } + + @Test func precisionWarningStartsAtNeighborhood() { + #expect(!ChannelShare.shouldWarn(forGeohash: "u4pru")) // city = 5 + #expect(ChannelShare.shouldWarn(forGeohash: "u4pruy")) // neighborhood = 6 + #expect(ChannelShare.shouldWarn(forGeohash: "u4pruyzd")) + } +} From 0152344554e05c4a6f542a37c3ebc7b34b265239 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Sat, 1 Aug 2026 18:21:28 +0530 Subject: [PATCH 34/35] feat: verification seal on private message sender rows (#1573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: show verification seal on private message sender rows DM-only filled seal next to verified peers' names closes the remaining UI gap from #1439 without dressing public mesh timelines. Co-authored-by: Cursor * fix: skip in-string verified ✓ on private rows with SF Symbol seal Co-authored-by: Cursor * Add verified_sender catalog entry (30 locales) + live seal repaint on verify The accessibility label existed only in code, so VoiceOver read english in 29 locales; the coverage test can't see source-only keys. Also forward peerIdentityStore.$verifiedFingerprints into objectWillChange so toggling verification repaints rows in an open DM instead of waiting for the next unrelated invalidation. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Cursor Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/ConversationUIModel.swift | 21 ++ bitchat/Localizable.xcstrings | 185 ++++++++++++++++++ bitchat/ViewModels/ChatMessageFormatter.swift | 7 +- .../Views/Components/TextMessageView.swift | 9 + bitchat/Views/Media/MediaMessageView.swift | 9 + 5 files changed, 229 insertions(+), 2 deletions(-) diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 91f0afef..53062d45 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -128,6 +128,18 @@ final class ConversationUIModel: ObservableObject { message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID } + /// Whether a private-message row should show the filled verification seal + /// next to the sender name (#1439). Scoped to DMs only — public timelines + /// have different trust semantics and stay undressed. + func showsVerifiedSeal(for message: BitchatMessage) -> Bool { + guard message.isPrivate, + message.sender != "system", + !isSentByCurrentUser(message), + let peerID = message.senderPeerID else { return false } + guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } + return chatViewModel.peerIdentityStore.isVerified(fingerprint) + } + func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? { if peerID.isGeoDM || peerID.isGeoChat { return chatViewModel.geohashDisplayName(for: peerID) @@ -219,6 +231,15 @@ final class ConversationUIModel: ObservableObject { self?.refreshComputedState() } .store(in: &cancellables) + + // Verify/unverify while a DM is open must repaint existing rows — + // showsVerifiedSeal is computed per render, so forward the store change. + chatViewModel.peerIdentityStore.$verifiedFingerprints + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } private func refreshComputedState() { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index c43da7a2..f869ab85 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -26973,6 +26973,191 @@ } } }, + "content.accessibility.verified_sender" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرسل موثق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "যাচাইকৃত প্রেরক" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifizierter absender" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verified sender" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remitente verificado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فرستنده تأییدشده" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beripikadong nagpadala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expéditeur vérifié" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שולח מאומת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सत्यापित प्रेषक" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengirim terverifikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mittente verificato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "確認済みの送信者" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확인된 보낸 사람" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengirim yang disahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रमाणित प्रेषक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geverifieerde afzender" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zweryfikowany nadawca" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remetente verificado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remetente verificado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждённый отправитель" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifierad avsändare" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "சரிபார்க்கப்பட்ட அனுப்புநர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ผู้ส่งที่ยืนยันแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doğrulanmış gönderen" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Підтверджений відправник" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصدیق شدہ مرسل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Người gửi đã xác minh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已验证的发送者" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已驗證的傳送者" + } + } + } + }, "content.accessibility.view_fingerprint_hint" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index ae663f84..1a00315b 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -68,7 +68,10 @@ final class ChatMessageFormatter { suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } - if isVerifiedSender { + // Private rows render a filled SF Symbol seal beside the lock + // (TextMessageView / MediaMessageView); skip the in-string ✓ there + // so verified DMs don't show two markers. + if isVerifiedSender, !message.isPrivate { appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) @@ -388,7 +391,7 @@ final class ChatMessageFormatter { suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } - if isVerifiedSender { + if isVerifiedSender, !message.isPrivate { appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index a16e3ac6..ed376acf 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -50,6 +50,15 @@ struct TextMessageView: View { .padding(.trailing, 4) .accessibilityHidden(true) } + if conversationUIModel.showsVerifiedSeal(for: message) { + Image(systemName: "checkmark.seal.fill") + .font(.bitchatSystem(size: 8)) + .foregroundColor(Color.green.opacity(0.85)) + .padding(.trailing, 4) + .accessibilityLabel( + String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message") + ) + } if message.isBridged { Image(systemName: "network") .font(.bitchatSystem(size: 8)) diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 2e2d1429..29711f38 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -48,6 +48,15 @@ struct MediaMessageView: View { .padding(.trailing, 4) .accessibilityHidden(true) } + if conversationUIModel.showsVerifiedSeal(for: message) { + Image(systemName: "checkmark.seal.fill") + .font(.bitchatSystem(size: 8)) + .foregroundColor(Color.green.opacity(0.85)) + .padding(.trailing, 4) + .accessibilityLabel( + String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message") + ) + } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center, spacing: 4) { Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) From 1f59e814f90c3f489f48d68262cb1bf640bf6181 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Sat, 1 Aug 2026 18:21:31 +0530 Subject: [PATCH 35/35] Keyboard navigation for @-mention suggestions (#1542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tab and arrow keys for mention autocomplete Highlight the selected suggestion and let Tab accept it. Up/down move the selection when the mention panel is open; Tab otherwise still cycles focus. Co-authored-by: Cursor * Retrigger CI after flaky GeoRelayDirectory iOS test Co-authored-by: Cursor * fix: navigate mention suggestions with the same macOS key monitor as commands Arrow keys never reach SwiftUI while the composer field editor has focus, so adopt the NSEvent local monitor from #1504. Align accept keys (Return or Tab), add Escape to dismiss, and match highlight opacity. Co-authored-by: Cursor * Fix dead key monitor: gate on live state, not a value-captured Bool A synthetic-event harness against the extracted modifier showed the realistic path broken: the panel is hidden when the composer appears, so onChange(of: isActive) ran on the previous render's modifier value and installed a monitor whose closure had captured isActive == false — it passed every key through forever. Arrows/Tab/Escape never worked on a real Mac. Install the monitor once for the view's lifetime and gate each event on an isActive closure that reads the reference-typed model live. Same fix applies to the iOS onKeyPress guards for consistency. Harness now passes all paths including deactivate/reactivate. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Cursor Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/ConversationUIModel.swift | 31 ++++++ bitchat/Views/ContentComposerView.swift | 132 +++++++++++++++++++++++- 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 53062d45..4883a04c 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -9,6 +9,7 @@ import UIKit final class ConversationUIModel: ObservableObject { @Published private(set) var showAutocomplete = false @Published private(set) var autocompleteSuggestions: [String] = [] + @Published private(set) var selectedAutocompleteIndex = 0 @Published private(set) var currentNickname: String @Published private(set) var isBatchingPublic = false @Published private(set) var canSendMediaInCurrentContext = true @@ -36,6 +37,7 @@ final class ConversationUIModel: ObservableObject { self.isBatchingPublic = chatViewModel.isBatchingPublic self.showAutocomplete = chatViewModel.showAutocomplete self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions + self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext bind() @@ -104,6 +106,31 @@ final class ConversationUIModel: ObservableObject { chatViewModel.completeNickname(nickname, in: &text) } + /// Accept the currently highlighted mention suggestion, if any. + func completeSelectedSuggestion(in text: inout String) -> Bool { + guard showAutocomplete, + autocompleteSuggestions.indices.contains(selectedAutocompleteIndex) + else { return false } + _ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text) + return true + } + + /// Dismiss the mention suggestion panel without inserting (Escape). + func dismissAutocomplete() { + guard showAutocomplete else { return } + chatViewModel.showAutocomplete = false + chatViewModel.autocompleteSuggestions = [] + chatViewModel.autocompleteRange = nil + chatViewModel.selectedAutocompleteIndex = 0 + } + + func moveAutocompleteSelection(by delta: Int) { + guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return } + let count = min(4, autocompleteSuggestions.count) + let next = (selectedAutocompleteIndex + delta + count) % count + chatViewModel.selectedAutocompleteIndex = next + } + func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme) } @@ -205,6 +232,10 @@ final class ConversationUIModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$autocompleteSuggestions) + chatViewModel.$selectedAutocompleteIndex + .receive(on: DispatchQueue.main) + .assign(to: &$selectedAutocompleteIndex) + chatViewModel.$isBatchingPublic .receive(on: DispatchQueue.main) .assign(to: &$isBatchingPublic) diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index b5020980..64021d52 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -2,6 +2,9 @@ import SwiftUI #if os(iOS) import UIKit #endif +#if os(macOS) +import AppKit +#endif struct ContentComposerView: View { @EnvironmentObject private var conversationUIModel: ConversationUIModel @@ -29,7 +32,7 @@ struct ContentComposerView: View { VStack(alignment: .leading, spacing: 6) { if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in + ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in Button(action: { _ = conversationUIModel.completeNickname(suggestion, in: &messageText) }) { @@ -43,6 +46,11 @@ struct ContentComposerView: View { .padding(.horizontal, 12) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) + .background( + index == conversationUIModel.selectedAutocompleteIndex + ? palette.secondary.opacity(0.15) + : Color.clear + ) } .buttonStyle(.plain) } @@ -73,7 +81,28 @@ struct ContentComposerView: View { .textInputAutocapitalization(.sentences) #endif .submitLabel(.send) + .modifier(AutocompleteKeyboardNavigationModifier( + isActive: { conversationUIModel.showAutocomplete + && !conversationUIModel.autocompleteSuggestions.isEmpty }, + onMove: { delta in + conversationUIModel.moveAutocompleteSelection(by: delta) + }, + onAccept: { + conversationUIModel.completeSelectedSuggestion(in: &messageText) + }, + onDismiss: { + conversationUIModel.dismissAutocomplete() + } + )) + // Return while the mention panel is open completes the + // highlight instead of sending — matches command suggestions + // (#1504) and keeps Tab/Return/Escape on one convention. .onSubmit { + if conversationUIModel.showAutocomplete, + !conversationUIModel.autocompleteSuggestions.isEmpty, + conversationUIModel.completeSelectedSuggestion(in: &messageText) { + return + } onSendMessage() // Only the return-key path: it steals focus on iOS, so // every message would cost a tap to reopen the keyboard. @@ -374,3 +403,104 @@ private extension ContentComposerView { ) } } + +/// Arrow/Tab/Return/Escape navigation for the mention suggestion list. +/// +/// Deployment targets are iOS 16 / macOS 13, so `.onKeyPress` (iOS 17 / +/// macOS 14+) is gated and unavailable on the minimum OS. Separately, on +/// macOS the single-line field editor consumes `moveUp:`/`moveDown:` itself, +/// so arrow keys never reach SwiftUI while the composer has focus — the +/// same reason command suggestions (#1504) use an `NSEvent` local monitor. +/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+. +private struct AutocompleteKeyboardNavigationModifier: ViewModifier { + /// Live activity check, not a captured Bool. The macOS monitor closure is + /// registered once for the view's lifetime; a plain `Bool` would freeze + /// the value captured at install time (this is a value type), so a panel + /// that opens after the monitor installs would never intercept a key. + /// The provider closes over the reference-typed model and reads current + /// state on every event. + let isActive: () -> Bool + let onMove: (Int) -> Void + let onAccept: () -> Bool + let onDismiss: () -> Void + + #if os(macOS) + @State private var keyMonitor: Any? + #endif + + func body(content: Content) -> some View { + #if os(macOS) + content + .onAppear { installKeyMonitor() } + .onDisappear { removeKeyMonitor() } + #else + if #available(iOS 17.0, *) { + content + .onKeyPress(.upArrow) { + guard isActive() else { return .ignored } + onMove(-1) + return .handled + } + .onKeyPress(.downArrow) { + guard isActive() else { return .ignored } + onMove(1) + return .handled + } + .onKeyPress(.tab) { + guard isActive() else { return .ignored } + return onAccept() ? .handled : .ignored + } + .onKeyPress(.escape) { + guard isActive() else { return .ignored } + onDismiss() + return .handled + } + } else { + content + } + #endif + } + + #if os(macOS) + private func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + handleKeyDown(event) + } + } + + private func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + } + keyMonitor = nil + } + + /// Standard autocomplete navigation (aligned with #1504): arrows move + /// the highlight, return/tab insert, escape dismisses. Returning nil + /// consumes the event so return completes instead of sending while the + /// list is up. Inactive monitors pass everything through. + private func handleKeyDown(_ event: NSEvent) -> NSEvent? { + guard isActive(), + event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { + return event + } + + switch event.keyCode { + case 126: // up arrow + onMove(-1) + return nil + case 125: // down arrow + onMove(1) + return nil + case 36, 48: // return, tab + return onAccept() ? nil : event + case 53: // escape + onDismiss() + return nil + default: + return event + } + } + #endif +}