mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
93 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
0152196ac2
|
Deflake CI, and make the flake class unrepeatable (#1491)
* Deflake two iOS-sim CI tests with unbounded timing assumptions Both failed on main-adjacent CI runs during this session's PRs. Neither was a product bug; both asserted things about real time that a loaded runner is under no obligation to honour. **NetworkReachabilityGateTests: a wall-clock upper bound.** test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit slept 500ms for real, then asserted total elapsed time was under 1.4s to prove a duplicate mid-window had not restarted the 1.0s debounce. One CI run took 3.75s. No wall-clock bound can separate "deadline preserved" from "runner is slow", because Task.sleep and the asyncAfter flush are both real time and neither is bounded above. The deadline property was already covered deterministically one level down: test_debounce_duplicateObservationsPreservePendingDeadline drives ReachabilityDebounce with injected timestamps and checks pendingRemaining directly. So the monitor test now asserts only what needs a real monitor — that a duplicate still yields exactly one committed false through the debounce — with an injected clock for the arithmetic and a generous liveness budget. Renamed to say what it actually checks. No coverage lost, and it runs in 0.14s instead of ~3.8s because the real sleep is gone. **NoiseEncryptionServiceTests: injected timeouts that also arm during setup.** #1483 diagnosed and fixed exactly this in the quarantine-restore test, but two sibling tests kept the shape. Their injected ordinaryResponderHandshakeTimeout (0.04 and 0.06) also arms during the establishSessions setup handshake, where bob is the responder — so a preempted runner fires it mid-setup, tears down the half-open responder, and message 3 gets answered as a fresh initiation. The CI failure named setup's own `#expect(finalMessage == nil)` seeing a 96-byte message 2, which is precisely the signature #1483 recorded. Raised both to 1.0s, matching #1483's remedy: the scenarios still need the responder timeout to fire, and it still does, just with room for setup to complete first. Thin 1-second waitUntil budgets in the file now use the shared TestConstants.longTimeout; every one of them backs a positive assertion, so waitUntil still returns the moment the condition holds and nothing gets slower in the passing case. No assertion weakened in either file. Verified 3x sequentially and 3x with all 18 cores saturated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Raise two more starvation-prone test deadlines Both surfaced on the CI runs for this PR and #1487, both in tests neither PR touches, both the same shape as the two already fixed here: a deadline sized for the work rather than for a runner executing many suites at once. VoiceNotePlaybackControllerTests waited 5s for a @MainActor Task that playback schedules for the session acquire and its failure path. When that Task is not scheduled in time the helper reports *two* failures — the wait, and the `!isPlaying` the un-run failure path has not reset yet — which reads like a playback bug rather than a starved scheduler. That is exactly what CI showed. GeoRelayDirectoryTests waited 10s for work the directory runs in Task.detached(priority: .utility); utility priority competes with every other suite. The retry-scheduling case timed out at exactly 10.06s with the retry never scheduled, reading like a missing retry rather than a starved background task. Raised to 30s each with the reasoning recorded at the helper. Both helpers return as soon as their condition holds, so nothing slows down when tests pass — only the genuine-failure case takes longer to report. Verified 3x with all cores saturated: both suites clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Make the flake class unrepeatable, not just fixed Raising four deadlines fixed the four tests that happened to fire. The class was still there: fourteen separate waitUntil helpers, most defaulting to 1.0s, plus wait call sites with literal budgets. The fifth instance would have landed the same way, on someone else's unrelated PR. The rule, now written down in TestConstants.settleTimeout: **a wait deadline is not a latency budget.** It exists so a genuine hang eventually fails the suite, so size it for the worst-case scheduler, never for how long the operation should take. Waits return as soon as their condition holds, so a generous deadline is free in the passing case and only extends genuine failures. - Every wait helper now defaults to TestConstants.settleTimeout (30s), and the literal wait call sites below the floor were converted too — sixteen sites across ten files. - TestTimingHygieneTests enforces it by scanning the test sources: wait defaults and wait call sites must be at least minimumSettleTimeout, and no test may assert an upper bound on elapsed wall-clock time (the assertion that started this, which cannot separate correct behaviour from a slow machine). - Both rules waive per line with "test-timing-ok: <reason>", accepted on the line or in the comment block above it so the reason has room to be a sentence. One legitimate use so far: a NEGATIVE wait in NoiseCoverageTests asserting a promotion has *not* completed within 50ms, where a long deadline would only make the suite slow while still passing. Injected production timeouts are deliberately not matched — the Noise handshake timeouts are the behaviour under test, and short values are correct there. Verified the guard actually fails: a canary file with both banned shapes was flagged with file and line, and the suite went green again once it was removed. Full suite passes with all 18 cores saturated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Close the guard's blind spot: named short timeouts A fifth flake landed on CI while the first guard was in review — GossipSyncBoardTests timing out at 1.03s — and the guard did not catch it, because the deadline was `TestConstants.shortTimeout` rather than a literal. A literals-only scan cannot see a short value behind a symbol, which is the more common way it is written: 81 wait sites used shortTimeout (1s) or defaultTimeout (5s), every one of them below the floor. Rather than guess which of those were safe to raise, all 81 were converted and the suite timed. Runtime went 15s -> 72s, which located the genuine negative waits precisely: ten tests that assert something does *not* happen and therefore always run their deadline out. Measurement instead of a heuristic, since a mis-guess in either direction is invisible — too short reintroduces the flake, too long silently costs a minute a run. Those 28 sites now use `TestConstants.negativeWaitWindow`, a named constant whose doc explains the inverted reasoning: for a negative wait, starvation can only make the assertion *more* likely to hold, so short is correct, and the name states the polarity instead of leaving a bare literal that reads like the mistake. Suite is back to 15.3s. The guard now also rejects `timeout: TestConstants.shortTimeout` and `defaultTimeout`, while accepting `negativeWaitWindow` by name. Re-verified with a canary carrying every banned shape — literal default, both named constants, and an elapsed-time upper bound. All four were flagged with file and line; the suite went green again on removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Delete shortTimeout now that nothing may use it The hygiene guard bans `TestConstants.shortTimeout` at every wait site and the last users were converted, so Periphery correctly flagged the constant itself as dead and failed CI. Remove it from both TestConstants copies; the banned-name entry stays so the symbol cannot quietly return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
132120a88e
|
Close the three holes the #1486 review found (#1488)
* Fix the three follow-ups from the #1486 review Three defects shipped with the censorship-resilience merge, all confirmed against main: 1. Source-manifest verification silently accepted added files. shasum -c checks only the files the manifest lists, and the Xcode project compiles every source file present in the tree — so a hostile mirror could pass verification by adding a file rather than modifying one. The manifest header and VERIFYING-A-BUILD.md now require the completeness check (git status --porcelain, or a path diff for tarballs) alongside the hash check. 2. A relay removed while Tor was bootstrapping reconnected anyway. dropRelays never subtracted from pendingTorConnectionURLs, and a custom relay passes the allow-list filter, so draining the pending queue resurrected a relay someone had explicitly deleted. 3. Turning Tor off mid-bootstrap read as 'network may be blocking tor'. shutdownCompletely left the detached 75s poll loop running, which then stamped bootstrapDidStall over the clean shutdown state; and the stall handler guarded on torEnforced, which is compile-time true in release, instead of the runtime preference. The poll loop is now generation-fenced (shutdown, dormancy, and restart each invalidate it) and the handler consults persistedTorPreference(). Both app-side fixes carry regression tests proven to fail pre-fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Codex review: ignored files and manifest placement git status --porcelain omits ignored paths, and .gitignore covers build/ — a planted bitchat/build/Evil.swift would compile via the synchronized group while the documented check stayed silent. The checkout check now uses --ignored. The downloaded manifest also has to live outside the tree, or it trips the completeness checks itself; the doc now says so and references it at /tmp throughout. 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> |
||
|
|
c1ce9029d8
|
Keep working when the network is hostile, and make the app verifiable (#1486)
Tier 3 of a protest-hardening review. The BLE mesh is already properly fenced off from network reachability and needs nothing here; every gap is on the internet side, or in how someone gets a build they can trust. Say when Tor is blocked. The bootstrap poll loop simply ended at its 75-second deadline, leaving isStarting true with no further state, so a network that blocks Tor was indistinguishable from a slow one and the UI said "starting tor…" indefinitely. TorManager now exposes bootstrapDidStall and posts .TorBootstrapDidStall, and the app reports that mesh messaging still works while internet delivery is paused. It is cleared on each new start or restart, so a later attempt can report again. Let relays be added by hand. The four built-in relays are well-known clearnet hostnames, which is four names for a censor to block and no recourse short of a new build. NostrRelaySettings persists up to eight additional relays, normalized, .onion accepted, with a settings editor. They join the same target set as the built-ins and are subject to the same activation policy. Removal reconciles against the previous set: the teardown path iterates the current targets, so without that a removed relay's socket and queued sends would linger — covered by a test that fails without it. The merged list is cached rather than recomputed, because allowedRelayList consults it once per candidate URL and would otherwise read UserDefaults inside that loop. Stop stranding people who denied location. The activation gate required location permission or a mutual favorite, but teleporting into a geohash requires neither, so someone with no permission and no favorites could sit in a channel that never connected while Tor and the relays stayed suppressed and nothing said why. Being in a location channel is now a third arm of the gate, in both the activation service and the relay manager's copy of the policy, and leaving the channel closes it again. Stop burning the Tor timeout when Tor is off. GeoRelayDirectory awaited Tor readiness unconditionally, but with the preference off TorManager has been shut down, so every refresh spent the full bootstrap deadline and the directory froze on its cached copy. It now keys on the preference, not on live readiness: Tor wanted but unavailable must still skip the fetch rather than fall back to clearnet. Say what turning Tor off costs. The toggle's copy described it as hiding your IP "for location channels", understating both scope and consequence. It now names private messages too, and while the toggle is off the settings screen states that every relay can see the device IP. Make builds verifiable. There was no release verification of any kind: no signatures, no checksums, no documented procedure. Post-takedown that is the acute gap, because mirrors appear and people install whatever they can find during a shutdown. source-manifest.yml publishes a per-tag SHA-256 manifest with a provenance attestation, self-checking before it publishes, and docs/VERIFYING-A-BUILD.md explains how to verify source and states plainly that compiled builds from anywhere but the App Store cannot be verified. It also records the gaps honestly: no published signing key, no reproducible build, no non-GitHub mirror. docs/TOR-INTEGRATION.md was substantially stale — it documented a torrc, SOCKSPort and ControlPort that the in-process Arti client does not use, and claimed there are no user-visible settings — so it is rewritten, including the deferred gap below. Deferred: no Tor bridges or pluggable transports. arti-client is built without pt-client or bridge-client and bootstraps from stock config, so in a country that blocks Tor outright there is still no circumvention path — only a clear report that there isn't. Closing it needs the Rust features, bridge config through the FFI, and an xcframework rebuild under the pinned toolchain with a provenance update, which is its own change. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c079d2ab5d
|
Harden what a locked or seized device gives away (#1484)
* Harden what a locked or seized device gives away
The realistic compromise for many of the people this app is built for is
not interception but a phone taken, and often unlocked under coercion.
Content encryption is in good shape; these are the gaps around it.
Hide notification previews, by default. Notification content is rendered
by the system on the lock screen, so it was readable without unlocking:
DM alerts carried the sender's nickname and the full message body, and
geohash alerts put the geohash in the title. Alerts now state that a DM,
mention, or location-channel activity arrived and withhold the rest until
the app is opened. userInfo still carries the routing peer ID and deep
link, neither of which the system displays, so taps land where they did.
A settings toggle restores full previews for anyone who wants them.
Default-on is the deliberate part: a phone face-up on a table should not
narrate conversations, and someone who wants previews can say so.
Cover the window on willResignActive, so the snapshot iOS stores for the
app switcher shows a placeholder rather than an open conversation. Opaque
rather than blurred, because blurred large text stays partly legible and
the snapshot goes to disk. Added synchronously from a UIKit notification
with queue: nil, since the capture follows shortly after and an
OperationQueue hop or a SwiftUI state change can lose that race. Panic
wipe already deleted snapshots already on disk; this stops new ones from
being worth deleting.
Bound media by age as well as size. The 100 MB quota only ever considered
incoming files, so outgoing media had no lifetime at all and a received
photo could outlive its conversation indefinitely. A launch-time sweep now
deletes managed media older than seven days, incoming and outgoing, with
the same exemptions quota eviction honors: in-flight live captures and
files reserved by a delivery or deletion in progress.
Make /clear tell the truth on the mesh timeline. It recorded an echo
watermark and left the gossip archive on disk for up to 6 hours, so
someone who cleared before a police stop had deleted nothing. Clearing now
erases the archive too. The watermark still matters: it suppresses
pre-clear messages this device hears again from peers. The cost is that
the device stops serving recent public backlog until it hears fresh
traffic, which is a fair reading of what clearing a timeline means.
Documented in PRIVACY_POLICY.md and the privacy assessment, including a
new section on what is deliberately NOT addressed: there is still no
duress mechanism of any kind (no decoy passphrase, no wipe-on-failed-auth,
no app lock), macOS gets no file-protection classes, and media is not
sealed at the app layer. The duress question is a product decision as much
as an engineering one, since in some jurisdictions destroying data on
demand is itself an offence and hiding may protect someone better than
destroying, so it is called out rather than guessed at.
Three findings from the audit that prompted this work turned out to be
already fixed on main and are not included: keychain accessibility is
AfterFirstUnlockThisDeviceOnly with a retrying migration, the panic media
wipe uses a two-location durable marker transaction, and panic already
discards staged share-extension content.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Inject the previews preference instead of reading shared defaults
CI caught a real problem this introduced. `NotificationServiceTests`
already asserted the full-preview title and body, and both it and the new
redaction tests read `NotificationPrivacySettings` from
`UserDefaults.standard` in the same process. Whichever ran second
depended on the other's cleanup, so it passed locally and failed in CI:
XCTAssertEqual failed: ("🔒 new dm") is not equal to ("🔒 DM from Alice")
Fixed at the source rather than by ordering or serialization.
`NotificationService` now takes a `hidePreviewsProvider`, defaulting to
the real preference, so each test states which behavior it asserts. The
pre-existing test asks for previews shown and gains a redacted
counterpart; the redaction tests no longer touch the shared store.
`NotificationPrivacySettings` also gained store-injecting accessors so
the default-value and round-trip assertions can use an isolated suite
rather than mutating preferences other tests read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
934b2cd2d3
|
Harden iOS-sim CI: destination fallback + Noise reconnect test determinism (#1483)
Two CI-robustness fixes for the iOS-simulator job seen on main: 1. The destination picker no longer dies on runner images with placeholder/unavailable simulator lists (macos-26-arm64 20260720.0258): it filters error/unavailable rows, falls back to xcrun simctl's available-device list, and as a last resort creates a simulator from the newest installed iOS runtime's own supported iPhone device types. Each path logs which route was taken. 2. Deflakes NoiseEncryptionServiceTests' quarantine-restore test: the injected 20ms responder timeout also armed during the SETUP handshake, so a loaded runner tore down the half-open responder session before the scenario began (production quarantine/restore verified correct — the failure signature was a fresh msg2 to a session-less peer). Timeout raised to 1s and the fixed 100ms sleep replaced with a bounded waitUntil on the restore condition. No assertion weakened; 10/10 sequential + 3/3 under full-core CPU load. |
||
|
|
a1711bd399
|
Retry unacknowledged DMs after Noise replacement (#1462)
DMs sent through an established Noise session are retained in the durable MessageRouter outbox until an authenticated delivery/read ack, and retried under the same message ID when the peer's replacement handshake authenticates — fixing DMs silently lost into stale local sessions after a remote app restart. Mesh ack handling is peer-scoped end-to-end (alias-scoped delivery status, peer-bound markDelivered, PeerMessageKey retry state), so a colliding message ID from another conversation can no longer clear or promote foreign state; bridge-drop dedup keys are recipient-scoped with hashed persistence. Includes review fix: acks arriving after a relaunch (durable outbox restored, conversation not) now clear the peer-scoped router entry unconditionally — only the UI status transition is gated on conversation presence — so a delivered message can no longer re-send to the attempt cap and be marked failed despite delivery. Regression test simulates the relaunch through real store persistence; the dead allowedPeerIDs parameter (unscoped-tombstone trap) is removed. |
||
|
|
a4d294015a
|
Keep DMs canonical across transport aliases (#1461)
Joins the two authenticated aliases of one account — the full noise-key conversation ID used by the Nostr path and its derived 16-hex mesh short ID — into one canonical conversation, migrating messages and handing off an open DM sheet (short ID while connected, stable ID offline). Delivery/read-ack outbox removal is scoped to the authenticated peer's aliases with per-peer tombstones, so a colliding message ID queued for a different peer survives, including across protected-data cold-load recovery. Geohash DMs never enter the alias path (their conversation keys carry no noise key). |
||
|
|
c72bb4ca2e
|
Make private-media deletion transactional (#1468)
Receiver-side private-media deletion (per-bubble delete and /clear) becomes a write-ahead transaction: a deletion journal in BLEPrivateMediaReceiptStore is the atomic commit point, materialization (tombstones + payload unlinks) is idempotent and retried on recovery, path reservations in BLEIncomingFileStore prevent delete racing an in-flight arrival, and overlapping /clear operations are serialized with panic-generation invalidation. Integrates with the receipt quarantine (a pending journal entry outranks quarantine; materialization never resurrects a quarantined ID). Review fixes included: (1) /clear no longer deletes outgoing media mirrored into another conversation (alias protection now mirrors the incoming path, with a regression test that fails pre-fix); (2) explicit delete of legacy incoming media actually unlinks the decrypted payload when unreferenced — gated on pending-delivery/reservation state, restoring main's delete semantics safely instead of leaving plaintext for quota cleanup; (3) refused deletions surface a localized system message in the affected chat instead of failing silently (30-locale key). Full local suite 1876 green. |
||
|
|
fb451bc6d0
|
Persist authenticated private-media delivery receipts (#1466)
Adds a durable receiver-side ledger (BLEPrivateMediaReceiptStore) mapping a deterministic private-media message ID — hash-bound to sender, recipient, and entropy-bearing filename — to the stored file before UI delivery and before the Noise-encrypted delivery ACK, so sender retries and relaunches cannot create duplicate bubbles or files. Receipts are unforgeable without the Noise session; capability bit 9 rides the existing announce bitfield (no wire-format change; rolling upgrade safe both directions). Includes review fixes: corrupt receipt records are quarantined per-record (bytes preserved at <id>.json.corrupt, only that ID fail-closed — previously one bad record silently disabled ALL inbound private media forever); the panic reset now reaches BLEService's own store instance via completePanicReset with a production-wiring test; and both content.delivery.reason.* strings ship with full 30-locale coverage. |
||
|
|
e3e97d51ec
|
Preserve early Noise ciphertext across reconnect promotion (#1464)
Companion to #1463 (they were built as a unit): adds a bounded deferral buffer (4/peer, 32 global, byte-budgeted, responder-timeout lifetime) for ciphertext that arrives before XX message 3 or before BLE installs generation-bound transport state, retried exactly once after the serialized authentication/restore callback. Reclassifies malformed/forged/replayed/oversized/rate-limited ciphertext as drop-only, so attacker bytes can no longer evict an established session (on main, a 1-byte forgery evicts the session via the old clearSession + re-handshake path). Serializes Noise packet reception as messageQueue barriers. Honors #1463's timeout-restore deferral through the same-generation ready path (verified by negative test: without the gate, parked DMs drain under discarded keys). Full local suite 1789 tests green. |
||
|
|
78a81e5b57
|
Make ordinary Noise reconnects atomic and race-safe (#1463)
Replaces destroy-then-rebuild Noise re-handshakes with an atomic reconnect protocol: prepared XX message-1 handoff tokens (claim-once, invalidated by crossed inbound initiations), receive-only quarantine of the established transport while an inbound replacement proves identity (promote on success, rollback+cooldown on failure/timeout), deterministic lower-peerID-wins crossed-initiator resolution, and a per-link-epoch BLE revalidation policy that re-proves cached sessions inside the same bleQueue critical section as the link rebind. On main, a fresh msg1 simply destroys an established session and rekey spans two non-atomic barriers. Includes the review fix: timeout-restores defer outbound queue draining until the convergence retry completes (restore reason plumbed end-to-end), so DMs are never drained under keys a restarted peer already discarded — with a deterministic interleaving test. Identity-mismatch restores drain immediately. Full local suite 1768 tests green. |
||
|
|
2d96fd99a1
|
Encrypt private media before BLE fragmentation (#1434)
Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send. Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling. Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests). |
||
|
|
b96a41054e
|
Pin announce signing keys to stop mesh identity spoofing (#1349)
TOFU-pins the Ed25519 signing key per noise key so a self-consistent announce that reuses a victim's peerID+noise key with the attacker's signing key/nickname is rejected instead of overwriting the registry and persisted identity. Complements #1432: that PR's signed-LEAVE verification reads the stored signing key this PR protects from announce-path poisoning. Rebased onto main as a single commit; swift build --build-tests green, 40/40 targeted tests incl. the spoofing e2e cases, and #1432's suites 50/50 unaffected. |
||
|
|
d326cecb63
|
Fix BLE identity state races (#1428)
Converts BLEAnnounceThrottle to a lock-backed class and moves myPeerID/myPeerIDData/myNickname into a lock-backed BLELocalIdentityStateStore snapshot, so announces can never observe a split peer-ID/wire-ID during panic rotation. Serializes sendAnnounce onto the messageQueue barrier and moves the panic-reset pendingNoiseSessionQueues clear onto collectionsQueue (the queue every other mutation site uses). Rebased onto main after #1431/#1432; integrates with #1431's panic-suspend structure (guard retained in both the outer sendAnnounce and the deferred worker). |
||
|
|
b59ad97dd6
|
perf: verify Nostr inbound signatures once, off the main actor (#1352)
Moves inbound Nostr Schnorr verification and NIP-17 gift-wrap decryption off the main actor into per-relay bounded AsyncStream pipelines (parallel across relays, ordered per relay), dedups before crypto, and removes the redundant second re-verify in NostrInboundPipeline/GeoPresenceTracker. Adds a panic-wipe generation counter so in-flight decrypts are discarded after a wipe. Rebased onto main; #1451's 256 KiB pre-parse byte cap and tag limits are preserved and still enforced before any crypto/allocation. |
||
|
|
16324c819f
|
Bind Noise sessions to claimed peer identities; require signed leaves (#1432)
Adds remote-static-key->peerID binding at Noise handshake completion (closes a mesh impersonation/MITM hole where a peer could complete a handshake under another peer's ID). Also hardens LEAVE handling to require a verified signature and suppresses relay of unverifiable leaves. |
||
|
|
cd727c6867
|
Make panic wipe deterministic and device-bound (#1431)
* Make panic wipe deterministic and device-bound * Scope install markers to iOS * Harden panic recovery and service shutdown * Invalidate queued BLE ingress during panic * Harden panic keychain and media cleanup --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: jack <jack@deck.local> |
||
|
|
733098bb63
|
fix: use country-level resolution for low-precision geohashes (#1044)
* fix: use country-level resolution for low-precision geohashes (#887) * fix: skip admin fallback when country exists but is duplicate Prevents mixed labels like "United Kingdom and Scotland" for single-country geohashes. Admin fallback now only triggers when no country is available from the placemark. * fix: migrate stale low-precision bookmark names so country-first logic applies Users who bookmarked a <=2-char geohash before the country-first resolver kept the old administrativeArea cache (e.g. "England" for `gc`) because resolveBookmarkNameIfNeeded bails when bookmarkNames[gh] is non-nil. Add a one-shot, versioned migration that drops cached names for <=2-char geohashes on load; the next LocationChannelsSheet .onAppear re-resolves them via the fixed logic. Higher-precision entries are untouched. * Fix low-precision geohash country names --------- Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> Co-authored-by: jack <jackjackbits@users.noreply.github.com> |
||
|
|
820c933958
|
Harden PTT audio and the 1.7.1 release (#1423)
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage. Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives. |
||
|
|
b205a55523
|
Consolidate duplicate BLE links after restore; duplicate fragment-stream suppression; onChange coalescing (#1426)
* Consolidate duplicate BLE links after restore; suppress duplicate fragment streams; onChange coalescing Field evidence (two-phone test after BLE state-restoration relaunches): both phones held 2-3 simultaneous same-role connections to each other — one side received every packet from three distinct centrals all bound to the same peer — so every PTT voice frame arrived 3x, and a 41KB voice file went out as TWO complete independent 89-fragment streams (different assembly ids), both fully reassembled and the duplicate only dropped at the very end by messageID dedup. 2-3x airtime/battery on all traffic plus doubled reassembly memory. Root causes and fixes: - Duplicate links stayed unbound forever: announces (the only packet that binds a link to a peer) went through the per-peer duplicate-link collapse, so a peer's second/third link never received the announce it needed to become bound — and unbound links pass the collapse untouched, so every broadcast sprayed down all of them. Direct announces now bypass the collapse and reach every live link (relayed announces keep it); once bound, the existing collapse dedups all traffic. - Same-role link retirement: a verified direct announce now consolidates our central-role connections to that peer — keep the link the announce arrived on (or the most recently bound one), cancel other connected links bound to the same peer. One connection per role per peer is the normal dual-role topology; only same-role duplicates are touched, only links already announce-bound are retired (never pre-announce links), at most one retirement per peer per rebind-cooldown window, and the peer keeps a live link either way. Directness stays forgeable (TTL is unsigned), so a replay could nominate the survivor — bounded by the cooldown and by DM routing's existing canDeliverSecurely gate. A rotation rebind now also cancels other stale links still bound to the rotated-away ID, so the ghost identity retires promptly. - Deterministic preferred-link collapse: when several bound links to one peer are candidates, collapse now keeps the peer's most recently bound link (the reverse-mapped one) instead of dictionary order; links without a discovered characteristic are excluded from fanout (they cannot be written to, and could silently eat a peer's collapsed copy). links(to:) now reports all bound peripheral links, and removing one duplicate no longer clobbers the reverse map of the survivor. - Duplicate fragment streams: a transferId-less resend (gossip-sync replay, spool) of file content already being fragmented out to a covering audience is dropped at the outbound scheduler (broadcast covers everyone; directed covers its recipient). App-initiated sends carry an explicit transferId the progress UI tracks and always run. A peer that asks after the stream completes still gets a resend. - didUnsubscribeFrom no longer flaps a peer that is still live on other links (the far side retiring its duplicate arrives as an unsubscribe); didDisconnectPeripheral bookkeeping is skipped for self-retired links by removing the store entry before cancelling. Also: guard the DeliveryStatus onChange state write in TextMessageView/ MediaMessageView (unconditional per-row writes under a message storm tripped SwiftUI's "tried to update multiple times per frame" warning). The restore-path bgRemaining=∞ log was already fixed on main (#1425 review follow-up 07b5ac31: init-time seed + sampler-routed restore captures); verified, no change needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: multi-link disconnect guard, characteristic-aware retirement Adversarial review of the duplicate-link PR (merge-with-nits): - didDisconnectPeripheral gets the same multi-link guard as the unsubscribe path: when a duplicate link drops naturally while the peer stays live on another (dual-role central link, or a second bound link during the post-restore consolidation window), peer-disconnect bookkeeping (markDisconnected + disconnect notify) no longer runs — a UI blip until the next announce re-marked the peer connected. The reverse map was just repaired onto a connected survivor, so directLinkState is the accurate probe. Scan restart and connect-slot refill stay unguarded: they respond to the physical drop regardless of remaining logical links. (No unit test: driving the CBCentralManager delegate requires CBPeripheral instances, which cannot be constructed in tests; the policy pieces backing the guard are covered.) - Retirement is now characteristic-aware: the policy snapshot carries characteristic presence, and keptPeripheralUUID selects anchors only among writable links while any exist — consolidation must not keep a link mid-service-rediscovery (didModifyServices cleared its characteristic) and cancel the writable duplicate, stranding outbound traffic on the central link until rediscovery finishes. When neither anchor is writable but a writable duplicate exists, consolidation defers to a later announce instead of guessing. The reverse-map survivor repair in removePeripheral prefers writable links for the same reason. Three new policy tests cover charless-vs-writable. Deferred with code comments per review: central-side collapse keeps the oldest subscription (no recency signal; remote consolidates within its cooldown), and the extra preferred-bindings bleQueue hop in sendOnAllLinks (fold into a combined snapshot if profiling flags 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> |
||
|
|
f8ab0a7dc0
|
Fix restore-path main↔bleQueue deadlock and courier drop amplification (#1425)
* Fix restore-path main↔bleQueue deadlock and courier drop amplification A device froze permanently in a two-phone test. Debugger stacks showed an ABBA deadlock: the main actor was in bleQueue.sync (delivery-ack send → broadcastPacket → readLinkState) while bleQueue was in main.sync (captureBluetoothStatus reading backgroundTimeRemaining). The load that lined the two edges up came from a courier-drop amplification storm: drop dedup was in-memory only while the outbox driving 120s re-deposits is persisted, so every relaunch republished the same undelivered DM as a fresh 24h relay drop and every gateway relaunch re-fetched the whole backlog — ~20 copies of one DM delivered in 40ms, each triggering decrypt + delivery + ack + handshake work. Fixes, in rank order: - Edge B (P0): captureBluetoothStatus no longer main.syncs from bleQueue; backgroundTimeRemaining is sampled on main and cached behind a lock. Invariant documented: bleQueue must NEVER sync-dispatch to main. - Edge A (P0, defense in depth): sendDeliveryAck / sendReadReceipt / sendPrivateMessage / sendNoisePayload / triggerHandshake hop to messageQueue like sendMessage, so no main-actor call path reaches readLinkState's bleQueue.sync. - Drop dedup (P1): publishedDropKeys and seenDropEventIDs persist across relaunches (new BridgeDropDedupStore, entries expire with the 24h NIP-40 drop window; wiped on panic) — one drop per message ID per 24h regardless of relaunch count. - Receiver dedup (P1): openCourierEnvelope dedups on the inner private message ID before delivery, so a duplicate copy costs one decrypt and never re-delivers, re-acks, or re-triggers a handshake. - Handshake gating (P2): queued acks initiate a Noise handshake only for reachable peers; mail from absent/rotated identities no longer turns each copy into a mesh-wide handshake flood (the ack stays queued and flushes when a session eventually establishes). - Outbox (P3): re-enqueueing a queued message ID carries over its depositedCourierKeys so resends stop re-burning the same courier slots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: offline-drop durability, gateway handoff retry, restore-log freshness, coalesced persist Adversarial review of the storm/deadlock PR surfaced four issues: - Offline blackhole (must-fix): a deposit made while relays were down persisted its dedup key even though the drop only sat in the in-memory pending queue — app killed before reconnect meant the relaunch lost the drop but the persisted key blocked every re-deposit for 24h. The persisted snapshot now excludes keys still pending; they become durable only when flushPendingDrops actually publishes them. - Gateway handoff: seen-event IDs were consumed before the deliverToPeer handoff; a failed handoff (peer walked away) permanently dropped the event for a single-gateway island. deliverToPeer now reports whether the handoff was attempted, and a failure releases the seen slot so a relaunch or backlog redelivery retries. - Restore-path logs: central/peripheral-restore captures logged the init sentinel bgRemaining=∞. The cache is now seeded in init's main-thread branch and restore captures route through the sampler, which refreshes the cached budget before logging. - Persist cost: the dedup record was a full JSON encode + atomic write on the main actor per mutation (once per event during a backlog re-fetch). Writes now coalesce behind a 1s window, flushed immediately on background/terminate; panic wipe stays immediate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove BoundedIDSet.remove, orphaned by the ExpiringIDSet migration The drop-dedup sets that needed slot release moved to ExpiringIDSet; remaining BoundedIDSet users only insert and check. Periphery caught 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> |
||
|
|
b7c6f42b3a
|
Close forged-directness link-rebind DoS on DM routing (#1421)
* Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut Residual gap after the rotation-heal containment (#1401): "verified direct" announces prove the signature but not directness — TTL is unsigned — so a malicious connected peer can replay a victim's fresh announce with its TTL restored. When the victim has no live link, the replayer's link rebinds to the victim's ID and reads as "connected", and MessageRouter's connected fast-path then trusts it outright: every DM stalls on a Noise handshake the replayer can never complete and is silently lost while showing "sent". Router-level trust gate (no wire change): - Transport gains canDeliverSecurely(to:) — BLE answers with an established Noise session; Nostr keeps its prompt-delivery predicate; the protocol default forwards to canDeliverPromptly for transports without a forgeable link layer. - MessageRouter.sendPrivate only trusts a connected link outright when it can deliver securely; otherwise it still sends (kicking the handshake on a genuine link) but retains a copy and hands a sealed copy to couriers, like the reachable path. flushOutbox gets the same gate so a flush over an insecure link resends instead of dropping the retained copy. Presence hardening (defense in depth): a "direct" announce arriving on a link already bound to a different peer no longer shortcuts the claimed peer into "connected" — only real link state does. Genuine first-contact and direct announces are unaffected; only the ambiguous heal path loses the forgeable shortcut. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff Review follow-ups on the canDeliverSecurely gate: - flushOutbox no longer counts connected-but-insecure flushes toward the maxSendAttempts drop: the message was actually transmitted over a live link, so a peer whose Noise handshake stalls across reconnect flapping must not burn through the cap and lose the store-and-forward copy the gate exists to preserve. Retention stays bounded by the 24h outbox TTL and the per-peer FIFO cap; acks still clear it. Attempt-counting stays for reachable-only (heuristic) sends. Regression test: >8 connected- insecure flushes keep the retained copy, drop callback never fires. - Document the known second-replay presence gap: linkBoundToOtherPeer reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals the link, so once a first replay has rebound a link to an absent victim's ID, a second replay marks the victim connected. Presence display only — DMs stay on the retain+courier path via the router gate. Not closed at the announce layer because the post-rebind state is indistinguishable from a legitimate rotation/reconnect heal (a supported, field-verified flow). Covered by a two-announce test. - Note the accepted courier-spray metadata tradeoff at the connected- insecure send: nearby verified peers receive a sealed copy (they learn a DM exists, never its content), cleared on ack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery Codex review follow-ups on 5017c232: - P1: a legitimate rotation announce necessarily arrives on a link still bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the new identity as disconnected — and the rebind only fixed link state, never the registry. A healed rotation then read as disconnected until the peer happened to announce again. rebindLinkAfterVerifiedDirect- Announce now promotes the rebound identity to connected after the containment checks pass (registry markConnected + topology/snapshot/ peer-list refresh; the .peerConnected UI event already fired from the announce path). This consciously moves the forged-presence residue from second-replay to first-successful-rebind — bounded by the rebind containment (never steals a live identity, one rebind per link per cooldown) and still display-only: DMs stay gated on canDeliverSecurely. Comments and the pinned tests updated; new regression test covers rotate-on-open-link -> rebind -> isPeerConnected(new) == true. - P2: BLEService.canDeliverSecurely probed the Noise session with the peer ID as given, but sessions are keyed by the short wire ID — a send keyed by the full 64-hex Noise key (favorites resolution) misread an established session as insecure and needlessly retained + couriered every DM until ack. Normalize with toShort() like isPeerConnected. Test drives a real XX handshake and asserts both ID forms pass. - Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only" flake fired again and slipped past its baselined USR (read exists in loadFromDisk; the indexer intermittently misses it). Replace the baseline entry with retain_codable_properties: true — deterministic, and a truly-dead Codable field is a persisted-format change anyway. Local periphery scan --strict: no unused code detected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c8479c15e3
|
PTT hardening: per-peer burst keys + live-capture storage quota (#1420)
* PTT hardening: burst-ID collision hijack + live-capture quota bypass Fix C — burst-ID collision hijack: inbound live-voice assemblies and the finished-burst registry were keyed by the sender-chosen burst ID alone, so an attacker who observed a public burst ID could race a START and capture the real talker's frames (packets from the true sender were dropped as "collisions"). Assemblies now key on (peerID, scope, burstID): a colliding START from another peer opens its own capped assembly and can never divert the victim's frames; finalized-note absorption matches on the same triple, preserving the sender/scope binding. Live capture files also gain the peer ID in their name so colliding bursts land on distinct paths (still rejected by burstID(fromVoiceFileName:), so live names remain unabsorbable). Fix B — live-burst files bypassed the incoming-media quota: progressive voice_live_*.aac captures were written via raw FileHandle without ever touching BLEIncomingFileStore.enforceQuota, growing disk unbounded outside the 100 MB LRU accounting. The coordinator now takes an injected BLEIncomingFileStore, reserves pttMaxBurstBytes before opening a capture, and sweeps orphaned voice_live_* partials from previous sessions at startup. enforceQuota gains an `excluding:` set so in-flight partials are never LRU-evicted mid-stream; existing callers are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Review fixes: pattern-guard live captures in quota, scope in capture names Quota-eviction gap: BLEFileTransferHandler enforces the quota for every finalized arrival via enforceQuota(reservingBytes:) with no exclusion set, so a file landing at quota could LRU-evict an in-flight live capture — unlinking the inode under the coordinator's open FileHandle and leaving a dead bubble. Protection is now layer-independent: enforceQuota itself skips voice_live_* names (they still count toward usage), and the excluding: parameter is gone since the pattern guard covers its only caller. Orphaned partials are still reclaimed by the coordinator's startup sweep, which the quota deliberately never touches. Same-peer cross-scope truncation: makeIncomingURL omitted the scope, so one peer running a DM burst and a public burst with the same burst ID mapped both assemblies onto one path — and FileManager.createFile truncates, so each START corrupted the other capture. Names now mirror the assembly key: voice_live_<burstHex>_<peerID>_<dm|mesh>.aac. The sweep and quota guard match on the voice_live_ prefix and burstID(fromVoiceFileName:) still rejects every live name, so live captures remain unabsorbable; sameBurstIDCoexistsAcrossScopes now asserts both files survive with intact contents. Nits: the coordinator's file operations route through the store's injectable FileManager instead of FileManager.default, and the TestEnvironment.isRunningTests branch in init is replaced by a sweepsOnInit parameter (tests sharing the real application-support directory pass false for hermeticity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Promote kept live captures off the voice_live_ name at finalize Codex P1 follow-up: when a burst finalizes with frames but its .m4a note never arrives, the voice_live_*.aac capture stays behind as the bubble's replayable audio — yet the startup sweep deletes every voice_live_* file and the quota guard skips them forever, so a kept fallback was both quota-immune for the rest of the session and doomed at the next launch. finalize now promotes the capture to a plain voice_ name (same suffix) and republishes the row pointing at it; the finished-burst registry tracks the promoted URL so a late note still absorbs and deletes it. voice_live_ is thereby scoped to genuinely in-flight captures: the sweep never touches a referenced fallback, and promoted files age out of the quota like any finalized media. If the move fails the live name is kept — exactly the pre-promotion behavior. Premise correction, verified while tracing the reference model: chat rows are NOT persisted across restarts (ConversationStore is in-memory; the gossip archive replays MessageType.message packets only), so today's sweep never orphaned a persisted row — the fix removes the in-session quota immunity and makes the invariant hold if row persistence ever lands. Crash case stays deliberate and documented: a mid-burst partial from a dead process is swept because no surviving row can reference it. Tests: finalize-as-fallback promotes the file, repoints the row, and survives a later coordinator's startup sweep; promoted fallbacks are LRU-evictable by the quota; the sweep still removes true orphans while sparing promoted names; existing burst tests updated for the promoted paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
84d315e62d
|
Bridge dedup: content-derived stable mesh message ID (#1419)
* Bridge dedup keys on a content-derived stable mesh message ID Public mesh messages carry no message ID on the BLE wire, so every non-origin device minted a fresh UUID and the bridge's m-tag dedup only matched on the origin device: duplicate bridged rows, misattributed "across the bridge" counts, redundant downlink rebroadcasts, and an m-tag spoof vector (an attacker could claim a victim's message ID). Every device now derives the same stable ID from the signed wire fields (sender ID + ms timestamp + trimmed content, SHA256/32 hex) via the new MeshMessageIdentity — zero BLE wire change. The bridge event's m tag carries the origin coordinates ["m", senderIDHex, timestampMs] and receivers recompute the key from those plus the event's own content instead of trusting a claimed ID; old-format/absent tags fall back to the event ID as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix mixed-version message loss: m tag leads with the derived stable ID The previous layout (["m", senderIDHex, timestampMs]) broke v1.7.0 receivers: their parser takes m[1] unconditionally as the timeline dedup key whenever the tag has >= 2 elements, so every bridged message from a new-version sender keyed on the CONSTANT sender hex and inject-dedup dropped all but the first. The tag is now ["m", <derived stable ID>, senderIDHex, timestampMs]: old parsers get a per-message-unique m[1] (exactly today's semantics), while the new parser recomputes the ID from elements 2-3 plus the event's own content and never trusts element 1, keeping the recompute-don't-trust property. Also: - Soften the overstated security claim in MeshMessageIdentity and the BridgeService classify comment: forging a chosen ID onto different content is infeasible, but all three hash inputs are cleartext on the radio, so identical-content front-running by a radio-local attacker remains possible (no worse than the unbridged mesh). - Fix the stale archivedEchoKeys rationale: re-synced copies of others' messages now carry the derived stable ID (insert-by-ID catches them); the content key remains for echo--prefixed archive rows + self echoes. - Tests: old-parser semantics on the new tag (m[1] per-message-unique and equal to the derived ID) and a forged-m[1] event that cannot pre-poison a genuine message's dedup slot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
dd6b624cae
|
Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 (#1418)
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed, well-scoped findings; deeper architectural/security items are tracked separately. - PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s 150ms retry pause left the mic live and streaming for up to 120s, because cancel() no-op'd once `completed` was set. Bail after the sleep if the hold was released, and make cancel() always tear down a late-started capture. - Bridge courier depositDrop reported success and burned the dedup slot before the drop was actually published (evicted/compose-fail = lying 📦 "carried" with no retry). Only consume publishedDropKeys on durable accept; add BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey). - Blocked senders resurfaced via archived "heard here earlier" echoes, the one path that bypassed the live block filter — filter at seed time. - A late optimistic .sent clobbered the router's .carried state; extend ConversationStore.shouldSkipStatusUpdate to a full precedence guard (sending < sent < carried < delivered < read). - Read receipts were permanently burned when the router dropped them (marked sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only record as sent on a successful route, else retry on the next read scan. - MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a peer that never reconnects sat on .sending until relaunch — run it in the 120s bridge sweep. - Sightings tally now rolls over at midnight while idle; wave notification action localized across all 29 locales; bridged anon#tag uses suffix(4) like everything else; makeThrowawayIdentity delegates to NostrIdentity.generate(); .swiftlint.yml excludes .claude worktrees. - Add regression tests: carried→sent no-downgrade, carried→delivered upgrade, evicted pending drop stays retryable. - Bump MARKETING_VERSION to 1.7.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage The stricter no-downgrade guard (delivered/carried never regress to sent) broke two things the earlier commit didn't catch locally (perf tests are skipped in the default run, and Periphery runs only in CI): - PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered assuming both directions apply; the delivered -> sent half is now correctly skipped, so the pass measured 0 updates. Alternate two delivered timestamps instead — every update is real, no downgrade. - Routing the geoDM "not in a location channel" error into the thread removed the only caller of ChatPrivateConversationContext.addSystemMessage, leaving it (and its mock) dead per Periphery. Drop the protocol requirement, the mock impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is compile-time enforced: the context can no longer emit a public system line). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge - Read receipts: claim the receipt in sentReadReceipts synchronously before spawning the routing task (chat open runs two read scans in one MainActor stretch, so the async insert let every unread message route twice), and release the claim when the route fails so the retry-on-failed-route behavior is preserved. - Delivery status: extend the no-downgrade guard so the `.sending` stamp a pre-handshake resend emits can no longer clobber carried/delivered/read (the 📦 indicator survived `.sent` but not `.sending`). - Archived echoes: blocking a peer now purges their carried public messages from the gossip archive at block time (UnifiedPeerService and /block), while the fingerprint-to-peerID mapping is still known — the seed-time filter can't resolve offline non-favorite strangers and stays only as defense-in-depth. New Transport hook (default no-op) + GossipSyncManager.removePublicMessages with immediate persist. - Bridge courier: an envelope that can't encode within the drop size caps fails identically on every attempt; consume the dedup slot so the 120s retry sweep stops re-running Noise sealing on it. - MeshSightingsTracker: cache the day-key DateFormatter instead of building one per call. Tests: double-markAsRead dedup + failed-route retry, carried→sending no-downgrade matrix, block-time purge (manager + service wiring), oversize-drop slot consumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Also skip the sent→sending downgrade in the delivery-status guard Codex review follow-up: sendPrivateMessage without an established Noise session emits `.sending` asynchronously, so it can land after the message already reached `.sent` and visibly walk "Sent" back to "Sending...". Treat `.sending` as weaker than `.sent` too — the status was already truthful. `.failed` → `.sending` stays allowed so a retry after a real failure remains visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Retain Codable properties in Periphery scan (same fix as #1421) The noiseKey assign-only false positive fired persistently on this branch (twice, including a rerun) despite the baselined USR. Byte-identical to the fix on fix/announce-replay-link-steal so the branches merge cleanly in either order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
020de96519
|
Mesh bridging: stitch nearby mesh islands over the internet, courier drops, unified settings (#1412)
* Mesh bridging: stitch nearby mesh islands over Nostr, courier drops, settings surface
Three features plus a UI consolidation, all opt-in behind a new Bridge toggle:
Channel bridge: while bridging, outgoing public mesh messages are also
signed (with a derived, unlinkable per-cell Nostr identity) as kind-20000
events tagged #r with the local geohash-6 cell and published to the cell's
deterministic geo relays; mesh-only peers deposit via new toBridge/fromBridge
carrier directions through a bridge gateway (bridge + gateway toggles).
Remote islands' events render into the mesh timeline marked with a network
glyph. Events carry the original mesh message ID so the store's insert-by-ID
absorbs radio/bridge duplicates in either order. Loop prevention mirrors
GatewayService (three BoundedIDSet caches + skip-if-seen-locally + budgets).
Nothing crosses a bridge unless its author signed it for the bridge; a
per-message "nearby only" composer toggle keeps a message radio-only.
Courier over the bridge: sealed courier envelopes park on default relays as
kind-1401 drops tagged #x with their day-rotating recipient tag (NIP-40
expiry), signed by per-drop throwaway keys. Recipients subscribe for their
own candidate tags; bridge gateways watch verified local peers' tags and
hand matching drops over as directed courier packets. DM delivery to known
peers stops requiring a physical courier encounter; the Noise-X seal never
opens in transit.
Presence: kind-20001 heartbeats on the rendezvous feed a "people across the
bridge" count in the header (approximate: local participants subtracted by
radio-copy attribution).
Settings/Info: AppInfoView is now a segmented Settings/Info sheet. Settings
hosts appearance, voice (fixes the duplicated Voice section), a Connectivity
section (bridge + gateway + Tor toggles, the latter two moved out of the
location sheet), and a confirmed panic-wipe button. New announce TLV 0x06
advertises the gateway's rendezvous cell; PeerCapabilities gains .bridge.
i18n: 23 new keys across all 29 locales; coverage tests green.
Tests: 50 new app tests + 3 BitFoundation tests; full suite 1445 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Settings polish: one toggle style, sticky Info-first tab, location access in Settings
- The live-voice toggle now uses the same settings card + IRC pill as the
connectivity toggles (settingToggle, renamed from connectivityToggle).
- Segmented control orders Info first; the selected pane persists across
opens (AppStorage), so first-ever open lands on Info and afterwards the
sheet reopens where it was left.
- "remove location access" moved from the channels sheet into the Settings
Connectivity section (same key, still deep-links to system settings).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Location UX + copy: state-aware access control, honest empty state, clearer bridge/gateway text
- Settings' location control now covers all three permission states: grant
(real prompt, only possible while never-asked), open-system-settings when
denied, remove-access when granted. The channels sheet keeps its own grant
path for people who start there.
- The channels list no longer spins forever without permission; it shows
"grant location access to find nearby channels" instead (new key, 29
locales).
- Bridge and gateway subtitles rewritten for clarity; the gateway subtitle
moved to a new key since it now carries bridge traffic, and the old
geohash-only key is deleted. The word "user" is banned from copy in every
locale ("this person is blocked").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Field-test fixes: dedupe relay-connectivity triggers, throttle presence, log bridge decisions
First on-device run confirmed publishes accepted and the subscription
delivering, but exposed trigger spam: NostrRelayManager's isConnected
re-emits per relay recompute, so presence published 5x/second and the
courier-drop subscription rebuilt 6x in 300ms. removeDuplicates() on the
sinks + a 30s presence throttle (same-second heartbeats are byte-identical
events anyway). Also: injection/skip/downlink now log under 🌉 so field
verification is observable — the first test looked silent precisely because
dedup correctly suppressed same-island bridged copies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix restart self-echo: recognize own rendezvous events by derived pubkey
Second field run proved bidirectional bridging live (~1-2s Mac<->iPhone via
Tor) but caught a bug: relay backfill after an app relaunch re-delivered the
device's own pre-restart events, and with the in-memory published-ID cache
wiped they rendered as bridged copies of your own messages. The rendezvous
identity is deterministically derived per cell, so self-recognition by
pubkey needs no cache and survives restarts; own events are also marked
never-downlink. Regression test simulates the fresh-launch state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: mesh, bridge, and groups in one list
The header's bridged count had no matching faces anywhere — the people
sheet only knew mesh peers. BridgeService now publishes named participants
(nickname from message tags, geohash-style #last4 disambiguation, presence
keeps a known name alive) and the mesh people sheet gains an
"across the bridge" section between mesh peers and groups. Display-only
rows in v1 (bridged identities have no DM route yet). Two new catalog keys
x29 locales; also normalizes one out-of-sort-order entry inherited from a
hand-edited key on main.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Header: one people icon, one count, one sheet
Fold the bridged-people count into the main person.2.fill count instead of
a second network-glyph counter; the merged people sheet (mesh / across the
bridge / groups) is the breakdown. VoiceOver still announces how many of
the total are across the bridge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet symmetry: #mesh section header, no active count
Every section now gets the same glyph+label header shape (shared
PeopleSectionHeader): #mesh over the peer list, across-the-bridge over
bridged people. The "N active" line is gone (mesh); location channels keep
their geohash subtitle. Dead subtitle/count helpers removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* One switch: the bridge toggle drives all internet sharing
Field feedback: two toggles (bridge + gateway) with an invisible dependency
was a trap — bridged messages silently never reached mesh-only neighbors
unless a second lever was found and flipped. Collapsed to a single switch
that does the right thing for your situation:
- Bridge ON + internet: your messages cross, you see the bridge, AND your
device serves its island — accepts toBridge deposits, carries remote
messages onto the radio, watches courier drops for verified local peers,
advertises the cell, and runs the geohash-channel gateway.
- Bridge ON, no internet: you ride whoever nearby is serving.
- Bridge OFF: nothing of yours crosses; radio reception of bridged traffic
stays passive and free.
With every online bridger serving, downlink gets a 0.2-1.5s jittered
holdoff + send-time suppression recheck so co-located gateways don't burn
duplicate airtime (two-gateway test included). The internet-gateway card is
gone from Settings (GatewayService now follows the bridge switch, with
launch-time migration); its orphaned catalog keys deleted and the bridge
subtitle broadened across all 29 locales. Also: MeshPeerList's empty state
("nobody around...") aligned to the section row rhythm.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Bridge pumps its own location; fix centered people sheet; stop drop-subscription churn
Field session 3 found the bridge silently cell-less: it read
availableChannels passively, which only flow while some other feature
(channels sheet, location notes, geo sampling) happens to pump location —
turn those off and the bridge never gets a rendezvous. BridgeService now
requests a one-shot fix whenever it's enabled without a cell (and
piggybacks one on the presence timer so moving devices migrate cells).
Also from the session: the people sheet's scroll content hugged its widest
child and got centered on iPhone when the list was empty — pinned to full
width, leading. And the courier-drop subscription rebuilt every ~60s on
verified announces despite an unchanged tag set — now resubscribes only
when the tags actually change.
(Also merges origin/main: keychain test isolation #1413.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix launch race: bridge reacts to the permission callback, retries cell-less
Field session 4: the launch-time location request ran before the CoreLocation
authorization callback delivered, so refreshChannels() silently no-opped
(it requires .authorized) and nothing ever retried — the bridge stayed
cell-less all session. Three layers now close it:
- a $permissionState sink re-enters refreshRendezvous the moment
authorization resolves (the fast path),
- the maintenance timer arms even without a cell and retries the full
rendezvous refresh (the backstop; it previously required a cell, which
made it useless for exactly this failure),
- flipping the bridge switch while never-asked triggers the location
prompt — that's the user-initiated moment for it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: one header style for every section; bridged people visible while own bridge is off
GroupChatList's header now uses the shared PeopleSectionHeader (glyph +
label, same size/padding as #mesh and across-the-bridge; keeps its key and
header trait). Bridge section and the header count are no longer gated on
this device's own toggle: bridged people arrive over passive radio from a
serving neighbor, and whoever is visible in the timeline belongs in the
sheet and the count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: equalize the first section's gap
MeshPeerList's first row kept a legacy 10pt top bump from when nothing sat
above it, and the outer VStack's 6pt inter-child spacing applied between
the #mesh header and the list but not inside the other sections. Both gone:
sections own their rhythm (header 12/4, rows 4), spacing 0 outside.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: rows drop their leading glyphs — the section header carries the type
Mesh rows lose the per-state transport icon (connected/relayed/nostr/
offline), bridge rows the network glyph, group rows the person.3 icon.
Trailing state badges (star, lock, verified, unread, blocked, crown) stay,
and the row accessibility description still announces connection state, so
VoiceOver loses nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove dead code left behind by the Settings|Info consolidation
Periphery (--strict) flagged five leftovers on this branch:
- LocationChannelsModel.setGatewayEnabled: the standalone internet-gateway
toggle is gone (the bridge switch drives internet sharing), so nothing
calls it; the gatewayEnabled published property stays for the header dot.
- AppInfoView Strings.Location title/enable/openSettings: the old Location
section's header and permission buttons no longer exist. Their orphaned
Localizable.xcstrings entries go with them (the gateway-toggle keys were
already pruned).
- BridgePeopleList's appTheme environment value was never read.
The sixth CI finding (PrekeyBundleStore.StoredBundle.noiseKey assign-only)
is a Periphery flake: the property is read in loadFromDisk, the finding
didn't reproduce locally or on the next CI run of unchanged code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* People sheet: mesh rows get their transport glyph back
The mesh section is the one heterogeneous list — the leading icon encodes
HOW a peer is reachable (radio / relayed / nostr-only / offline), which the
header can't say. Bridge and group rows stay glyph-free.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix courier drop skipped by stale BLE reachability; periodic deposit sweep
Field test: a DM sent seconds after the recipient's radio vanished still
saw them as "reachable" (60s verified retention), so canDeliverPromptly
held, every deposit was skipped, and the message sat spooled with no retry
path. MessageRouter now sweeps its outbox every 2 minutes and publishes
bridge drops for messages whose recipient no transport can promptly reach;
the drop layer's message-ID dedup makes the sweep idempotent. Regression
test reproduces the exact field sequence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Show "carried" when a message ships as a bridge drop
Field feedback: dropped DMs delivered but the sender saw nothing — the
drop path never fired onMessageCarried, and the recipient's delivery ack
has no radio route back until the peers next share a transport. depositDrop
now reports whether a fresh drop was sealed and the router marks the
message carried (📦) on both the send path and the sweep; the ack still
upgrades it to delivered whenever a route exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix locale word order: offline tag after name in DM header; localized "ago"
The private-chat header rendered the localized offline word in the
availability-glyph slot, before the name — "sin conexión bob". Offline now
shows the same dimmed person glyph the mesh list uses, with the word as a
small trailing tag after the name and lock, so it reads correctly in every
locale. Full sweep of views found one more composition bug: notice
timestamps glued English "ago" onto a localized duration; now the whole
phrase comes from RelativeDateTimeFormatter (same as the "fades" label).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* DM header offline state: icon only
The availability slot now reads uniformly as a glyph (radio / relayed /
globe / dimmed person), matching the mesh list; the text tag is gone.
VoiceOver still announces "offline" via the glyph's accessibility label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Offline glyph: slashed antenna instead of dimmed person
Offline is now the visual negation of connected (same antenna glyph,
slashed) in both the DM header and mesh list rows; a generic person icon
didn't say "unreachable".
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>
|
||
|
|
d499c3e415
|
Isolate tests from the developer's real login keychain (#1413)
* Isolate tests from the developer's real login keychain Every test run prompted for the login keychain password (repeatedly, since the xctest runner's code signature changes each build, so "Always Allow" can never stick) and silently deleted the developer's real Nostr identity via panic-mode tests. Two holes let tests reach the real keychain: - NostrIdentityBridge() defaulted to the real KeychainManager. Tests inject mocks, but app-side constructions with no injection point (LocationNotesManager's static bridge, GeohashPresenceService, BoardManager, AppRuntime) read the real chat.bitchat.nostr item when exercised under test. - clearAllAssociations() used raw SecItem* calls that bypassed the injected keychain entirely, so panicClearAllData tests wiped the real Nostr identity items on every run. Fix: centralize FavoritesPersistenceService's test-guarded in-memory default as KeychainManager.makeDefault() and use it for all default keychain parameters, and add deleteAll(service:) to KeychainManagerProtocol so clearAllAssociations() goes through the injected keychain like every other operation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Share one in-memory test keychain per process (Codex P2) A fresh store per makeDefault() call diverges from production, where separate default-constructed bridges share chat.bitchat.nostr — BoardManager's publish and NIP-09 delete paths would derive different geohash identities under test. PreviewKeychainManager gains a lock since the shared instance is reached from arbitrary threads. 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> |
||
|
|
74b8a47d02
|
Empty-mesh liveliness: nearby conversations, echoes, wave, dead drops, radar (#1409)
* Empty-mesh liveliness: nearby conversations, echoes, wave action, dead drops, radar The empty mesh timeline was a dead end: a grey zero and "nobody in range yet". This turns it into a live surface and gives the app pull when the mesh wakes up: - Nearest conversation: background geohash sampling now tracks actual chat messages (not just presence) per regional channel; the empty state surfaces the busiest nearby conversation with a preview, one tap to join (GeohashChatActivityTracker, fed from GeoPresenceTracker). - Echoes: the carried 6h store-and-forward window renders as dimmed "heard here earlier" rows at launch (new Transport collectArchivedPublicMessages -> GossipSyncManager snapshot, decoded with signature-derived nicknames; content-identity dedup guards against re-synced duplicates). - Wave: the "bitchatters nearby" notification gains a "wave" quick action that broadcasts a mesh 👋 straight from the notification, even backgrounded (first UNNotificationCategory in the app). - Dead drops: /drop pins a note to the current building geohash as a kind-1 location note with a 24h NIP-40 expiry; expired notes are now dropped client-side at ingest; the notices sheet shows "fades in Xh"; a "location notes" toggle plus location-permission controls live in app info (also fixes the duplicated Voice section). - Radar: an ambient sonar animation shows the radio scanning, with a privacy-safe daily tally ("N devices passed within range today" via salted per-day hashes) and a "notes left here" hint that opens the notices geo tab. All new user-facing strings ship in all 29 locales. 1403 tests green, including new suites for the activity tracker, sightings tally, and note expiry/drop publishing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review round: app-info polish, urgent/expiry parity, centered radar, pin fill, Codex P2s - App info: LOCATION header uppercased like sibling sections; location notes and live voice descriptions shortened (29 locales); redundant "location access granted" line removed. - Notices parity: urgent + expiry controls now show on the geo tab too; the bridged Nostr note carries ["t","urgent"] and NIP-40 so relay-side readers see both; urgent parsed back from incoming notes. - Radar moved from the top of the empty state to the center of the chat area, below the help text (empty state fills the visible height). - Header pin fills whenever the scope has notices (was: only unseen), and Nostr-only nearby notes now light it too. - Codex P2 fixes: notification completion deferred until the wave action is handled (background suspension dropped the send); NIP-40 notes now prune on a timer when they expire while displayed; the location-notes kill switch retargets the nearby-notes counter immediately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Hide macOS segmented picker's built-in label in notices composer (duplicate 'expires in') Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Geo notes: permanent (∞) expiry default, urgent stays mesh-only - Geo expiry picker gains ∞ as the default: a permanent note posts as a pure relay note (no NIP-40 tag, no mesh-board copy — a board copy must fade within days, contradicting the ∞ the user picked). 1/3/7d keep the board + bridged-note path with NIP-40. - The notes manager is now owned by the notices sheet (not the list) so the composer local-echoes ∞ notes into the list; it revives via refresh() after a tab-switch cancel, and its expiry-prune timer survives cancel (weak self, dies with the instance). - Urgent toggle returns to mesh-only per review — notes are ambient; the read-side urgent-tag parse stays so tagged notes still render. - macOS: hide the segmented picker's built-in label (duplicate "expires in"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Clearing the mesh timeline dismisses echoes for good; tighter divider copy Triple-tap /clear emptied the timeline but the next launch re-seeded "heard here earlier" from the persisted archive. A MeshEchoSettings watermark now records the clear; only messages heard after it come back (the archive itself still carries everything for peers' sync). The echo dedup keys reset with it, and panic wipe drops the watermark. Divider copy tightened to "heard here earlier · last 6h" (29 locales). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Echoes visual polish: tinted history block, radar-captioned tally, ambient footer Device-test feedback on the echoes screen: - Archived echoes now sit on a subtle tinted background (secondary at 8%) in addition to the dim, so "heard here earlier" reads as one distinct block; the divider carries the echo ID prefix to join it. - "N devices passed within range today" moves out of the narration lines to sit centered under the radar as its caption. - When the timeline holds only echoes/system lines, a compact ambient footer (small radar + tally + live hints) renders below the history instead of the whole ambient layer vanishing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Notes strip persists above the mesh chat; leaner empty-state narration - The 📍 "notes left here" line was empty-state-only, so starting a conversation hid it. It is now a tappable strip pinned above the mesh timeline whenever unexpired notes exist at this place (opens the notices geo tab); the nearby-notes counter runs for the whole mesh timeline, not just the empty state. - Empty state narration drops "nobody in range yet..." (the radar and the sightings caption already say it) and the nearby-conversation hint moves below the help line instead of splitting the narration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Radar means searching: hide the sweep once mesh peers are connected or reachable 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> |
||
|
|
ef848857b7
|
Remove dead code found by full Periphery audit; add scan config + advisory CI (#1410)
Periphery 3.7.4 audit of both schemes (macOS + iOS, intersected so platform-specific code is never touched), with test targets indexed and the share extension built. 277 dead declarations removed or demoted: dead forwarding wrappers (ChatViewModel+Nostr/+PrivateChat), removed- feature remnants (autocomplete command suggestions, back-swipe tuning, MediaSendError, GeohashParticipantTracker), unused Tor dormancy bindings, assign-only properties, unused parameters (renamed to _), and redundant public accessibility. 13 orphaned localization keys deleted across all 29 locales (old pre-#1392 location-notes UI, app_info warnings). Two real tests were flagged as unused because they never ran: Swift Testing methods missing @Test (NostrProtocolTests. testAckRoundTripNIP44V2_Delivered, NotificationStreamAssemblerTests. testAssemblesCompressedLargeFrame). Re-armed both; they pass. Deliberately kept, now recorded in .periphery.baseline.json: iOS-only code invisible to the CI macOS scan, C FFI signatures, keep-alive NWPathMonitor reference, InboundEventKey.eventID (dedup semantics), wifiBulk capability bit (reserved for Wi-Fi bulk work, used by BitFoundation package tests), and the String secureClear cluster (exercised by package tests). New: .periphery.yml config and an advisory Dead Code CI job (mirrors the SwiftLint precedent from #1361) that fails on findings not in the committed baseline. Verified: full macOS app suite, BitFoundation (119) and BitLogger (13) package tests green; periphery scan --strict exits clean. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d5cf64f99e
|
Require signed sender for broadcast file transfers (#1406 follow-up) (#1407)
* Require signed sender for broadcast file transfers (#1406 follow-up) Broadcast file transfers trusted the packet's claimed senderID whenever the peer was merely connected (resolveKnownPeer allowConnectedUnverified: true), unlike public messages and public voice frames, which both require a valid packet signature from the claimed sender. Codex flagged the consequence on PR #1406: a peer that observed a public voice burst could broadcast a spoofed voice_<burstID>.m4a note under the talker's senderID, and ChatLiveVoiceCoordinator.absorbFinalizedVoiceNote would replace the signature-verified live bubble with attacker audio (senderPeerID + scope were the only bindings, both attacker-forgeable on this path). Bring broadcast file transfers up to the same bar as public messages: verify the packet signature against the registry signing key, falling back to the persisted-identity signature lookup, before trusting the sender. Directed (private) transfers keep the lenient connected-peer path — they are addressed to us specifically and carry no broadcast exposure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Exempt self broadcasts from the file-transfer signature gate Review of #1407 caught a regression: our own broadcast files replayed via gossip sync arrive with ttl==0 (so isSelfEcho does not drop them) and cannot be verified against the peer registry or identity cache, so the new broadcast signature guard would drop them. Mirror BLEPublicMessageHandler's self exemption — self packets are trivially authentic — and add a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Stop relaying broadcast file packets that fail sender authentication Codex review on #1407: the new signature gate dropped spoofed broadcast files locally, but BLEService's .fileTransfer case still fell through to scheduleRelayIfNeeded, so a forged file kept propagating to downstream (possibly older, ungated) nodes. Have the handler report failed sender authentication and skip the relay step, like invalid board posts and voice frames. Local-only drops (malformed payload, quota, save failure) and files directed to other peers still relay unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix CI hang: sign the max-size reassembly file transfer, unhang timeouts Both CI runs on #1407 died at the 5-minute watchdog (SIGKILL, exit 137) with the test process fully idle. Root cause was a pair of issues in FragmentationTests: - "Max-sized file transfer survives reassembly" injected an UNSIGNED broadcast file from an unknown peer, which the new broadcast signature gate now drops by design. Sign the packet and preseed the sender's signing key, mirroring the public-message reassembly tests. - CaptureDelegate's wait helpers could never time out: the timeout task threw, but withThrowingTaskGroup then awaited the sibling child that was parked in a non-cancellable withCheckedContinuation, deadlocking the whole run (hang instead of a 5s failure). Resume the parked continuation from a cancellation handler so timeouts now fail fast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
35fb9fdd42
|
Fix received DM images stuck as grey box: stamp incoming media delivered, reveal tap as Button (#1402)
* Make image tap-to-reveal a Button so the DM sheet can't swallow it Received images in a DM rendered as a grey "tap to reveal" box that never revealed: the DM sheet wraps the whole conversation in a high-priority swipe-to-close DragGesture (ContentSheetViews), and an ancestor high-priority gesture starves descendant TapGestures — the image's reveal/open tap never fired. Button actions survive that suppression (the sheet's own header buttons work for the same reason), so the tap now lives on a plain-style Button wrapping the image; swipe-to-hide stays attached as a simultaneous gesture. Fixes #1388 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Move the cancel control out of the reveal Button Nested buttons don't get reliable independent hit testing, and the outer reveal tap is a no-op while sending, so the in-flight cancel x could become untappable. The cancel overlay now sits on the Button rather than inside its label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Root cause: received media stuck in .sending — grey box, dead tap Field testing showed the Button conversion alone didn't fix #1388: a received DM image still rendered as a flat grey box with a dead tap. The real culprit is BitchatMessage's initializer, which defaults every private message without an explicit status to `.sending`. BLEFileTransferHandler built incoming media messages without one, so every received private image/voice note was permanently "sending": mediaSendState returned progress 0 → BlockRevealMask rendered 0% of the image (the flat grey box is the blur overlay over an empty mask), and the reveal tap was disabled by the isSending guard — regardless of which gesture carried it. Two layers: - BLEFileTransferHandler now stamps incoming private media `.delivered(to: <local nickname>, at: <packet time>)`, matching the received-text path in ChatPrivateConversationCoordinator. - MediaMessageView treats received messages as never-sending, so no other construction path can reproduce the grey-box state. 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> |
||
|
|
3e9230229d
|
Heal peer-ID rotation: rebind link on verified announce, retire ghost peer (#1401)
* Heal peer-ID rotation: rebind link on verified announce, retire ghost When a peer relaunches it rotates its ephemeral peer ID, but a still-open BLE connection kept its stale peripheral/central→peerID binding for the reachability retention window (~45-60s). Until it aged out, the other side showed a duplicate ghost peer and dropped the rotated peer's direct announces as spoofing attempts. Root cause: the ingress guard rejected a direct announce whose claimed sender differed from the link binding before signature verification could ever see it, so the binding could never heal. - Ingress guard: let a mismatched direct announce through, attributed to the claimed sender; REQUEST_SYNC keeps the strict binding check. - Bind sites: raw (pre-verification) announces may only bind unbound links, never rebind bound ones — rebinding now requires the announce handler's signature verification to pass. - On a verified direct announce whose ingress link is bound to a different peer, rebind the link to the announced ID and retire the rotated-away ID immediately (registry + gossip + UI), mirroring handleLeave. - BLELinkStateStore.bindPeripheral: drop the previous peer's reverse mapping on rebind so the retired ID no longer claims the link. Fixes #1387 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Contain forged-directness rebinds: no identity steal, per-link cooldown The announce signature does not authenticate directness (TTL is excluded from signing because relays mutate it), so a bound peer could replay another peer's fresh signed announce with its TTL restored and reach the rotation rebind path. Contain what such a replay can do: - Refuse a rebind when the claimed identity already owns another live link — a replayed announce can no longer steal a connected peer's binding or route its directed traffic to the replaying link. - Allow at most one rebind per link per cooldown window (60s) so two identities cannot fight over a link in a replay flip-flop, with each flip retiring the other peer. A replay against an identity with no live link remains possible, but that capability already exists today on unbound links, where any raw direct announce binds pre-verification. 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> |
||
|
|
78a291ab77
|
Public-mesh push-to-talk: signed live voice bursts in the mesh channel (#1406)
* Live push-to-talk voice for DMs: stream while you talk, voice note as fallback
Holding the mic in a DM now streams AAC frames live over the Noise session
(walkie-talkie style, ~0.5s mouth-to-ear at one hop) while recording the same
audio as a normal voice note. On release the note ships through the existing
fileTransfer pipeline; receivers that heard the live stream absorb it silently
into the same bubble (matched by the burst ID embedded in the file name), so
reliability comes for free and nobody sees duplicates.
Protocol:
- NoisePayloadType.voiceFrame = 0x08 carrying VoiceBurstPacket
(burstID + seq + START/data/END/CANCELED, length-prefixed AAC frames)
- 210-byte burst-content budget keeps each Noise packet inside the 256-byte
padding bucket: one BLE frame, never the fragment scheduler
- fire-and-forget: frames are dropped (never queued) without an established
session; live is only offered when the peer is mesh-reachable
Receive:
- ChatLiveVoiceCoordinator assembles bursts (jitter-ordered, 0.5s gap skip,
3s idle end, flood/size caps), persists progressively as ADTS .aac so even
a partial burst is a replayable bubble
- live autoplay only when the conversation is on screen, app active, and the
new app-info "live voice messages" toggle is on (also gates live sending)
- one-playback-at-a-time via a shared ExclusivePlayback slot
Capture:
- PTTCaptureEngine taps AVAudioEngine, dual-encodes: live AAC frames + the
finalized .m4a (same 16kHz/mono/16kbps settings as VoiceRecorder)
- VoiceRecordingViewModel now drives a pluggable VoiceCaptureSession; the
composer HUD shows a pulsing LIVE treatment when streaming
Includes the push-to-talk design doc, 6 new localization keys across all 29
locales, and unit tests for framing, packetizer budget, ADTS output, codec
round-trip, and the assembly/absorb lifecycle. Public-mesh PTT (MessageType
0x29) lands separately on top of this.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Public-mesh push-to-talk: signed live voice bursts in the mesh channel
Extends live PTT from DMs to the public mesh timeline. Holding the mic in
the mesh channel now broadcasts the burst live as signed voiceFrame packets
(MessageType 0x29) while the finalized voice note still ships on release —
new clients hear you as you speak and absorb the note silently into the live
bubble; old clients (and late joiners) keep receiving the note exactly as
before, so mixed-version meshes lose nothing.
Wire/relay:
- MessageType.voiceFrame = 0x29: ephemeral signed broadcast, never
gossip-synced (SyncTypeFlags maps it to no bit), never padded (padding to
the 512 block would push every ~490-byte signed packet into fragmentation)
- RelayController treats voiceFrame like media fragments: dense-graph TTL
clamp contains the sustained ~15 pkt/s per-talker stream, tight 8-25 ms
jitter keeps multi-hop latency inside the receiver's 350 ms jitter buffer
- inbound gate mirrors public messages: broadcast-only, 30 s freshness cap,
packet signature verified against the claimed sender's announce before any
audio reaches the UI
App:
- ChatLiveVoiceCoordinator gains burst scopes: public bubbles land in the
mesh timeline, autoplay only while that timeline is on screen, and the
finalized-note absorb is scope-bound (a public note can't replace a DM
burst or vice versa)
- floor courtesy: while someone talks live in the public channel the
composer mic tints red and pulses, with an accessibility value naming the
talker ("%@ is speaking", localized in all 29 locales); holding still
works — a decentralized mesh has no floor arbiter, the tint just
discourages talk-over
Tests: relay policy (sparse cap + dense clamp), public bubble + talker
indicator lifecycle, note absorption into the mesh store, and scope-binding
rejection; full suite green (1382 tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* PTT follow-ups from review + field test: peer-ID normalization, toggle gates inbound, drop-path diagnostics
Codex review fixes (#1403):
- makeVoiceCaptureSession normalizes the selected peer with toShort() before
the reachability/session checks and binds the send target to that same
routing ID — a conversation selected under the stable 64-hex Noise key no
longer silently falls back to a classic note while the short-ID session is
established
- the live-voice toggle now gates inbound bursts too: off means
classic-notes-only in both directions (no live bubble, partial file, or
early notification; the finalized note still arrives), with a test
Field-test diagnostics (first device run: DM frames decrypted but no bubble
appeared, with no log evidence of which guard dropped them):
- coordinator logs undecodable frames (size + hex prefix) and blocked drops
- makeAssembly logs directory/file-handle failures instead of returning nil
silently
- PTTLiveVoiceSession logs capture start and finish (packet/frame/duration
counts); PTTCaptureEngine logs engine start success/failure with the input
format; BLEService.sendVoiceFrame logs no-session drops
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix iPhone live-capture failure: dead input unit (AURemoteIO -10851, 0 Hz)
Field testing showed the phone's live capture failing at mic enable with
AURemoteIO -10851 and an input format of 0 Hz / 2 ch — an input unit bound
to an earlier (playback-only or settling) audio session. The Mac, which has
no session lifecycle, captured fine, which is why public bursts from the Mac
worked while phone-side sends degraded from working (first hold) to sporadic
to dead across holds.
Three layers of defense:
- PTTCaptureEngine recreates its AVAudioEngine on every start(), after the
session is configured, so the input unit binds to the session that is
active now; a dead input (0 Hz or 0 channels) is now a distinct, logged
error instead of a silent setup failure
- PTTLiveVoiceSession retries the capture start once after a 150 ms
route-settle pause
- VoiceRecordingViewModel falls back to the classic VoiceRecorder within the
same hold if the live engine still cannot start — a route glitch now costs
the live stream, never the voice note
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Blue mic when the hold will stream live
The mic button now shows readiness at a glance — and doubles as a build
marker for device testing:
- blue: holding will stream live (DM peer reachable with an established
Noise session, or the public mesh channel)
- accent (orange in DMs): holding records a classic voice note (no session
yet, peer unreachable, or live voice toggled off)
- red states unchanged (recording, floor busy)
Refactors capture-backend selection into a single liveVoiceTarget() so the
indicator and makeVoiceCaptureSession can never disagree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Revert the blue live-ready mic to the normal accent color
The build-verification marker did its job; idle mic color goes back to the
accent. The LIVE recording HUD remains the signal for whether a hold is
streaming. Keeps the liveVoiceTarget() refactor so backend selection stays
in one place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Leave a trace on every mic press and every inbound-frame drop
Field testing read "tap does nothing" as breakage: the mic start is async
(permission check + engine spin-up), so releasing before recording begins
has always been a silent cancel — for classic voice notes too. Every press
now logs which backend it chose and, for quick presses, that it released
before recording started.
Also logs the two remaining silent drops: inbound voice frames rejected by
the live-voice toggle (the one unlogged guard left in the receive path) and
the classic-note fallback now includes the toggle state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix mic hold dying instantly in DMs: sheet swipe gesture starved the composer
Field logs showed every DM mic hold ending 3-10 ms after it began, on both
platforms, while public-channel holds worked — the private sheet wraps its
entire content (composer included) in a high-priority swipe-right-to-leave
DragGesture, and a high-priority ancestor drag cancels the mic button's
press-and-hold within milliseconds. Same starvation mechanism as the DM
image-reveal bug (#1402), hitting a drag instead of a tap.
The swipe-to-leave gesture now lives on the message list only, so the
composer's gestures (mic hold, text field, buttons) are out of its reach and
the swipe still works where users actually swipe.
Also stops touching the capture engine when a hold cancels before the engine
ever started: probing inputNode on a never-started engine instantiates its
input unit against whatever session is active and spams benign-but-alarming
AURemoteIO -10851 errors into field logs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reorder app info sheet: usage first, then settings, then reference
New section order: HOW TO USE, then the adjustable bits (appearance, voice,
network), then the reference material (features, privacy, symbols legend).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* App info: flow HOW TO USE into one paragraph; "list" and "person" wording
The six how-to-use bullets now read as a single comma-separated paragraph
(same instruction strings, legacy bullet prefix stripped at render). Two
wording updates across all 29 locales: the people icon opens the "list"
(not "sidebar"), and you tap a "person's" name (not a "peer's") to start
a DM.
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>
|
||
|
|
81c45e9649
|
Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix (#1398)
* Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix
Remaining findings from the July 7 locked-phone test sessions, all rooted
in reconnect/relaunch behavior:
- All Nostr acks (READ and DELIVERED, direct and geohash) now flow through
the paced queue that previously only throttled direct READ receipts.
Reconnect redelivery produced 8 DELIVERED acks in under a second, which
damus rejects ("noting too much").
- Processed gift-wrap event IDs persist across launches
(NostrProcessedEventStore, wired through MessageDeduplicationService,
debounced writes, wiped on the existing clear/panic paths). NIP-59
randomizes gift-wrap timestamps, so the 24h-lookback DM subscriptions
redeliver the same events every launch; without a cross-launch record
each relaunch reprocessed old PMs and acks — the re-ack bursts and the
"delivered ack for unknown mid" warnings (now debug: a stale ack is
expected occasionally and not actionable).
- Own fragments handed back by sync replay (the deliberate RSR ttl=0
restore path) now re-enter the gossip sync store before the self-drop.
The fragment store is not archived, so after a relaunch our sync filter
did not cover our own fragments and peers re-offered them every 30s
round indefinitely; recording them stops the redelivery after one round
while keeping assembly skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address Codex review on #1398: shared ack pacer, transient clears keep disk record
- Geohash acks are sent through short-lived NostrTransport instances
(makeGeohashNostrTransport creates one per ack), so the per-instance ack
queue never paced a burst. Acks now flow through a pacer shared across
instances: Dependencies.live wires the process-wide sharedAckPacer, and
the default Dependencies init builds an isolated pacer from the same
injected scheduleAfter so tests keep stepping the throttle manually.
- clearNostrCaches() runs on every geohash channel switch, so it no longer
wipes the persisted gift-wrap record (that stays on the clearAll/panic
path). Persistence is now append-merge instead of snapshot-overwrite —
serialized on the store's IO queue — so a transient in-memory clear
between debounced flushes can't shrink the on-disk record either.
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>
|
||
|
|
f79c2e3e9f
|
BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1396)
* BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395) iOS cancels nothing for us: pending CBCentralManager connects never expire, complete whenever the peer reappears in range, and relaunch the app via the existing state-restoration path. Use that as the wake-on-proximity mechanism: - BLERecentPeripheralCache: retains handles to recently seen/dropped peripherals (LRU 16, 15 min max age to respect BLE address rotation) - On backgrounding, arm indefinite pending connects to cached peripherals within a slot budget (2 of 6 central slots reserved for live background discovery); armed entries carry lastConnectionAttempt == nil so a quick background/foreground bounce can't strand them as connecting - The 8s app-level connect timeout defers while backgrounded so discovery-driven background connects also stay pending - Foreground return cancels stale pending connects (including connecting entries rebuilt by state restoration after a relaunch) and hands control back to the scanner/scheduler - A link dropped while backgrounded re-arms after the disconnect-settle window, so a peer walking away and returning wakes us again - Packet ingress while backgrounded triggers a catch-up maintenance pass (announce/flush/drain) since the maintenance timer is suspended with the app; rate-limited to the normal 5s cadence Battery cost ~0: pending connects live in the controller's allowlist (no scanning, no app CPU), and the catch-up pass only runs inside wake windows the radio already granted. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * Restore: seed wake-on-proximity cache and resume service discovery Field finding from on-device testing: after a state-restoration relaunch, the recent-peripheral cache starts empty, so backgrounding shortly after a restore armed no pending connects. Seed the cache from the restored peripherals — they are the freshest proximity candidates we have. Also resume service discovery for peripherals restored as connected with no characteristic: the CBCharacteristic reference dies with the old process, and without rediscovery the link sits connected-but-unusable until the peer drops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Defer restored-link service rediscovery until poweredOn Field finding: CBPeripheral.discoverServices issued inside willRestoreState fires before the central manager reaches poweredOn — CoreBluetooth drops the command with an API MISUSE warning, leaving restored-connected links characteristic-less after all. Move the rediscovery to centralManagerDidUpdateState(.poweredOn), which restoration guarantees runs afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Let disconnect re-arms use the freed slot (Codex P2 on #1396) The background-entry arm reserves 2 of 6 central slots for live discovery, but the disconnect re-arm path shared that budget: with 4+ links remaining the budget hit zero and the just-dropped peer was never armed — defeating walk-away/walk-back re-arming in dense meshes. The disconnect path now arms with no reserve, consuming the slot the disconnect itself freed. 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> |
||
|
|
0c3136282e |
Revert "BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)"
This reverts commit b0433240354e8dde5600f59f9778fbddee536dc4. |
||
|
|
b043324035
|
BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)
iOS cancels nothing for us: pending CBCentralManager connects never expire, complete whenever the peer reappears in range, and relaunch the app via the existing state-restoration path. Use that as the wake-on-proximity mechanism: - BLERecentPeripheralCache: retains handles to recently seen/dropped peripherals (LRU 16, 15 min max age to respect BLE address rotation) - On backgrounding, arm indefinite pending connects to cached peripherals within a slot budget (2 of 6 central slots reserved for live background discovery); armed entries carry lastConnectionAttempt == nil so a quick background/foreground bounce can't strand them as connecting - The 8s app-level connect timeout defers while backgrounded so discovery-driven background connects also stay pending - Foreground return cancels stale pending connects (including connecting entries rebuilt by state restoration after a relaunch) and hands control back to the scanner/scheduler - A link dropped while backgrounded re-arms after the disconnect-settle window, so a peer walking away and returning wakes us again - Packet ingress while backgrounded triggers a catch-up maintenance pass (announce/flush/drain) since the maintenance timer is suspended with the app; rate-limited to the normal 5s cadence Battery cost ~0: pending connects live in the controller's allowlist (no scanning, no app CPU), and the catch-up pass only runs inside wake windows the radio already granted. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bbe5e1ef4e
|
Fix reachability-gate races flagged by Codex on #1389 (#1394)
P1: TorManager.shutdownCompletely() resets didStart asynchronously (after Arti has actually stopped, up to ~5s later). A brief offline->online flap could call startIfNeeded() inside that window; the guard on didStart dropped it, and nothing reevaluated afterwards, so Tor stayed down while activationAllowed was true. Track shutdowns in flight and record a deferred start, honored when the last shutdown finishes (still gated on allowAutoStart/foreground at that point). P2: NWPathReachabilityMonitor.ingest() cancelled and rescheduled the flush a full debounce interval from "now" on every observation, even duplicates (e.g. interface detail changes while still unsatisfied). ReachabilityDebounce already preserves the original pending.since, so schedule the flush for the remaining time to the true deadline instead of restarting the window. Tests: debounce deadline preservation (pure) + a monitor-level timing test that a mid-window duplicate does not postpone the offline commit. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8415c52913
|
Unified notices: one pin, one sheet for board pins + location notes (#1392)
* Unified notices: merge board pins and location notes into one sheet One pin icon in the header now opens a single Notices sheet with a geo/mesh scope toggle, replacing the separate board, location-notes, and mesh-only note buttons: - geo tab: current geohash's notices — mesh-synced board posts merged and deduped with Nostr kind-1 location notes, with per-item mesh/net source badges. Scope follows the selected location channel, or the device's building geohash when chatting on mesh. - mesh tab: mesh-local board only (fully offline). - One composer: geo posts go to the board and bridge to Nostr (existing bridge), so mesh and internet see the same notice. - Merged delete: tombstoning an own board post now also retracts the bridged Nostr copy via NIP-09 (new createDeleteEvent, bridged event ids tracked in BoardManager); own Nostr-only notes are deletable too. - LocationNotesManager accepts any channel-precision geohash (1-12 chars), not just building-level. BoardView and LocationNotesView are superseded by NoticesView. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Notices round 2: honest composer, friendlier copy, new-pin chat alerts - Urgent + expiry controls now appear on the mesh tab only: the bridged Nostr copy of a geo post carries neither, so relay-side readers would never see them. Geo posts default to non-urgent with 7-day expiry, and the bridged note now gets a NIP-40 expiration tag so honoring relays drop it in step with the board copy. - Geo tab explainer reuses the original location-notes description (keeps its 29 existing translations); mesh tab gets a new plain- language description. - New-pin chat alerts, fully local (no wire traffic): BoardStore fires postArrivals for posts newly accepted from the wire; BoardAlertsModel filters own posts, dedups by postID, and for urgent pins created within the last 30 minutes emits one system line into the matching timeline (geo pin -> that geohash's chat, mesh pin -> mesh chat), collapsing simultaneous arrivals into a count line. - Routine pins light up the header: the pin icon tints orange whenever the current scope has notices at all, and fills (pin.fill) while unseen new pins are waiting; opening the sheet clears them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * i18n: translate the unified-notices strings into all 28 non-English locales Adds the 13 new notices keys (sheet title, geo/mesh tabs, mesh description, source badges, urgent alert lines, button tooltip and accessibility strings) to the string catalog with translations for every locale the app ships. The geo tab already reuses the fully translated location_notes.description; this covers the rest. Insertion preserves the catalog's case-insensitive key order, so the diff is purely additive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Codex review: panic-wipe reset, scoped badge clear, geohash-aware dedupe - BoardStore.wipe() now emits didWipe; BoardAlertsModel subscribes and resets, so a panic wipe drops pending urgent lines (which could otherwise re-append pre-wipe content into chat after the collapse flush), unseen badge scopes, and handled-post history. - Opening the notices sheet clears unseen badges only for the scopes it actually shows (mesh + current geo scope); pins for other geohash channels keep their badge until visited. - LocationNotesManager.Note now retains the matched g tag, and the bridged-copy dedupe requires the note's geohash to equal the board post's — a same-text note from a neighboring cell is no longer swallowed as a duplicate. 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> |