mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
1065 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cfa875459d |
Review fix: record withheld receipts in both tracking sets
Codex P1: the manager-path withheld claim landed only in PrivateChatManager.sentReadReceipts, while the lifecycle read pass dedups against ChatViewModel's persisted set — enabling receipts before the next lifecycle pass could send a receipt for a message read while the setting was off. The withheld branch now records into the owner's persisted set too (markReceiptHandled, wired in the bootstrapper); test strengthened to require both sets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3e46829f32 |
Add a read-receipt toggle
Read receipts were unconditional: on chat open and on didBecomeActive, twice. A receipt is a presence oracle — it says the person is awake, holding their phone, and opened the app at that exact moment, which is precisely the metadata the stated threat model says someone should be able to withhold. Every mainstream messenger ships this switch. - ReadReceiptSettings (default ON = existing behavior), toggle in the settings privacy section, reset on panic wipe. - All four origination paths gated: mesh/nostr routing, direct mesh receipts, geohash receipts, and PrivateChatManager's read pass. Withheld receipts are still recorded locally as sent, so re-enabling the setting never fires a retroactive burst disclosing past reads. - Only outbound receipts are affected; receipts from others display. - 2 strings x 30 locales; tests pin the default, the reset, and that nothing reaches the transport while off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9b84b36122
|
Localize the last hardcoded UI strings; wire up two dead translations (#1656)
* Add 26 code-referenced keys to the catalog + a test that closes the gap
LocalizationCoverageTests validated the catalog but never the code: a
String(localized:) whose key is missing from every catalog compiles
fine and silently ships its English defaultValue to all 29 non-source
locales. That blind spot let 26 keys go untranslated while CI stayed
green: the entire notices/board composer (10), the private-media
encryption warnings and delivery-failure reasons (9), both
courier-header strings, two delivery states, two media failure
reasons, and the [? people] channel row.
- All 26 keys added with full 30-locale coverage.
- New everyCodeReferencedKeyExistsInACatalog scans the source for
literal String(localized:) keys and fails on any key absent from
both catalogs. (The audit's original count of 17 was itself an
undercount — its scan missed multi-line String( localized: calls;
the test's regex does not.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Localize CommandProcessor: all 48 command strings, 30 locales
CommandProcessor had zero String(localized:) calls — every command
error, usage hint, and the entire /help reference was hardcoded
English. The autocomplete panel (CommandInfo) is fully localized, so a
non-English speaker got localized suggestions while typing and English
the moment anything went wrong or they asked for help.
- 47 literal sites converted to String(localized:)/String(format:)
with positional specifiers where languages reorder (%1$@, %2$lld).
- /trace's "hop"/"hops" pluralization split into path_one/path_many;
the "you" chain label localized too.
- helpText and groupUsage become computed so they resolve per-locale.
- 49 command.* keys added with full 30-locale coverage (command
syntax stays verbatim; only prose translates).
- Copy fix from the audit: "blocked X. you will no longer receive
messages from them" → "…no longer see their messages" (blocking
filters at display time; packets still arrive and relay).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Localize the last hardcoded UI strings; wire up two dead translations
The stragglers from the unlocalized-strings audit:
- Nearby push notification: "bitchatters nearby!" title and the
hand-rolled English pluralization ("1 person around" / "N people
around") now localize via body_one/body_many.
- Voice recording errors (mic permission, start/too-short/save
failures) and the "Recording Error" alert title — also brought into
the lowercase house voice.
- The recording HUD's Cancel label now uses common.cancel.
- "No image selected" in both image pickers.
- The DM screenshot echo "you took a screenshot" (the wire-format
sibling stays English for Android compat).
- "Voice/Images are only available in mesh chats." had fully
translated catalog entries in all 30 locales that were never looked
up — the call sites passed the raw string instead of resolving the
key. Wired up via String(localized:).
10 new keys x 30 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
5c4b814c56
|
Localize CommandProcessor: all 48 command strings, 30 locales (#1657)
* Add 26 code-referenced keys to the catalog + a test that closes the gap LocalizationCoverageTests validated the catalog but never the code: a String(localized:) whose key is missing from every catalog compiles fine and silently ships its English defaultValue to all 29 non-source locales. That blind spot let 26 keys go untranslated while CI stayed green: the entire notices/board composer (10), the private-media encryption warnings and delivery-failure reasons (9), both courier-header strings, two delivery states, two media failure reasons, and the [? people] channel row. - All 26 keys added with full 30-locale coverage. - New everyCodeReferencedKeyExistsInACatalog scans the source for literal String(localized:) keys and fails on any key absent from both catalogs. (The audit's original count of 17 was itself an undercount — its scan missed multi-line String( localized: calls; the test's regex does not.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Localize CommandProcessor: all 48 command strings, 30 locales CommandProcessor had zero String(localized:) calls — every command error, usage hint, and the entire /help reference was hardcoded English. The autocomplete panel (CommandInfo) is fully localized, so a non-English speaker got localized suggestions while typing and English the moment anything went wrong or they asked for help. - 47 literal sites converted to String(localized:)/String(format:) with positional specifiers where languages reorder (%1$@, %2$lld). - /trace's "hop"/"hops" pluralization split into path_one/path_many; the "you" chain label localized too. - helpText and groupUsage become computed so they resolve per-locale. - 49 command.* keys added with full 30-locale coverage (command syntax stays verbatim; only prose translates). - Copy fix from the audit: "blocked X. you will no longer receive messages from them" → "…no longer see their messages" (blocking filters at display time; packets still arrive and relay). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
eca147191c
|
Add 26 code-referenced keys to the catalog + a test that closes the gap (#1654)
LocalizationCoverageTests validated the catalog but never the code: a String(localized:) whose key is missing from every catalog compiles fine and silently ships its English defaultValue to all 29 non-source locales. That blind spot let 26 keys go untranslated while CI stayed green: the entire notices/board composer (10), the private-media encryption warnings and delivery-failure reasons (9), both courier-header strings, two delivery states, two media failure reasons, and the [? people] channel row. - All 26 keys added with full 30-locale coverage. - New everyCodeReferencedKeyExistsInACatalog scans the source for literal String(localized:) keys and fails on any key absent from both catalogs. (The audit's original count of 17 was itself an undercount — its scan missed multi-line String( localized: calls; the test's regex does not.) Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dba67c1466
|
Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout (#1653)
* Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout The file-local waitUntil defaulted to 3s — below the documented minimumSettleTimeout floor — and the recent-chats geo-dedup wait missed it on a loaded runner right after merge (the Combine hop through receive(on: .main) was starved). Every caller waits for a condition to become true, so green runs return immediately and never pay the 30s deadline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix the real geo-dedup race: re-assert tracker state on each poll The 30s deadline didn't help — the recent-chats geo-dedup wait wasn't slow, it was permanently broken under parallel swift-test load: the view model's own channel binding delivers its initial .mesh selection asynchronously and resets the active participant geohash when it lands (GeohashSubscriptionManager → setActiveParticipantGeohash(nil)), wiping the test's setup so the dedup could never happen. Reproduced locally with `swift test --parallel`; standalone runs never hit it. The wait now re-asserts setActiveGeohash + recordParticipant on every poll (both idempotent) — the same self-healing pattern the geohash timeline test already uses for singleton interference. Interference heals on the next poll; a genuine dedup failure still times out. 6× full parallel runs green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5b592c8bae
|
Add a persistent connectivity banner and stop the radar lying (#1597)
* Add a persistent connectivity banner and stop the radar lying Two "the app looks fine while it isn't" problems from the UX audit: - Bluetooth-off had exactly one signal: a dismissible alert. Once dismissed, the app looked completely normal — empty timeline, radar sweeping "searching for people" — while the radio was off. The radar animation now requires a radio that can actually scan, and a persistent red banner under the header carries the state instead: off (tap → settings), denied (tap → settings), or unsupported (mesh unavailable; location channels still work). - A Tor bootstrap stall was only ever announced inside geohash timelines (addGeohashOnlySystemMessage), so someone sitting in #mesh or a DM whose messages route over Nostr got silence and hanging "sent" states. The stall now also drives a chrome-level torBlocked flag and the same banner: "tor can't connect — internet features are paused. mesh still works." The banner renders nothing when everything is healthy, and .unknown/ .resetting radio states deliberately stay quiet — a false "bluetooth is off" flash at launch would be the same kind of lie. Priority and quiet-start behavior are pinned by ConnectivityIssueTests. 4 new strings, all 30 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: banner in the sheet, real fix targets, tor-clear tests - The people/DM sheet covers the root header, so the connectivity banner vanished exactly where people sit longest. It now mirrors into the sheet via a top safe-area inset over both the people list and the DM view. - "tap to fix" for powered-off Bluetooth opened the macOS privacy permission pane, where an already-authorized person can't fix anything. New SystemSettings.bluetoothPower routes to the Bluetooth pane; denied still goes to the privacy anchor. The one-shot alert's settings button routes by state the same way. - Tests pin that torBlocked clears on tor-ready and on preference change (no stale "tor can't connect" after toggling tor off). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
66fdea52ae
|
Make the panic wipe confirmable and its outcome visible (#1588)
* Make the panic wipe confirmable and its outcome visible The triple-tap panic wipe fired instantly with no confirmation, on the same 18pt logo whose single tap opens App Info — while the far less destructive triple-tap-to-clear-chat 40pt below it asks first, and the Settings-pane panic button has always confirmed. A fumbled tap on the app's only help affordance could permanently destroy identity, keys, and messages. - Triple-tap now raises the same confirmation dialog the Settings pane uses (one extra tap under duress; zero accidental wipes). - A successful wipe leaves a "all data wiped — new identity created" system message: in duress, "did it work?" must not be a guess — the natural response to uncertainty is tapping again. - A wipe that does not commit (panicRecoveryBlocked) was previously invisible: the app looked wiped, sat silently offline, and keys could still be on disk. It now shows a persistent red banner under the header saying data may remain and a relaunch retries. - Settings/privacy copy no longer says "instantly" (all 30 locales). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: dismiss sheets on wipe; host the dialog durably - Codex P1: the App Info danger-zone wipe left its sheet presented, so the failed-wipe banner (and the success message) rendered underneath an opaque modal. panicClearAllData now dismisses all chrome-owned presentation first; pinned by a new architecture test. - Review nit: the confirmation dialog moved from the logo Text to the ContentView header stack, so a covered/removed header can't take a pending dialog down with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c6cb4210e4
|
Add a recent-chats section so DM conversations can't become unreachable (#1598)
* Add a recent-chats section so DM conversations can't become unreachable The largest IA hole from the design/UX audit: a direct conversation with someone who went offline (and isn't a mutual favorite) had no row anywhere in the UI. The roster filters to connected/reachable/mutual- favorite, the header envelope only exists while unread, and /msg can't resolve offline strangers — read a DM from a passerby, close the sheet, and the thread was still in memory with no way back to it. - PeerListModel now derives RecentChatRow entries from the ConversationStore's direct conversations: people absent from the rosters above, newest activity first, unread flag, deduplicated by fingerprint across ephemeral/stable peer-ID mirrors (newest wins), groups and blocked peers excluded. - A "chats" section (same header shape as #mesh/groups) renders them in the people sheet on both mesh and location channels — geoDMs from a channel since left stay reachable too. Tapping reopens the DM via the existing startConversation path; the section renders nothing when empty. - Rebuilds are driven by direct-conversation store changes only, so public-timeline traffic doesn't churn the sheet. 2 new strings, all 30 locales. New architecture test pins that an offline stranger's thread gets a row while group threads don't. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: geo dedup and geoDM names in recent chats - A person still visible in the geohash roster could also get a recent chat row (the builder only subtracted mesh rows). Visible geohash pubkeys now join the identity set via their PeerID(nostr_:) form, so the absent-from-rosters contract holds on location channels too. - GeoDM rows went through resolveNickname, which only consults mesh identity sources and rendered nostr_… keys as an opaque anon fallback. They now resolve through geohashDisplayName (Nostr key mapping, bare-key fallback). Both pinned in the recent-chats architecture test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4f30433406
|
Remove the public-channel screenshot broadcast (#1596)
* Remove the public-channel screenshot broadcast Taking a screenshot used to announce "* nickname took a screenshot *" to the entire active public channel — a mesh broadcast, or on geohash channels a mined ephemeral event published to the 5 nearest Nostr relays, permanently timestamping that this nickname was present and active at that place. Documenting something (or someone) is a core use of a protest app; it must not out the person doing it. - Public channels (mesh and geohash) no longer send anything on screenshot. The mined-event helper is deleted and the lifecycle context trimmed of its now-unused requirements. - A geohash timeline screenshot instead raises the existing local location-privacy warning alert (same one the channel sheet uses) — warn the person, tell no one. - DM screenshot notices remain, and the local "you took a screenshot" echo is now honest: it only appears when the notice actually went to the peer (it used to render even when no established session existed and nothing was sent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: pin the screenshot routing table The geohash warn path lived only in AppRuntime with no coverage. The decision is now a pure nonisolated table (AppRuntime.resolveScreenshotResponse) pinned by tests: location sheet or geohash timeline → local privacy alert (nothing sent), App Info → nothing, mesh/DM → chat layer (where public channels stay silent). Mesh deliberately gets no local alert: a mesh screenshot reveals no place and triggers no send, so there is nothing actionable to warn about — alerting on every screenshot would train people to dismiss the one alert that matters. Rationale now documented on the response enum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4122a4dacd
|
Stop claiming encryption and privacy properties that aren't live (#1595)
* Stop claiming encryption and privacy properties that aren't live
Two of the app's most safety-critical signals could show more security
than actually existed:
1. getEncryptionStatus was sticky-optimistic: any peer whose fingerprint
was ever persisted resolved to noiseSecured/noiseVerified even when
the live Noise session was .none, .handshaking, or .failed. After a
cold launch or a handshake failure the DM header showed a solid lock
and the composer caption claimed "end-to-end encrypted" with no
secure session in existence. The status now reflects only the live
session; a remembered fingerprint decides what an established
session upgrades to (verified vs secured), nothing more. Peers
reachable only over Nostr keep the encrypted caption — gift-wrapped
delivery is end-to-end without a Noise session.
2. Privacy copy told three lies (all 30 locales fixed):
- "ephemeral identity — new peer ID generated regularly": the peer
ID is stable until a panic wipe (rotation is spec-only). Now
"local-only identity — your identity is a keypair created on this
device; panic wipe replaces it instantly."
- "no servers, accounts, or data collection": location channels and
internet delivery go through Nostr relays. Now "no accounts, no
phone numbers, no data collection."
- location channels: "your IP address is hidden by routing all
traffic over tor" — tor is a toggle. The description now says tor
is the default, and the sheet shows the existing red tor-off
warning at the point of joining whenever the toggle is off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: gate nostr availability on a stored recipient key
- Codex P2 / review: a mutual favorite with a nil peerNostrPublicKey
still reported .nostrAvailable, so the DM caption could claim
"end-to-end encrypted" with neither a Noise session nor a usable
Nostr key. BitchatPeer.connectionState (and the header fallback in
PrivateConversationModel) now require a stored recipient key —
mirroring NostrTransport's reachability rule. Regression test:
mutual + nil key → .offline; key present → .nostrAvailable.
- Copy: "local-only identity" → "device-local identity" (all 30
locales) — once a fingerprint or QR is shared it isn't "only" local;
device-local says where the keys live without over-claiming.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
6ef3945179
|
Deflake gift-wrap tests: settle deadlines, not latency budgets (#1651)
* Deflake gift-wrap tests: settle deadlines, not latency budgets The gift-wrap round-trip tests failed three CI runs this week (handleGiftWrap_privateMessageStoresConversationAndMapping, handleGiftWrap_deliveredAckUpdatesExistingMessage, handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates), each with the same signature: the async NIP-17 unwrap missed a 5s wait on a loaded runner. 40 local iterations of both suites pass clean — the failures are scheduler starvation, exactly the class TestConstants.settleTimeout documents. Every positive wait in ChatViewModelExtensionsTests and ChatNostrCoordinatorContextTests now uses settleTimeout (30s): the explicit 5.0s literals on the gift-wrap waits, the longTimeout media waits, and the bare-default channel-switch waits. All are expected-true waits, so passing runs return immediately and never pay the deadline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deflake SimulatedMesh TTL budget: settle discovery before the baseline publicMessageRelaysAcrossLineTopologyWithinTTLBudget snapshotted its frame baseline after a single 2s advance, but discovery is not quiet by then: every first-seen peer schedules an afterglow re-announce at a random 0.3-0.6s delay (BLEAnnounceHandler), and each of those can cascade another relay round. Whether that traffic lands before or after the snapshot depends on the draw — CI measured the "single message" at 14 and 18 frames against a budget of 12. The test now advances until the mesh goes a full window with no new frames before taking the baseline, so the budget only ever measures the message under test. 30 local iterations green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f0249d9cd7
|
Make the Periphery scan blocking (#1648)
The job shipped with its own promotion condition — "drop continue-on-error once the baseline proves stable" — and the baseline has now been stable across a month of merges since it landed July 8 (#1410). Known macOS-scan false positives stay suppressed by the committed baseline; --strict already fails the step on NEW dead code, so the only change is that the failure now blocks instead of annotating. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6f961638f2
|
Timestamp sanity windows: future-dated QR codes and implausible Nostr DM rumors (#1647)
Two re-assessment findings: - verifyScannedQR checked only staleness (now - ts > maxAge), so a future-dated timestamp bought a QR a longer validity window than a fresh one. The freshness check is now symmetric. - Inbound Nostr DMs had no client-side created_at validation; the age bound relied entirely on relays honoring the subscription's `since` filter. Both gift-wrap decrypt paths now drop rumors outside [now - lookback - skew, now + skew]. The inner rumor timestamp is the sender's true send time (only the outer gift wrap is randomized per NIP-17), so the window mirrors exactly what an honest relay already guarantees — a dishonest relay can no longer inject stale or future-dated DMs. Existing mitigations (persistent gift-wrap dedup, relay-side since) are unchanged; this closes the malicious-relay gap. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2a9fb4d53f
|
Make SwiftLint blocking in CI now that the violation backlog is zero (#1646)
* Make SwiftLint blocking in CI now that the violation backlog is zero The lint job has been advisory since #1361, with the backlog tracked in #1088. That backlog is now down to 28 violations, so this clears it and flips the job to enforcing instead of adding baseline machinery: - Fix all remaining violations: closure parameters/capture lists joined onto their opening-brace line (BLEService, NoiseSessionManager, ChatMediaTransferCoordinator, BLENoisePacketHandlerTests), one trailing comma, one opening-brace spacing (both via swiftlint --fix). - Move the two periphery:ignore comments above the doc blocks they were orphaning; verified with a periphery --strict scan that the commands still register (no new findings vs baseline). - Disable the todo rule: the single TODO cites tracked issue #1434 and is a deliberate marker, not lint debt. - Exclude .device-lab from local runs (untracked; CI never sees it). - CI: drop continue-on-error and the "(advisory)" label; run with --strict so any new warning fails the job. Full SwiftPM suite green (2,004 tests / 216 suites) after the formatting changes; swiftlint lint --strict exits 0 with the same 0.65.0 the CI container pins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix is_disjoint violation introduced by #1542 The strict lint gate caught this on its first run: the mention-suggestion key handler used modifierFlags.intersection([...]).isEmpty, which is exactly the pattern isDisjoint(with:) expresses directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9c84d4ce4f
|
Fail closed on unverifiable handshake peer IDs + check SecRandomCopyBytes results (#1645)
Two hardening fixes from the repo evaluation: - NoiseSessionManager.authenticatedRemoteKey returned true for peer IDs that are neither 16-hex wire IDs nor full Noise-key IDs — an accept-any-key fallback kept for test harnesses. It now fails closed; the Noise/integration/E2E tests that relied on it address peers by key-derived wire IDs instead (the pattern NoiseCoverageTests already used), and a new regression test pins the rejection. - Four SecRandomCopyBytes call sites discarded the return status. The two verification nonces now fail their operation on error, the Nostr device identity seed uses CryptoKit key generation (cannot fail, and can no longer silently persist an all-zero seed), and BIP-340 aux randomness throws on failure like the adjacent nonce path. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1f59e814f9
|
Keyboard navigation for @-mention suggestions (#1542)
* 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 <cursoragent@cursor.com> * Retrigger CI after flaky GeoRelayDirectory iOS test Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0152344554
|
feat: verification seal on private message sender rows (#1573)
* 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 <cursoragent@cursor.com> * fix: skip in-string verified ✓ on private rows with SF Symbol seal Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
681c180060
|
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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * chore: retrigger CI after unrelated VoiceRecorder flake Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
c66015d029
|
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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
68edb34469
|
Location channels: locale-derived one-tap quick join (#1444)
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 <noreply@anthropic.com> |
||
|
|
2c9a4e07c6
|
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> |
||
|
|
8f9489790d
|
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. |
||
|
|
948d6a85b9
|
Peer ID rotation: working primitives + spec for iOS/Android review (#1487)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * docs: rotation capability bit is 14 now — 11-13 claimed by in-flight work Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9edb7c26ef
|
Silence the four release-build warnings (#1583)
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 <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>v1.7.1 |
||
|
|
6f32363774
|
Deflake VoiceRecorderTests: replace timed semaphores with async events (#1572)
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 <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
59a9f628df
|
test: pin that unknown file TLVs are skipped, not fatal (#1550)
`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> |
||
|
|
7b39d72bec
|
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> |
||
|
|
3a75567f5c
|
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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> |
||
|
|
f269617004
|
Fix the retire↔reconnect oscillation: redundant-link survivor is the newest connection (#1566)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5780405dce
|
Fix the SimulatedMesh announce-loss flake (#1564)
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 <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1d0dc58221
|
Make the completion-grace restart test deterministic (#1563)
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 <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6414a59851
|
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. |
||
|
|
6c8499a603
|
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 |
||
|
|
e2bd13a7f2
|
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 <cursoragent@cursor.com> |
||
|
|
e7f4ef0912
|
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 <cursoragent@cursor.com> |
||
|
|
4ef5558d7b
|
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). |
||
|
|
81837d7202
|
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 |
||
|
|
ab835e58c9
|
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 <cursoragent@cursor.com> * Fix blocked-mention test resetting private(set) state Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
e8f95e9a88
|
Fix built-in relay actor isolation (#1528) | ||
|
|
b49400ff0c
|
Fix $$ escaping that broke every Xcode just recipe (#1525) | ||
|
|
e2b409e466
|
Fix #1538: release stale bindings on rotation instead of leaving a ghost identity (#1554)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4226f01503
|
Link layer slice 5: BLELinkEvent — the port has a name, the delegates have their own files (#1551)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cdebdd9347
|
Link layer slice 4: deterministic multi-node mesh simulation (and the panic-announce bug it caught) (#1548)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2f5b56ce57
|
Link layer slice 3: bindings and link-auth become engine-owned (the option-B domain flip) (#1547)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a0b7985cbe
|
Link layer slice 2: cohere link-auth state and split bindings from the physical store (#1540)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2c22b117b2
|
Extract the central-role radio policy into BLERadioController (#1539)
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 <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d39467f7d3
|
Defer alert-binding dismissal writes out of the view update (#1537)
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 <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c6b7096b2f
|
BLE transport architecture V3: one engine domain, capability ports, feature-owned state (#1498)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Document the V3 transport architecture and remaining roadmap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Update the V3 doc for the completed feature-peeling and Transport split Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Assert the armed deadline count in the injected-clock ping test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
eadd3a20c1
|
Add Play Store link to README (#1524) |