Compare commits

...
25 Commits
Author SHA1 Message Date
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>
2026-07-10 15:16:02 +02:00
jackandGitHub 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.
2026-07-10 14:04:09 +02:00
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>
2026-07-09 19:35:18 +02:00
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>
2026-07-09 18:23:46 +02:00
304460ee83 Nearby notes: tap-to-reveal (consent-gate the location subscription) + subscription hygiene (#1422)
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool

The nearby-notes counter used to open a live building-precision (precision-8)
geohash REQ to the closest geo relays whenever the mesh public timeline was
visible — a passive location side-channel with no opt-in. Now nothing
subscribes until one explicit act reveals the counter for the session: tapping
the new "check for notes left here" line on the empty mesh timeline (static,
no network), opening the notices sheet's geo tab, or a successful /drop. The
app-info setting stays the default-ON kill switch and still gates /drop.

Subscription hygiene alongside:
- LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the
  main actor like the timer teardown) instead of only invalidating timers.
- New refcounted LocationNotesPool dedupes the counter's and the notices
  sheet's identical 9-cell kind-1 REQs into one shared manager per geohash;
  both callers release-and-reacquire instead of retargeting in place, and the
  sheet releases its ref on dismissal.

The reveal affordance is localized across all 29 catalog locales, and new
NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell
filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool
refcounting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only

Fixes the verified review findings on the tap-to-reveal PR:

- Permission dead-end: the "check for notes left here" hint now renders
  only when location permission is already authorized (it never prompts).
  Previously a location-denied install could tap it, flip the sticky
  revealed flag, and get nothing for the rest of the session because
  retarget() guards on authorization. Predicate lives on
  NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive
  to the published permission state.

- Building-cell sampling: background geohash sampling subscribed
  geo-sample-<gh> for every regional level including the precision-8
  building cell, pre-reveal — contradicting the claim that nothing
  building-precision hits a relay before the explicit act.
  GeoChannelCoordinator now excludes the building level until
  NearbyNotesCounter.revealed (injectable publisher for tests); coarser
  levels keep the nearby-conversation hint and participant counts
  working, and bookmarks stay exempt (bookmarking is explicit).

- Implicit reveal: opening the notices sheet no longer reveals — the
  sheet auto-lands on the geo tab whenever a location channel is
  selected, so browsing a remote geohash and opening notices revealed
  the LOCAL building subscription. reveal() now fires only on the
  person actively picking the geo segment, and only when the sheet has
  a geo scope (the empty-mesh hint tap and /drop stay as before).

- Subscription hygiene: switching the sheet geo → mesh releases the
  pooled notes manager (the REQ was left streaming behind the mesh
  board); switching back re-acquires from the pool. The dismissal
  release stays balanced — liveGeoManager is nil after the tab-switch
  release.

- VoiceOver: the hint button exposes the plain localized action text
  instead of the decorated "* 📍 … *" label.

- Removed LocationNotesManager.setGeohash: zero callers, and calling it
  on a pooled instance would corrupt the pool's keying and refcounts.

New tests: hint permission gate, explicit-geo-tab reveal contract,
building-cell sampling exclusion before/after reveal, pool
release/re-acquire round trip. Full suite (1467) green; iOS simulator
build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline

CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the
snapshot -> allPeers binding crosses the mock transport's unstructured
Task, UnifiedPeerService.updatePeers, a receive(on: main), and another
Task { @MainActor } in bindPeerService — all contending with every
parallel worker. On the failing runner the whole suite took 10.1s
(usually ~4.4s locally), so the positive 5s defaultTimeout wait lost
the race. That's exactly the case TestConstants.longTimeout documents;
passing runs return as soon as the condition holds and never pay it.

Not caused by the tap-to-reveal changes: nothing on that pipeline was
touched, and 20 full parallel-suite loops each on the branch and on
origin/main reproduce zero failures locally. The two sibling waits on
the same pipeline in this file get the same timeout.

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>
2026-07-09 16:47:08 +02:00
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>
2026-07-09 16:46:33 +02:00
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>
2026-07-09 16:45:45 +02:00
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>
2026-07-09 16:44:47 +02:00
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>
2026-07-09 16:44:02 +02:00
021af3a22d Remove write-only peerNostrPubkey left by the read-receipt gate removal (#1417)
The variable only fed the deleted guard (#1415); the favorites lookup in
the unified-peer branch existed solely to populate it.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:04:30 +02:00
8c9c1cc6ac Bump version to 1.7.0 (#1416)
Feature release: mesh bridging, push-to-talk voice, empty-mesh
liveliness, store-and-forward DM routing.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:55:45 +02:00
55d51ba084 DMs to unreachable peers use store-and-forward instead of failing instantly (#1415)
* DMs to unreachable peers route through store-and-forward; drop stale reachability gates

Field-found: sendPrivateMessage pre-judged reachability and marked the
message failed without ever calling the router, so the retained outbox,
courier deposits, and bridge drops never ran — a DM composed after a
peer's reachability window lapsed was dead on arrival while an identical
one sent a minute earlier delivered. Messages now always route; a live
path earns "sent", everything else stays "sending" until the router
reports carried/delivered or expires it as failed.

A sweep for sibling gates found and fixed:
- startPrivateChat refused offline non-mutual favorites ("mutual favorite
  required for offline messaging") — store-and-forward only needs the
  recipient's noise key, so the gate and its string are gone.
- markPrivateMessagesAsRead skipped the whole receipt pass for peers
  without a stored Nostr key, starving mesh-connected non-favorites; the
  router picks the transport now (sentReadReceipts dedups the parallel
  PrivateChatManager path).
- DM/geoDM blocked notices and the unknown-group error posted to the
  active public timeline; they now land in the thread they're about via
  addLocalPrivateSystemMessage.
- Banned copy: literal "user" fallbacks in code become "anon" (the
  default-nickname convention), and es/fr/it/pt translations of four keys
  still saying usuario/utilisateur/utente/usuário now say person. Orphaned
  keys removed (system.dm.unreachable, content.delivery.reason.unreachable,
  system.chat.requires_favorite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake BLEServiceCoreTests: longTimeout for positive waits

CI flaked on duplicatePacket_isDeduped: the first message landed after the
5s wait expired on a loaded runner (the test's later count==1 assertions
passed, proving late delivery, not lost delivery). All four positive waits
in the file now use TestConstants.longTimeout, which exists for exactly
this — waitUntil returns as soon as the condition holds, so passing runs
never pay the longer ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix read-receipt test contamination; baseline Periphery noiseKey flake

Two CI failures, both environmental:

- markReadReceiptSent_returnsFalseOnSecondCall flaked because every test
  ChatViewModel shared one per-process read-receipts scratch suite; the
  lifecycle test persists the same "read-1" ID (a path the receipt-gate
  removal now reaches), and test order decided who saw whose state. Each
  instance now gets its own UUID-suffixed suite.

- Periphery intermittently misses the loadFromDisk read of
  PrekeyBundleStore.StoredBundle.noiseKey and fails --strict with a false
  "assign-only" finding (recurrence of a known flake). Its USR is
  baselined; an in-source periphery:ignore can't work because strict mode
  flags it as superfluous on runs where the indexer gets it right.

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>
2026-07-08 22:42:39 +02:00
dc99d641c8 Give the macOS app a proper icon matching iOS (#1414)
The macOS Debug configuration uses AppIconDebug, which had no mac-idiom
entries at all, so Debug builds shipped with the generic dock icon. And
the mac slots in AppIcon reused the full-bleed square iOS artwork; macOS
does not mask icons at render time, so even Release showed a sharp-
cornered square.

- Regenerate all AppIcon mac PNGs with the Big Sur icon grid baked in
  (824x824 rounded-rect body on a transparent 1024 canvas + standard
  drop shadow), derived from the same 1024 iOS art.
- Add a full mac icon set to AppIconDebug from the green debug artwork.
- Add scripts/generate-mac-appicon.swift (pure CoreGraphics) to
  re-derive the mac sizes whenever the 1024 source changes.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:07:40 +02:00
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>
2026-07-08 21:45:38 +02:00
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>
2026-07-08 17:20:00 +02:00
4baeaab717 Geo notes latency: EOSE scoped to reached relays + connecting state with auto-retry (#1411)
* 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>

* Geo notes latency: EOSE scoped to reached relays, connecting state with auto-retry

Field finding: opening notices on a cold launch showed "no notices yet"
after ~10s, with notes popping in later — the empty state was a lie told
by two mechanisms:

- EOSE tracking waited on ALL target relays, including ones still
  mid-Tor-circuit; one dead relay of five pinned "loading" until the
  10s fallback. Trackers now count a relay only once the REQ actually
  reached it (marked from the send completion, or proven by its EOSE),
  so the first responding relay resolves the initial load and dropped
  targets can't stall it. The 10s fallback stays as backstop.
- When that fallback fired with ZERO connected target relays (Tor still
  bootstrapping), LocationNotesManager reported .ready with no notes.
  It now enters a .connecting state — rendered as "connecting to
  relays…" instead of the empty state — and polls every 3s, re-
  subscribing for a fresh initial fetch the moment a target relay
  comes up.

New NostrRelayManager.isAnyRelayConnected(among:) feeds the check via
an injectable dependency (tests default to legacy behavior). String
localized in all 29 locales. 1412 tests green, iOS+macOS builds clean.

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>

* Mark EOSE relays at send initiation, closing the in-flight-send race

Codex P2 on #1411: with several sockets already connected, a fast
relay's EOSE could complete the tracker while a slower relay's async
send completion hadn't yet moved it out of awaitingSend — the initial
load reported done with that relay's stored events still pending.
Relays are now marked awaiting-EOSE synchronously when the REQ send is
initiated (and when a flush skips an already-subscribed relay), so
every relay that was actually asked is counted before any EOSE can
race. Failed sends resolve via the disconnect settle or the fallback.

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>

* Resolve leftover merge conflict markers from the main restack

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop two Nostr helper constants resurrected by the restack merge (dead on main)

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>
2026-07-08 14:16:46 +02:00
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>
2026-07-08 13:24:44 +02:00
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>
2026-07-08 11:24:19 +02:00
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>
2026-07-08 11:07:40 +02:00
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>
2026-07-08 10:08:36 +02:00
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>
2026-07-08 10:08:23 +02:00
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>
2026-07-08 09:23:16 +02:00
eacd8f0750 Live push-to-talk voice for DMs (streams while you talk, voice note as fallback) (#1403)
* 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>

* 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>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:20:58 +02:00
6886035632 Fix press-and-hold mic dying instantly in private chats (#1405)
The private-chat sheet wraps its entire content — composer included — in a
high-priority swipe-right-to-leave DragGesture. A high-priority ancestor
drag preempts child gestures, so the composer mic's press-and-hold was
cancelled 3-10 ms after touch-down (observed on both iOS and macOS in field
logs), making voice notes unrecordable inside DMs while working fine in the
public timeline. Same starvation mechanism as the DM image-reveal bug
(#1402), hitting a drag instead of a tap.

The swipe-to-leave gesture now attaches to the message list only — where
users actually swipe — leaving the composer's gestures (mic hold, text
field, buttons) out of its reach.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:20:54 +02:00
e642f696cb Header: globe opens channel sheet; deterministic crowding cascade (#1400)
The gateway globe indicator was a plain Image with a tooltip but no tap
handler. Wrap it in a Button that opens the location channels sheet,
where the gateway toggle lives, so tapping it lets people turn the
gateway on or off. Adds an accessibility hint localized into all 29
locales.

When the header got crowded with indicator icons, the bitchat/ logo and
the nickname field both sat at layout priority 0, so compression was
split arbitrarily between them — and the logo, lacking a lineLimit,
wrapped to two lines inside the fixed-height bar. Make the degradation
order explicit: the nickname shrinks first, the logo holds full width
until the name is gone and then truncates on one line, and the icon
cluster (priority 3) never compresses.

Render-verified with an offscreen ImageRenderer harness at 320/375/500pt
with the maximum icon load (globe, courier, envelope, pin, bookmark).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 08:46:40 +02:00
282 changed files with 29680 additions and 5800 deletions
+30
View File
@@ -0,0 +1,30 @@
name: Dead Code
on:
push:
branches:
- main
pull_request:
jobs:
periphery:
name: Periphery scan
runs-on: macos-latest
timeout-minutes: 30
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
# block merges. Drop continue-on-error once the baseline proves stable.
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install Periphery
# homebrew-core formula; the peripheryapp tap lags years behind.
run: brew install periphery
- name: Scan for dead code
# Config comes from .periphery.yml; known findings (mostly iOS-only
# code invisible to a macOS scan) are suppressed by the committed
# baseline. --strict fails the step when NEW dead code appears.
run: periphery scan --strict --disable-update-check
+20 -5
View File
@@ -131,10 +131,10 @@ jobs:
echo "No coverage data found; skipping summary."
fi
# SPM tests above only compile the macOS slice; this job covers the
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
# SPM tests do not link the shipping app targets. This job covers the
# iOS-conditional paths and both universal Release link configurations.
ios-build:
name: Build iOS app (simulator)
name: Build Release apps (universal)
runs-on: macos-latest
timeout-minutes: 15
@@ -143,14 +143,29 @@ jobs:
uses: actions/checkout@v5
- name: Build iOS (simulator, no signing)
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
# Build both simulator architectures so CI validates every vendored
# Arti simulator slice and the configuration that ships.
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \
-configuration Release \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
build
- name: Build macOS (universal, no signing)
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (macOS)" \
-configuration Release \
-destination 'generic/platform=macOS' \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
build
+1
View File
@@ -0,0 +1 @@
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
+21
View File
@@ -0,0 +1,21 @@
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
#
# CI runs the macOS scheme only (an iOS scan needs a device destination and
# doubles the build time). macOS-only scans falsely flag iOS-only code —
# state restoration, screenshot handlers, background BLE sampling — so those
# findings live in .periphery.baseline.json rather than being "fixed".
# When auditing by hand, scan BOTH schemes and intersect:
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
project: bitchat.xcodeproj
schemes:
- bitchat (macOS)
retain_swift_ui_previews: true
# Codable properties are (de)serialized via synthesized conformances the
# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle
# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk —
# and slipped past its baselined USR. Retaining Codable properties outright
# is deterministic; a truly-dead Codable field is a persisted-format change
# anyway, never a safe mechanical delete.
retain_codable_properties: true
relative_results: true
baseline: .periphery.baseline.json
+1
View File
@@ -2,6 +2,7 @@
# (CI checkouts are fresh, so this only matters in a working tree).
excluded:
- .build
- .claude
- .swiftpm
- .DerivedData
- DerivedData
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.6.0
MARKETING_VERSION = 1.7.1
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+107 -117
View File
@@ -1,165 +1,155 @@
# bitchat Privacy Policy
*Last updated: June 2026*
*Last updated: July 2026*
## Our Commitment
bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
bitchat is designed for private, account-free communication. This policy describes what the app keeps on your device, what it sends when you use mesh or optional internet features, and how long local data can remain.
## Summary
- **No personal data collection** - We don't collect names, emails, or phone numbers
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
- **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** - You can verify these claims by reading our code
- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays.
- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK.
- **No sale of data** — the project does not sell user data or build advertising profiles.
- **Open source** — the storage, networking, and cryptography described here can be inspected in the source code.
## What Information bitchat Stores
## What bitchat Stores on Your Device
### On Your Device Only
1. **Identity and cryptographic keys**
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
1. **Identity Keys**
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
- Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts
- Private keys never leave your device; public keys are shared when needed for messaging
2. **Nickname, preferences, and relationships**
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
2. **Nickname**
- The display name you choose (or auto-generated)
- Stored only on your device
- Shared with peers you communicate with
3. **Private group state**
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
- Current group keys are stored in the keychain. Group state remains until you leave or remove the group, panic-wipe the app, or remove the app.
3. **Message History** (if enabled)
- When room owners enable retention, messages are saved locally
- Stored encrypted on your device
- You can delete this at any time
4. **Queued and carried private messages**
- An outgoing private message that has not been acknowledged may remain for up to 24 hours in a bounded, encrypted outbox. The outbox is sealed with ChaCha20-Poly1305 and its key is stored in the keychain.
- A device acting as a courier may store a bounded opaque end-to-end encrypted envelope for another user for up to 24 hours. The courier cannot read its message content.
- A panic wipe deletes both stores.
4. **Favorite Peers**
- Public keys of peers you mark as favorites
- Stored only on your device
- Allows you to recognize these peers in future sessions
5. **Recent public mesh messages and notices**
- Signed public mesh messages may be kept in a protected local gossip archive for up to 15 minutes so they can cross mesh partitions and survive a short relaunch.
- Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable.
- These items are public to the mesh or board where they are posted; they are not confidential messages.
5. **Optional Location Channel State**
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
- Stored locally on your device so the location-channel UI can restore your choices
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
- Exact latitude and longitude are not persisted by bitchat
6. **Media attachments**
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
- Incoming media is subject to a 100 MB quota with oldest-file eviction. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk.
### Temporary Session Data
7. **Optional location-channel state**
- Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them.
- Per-geohash Nostr identities are derived locally from a device seed stored in the keychain.
- bitchat does not persist exact latitude or longitude and does not include exact coordinates in mesh or Nostr messages.
During each session, bitchat temporarily maintains:
- Active peer connections (forgotten when app closes)
- Routing information for message delivery
- Cached messages for offline peers (12 hours max)
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
## Temporary Session Data
## What Information is Shared
While running, bitchat maintains active connections, routing state, deduplication state, and bounded in-memory conversation timelines. Closing the app clears the in-memory timelines and active connections, but it does not erase the persistent stores listed above.
### With Other bitchat Users
## What Is Shared
When you use bitchat, nearby peers can see:
- Your chosen nickname
- Your ephemeral public key (changes each session)
- Messages you send to public rooms or directly to them
- Your approximate Bluetooth signal strength (for connection quality)
### With Nearby Mesh Users
### With Room Members
Depending on the feature you use, nearby peers can receive:
When you join a password-protected room:
- Your messages are visible to others with the password
- Your nickname appears in the member list
- Room owners can see you've joined
- Your chosen nickname and public Noise/signing identity material.
- Announce metadata such as supported capability flags and a bounded list of short direct-neighbor identifiers. When the bridge is enabled, an announce can also include its coarse rendezvous geohash cell.
- Public mesh messages, public notices, and group-control packets you intentionally send.
- Private ciphertext addressed to them, or opaque courier ciphertext they agree to carry.
- Radio metadata available to the receiver, such as approximate Bluetooth signal strength.
### With Nostr Relays (Optional Features)
Noise identity keys can persist across sessions; do not treat them as anonymous identifiers. Panic wipe rotates local identity state.
If you enable Nostr-backed features:
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
### With Private Group Members
## What We DON'T Do
Private group members receive the group's name, roster, key epoch, and encrypted group traffic needed to participate. Group messages are confidential to devices holding the current group key, subject to the security of those devices and members.
bitchat **never**:
- Collects personal information
- Sells or shares your exact GPS location
- Stores data on servers we operate
- Sells your data to advertisers or data brokers
- Uses analytics or telemetry
- Creates user profiles
- Requires registration
### With Nostr Relays and Internet Gateways
## Encryption
Internet-backed features are optional. When enabled or used:
All private messages use end-to-end encryption:
- **X25519** for key exchange
- **AES-256-GCM** for message encryption
- **Ed25519** for digital signatures
- **Argon2id** for password-protected rooms
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
- A device with gateway features enabled may relay signed bridge/location traffic or opaque courier envelopes for nearby mesh devices.
## Your Rights
Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy.
You have complete control:
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
- **No Account**: No account record exists for you to delete from us
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
## Location and Apple Services
## Bluetooth & Permissions
Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels.
bitchat requires Bluetooth permission to function:
- Used only for peer-to-peer communication
- Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings
- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat.
- A selected geohash can still reveal an approximate area to peers and relays.
- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms.
- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app.
## Location Permission
## Microphone, Camera, and Media Permissions
Location permission is optional and is used only for location channels:
- Used to compute local geohash channels and display names
- Requested as when-in-use permission
- Exact coordinates are not shared in messages or stored by bitchat
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
- You can revoke this permission at any time in system settings
- Microphone access is used only while you record a voice note or actively hold live push-to-talk. The resulting audio is sent to the mesh conversation you selected; public-conversation audio is public to that mesh, while private-conversation audio uses the private transport protections described below.
- Voice-note and live-audio files can remain in Application Support under the media retention rules above.
- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send.
- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive.
## Cryptography
Private and public features use different protections:
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
No cryptographic system can protect content after a recipient reads, copies, screenshots, or exports it.
## Data Retention Summary
- **In-memory chat timelines and active connections:** until the app closes or state is cleared.
- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first.
- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first.
- **Recent public mesh gossip:** up to 15 minutes.
- **Public board posts and tombstones:** until expiry, at most seven days.
- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal.
- **Nostr data:** according to the policies of the relays that receive it.
## Your Controls
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
- **No account:** The project operates no account record for you to request or export.
## What the Project Does Not Do
bitchat does not:
- Operate an account database or project-owned messaging backend.
- Include advertising, analytics, or tracking SDKs.
- Sell user data or create advertising profiles.
- Include exact GPS coordinates in bitchat mesh or Nostr message payloads.
## Children's Privacy
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
## Data Retention
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
- **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or delete the app
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
- **Everything Else**: Exists only during active sessions
## Security Measures
- All communication is encrypted
- No accounts or company servers
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
- Open source code for public audit
- Regular security updates
- Cryptographic signatures prevent tampering
The project does not knowingly operate a service that collects children's personal data. The app has no account registration or age-verification system. Users and guardians should understand that public mesh, board, bridge, and location-channel posts are visible to other participants and may be relayed.
## Changes to This Policy
If we update this policy:
- The "Last updated" date will change
- The updated policy will be included in the app
- No retroactive changes can make us collect data already held only in your app
Material behavior changes will be reflected in this document and its “Last updated” date. Updating this policy cannot retroactively retrieve data that remained only on a user's device.
## Contact
bitchat is an open source project. For privacy questions:
- View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main)
- Open an issue on GitHub
- Join the discussion in public rooms
## Philosophy
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Open an issue on GitHub.
---
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*
*This policy is released into the public domain under The Unlicense, like the project itself.*
+13 -1
View File
@@ -92,7 +92,8 @@
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ShareViewController.swift,
Info.plist,
bitchatShareExtension.entitlements,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
@@ -258,9 +259,13 @@
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
buildPhases = (
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
7E9B64F63F93443FB7BA12DF /* Resources */,
);
buildRules = (
);
fileSystemSynchronizedGroups = (
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
);
name = bitchatShareExtension;
productName = bitchatShareExtension;
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
@@ -388,6 +393,13 @@
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
);
};
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
-22
View File
@@ -36,34 +36,12 @@ enum AppEvent: Sendable, Equatable {
actor AppEventStream {
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
func stream() -> AsyncStream<AppEvent> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.onTermination = { [id] _ in
Task {
await self.removeContinuation(id)
}
}
}
}
func emit(_ event: AppEvent) {
for continuation in continuations.values {
continuation.yield(event)
}
}
func finish() {
for continuation in continuations.values {
continuation.finish()
}
continuations.removeAll()
}
private func removeContinuation(_ id: UUID) {
continuations.removeValue(forKey: id)
}
}
/// Identity key for a direct conversation. Equality and hashing use the
+9
View File
@@ -10,6 +10,10 @@ final class AppChromeModel: ObservableObject {
@Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false
@Published var isNoticesSheetPresented = false
/// When the sheet is opened for "notes left here" (empty mesh timeline),
/// it should land on the geo tab instead of the channel-derived default.
@Published var noticesSheetPrefersGeoTab = false
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@@ -62,6 +66,11 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true
}
func presentNotices(geoTab: Bool = false) {
noticesSheetPrefersGeoTab = geoTab
isNoticesSheetPresented = true
}
/// Builds the mesh topology map model from the transport's gossiped
/// graph plus the live nickname table. Unknown nodes (heard about via a
/// neighbor claim but never announced to us) fall back to a short ID.
+24 -11
View File
@@ -18,8 +18,6 @@ final class AppRuntime: ObservableObject {
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// and `ChatViewModel` observe and mutate it through its intent API.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel
@@ -42,7 +40,7 @@ final class AppRuntime: ObservableObject {
#endif
init(
keychain: KeychainManagerProtocol = KeychainManager(),
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
@@ -51,8 +49,6 @@ final class AppRuntime: ObservableObject {
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
@@ -221,7 +217,16 @@ final class AppRuntime: ObservableObject {
chatViewModel.applicationWillTerminate()
}
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
func handleNotificationResponse(
identifier: String,
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
userInfo: [AnyHashable: Any]
) {
if actionIdentifier == NotificationService.waveActionID {
chatViewModel.sendMeshWave()
return
}
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID)
@@ -308,21 +313,29 @@ private extension AppRuntime {
}
func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
let sharedContent = userDefaults.string(forKey: "sharedContent"),
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
let clearSharedContent = {
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
// A partial or malformed handoff must not linger in the shared
// app-group container indefinitely.
clearSharedContent()
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
clearSharedContent()
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
clearSharedContent()
switch contentKind {
case .url:
+18 -11
View File
@@ -225,8 +225,25 @@ final class Conversation: ObservableObject, Identifiable {
guard let current else { return false }
if current == new { return true }
// Never downgrade to a weaker delivery state. Ordering of certainty:
// sending < sent < carried < delivered < read. A late `.sent` write
// (e.g. the optimistic stamp after routing) must not clobber the
// `.carried` the router already set when it handed a copy to a
// courier/bridge, nor a `.delivered`/`.read` ack. A late asynchronous
// failure is weaker than a confirmed recipient receipt too, so it may
// not replace `.delivered`/`.read`. Same for the
// `.sending` stamp a pre-handshake resend emits asynchronously: it
// can land after the message already reached `.sent`, and "Sent" was
// already truthful. (`.failed` `.sending` stays allowed so a real
// failure retry is visible.)
switch (current, new) {
case (.read, .delivered), (.read, .sent):
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed):
return true
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed):
return true
case (.carried, .sent), (.carried, .sending):
return true
case (.sent, .sending):
return true
default:
return false
@@ -796,16 +813,6 @@ extension ConversationStore {
return messageIDs
}
/// Removes every direct conversation (panic clear).
func removeAllDirectConversations() {
let directIDs = conversationIDs.filter { id in
if case .direct = id { return true }
return false
}
for id in directIDs {
removeConversation(id)
}
}
}
// MARK: - Diagnostics support
+18
View File
@@ -12,6 +12,9 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
/// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String?
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
@@ -150,6 +153,17 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendVoiceNote(at: url)
}
/// Capture backend for the mic gesture: live PTT when the current DM
/// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession {
chatViewModel.makeVoiceCaptureSession()
}
/// Whether this message is a live voice burst still streaming in.
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
}
func cancelMediaSend(messageID: String) {
chatViewModel.cancelMediaSend(messageID: messageID)
}
@@ -175,6 +189,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
chatViewModel.$activePublicVoiceTalker
.receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker)
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
-6
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Combine
import Foundation
@@ -17,7 +16,6 @@ final class LocationChannelsModel: ObservableObject {
private let manager: LocationChannelManager
private let network: NetworkActivationService
private let gateway: GatewayService
private var cancellables = Set<AnyCancellable>()
init(
manager: LocationChannelManager? = nil,
@@ -102,10 +100,6 @@ final class LocationChannelsModel: ObservableObject {
network.setUserTorEnabled(enabled)
}
func setGatewayEnabled(_ enabled: Bool) {
gateway.setEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel,
permissionState == .authorized,
+138
View File
@@ -0,0 +1,138 @@
//
// NearbyNotesCounter.swift
// bitchat
//
// Counts unexpired location notes left at the user's current building-level
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
// subscribes while a view holds it active, and only when location notes are
// enabled and location permission is already granted (it never prompts).
// This is free and unencumbered software released into the public domain.
//
import Combine
import Foundation
@MainActor
final class NearbyNotesCounter: ObservableObject {
static let shared = NearbyNotesCounter()
@Published private(set) var noteCount = 0
/// Whether an explicit notes act (the empty-timeline "check for notes"
/// tap, opening the notices sheet's geo tab, or a successful /drop) has
/// unlocked the counter this session. Until then nothing subscribes:
/// merely looking at the mesh timeline must not open a building-precision
/// relay REQ that leaks location passively.
@Published private(set) var revealed = false
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable?
private var permissionCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
private let managerFactory: @MainActor (String) -> LocationNotesManager
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
init(
locationManager: LocationChannelManager = .shared,
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }
) {
self.locationManager = locationManager
self.managerFactory = managerFactory
self.releaseManager = releaseManager
}
/// Whether the empty-timeline "check for notes" hint should render.
/// The permission gate matters: `retarget()` never subscribes without
/// location authorization, so offering the hint to an unauthorized
/// install would be a silent dead-end tap, `revealed` flips, the hint
/// vanishes, and nothing else happens for the session. The hint never
/// prompts; it simply stays hidden until permission exists. The caller
/// passes its own observed permission state so the hint re-renders when
/// authorization changes.
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
!revealed && LocationNotesSettings.enabled && permissionState == .authorized
}
/// Marks the one explicit act that lets the counter subscribe. Sticky for
/// the rest of the session (the singleton's lifetime); `deactivate()`
/// deliberately does not reset it.
func reveal() {
guard !revealed else { return }
revealed = true
retarget()
}
/// Begins (or keeps) the notes subscription for the current building
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
/// hold it.
func activate() {
activeHolders += 1
guard activeHolders == 1 else { return }
channelsCancellable = locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// CoreLocation can revoke authorization while the view remains
// mounted. `availableChannels` deliberately retains its last value,
// so permission must be an independent invalidation signal or the
// building REQ survives on stale coordinates.
permissionCancellable = locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// The app-info kill switch must take effect immediately, not on the
// next location change or remount.
settingCancellable = NotificationCenter.default
.publisher(for: LocationNotesSettings.didChangeNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
retarget()
}
func deactivate() {
activeHolders = max(0, activeHolders - 1)
guard activeHolders == 0 else { return }
channelsCancellable = nil
permissionCancellable = nil
settingCancellable = nil
managerCancellable = nil
releaseManager(manager)
manager = nil
noteCount = 0
}
private func retarget() {
guard activeHolders > 0,
revealed,
LocationNotesSettings.enabled,
locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash
else {
managerCancellable = nil
releaseManager(manager)
manager = nil
noteCount = 0
return
}
if let manager {
guard manager.geohash != geohash.lowercased() else { return }
// Pooled managers are shared; never retarget one in place
// release the old cell and acquire the new one.
managerCancellable = nil
releaseManager(manager)
self.manager = nil
}
let fresh = managerFactory(geohash)
manager = fresh
managerCancellable = fresh.$notes
.receive(on: DispatchQueue.main)
.sink { [weak self] notes in
let now = Date()
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
}
}
}
-8
View File
@@ -25,10 +25,6 @@ final class PeerIdentityStore: ObservableObject {
stablePeerIDsByShortID[peerID] = stablePeerID
}
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
stablePeerIDsByShortID = mappings
}
func fingerprint(for peerID: PeerID) -> String? {
peerFingerprintsByPeerID[peerID]
}
@@ -94,10 +90,6 @@ final class PeerIdentityStore: ObservableObject {
invalidateEncryptionCache(for: peerID)
}
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
encryptionStatuses = statuses
}
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
verifiedFingerprints = fingerprints
}
-6
View File
@@ -3,7 +3,6 @@ import Combine
import Foundation
struct FingerprintPresentationState: Equatable {
let statusPeerID: PeerID
let peerNickname: String
let encryptionStatus: EncryptionStatus
let theirFingerprint: String?
@@ -56,10 +55,6 @@ final class VerificationModel: ObservableObject {
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
chatViewModel.beginQRVerification(with: qr)
}
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid
@@ -110,7 +105,6 @@ final class VerificationModel: ObservableObject {
}
return FingerprintPresentationState(
statusPeerID: statusPeerID,
peerNickname: peerNickname,
encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 50 KiB

@@ -27,6 +27,66 @@
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "mac_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "mac_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "mac_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "mac_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "mac_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "mac_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "mac_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "mac_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "mac_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "mac_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+10 -2
View File
@@ -104,12 +104,20 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
let actionIdentifier = response.actionIdentifier
let userInfo = response.notification.request.content.userInfo
// Complete only after the response is handled: for a background
// action (👋 wave) the system may suspend the app the moment the
// completion handler runs, which would drop the queued send.
Task { @MainActor in
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
self.runtime?.handleNotificationResponse(
identifier: identifier,
actionIdentifier: actionIdentifier,
userInfo: userInfo
)
completionHandler()
}
completionHandler()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
@@ -0,0 +1,472 @@
//
// AudioSessionCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// The raw audio-session calls the coordinator makes, abstracted so the
/// state machine is unit-testable with a mock (and compiles on the macOS
/// test host, where `AVAudioSession` doesn't exist).
///
/// Calls arrive on the coordinator's private serial queue never the main
/// thread. `setCategory`/`setActive` block on IPC to the audio server
/// (observed >1 s under contention on device, tripping the system gesture
/// gate), and Apple explicitly recommends activating the session off the
/// main thread.
protocol SessionApplying: Sendable {
func setCategory(_ category: AudioSessionCoordinator.Category) throws
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws
}
/// Sole owner of `AVAudioSession` category/activation for voice features.
///
/// Talk-over means capture (push-to-talk) and playback (inbound bursts,
/// voice notes) can be live simultaneously; letting each engine configure
/// the shared session directly made them stomp each other's category and
/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every
/// client acquires a `Token` and the coordinator:
///
/// - reference-counts activation: `setActive(true)` only on the first
/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the
/// last one releases no client can deactivate another's session;
/// - keeps one escalating category: playback-only holders get `.playback`,
/// any capture holder escalates to `.playAndRecord`, and the category is
/// never downgraded while anyone still holds a token (capture ending must
/// not yank the route out from under live playback);
/// - fans out `onInterrupted` on system interruptions and when the active
/// route's device disappears (no auto-resume: bursts are transient, the
/// next press or burst simply re-acquires). The escalating category change
/// fans out separately as `onCategoryEscalated` the session stays live,
/// so holders that can rebuild their engine against the new configuration
/// keep playing (talk-over is bidirectional); holders that don't provide
/// it fall back to `onInterrupted`.
///
/// Threading: all state lives on a private serial queue, which both
/// serializes rapid acquire/release pairs and keeps the blocking session IPC
/// off the main thread (`acquire` is `async` for exactly that hop; `release`
/// is fire-and-forget onto the queue). Holder callbacks always run on the
/// main actor.
///
/// Microphone *permission* queries stay with their callers; this type owns
/// only category and activation.
///
/// `@unchecked Sendable`: every mutable property is confined to `queue`.
final class AudioSessionCoordinator: @unchecked Sendable {
enum Use {
case playback
case capture
}
/// The session category the coordinator has applied (the `SessionApplying`
/// adapter maps these to concrete `AVAudioSession` category/mode/options).
enum Category {
case playback
case playAndRecord
}
/// Opaque handle for one client's hold on the session. Release exactly
/// once when done (extra releases are ignored).
///
/// `@unchecked` because the stored callbacks are `@MainActor`-isolated
/// closures (non-Sendable as stored types). Lifecycle state is protected
/// by `stateLock`, and callbacks are only ever invoked on the main actor.
final class Token: @unchecked Sendable {
fileprivate enum CallbackKind: Sendable {
case interrupted
case categoryEscalated
}
/// A callback snapshot is only valid for the lifecycle epoch in which
/// it was captured. `release` advances the epoch synchronously before
/// its queue work, so a callback already headed to the main actor can't
/// reach a client that has since released this token and reacquired a
/// different one.
fileprivate struct CallbackTicket: Sendable {
let token: Token
let kind: CallbackKind
let lifecycleEpoch: UInt64
}
private enum Lifecycle {
/// Registered on the session queue, but `acquire` has not yet
/// returned into the client's main-actor call frame.
case acquiring
case ready
case released
}
fileprivate let onInterrupted: @MainActor () -> Void
fileprivate let onCategoryEscalated: (@MainActor () -> Void)?
private let stateLock = NSLock()
private var lifecycle = Lifecycle.acquiring
private var lifecycleEpoch: UInt64 = 0
/// A terminal event that lands while the token is registered but not
/// yet handed off invalidates the acquire before its caller can start.
private var terminalEventPendingHandoff = false
fileprivate init(
onInterrupted: @escaping @MainActor () -> Void,
onCategoryEscalated: (@MainActor () -> Void)?
) {
self.onInterrupted = onInterrupted
self.onCategoryEscalated = onCategoryEscalated
}
/// Records an event at the same linearization point at which the
/// coordinator snapshots its holders. An acquiring token cannot safely
/// receive a callback yet: terminal events invalidate the acquire,
/// while category escalation needs no callback because its engine will
/// start against the already-escalated configuration.
fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? {
stateLock.withLock {
switch lifecycle {
case .acquiring:
switch kind {
case .interrupted:
terminalEventPendingHandoff = true
case .categoryEscalated:
break
}
return nil
case .ready:
return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch)
case .released:
return nil
}
}
}
/// Completes the main-actor ownership handoff if no terminal event
/// invalidated it. Because `acquire` itself is main-actor isolated, a
/// successful handoff returns directly into the caller without another
/// actor hop; no callback can interleave before the caller stores the
/// returned token.
fileprivate func completeHandoff() -> Bool {
stateLock.withLock {
guard lifecycle == .acquiring,
!terminalEventPendingHandoff
else { return false }
lifecycle = .ready
return true
}
}
/// Marks the token dead synchronously, before the asynchronous holder
/// removal. Returns false for an already-released token.
fileprivate func markReleased() -> Bool {
stateLock.withLock {
guard lifecycle != .released else { return false }
lifecycle = .released
lifecycleEpoch &+= 1
terminalEventPendingHandoff = false
return true
}
}
/// Revalidates a queue snapshot at the main-actor delivery boundary.
/// The lock is deliberately released before invoking client code: real
/// callbacks commonly call `release` on this same token.
@MainActor
fileprivate func deliver(_ ticket: CallbackTicket) {
let isLive = stateLock.withLock {
lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch
}
guard isLive else { return }
switch ticket.kind {
case .interrupted:
onInterrupted()
case .categoryEscalated:
(onCategoryEscalated ?? onInterrupted)()
}
}
}
/// Deterministic suspension points for lifecycle race tests. Production
/// instances use the nil defaults; the hooks never move session calls off
/// the coordinator queue or callback execution off the main actor.
struct TestingHooks: Sendable {
let beforeAcquireHandoff: (@Sendable () async -> Void)?
let beforeCallbackDelivery: (@Sendable () async -> Void)?
init(
beforeAcquireHandoff: (@Sendable () async -> Void)? = nil,
beforeCallbackDelivery: (@Sendable () async -> Void)? = nil
) {
self.beforeAcquireHandoff = beforeAcquireHandoff
self.beforeCallbackDelivery = beforeCallbackDelivery
}
}
static let shared = AudioSessionCoordinator(session: SystemAudioSession())
private let session: SessionApplying
private let testingHooks: TestingHooks
/// Confines all mutable state, serializes whole acquire/release
/// operations (two rapid presses can't interleave their category and
/// activation calls), and hosts the blocking session IPC off main.
private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated)
// Queue-confined state.
private var holders: [ObjectIdentifier: Token] = [:]
private var currentCategory: Category?
private var sessionActive = false
/// Written once in init, read in deinit never touched concurrently.
private var observers: [NSObjectProtocol] = []
init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) {
self.session = session
self.testingHooks = testingHooks
observeSystemNotifications()
}
deinit {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}
/// Configures + activates the session for `use` and registers the caller
/// as a holder. The blocking `AVAudioSession` calls run on the session
/// queue the caller suspends instead of stalling its thread (a PTT
/// press used to block main >1 s in `setActive`, tripping the system
/// gesture gate). `onInterrupted` fires (on the main actor) when the
/// client must stop using the session: a system interruption began or
/// its route's device went away. The client should stop its engine,
/// finalize any artifacts, and release resuming means acquiring again.
///
/// `onCategoryEscalated` fires instead when the session category
/// escalated underneath the holder (a capture client joined): the session
/// stays active, so a holder that can rebuild its engine against the new
/// configuration should restart and keep going. Holders that pass `nil`
/// get `onInterrupted` for escalation too. Escalation is delivered before
/// `acquire` returns, so the new holder starts its engine strictly after
/// existing ones were told to rebuild. Main-actor isolation is also the
/// ownership handoff boundary: if interruption or route loss lands after
/// queue registration but before that boundary, the provisional holder is
/// removed and `acquire` throws `CancellationError` instead of returning a
/// token whose callback already fired.
@MainActor
func acquire(
_ use: Use,
onInterrupted: @escaping @MainActor () -> Void,
onCategoryEscalated: (@MainActor () -> Void)? = nil
) async throws -> Token {
let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated)
let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
continuation.resume(returning: try self.activateOnQueue(use, registering: token))
} catch {
continuation.resume(throwing: error)
}
}
}
// Escalating playback -> playAndRecord reconfigures the hardware
// route; engines started against the old configuration must restart.
if !reconfigured.isEmpty {
SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session)
await deliver(reconfigured)
}
if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff {
await beforeAcquireHandoff()
}
guard token.completeHandoff() else {
// A call/Siri interruption or route loss landed after registration
// but before ownership handoff. Remove the provisional holder and
// fail instead of starting a client engine after the stop event.
release(token)
throw CancellationError()
}
return token
}
/// Drops one holder. Deactivates the session (notifying other apps) only
/// when the last holder releases. Safe to call more than once, from any
/// thread (including `deinit` paths): the work is fire-and-forget onto
/// the session queue, so the blocking deactivation IPC never runs on the
/// caller.
func release(_ token: Token) {
guard token.markReleased() else { return }
queue.async {
self.releaseOnQueue(token)
}
}
// MARK: - Queue-confined core
/// Returns callback tickets for pre-existing live holders whose engines
/// must restart because this acquire escalated the category.
private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] {
let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback
let categoryChanged = target != currentCategory
let previousCategory = currentCategory
if categoryChanged {
try session.setCategory(target)
currentCategory = target
}
if !sessionActive {
do {
try session.setActive(true, notifyOthersOnDeactivation: false)
} catch {
// Activation failed (e.g. a phone call owns the hardware):
// with no holder registered, an escalated category recorded
// here would stick and pin later playback-only acquires to
// .playAndRecord. Existing holders keep the category the
// hardware really has.
if categoryChanged, holders.isEmpty {
currentCategory = previousCategory
}
throw error
}
sessionActive = true
}
let reconfigured = categoryChanged
? holders.values.compactMap { $0.record(.categoryEscalated) }
: []
holders[ObjectIdentifier(token)] = token
return reconfigured
}
private func releaseOnQueue(_ token: Token) {
guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return }
guard holders.isEmpty else { return }
currentCategory = nil
guard sessionActive else { return }
sessionActive = false
do {
try session.setActive(false, notifyOthersOnDeactivation: true)
} catch {
SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session)
}
}
private func onQueue<T: Sendable>(_ body: @escaping @Sendable () -> T) async -> T {
await withCheckedContinuation { continuation in
queue.async {
continuation.resume(returning: body())
}
}
}
@MainActor
private func deliver(_ tickets: [Token.CallbackTicket]) async {
guard !tickets.isEmpty else { return }
if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery {
await beforeCallbackDelivery()
}
for ticket in tickets {
ticket.token.deliver(ticket)
}
}
// MARK: - System events (internal so tests can drive them directly)
/// A system interruption began: the session is already deactivated by the
/// OS, so just mark it inactive and tell every ready holder (on the main
/// actor) to stop. A provisional acquiring holder is invalidated instead.
/// No auto-resume the next acquire re-activates.
func handleInterruptionBegan() async {
let tickets = await onQueue { () -> [Token.CallbackTicket] in
self.sessionActive = false
return self.holders.values.compactMap { $0.record(.interrupted) }
}
await deliver(tickets)
}
/// The active route's input/output device disappeared (e.g. BT headset
/// off): ready holders' engines are wedged against a dead route stop
/// them; invalidate a holder whose acquire has not returned yet.
func handleRouteDeviceUnavailable() async {
let tickets = await onQueue {
self.holders.values.compactMap { $0.record(.interrupted) }
}
await deliver(tickets)
}
/// Test hook: suspends until every session operation enqueued before this
/// call including fire-and-forget `release`s has completed.
func drain() async {
await onQueue {}
}
private func observeSystemNotifications() {
#if os(iOS)
let center = NotificationCenter.default
observers.append(center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .began,
let self
else { return }
SecureLogger.info("AudioSession: interruption began", category: .session)
Task { await self.handleInterruptionBegan() }
})
observers.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable,
let self
else { return }
SecureLogger.info("AudioSession: route device became unavailable", category: .session)
Task { await self.handleRouteDeviceUnavailable() }
})
#endif
}
}
// MARK: - Production adapter
#if os(iOS)
private struct SystemAudioSession: SessionApplying {
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
let session = AVAudioSession.sharedInstance()
switch category {
case .playback:
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
case .playAndRecord:
// allowBluetoothHFP is not available on iOS Simulator
#if targetEnvironment(simulator)
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers]
)
#endif
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
try AVAudioSession.sharedInstance().setActive(
active,
options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : []
)
}
}
#else
/// macOS has no app-level audio session; the coordinator still runs its
/// bookkeeping so client code is identical across platforms.
private struct SystemAudioSession: SessionApplying {
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
}
#endif
+175
View File
@@ -0,0 +1,175 @@
//
// PTTAudioCodec.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
/// carries a bit reservoir across frames); one instance per burst.
/// Not thread-safe confine to one queue.
final class PTTFrameEncoder {
private let converter: AVAudioConverter
private var pendingInput: [AVAudioPCMBuffer] = []
init?() {
guard let pcm = PTTAudioFormat.pcmFormat,
let aac = PTTAudioFormat.aacFormat,
let converter = AVAudioConverter(from: pcm, to: aac)
else { return nil }
converter.bitRate = PTTAudioFormat.bitRate
self.converter = converter
}
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
pendingInput.append(buffer)
return drainConverter()
}
private func drainConverter() -> [Data] {
var frames: [Data] = []
while true {
let output = AVAudioCompressedBuffer(
format: converter.outputFormat,
packetCapacity: 8,
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
)
var error: NSError?
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
guard let self, let next = self.pendingInput.first else {
outStatus.pointee = .noDataNow
return nil
}
self.pendingInput.removeFirst()
outStatus.pointee = .haveData
return next
}
if status == .error {
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return frames
}
frames.append(contentsOf: Self.extractPackets(from: output))
// .haveData means the output buffer filled and more may be ready;
// anything else means the converter wants more input.
if status != .haveData { return frames }
}
}
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
var frames: [Data] = []
frames.reserveCapacity(Int(buffer.packetCount))
for index in 0..<Int(buffer.packetCount) {
let description = descriptions[index]
guard description.mDataByteSize > 0 else { continue }
let start = buffer.data.advanced(by: Int(description.mStartOffset))
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
}
return frames
}
}
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
/// inbound burst. Not thread-safe confine to one queue/actor.
final class PTTFrameDecoder {
private let converter: AVAudioConverter
private let pcmFormat: AVAudioFormat
private let aacFormat: AVAudioFormat
init?() {
guard let pcm = PTTAudioFormat.pcmFormat,
let aac = PTTAudioFormat.aacFormat,
let converter = AVAudioConverter(from: aac, to: pcm)
else { return nil }
self.converter = converter
self.pcmFormat = pcm
self.aacFormat = aac
}
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
/// while the decoder is still priming (the first frame of a stream).
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
frame.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
input.data.copyMemory(from: base, byteCount: frame.count)
}
input.byteLength = UInt32(frame.count)
input.packetCount = 1
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
mStartOffset: 0,
mVariableFramesInPacket: 0,
mDataByteSize: UInt32(frame.count)
)
guard let output = AVAudioPCMBuffer(
pcmFormat: pcmFormat,
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
) else { return nil }
var consumed = false
var error: NSError?
let status = converter.convert(to: output, error: &error) { _, outStatus in
if consumed {
outStatus.pointee = .noDataNow
return nil
}
consumed = true
outStatus.pointee = .haveData
return input
}
guard status != .error else {
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return nil
}
return output.frameLength > 0 ? output : nil
}
}
/// Sample-rate/channel converter from the microphone's native format to the
/// 16 kHz mono processing format. Stateful; not thread-safe.
final class PTTInputResampler {
private let converter: AVAudioConverter
private let outputFormat: AVAudioFormat
private let ratio: Double
init?(inputFormat: AVAudioFormat) {
guard let pcm = PTTAudioFormat.pcmFormat,
let converter = AVAudioConverter(from: inputFormat, to: pcm)
else { return nil }
self.converter = converter
self.outputFormat = pcm
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
}
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
var consumed = false
var error: NSError?
let status = converter.convert(to: output, error: &error) { _, outStatus in
if consumed {
outStatus.pointee = .noDataNow
return nil
}
consumed = true
outStatus.pointee = .haveData
return buffer
}
guard status != .error else {
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return nil
}
return output.frameLength > 0 ? output : nil
}
}
@@ -0,0 +1,84 @@
//
// PTTAudioFormat.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import Foundation
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
/// ~16 kbps deliberately identical to `VoiceRecorder`'s voice-note settings
/// so a burst's finalized `.m4a` and its live frames sound the same.
enum PTTAudioFormat {
static let sampleRate: Double = 16_000
static let channelCount: AVAudioChannelCount = 1
static let bitRate = 16_000
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
static let samplesPerFrame: AVAudioFrameCount = 1024
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
/// Uncompressed processing format (deinterleaved float PCM).
static var pcmFormat: AVAudioFormat? {
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
}
/// Compressed wire format.
static var aacFormat: AVAudioFormat? {
var description = AudioStreamBasicDescription(
mSampleRate: sampleRate,
mFormatID: kAudioFormatMPEG4AAC,
mFormatFlags: 0,
mBytesPerPacket: 0,
mFramesPerPacket: samplesPerFrame,
mBytesPerFrame: 0,
mChannelsPerFrame: channelCount,
mBitsPerChannel: 0,
mReserved: 0
)
return AVAudioFormat(streamDescription: &description)
}
/// Voice-note container settings for the finalized `.m4a`, mirroring
/// `VoiceRecorder.startRecording()`.
static var voiceNoteFileSettings: [String: Any] {
[
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: Int(channelCount),
AVEncoderBitRateKey: bitRate
]
}
}
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
/// stream is playable at any prefix a partially received burst is still a
/// replayable voice note.
enum ADTSFramer {
private static let headerSize = 7
/// MPEG-4 sampling frequency index for 16 kHz.
private static let samplingFrequencyIndex: UInt8 = 8
private static let channelConfiguration: UInt8 = 1
/// Wraps one raw AAC-LC frame in an ADTS header.
static func frame(_ aacFrame: Data) -> Data {
let frameLength = aacFrame.count + headerSize
var data = Data(capacity: frameLength)
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
data.append(0xFF)
data.append(0xF1)
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
// private bit 0, channel config high bit.
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
data.append(UInt8((frameLength >> 3) & 0xFF))
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
data.append(0xFC)
data.append(aacFrame)
return data
}
}
+509
View File
@@ -0,0 +1,509 @@
//
// PTTBurstPlayer.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
@preconcurrency import AVFoundation
import BitLogger
import Foundation
/// The engine operations behind live-burst playback, abstracted so the
/// player's lifecycle (jitter start, category-escalation restart, stop) is
/// unit-testable without real audio hardware.
@MainActor
protocol PTTPlaybackEngine: AnyObject {
/// The object `AVAudioEngineConfigurationChange` notifications are posted
/// for (nil for mocks no observer is registered).
var configChangeObject: AnyObject? { get }
func start() throws
func play()
func stop()
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
)
}
/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled
/// buffer. `dataConsumed` only means the node no longer needs the bytes; it
/// may arrive before the render pipeline has made the audio audible.
enum PTTPlaybackCompletionType: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
}
enum PTTPlaybackCompletionEvent: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
/// AVAudioPlayerNode invokes the requested callback when the node is
/// stopped too. That is not audible completion and must remain replayable.
case playbackStopped
}
/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start:
/// an engine instantiated against an earlier audio-session configuration keeps
/// rendering to the stale route (same class of failure as the capture side's
/// fresh-engine-per-press rule).
@MainActor
private final class SystemPTTPlaybackEngine: PTTPlaybackEngine {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
init(format: AVAudioFormat) {
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
}
var configChangeObject: AnyObject? { engine }
func start() throws {
engine.prepare()
try engine.start()
}
func play() {
node.play()
}
func stop() {
node.stop()
engine.stop()
}
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
) {
let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType {
case .dataConsumed: .dataConsumed
case .dataPlayedBack: .dataPlayedBack
}
let scheduledEngine = engine
node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in
// The API invokes this callback when the player is stopped as
// well. A configuration change can stop the engine before its
// notification reaches MainActor, so do not misclassify that
// flushed tail as audible playback.
guard scheduledEngine?.isRunning == true else {
completionHandler(.playbackStopped)
return
}
switch callbackType {
case .dataConsumed:
completionHandler(.dataConsumed)
case .dataRendered:
completionHandler(.dataConsumed)
case .dataPlayedBack:
completionHandler(.dataPlayedBack)
@unknown default:
completionHandler(.playbackStopped)
}
}
}
}
/// Completion callbacks arrive off the main actor, while engine rebuilds are
/// serialized on it. This small lock-backed latch lets a rebuild atomically
/// claim only buffers whose completion has not already fired even when the
/// callback's hop back to the main actor is still queued.
private final class PTTPlaybackCompletionState: @unchecked Sendable {
private enum State {
case scheduled
case completed
case retired
}
private let lock = NSLock()
private var state: State = .scheduled
/// Returns true exactly once when playback completion wins the race with
/// an engine rebuild or stop.
func complete() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .completed
return true
}
}
/// Returns true exactly once when a rebuild or stop claims this
/// still-pending schedule. Later callbacks from that engine are stale.
func retireIfPending() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .retired
return true
}
}
}
/// Plays one inbound live voice burst with a small jitter buffer.
///
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
/// an underrun (missing/late packets) simply pauses output until the next
/// buffer arrives, which self-heals timing without explicit silence
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
///
/// Talk-over is bidirectional: when push-to-talk capture starts while this
/// burst plays, the session category escalates underneath the engine the
/// player rebuilds a fresh engine against the new configuration and keeps
/// streaming instead of dying. Real interruptions (phone call, route device
/// gone) still stop it; the burst keeps assembling to file either way.
@MainActor
final class PTTBurstPlayer {
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
/// handful of category/route changes is plenty beyond it something is
/// thrashing and stopping cleanly beats an engine-rebuild loop.
private static let maxEngineRestarts = 8
private let makeEngine: @MainActor () -> PTTPlaybackEngine
private var engine: PTTPlaybackEngine
private let decoder: PTTFrameDecoder
private let coordinator: AudioSessionCoordinator
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would stop this player mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var queuedBuffers: [AVAudioPCMBuffer] = []
private var queuedDuration: TimeInterval = 0
private struct ScheduledBuffer {
let id: UInt64
let buffer: AVAudioPCMBuffer
let completionState: PTTPlaybackCompletionState
}
/// Buffers handed to the current engine whose completion has not yet
/// been processed on the main actor. Keeping the buffers themselves lets
/// a category-escalation rebuild replay the unfinished tail in order.
private var scheduledBuffers: [ScheduledBuffer] = []
private var nextScheduledBufferID: UInt64 = 0
/// Bumped on every engine rebuild or stop so completion tasks from a
/// torn-down engine cannot mutate the current generation's pending list.
private var engineGeneration = 0
private var engineRestarts = 0
private var engineStarted = false
private var finished = false
/// Latched off (internal read so tests can await the async failure path).
private(set) var stopped = false
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates `startIfReady` against double acquisition.
private var acquiringSession = false
private var deadlineTask: Task<Void, Never>?
private var sessionToken: AudioSessionCoordinator.Token?
/// Reserved before the session acquire suspends. Activation succeeds only
/// if no newer playback request claimed the floor in the meantime.
private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation?
private var configChangeObserver: NSObjectProtocol?
private(set) var isPlaying = false
/// Fires exactly once when the player stops for good (drain-out, cancel,
/// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark
/// the draining player it keeps alive after the assembly the player's
/// only long-lived owner is discarded on burst END.
var onStopped: (() -> Void)?
init?(
coordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil,
makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil
) {
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
self.decoder = decoder
self.coordinator = coordinator ?? .shared
self.exclusivity = exclusivity ?? .shared
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
self.makeEngine = factory
self.engine = factory()
deadlineTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
self?.startIfReady(force: true)
}
}
deinit {
// Backstop for an owner dropping the player before it stopped: the
// session coordinator retains registered tokens strongly, so a token
// leaked here would keep the session active (and pin any escalated
// category) for the app's lifetime. `release` is fire-and-forget
// onto the coordinator's queue, so it is deinit-safe.
if let token = sessionToken {
coordinator.release(token)
}
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
deadlineTask?.cancel()
}
/// Decodes and queues frames (in burst order). Starts playback when the
/// jitter buffer fills.
func enqueue(_ frames: [Data]) {
guard !stopped else { return }
for frame in frames {
guard let pcm = decoder.decode(frame) else { continue }
if engineStarted {
schedule(pcm)
} else {
queuedBuffers.append(pcm)
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
}
}
startIfReady(force: false)
}
/// The burst ended: stop once everything scheduled has played out.
func finishAfterDrain() {
finished = true
// The complete burst is queued no jitter left to wait for. This
// also matters when END lands while the async session acquire is
// still in flight: the queued audio must play out, not be treated
// as already drained.
startIfReady(force: true)
stopIfDrained()
}
/// Immediate stop (cancel, another playback taking over, interruption,
/// teardown).
func stop() {
guard !stopped else { return }
stopped = true
deadlineTask?.cancel()
removeConfigObserver()
queuedBuffers = []
retireScheduledBuffers()
if engineStarted {
engine.stop()
}
isPlaying = false
releaseSessionToken()
exclusivity.deactivate(self)
onStopped?()
}
private func startIfReady(force: Bool) {
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
// Acquiring the session suspends for its blocking IPC (off the main
// actor); frames arriving meanwhile keep queueing and are flushed
// onto the engine once it starts.
acquiringSession = true
playbackReservation = exclusivity.reserve(self)
Task { [weak self] in
await self?.acquireSessionAndStart()
}
}
private func acquireSessionAndStart() async {
let token: AudioSessionCoordinator.Token
do {
token = try await coordinator.acquire(
.playback,
onInterrupted: { [weak self] in self?.stop() },
onCategoryEscalated: { [weak self] in self?.restartEngine() }
)
} catch {
acquiringSession = false
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
// Playing unregistered would leave the engine exposed: another
// holder's last release deactivates the session mid-play, and no
// interruption/escalation fan-out ever reaches us. Bail like the
// engine-start failure below; the burst still assembles to file.
// (stop() also fires onStopped so a parked draining player is
// unparked instead of leaking.)
stop()
return
}
acquiringSession = false
// stop() (cancel, exclusivity, teardown) may have landed while the
// session was activating: hand the token straight back.
guard !stopped else {
coordinator.release(token)
return
}
sessionToken = token
guard let playbackReservation,
exclusivity.isCurrent(playbackReservation, for: self)
else {
// The request was superseded while audio-session activation was
// suspended. Do not even start the retired engine.
stop()
return
}
// Observe reconfiguration before starting so nothing lands between.
registerConfigObserver()
do {
try engine.start()
} catch {
// A capture racing this start can reconfigure the session while
// the engine spins up (its escalation fan-out no-ops on a player
// that never started): rebuild once against the settled
// configuration counted against the restart cap before
// giving up.
SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session)
removeConfigObserver()
engineRestarts += 1
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
// stop() removes the observer, hands the token back, and
// fires onStopped for any parked draining owner.
stop()
return
}
}
engineStarted = true
guard exclusivity.activate(self, reservation: playbackReservation)
else {
// A newer user-initiated playback reserved the floor while this
// older PTT request was suspended in audio-session activation.
// Never let the late completion steal playback back.
stop()
return
}
isPlaying = true
engine.play()
let buffered = queuedBuffers
queuedBuffers = []
queuedDuration = 0
for buffer in buffered {
schedule(buffer)
}
}
/// The audio session was reconfigured underneath the running engine
/// (category escalation for talk-over, or an engine configuration
/// change): rebuild a fresh engine against the new configuration and
/// keep streaming. Buffers already completed stay completed; the
/// unfinished scheduled tail is replayed in order on the fresh engine,
/// and frames still arriving continue scheduling after it.
private func restartEngine() {
guard engineStarted, !stopped else { return }
engineRestarts += 1
guard engineRestarts <= Self.maxEngineRestarts else {
SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session)
stop()
return
}
removeConfigObserver()
// Claim the unfinished tail before stopping the old engine. Stopping
// a player node may itself invoke its completion handlers; retiring
// the claimed entries first makes those callbacks unambiguously stale.
// A completion that fired just before this rebuild wins the latch and
// is excluded even if its MainActor task has not run yet.
let buffersToReplay = scheduledBuffers.compactMap { scheduled in
scheduled.completionState.retireIfPending() ? scheduled.buffer : nil
}
scheduledBuffers = []
engineGeneration += 1
engine.stop()
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session)
stop()
return
}
engine.play()
for buffer in buffersToReplay {
schedule(buffer)
}
SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session)
// If every old buffer completed before the rebuild, a finished burst
// can stop now. Otherwise the replayed tail keeps it alive until its
// new-generation completions arrive.
stopIfDrained()
}
private func registerConfigObserver() {
guard let object = engine.configChangeObject else { return }
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: object,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.restartEngine()
}
}
}
private func removeConfigObserver() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
let id = nextScheduledBufferID
nextScheduledBufferID &+= 1
let completionState = PTTPlaybackCompletionState()
scheduledBuffers.append(ScheduledBuffer(
id: id,
buffer: buffer,
completionState: completionState
))
let generation = engineGeneration
engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in
guard event == .dataPlayedBack else { return }
// Mark completion before hopping to MainActor. A rebuild can then
// distinguish already-completed audio from an unfinished tail
// even when this task has not run yet.
guard completionState.complete() else { return }
Task { @MainActor [weak self] in
guard let self, self.engineGeneration == generation else { return }
self.scheduledBuffers.removeAll { $0.id == id }
self.stopIfDrained()
}
}
}
private func retireScheduledBuffers() {
engineGeneration += 1
for scheduled in scheduledBuffers {
_ = scheduled.completionState.retireIfPending()
}
scheduledBuffers = []
}
private func stopIfDrained() {
guard finished, scheduledBuffers.isEmpty else { return }
// Started: everything scheduled has played out. Never started with
// nothing queued or in flight (e.g. no decodable frames): nothing
// will ever play. Otherwise the engine start is still pending (the
// async session acquire) and the queued audio must play out first.
guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return }
stop()
}
private func releaseSessionToken() {
sessionToken.map(coordinator.release)
sessionToken = nil
}
}
extension PTTBurstPlayer: ExclusivePlayback {
/// A live stream can't meaningfully pause; yielding the floor stops it.
/// The burst keeps assembling to file, so nothing is lost.
nonisolated func pauseForExclusivity() {
Task { @MainActor [weak self] in
self?.stop()
}
}
}
@@ -0,0 +1,326 @@
//
// PTTCaptureEngine.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// Owns one capture token and returns it even when the capture engine's owner
/// disappears without reaching its normal stop/cancel path. The coordinator
/// retains registered tokens strongly, so relying on `Token.deinit` cannot
/// reclaim an abandoned hold.
final class PTTCaptureSessionLease: @unchecked Sendable {
private let coordinator: AudioSessionCoordinator
private let lock = NSLock()
private var token: AudioSessionCoordinator.Token?
init(coordinator: AudioSessionCoordinator) {
self.coordinator = coordinator
}
func install(_ token: AudioSessionCoordinator.Token) {
let previous = lock.withLock {
let previous = self.token
self.token = token
return previous
}
previous.map(coordinator.release)
}
func release() {
let token = lock.withLock {
let token = self.token
self.token = nil
return token
}
token.map(coordinator.release)
}
deinit {
release()
}
}
/// Monotonic capture identity shared by main-actor lifecycle code and queued
/// engine callbacks. Removing a notification observer does not cancel a block
/// already enqueued on the main queue, so every callback must also prove it
/// still belongs to the current hold before mutating capture state.
final class PTTCaptureGeneration: @unchecked Sendable {
private let lock = NSLock()
private var value: UInt = 0
func begin() -> UInt {
lock.withLock {
value &+= 1
return value
}
}
func invalidate() {
lock.withLock { value &+= 1 }
}
func invalidate(ifCurrent generation: UInt) -> Bool {
lock.withLock {
guard value == generation else { return false }
value &+= 1
return true
}
}
func isCurrent(_ generation: UInt) -> Bool {
lock.withLock { value == generation }
}
}
/// Captures microphone audio for a live push-to-talk burst, producing both:
/// - live AAC frames via `onFrames` (called on the capture queue), and
/// - a finalized `.m4a` voice note on `stop()` the same artifact
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
/// handles delivery to receivers that missed the live stream.
/// `@unchecked Sendable`: every mutable property is confined to one executor
/// the capture `queue` (resampler/encoder/file/counters) or the main actor
/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) so
/// weak references may cross the `@Sendable` tap/notification closures, which
/// immediately hop back to the owning executor.
final class PTTCaptureEngine: @unchecked Sendable {
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
/// engine keeps running (the UI owns the gesture) but stops encoding.
private static let maxCaptureDuration: TimeInterval = 120
/// Recreated on every `start()`: an engine whose input unit was
/// instantiated against an earlier (playback-only or inactive) audio
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
private var engine = AVAudioEngine()
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
private let coordinator: AudioSessionCoordinator
private let sessionLease: PTTCaptureSessionLease
private let captureGeneration = PTTCaptureGeneration()
// Capture-queue-confined state.
private var resampler: PTTInputResampler?
private var encoder: PTTFrameEncoder?
private var file: AVAudioFile?
private var fileURL: URL?
private var encodedFrameCount = 0
private var running = false
private var captureStart = Date()
/// Whether `engine.start()` succeeded for the current capture
/// (see `stopEngineIfStarted`).
@MainActor private var engineStarted = false
@MainActor private var configChangeObserver: NSObjectProtocol?
/// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)?
enum CaptureError: Error {
case inputUnavailable
case audioSetupFailed
}
init(coordinator: AudioSessionCoordinator = .shared) {
self.coordinator = coordinator
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
}
deinit {
sessionLease.release()
}
/// Async because acquiring the session hops its blocking IPC off the main
/// actor (a PTT press used to stall main >1 s in `setActive`); the engine
/// itself still starts back on main once the session is configured.
@MainActor
func start(outputURL: URL) async throws {
let generation = captureGeneration.begin()
let token = try await coordinator.acquire(.capture) { [weak self] in
self?.handleInterruption(for: generation)
}
// The hold ended (stop/cancel) while the session was activating:
// starting the engine now would leave a hot mic after release.
guard captureGeneration.isCurrent(generation) else {
coordinator.release(token)
throw CancellationError()
}
sessionLease.install(token)
do {
try beginCapture(outputURL: outputURL, generation: generation)
} catch {
releaseSessionToken()
throw error
}
}
@MainActor
private func beginCapture(outputURL: URL, generation: UInt) throws {
// Fresh engine per capture so its input unit binds to the session
// that is active *now* (see `engine` doc comment).
engine = AVAudioEngine()
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
throw CaptureError.inputUnavailable
}
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
let encoder = PTTFrameEncoder(),
let pcmFormat = PTTAudioFormat.pcmFormat
else { throw CaptureError.audioSetupFailed }
let file = try AVAudioFile(
forWriting: outputURL,
settings: PTTAudioFormat.voiceNoteFileSettings,
commonFormat: pcmFormat.commonFormat,
interleaved: pcmFormat.isInterleaved
)
queue.sync {
self.resampler = resampler
self.encoder = encoder
self.file = file
self.fileURL = outputURL
self.encodedFrameCount = 0
self.captureStart = Date()
self.running = true
}
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.queue.async { self?.process(buffer, generation: generation) }
}
// Route/category changes reconfigure the engine underneath the tap;
// stop and finalize cleanly the .m4a captured so far still sends.
// Registered before start() so no reconfigure lands unobserved
// (handleInterruption also validates this capture generation).
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: engine,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleInterruption(for: generation)
}
}
engine.prepare()
do {
try engine.start()
} catch {
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
engine.inputNode.removeTap(onBus: 0)
queue.sync { self.teardown(deleteFile: true) }
throw error
}
engineStarted = true
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
}
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
@MainActor
func stop() -> (url: URL?, encodedFrames: Int) {
captureGeneration.invalidate()
stopEngineIfStarted()
let result: (URL?, Int) = queue.sync {
let url = fileURL
let frames = encodedFrameCount
teardown(deleteFile: false)
return (url, frames)
}
releaseSessionToken()
return result
}
@MainActor
func cancel() {
captureGeneration.invalidate()
stopEngineIfStarted()
queue.sync { teardown(deleteFile: true) }
releaseSessionToken()
}
/// Audio session interrupted (call, Siri) or the engine was reconfigured
/// mid-capture: behave like `stop()` finalize the `.m4a` container but
/// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()`
/// still returns the note for delivery.
@MainActor
private func handleInterruption(for generation: UInt) {
// Also invalidate a start whose acquire has registered its token but
// has not returned to this actor yet. Without this bump the callback
// is lost while `engineStarted == false`, and the resumed start can
// open the mic after the stop signal.
guard captureGeneration.invalidate(ifCurrent: generation) else { return }
guard engineStarted else {
releaseSessionToken()
return
}
stopEngineIfStarted()
queue.sync {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
}
releaseSessionToken()
SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session)
}
/// Touching `inputNode` on an engine that never started instantiates its
/// input unit against whatever session is active and spams AURemoteIO
/// errors a canceled-before-start hold must not touch the engine.
@MainActor
private func stopEngineIfStarted() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
guard engineStarted else { return }
engineStarted = false
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
@MainActor
private func releaseSessionToken() {
sessionLease.release()
}
// MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
guard captureGeneration.isCurrent(generation),
running,
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
let resampled = resampler?.resample(buffer)
else { return }
do {
try file?.write(from: resampled)
} catch {
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
}
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
encodedFrameCount += frames.count
onFrames?(frames)
}
private func teardown(deleteFile: Bool) {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
if deleteFile, let url = fileURL {
try? FileManager.default.removeItem(at: url)
}
fileURL = nil
}
}
+39
View File
@@ -0,0 +1,39 @@
//
// PTTSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// User preference for live push-to-talk voice. One switch controls both
/// directions: streaming your holds live, and auto-playing inbound bursts.
/// Off means voice messages behave exactly like classic voice notes.
enum PTTSettings {
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
static var liveVoiceEnabled: Bool {
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
}
/// Autoplay is foreground-only: audio must never start from the
/// background.
@MainActor
static var isAppActive: Bool {
#if os(iOS)
return UIApplication.shared.applicationState == .active
#elseif os(macOS)
return NSApplication.shared.isActive
#else
return true
#endif
}
}
@@ -0,0 +1,237 @@
//
// VoiceCaptureSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Foundation
/// Capture backend behind the composer's hold-to-record gesture.
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
/// additionally streams frames live while the button is held.
@MainActor
protocol VoiceCaptureSession: AnyObject {
/// Whether audio is leaving the device in real time while recording
/// drives the composer's LIVE treatment.
var isLive: Bool { get }
func requestPermission() async -> Bool
func start() async throws
/// Stops capture and returns the finalized voice-note file, or nil when
/// nothing valid was captured.
func finish() async -> URL?
func cancel() async
}
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@MainActor
final class VoiceNoteCaptureSession: VoiceCaptureSession {
private let recorder: VoiceRecorder
private let owner = VoiceRecorder.RecordingOwner()
var isLive: Bool { false }
init(recorder: VoiceRecorder = .shared) {
self.recorder = recorder
}
func requestPermission() async -> Bool {
await recorder.requestPermission()
}
func start() async throws {
try await recorder.startRecording(owner: owner)
}
func finish() async -> URL? {
await recorder.stopRecording(owner: owner)
}
func cancel() async {
await recorder.cancelRecording(owner: owner)
}
}
/// Testable surface of the live capture engine. Production uses
/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening
/// real audio hardware.
@MainActor
protocol PTTCapturing: AnyObject {
var onFrames: (([Data]) -> Void)? { get set }
func start(outputURL: URL) async throws
func stop() -> (url: URL?, encodedFrames: Int)
func cancel()
}
extension PTTCaptureEngine: PTTCapturing {}
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
/// recording, then finalizes the same audio as a standard voice note whose
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
/// heard the live stream absorb the note silently instead of seeing a
/// duplicate.
@MainActor
final class PTTLiveVoiceSession: VoiceCaptureSession {
let burstID: Data
private let sendPacket: (Data) -> Void
private let capture: any PTTCapturing
private let now: () -> Date
/// Capture-queue-confined stream state: packetizes frames and lazily
/// emits START so packet order is guaranteed by queue serialization.
private final class StreamState {
var packetizer: VoiceBurstPacketizer
var sentStart = false
init(burstID: Data) {
packetizer = VoiceBurstPacketizer(burstID: burstID)
}
}
private let stream: StreamState
private var startDate: Date?
private var completed = false
var isLive: Bool { true }
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
/// target peer; must be safe to call from any queue (BLEService hops to
/// its own message queue internally).
init(
sendPacket: @escaping (Data) -> Void,
capture: (any PTTCapturing)? = nil,
now: @escaping () -> Date = Date.init,
burstID: Data? = nil
) {
self.burstID = burstID ?? VoiceBurstPacket.makeBurstID()
self.sendPacket = sendPacket
self.capture = capture ?? PTTCaptureEngine()
self.now = now
self.stream = StreamState(burstID: self.burstID)
}
func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission()
}
func start() async throws {
let outputURL = try Self.makeOutputURL(burstID: burstID)
let sendPacket = sendPacket
let stream = stream
capture.onFrames = { frames in
if !stream.sentStart {
stream.sentStart = true
if let start = VoiceBurstPacket(
burstID: stream.packetizer.burstID,
seq: 0,
kind: .start(codec: .aacLC16kMono)
) {
sendPacket(start.encode())
}
}
for frame in frames {
for packet in stream.packetizer.add(frame) {
sendPacket(packet)
}
}
// Flush per callback batch: at ~130-byte frames the budget fits
// one frame per packet anyway, and holding residue would add
// ~100 ms of avoidable latency.
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
}
do {
try await capture.start(outputURL: outputURL)
} catch is CancellationError {
// The hold was released/canceled while the session acquire was
// in flight: the engine never started and the capture already
// handed its token back nothing to retry. A coordinator-side
// interruption during handoff also cancels acquire, but that is
// not a successful start and must propagate to the view model.
guard completed else { throw CancellationError() }
return
} catch {
// The HAL can briefly report a dead input right after the audio
// session (re)activates while the route settles; one retry after
// a short pause covers it (observed on iPhone field tests).
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
try? await Task.sleep(nanoseconds: 150_000_000)
// The hold may have been released/canceled during the retry pause.
// Starting the mic now would leave it live and streaming after the
// user let go, so bail instead of opening a hot mic.
guard !completed else {
capture.cancel()
return
}
try await capture.start(outputURL: outputURL)
}
startDate = now()
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
}
func finish() async -> URL? {
guard !completed else { return nil }
completed = true
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
let (url, encodedFrames) = capture.stop()
// stop() drained the capture queue, so touching `stream` is safe now.
let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration
guard elapsed >= VoiceRecorder.minRecordingDuration,
capturedDuration >= VoiceRecorder.minRecordingDuration,
let url
else {
sendControlPacket(.canceled)
if let url {
try? FileManager.default.removeItem(at: url)
}
return nil
}
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
let durationMs = UInt32((capturedDuration * 1000).rounded())
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
return url
}
func cancel() async {
let alreadyCompleted = completed
completed = true
// Always tear down the capture, even if a quick-release already marked
// us completed: the engine can start late (during start()'s retry
// pause), and only capture.cancel() stops the mic and deactivates the
// session. It is idempotent, so a redundant call is harmless.
capture.cancel()
if !alreadyCompleted {
sendControlPacket(.canceled)
}
}
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
sendPacket(packet.encode())
}
private static func makeOutputURL(burstID: Data) throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
}
}
@@ -9,6 +9,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0
/// Internal lifecycle visibility for deterministic acquisition tests.
var isPlaybackStartPending: Bool { sessionAcquireInFlight }
/// rounded so 4.9s shows "00:05"
var roundedDuration: Int {
guard duration.isFinite else { return 0 }
@@ -24,9 +27,24 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
private var player: AVAudioPlayer?
private var timer: Timer?
private var url: URL
/// Test seam; `AudioSessionCoordinator.shared` when nil.
private let sessionCoordinatorOverride: AudioSessionCoordinator?
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would pause this controller mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var sessionToken: AudioSessionCoordinator.Token?
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates against double acquisition on rapid play taps.
private var sessionAcquireInFlight = false
init(url: URL) {
init(
url: URL,
sessionCoordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil
) {
self.url = url
self.sessionCoordinatorOverride = sessionCoordinator
self.exclusivity = exclusivity ?? .shared
super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed
}
@@ -51,6 +69,16 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
deinit {
timer?.invalidate()
player?.stop()
// A per-row @StateObject can be discarded mid-playback (navigating
// away). Leaking the token here would hold the session forever
// never deactivating it, and pinning any escalated category for the
// app's lifetime. `release` is fire-and-forget onto the coordinator's
// queue, so it is deinit-safe: only the Sendable token crosses.
if let token = sessionToken {
sessionToken = nil
(sessionCoordinatorOverride ?? .shared).release(token)
}
}
func replaceURL(_ url: URL) {
@@ -68,11 +96,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
func play() {
guard ensurePlayerReady() else { return }
VoiceNotePlaybackCoordinator.shared.activate(self)
player?.play()
exclusivity.activate(self)
isPlaying = true
startTimer()
updateProgress()
isPlaying = true
// Acquired here (not in ensurePlayerReady): scrubbing a paused note
// must not hold the session while nothing is audible. The session
// calls block on audio-server IPC, so they run off the main thread;
// the player starts once the session is configured.
startPlayerAfterAcquiringSession()
}
func pause() {
@@ -80,6 +112,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer()
updateProgress()
isPlaying = false
releaseSession()
}
func stop() {
@@ -88,7 +121,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer()
updateProgress()
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
releaseSession()
exclusivity.deactivate(self)
}
func seek(to fraction: Double) {
@@ -96,8 +130,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
let clamped = max(0, min(1, fraction))
if let player = player {
player.currentTime = clamped * player.duration
if isPlaying {
player.play()
// While the session acquire is still in flight, don't start
// audio pre-activation the pending acquire's completion starts
// playback (from the new position) once the session resolves.
if isPlaying, !sessionAcquireInFlight {
startPreparedPlayer()
}
updateProgress()
}
@@ -112,18 +149,20 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
self.stopTimer()
self.updateProgress()
self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
self.releaseSession()
self.exclusivity.deactivate(self)
}
}
// MARK: - Private Helpers
private func preparePlayer(for url: URL) {
// Prepare player synchronously (only called when playback is requested)
// Load metadata synchronously, but do not call prepareToPlay here:
// paused scrubbing reaches this path and must not acquire playback
// hardware outside the AudioSessionCoordinator token lifetime.
do {
let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self
player.prepareToPlay()
self.player = player
duration = player.duration
currentTime = player.currentTime
@@ -141,18 +180,81 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
if player == nil {
preparePlayer(for: url)
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
#endif
return player != nil
}
/// All entry points (SwiftUI actions, `pauseForExclusivity`, the
/// delegate's main-queue hop) run on the main thread; the acquire itself
/// suspends while the blocking session IPC runs on the coordinator's
/// queue, and the player starts when it resolves. An acquire failure
/// leaves playback stopped: starting without a registered token would
/// bypass interruption fan-out and the coordinator's refcount. A
/// pause/stop landing mid-acquire hands the token straight back.
private func startPlayerAfterAcquiringSession() {
if sessionToken != nil {
startPreparedPlayer()
return
}
guard !sessionAcquireInFlight else { return }
sessionAcquireInFlight = true
let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared
Task { @MainActor [weak self] in
var token: AudioSessionCoordinator.Token?
do {
token = try await coordinator.acquire(.playback) { [weak self] in
self?.pause()
}
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
guard let self else {
// The row was discarded while acquiring; deinit had no token
// to release yet.
token.map(coordinator.release)
return
}
self.sessionAcquireInFlight = false
guard self.isPlaying else {
// Paused/stopped while the session was activating.
token.map(coordinator.release)
return
}
guard let token else {
self.failPlaybackStart()
return
}
self.sessionToken = token
self.startPreparedPlayer()
}
}
@discardableResult
private func startPreparedPlayer() -> Bool {
guard let player,
player.prepareToPlay(),
player.play()
else {
SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session)
failPlaybackStart()
return false
}
return true
}
private func failPlaybackStart() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
releaseSession()
exclusivity.deactivate(self)
}
private func releaseSession() {
sessionToken.map((sessionCoordinatorOverride ?? .shared).release)
sessionToken = nil
}
private func startTimer() {
if timer != nil { return }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
@@ -181,25 +283,75 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
}
}
/// Ensures only one voice note plays at a time.
/// Something that can hold the app's single audio-playback slot and yield it
/// when another playback starts (voice notes pause; live bursts stop).
protocol ExclusivePlayback: AnyObject {
func pauseForExclusivity()
}
extension VoiceNotePlaybackController: ExclusivePlayback {
func pauseForExclusivity() {
pause()
}
}
/// Ensures only one voice playback (note or live burst) runs at a time.
final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator()
private weak var activeController: VoiceNotePlaybackController?
private init() {}
func activate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
return
}
activeController?.pause()
activeController = controller
struct Reservation: Equatable {
fileprivate let generation: UInt64
}
func deactivate(_ controller: VoiceNotePlaybackController) {
private weak var activeController: (any ExclusivePlayback)?
private weak var latestReservedController: (any ExclusivePlayback)?
private var latestReservation = Reservation(generation: 0)
/// Internal so tests can isolate their own exclusivity slot; the app
/// uses `shared`.
init() {}
/// Records playback intent without interrupting audio that is already
/// audible. Async starters reserve before suspension, then activate only
/// after their audio resource is ready.
func reserve(_ controller: any ExclusivePlayback) -> Reservation {
latestReservation = Reservation(generation: latestReservation.generation &+ 1)
latestReservedController = controller
return latestReservation
}
/// Immediate activation for synchronous/user-initiated playback.
@discardableResult
func activate(_ controller: any ExclusivePlayback) -> Reservation {
let reservation = reserve(controller)
_ = activate(controller, reservation: reservation)
return reservation
}
/// Commits an earlier reservation only when it is still the newest
/// playback request. This prevents an older async acquire from stealing
/// the floor after a newer play gesture.
@discardableResult
func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool {
guard isCurrent(reservation, for: controller) else { return false }
if activeController === controller {
return true
}
activeController?.pauseForExclusivity()
activeController = controller
return true
}
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
latestReservation == reservation && latestReservedController === controller
}
func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller {
activeController = nil
}
if latestReservedController === controller {
latestReservedController = nil
}
}
}
+218 -64
View File
@@ -1,22 +1,95 @@
import Foundation
import AVFoundation
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
/// it behind a protocol lets lifecycle races be tested without opening the
/// microphone on the test host.
protocol VoiceAudioRecording: AnyObject {
var isRecording: Bool { get }
var isMeteringEnabled: Bool { get set }
func prepareToRecord() -> Bool
func record(forDuration duration: TimeInterval) -> Bool
func stop()
}
extension AVAudioRecorder: VoiceAudioRecording {}
protocol VoiceAudioRecorderCreating {
func makeRecorder(url: URL) throws -> any VoiceAudioRecording
}
private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
return try AVAudioRecorder(url: url, settings: settings)
}
}
/// Manages audio capture for mesh voice notes with predictable encoding settings.
actor VoiceRecorder {
enum RecorderError: Error {
enum RecorderError: Error, Equatable {
case microphoneAccessDenied
case recorderInitializationFailed
case recordingInProgress
case failedToStartRecording
}
static let shared = VoiceRecorder()
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1
private var recorder: AVAudioRecorder?
/// Identity of one press/hold. Every lifecycle mutation must present the
/// same owner that started the recorder, so a stale finish or cancel from
/// another hold cannot stop or delete the current recording.
final class RecordingOwner: @unchecked Sendable {}
/// Test-only scheduling seams for lifecycle boundaries that otherwise rely
/// on wall-clock sleeps. Production uses the real padding delay.
struct TestingHooks: Sendable {
let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)?
init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) {
self.waitForStopPadding = waitForStopPadding
}
}
private let sessionCoordinator: AudioSessionCoordinator
private let recorderFactory: any VoiceAudioRecorderCreating
private let permissionGranted: () -> Bool
private let paddingInterval: TimeInterval
private let maxRecordingDuration: TimeInterval
private let outputDirectory: URL?
private let testingHooks: TestingHooks
private var recorder: (any VoiceAudioRecording)?
private var currentURL: URL?
private var sessionToken: AudioSessionCoordinator.Token?
private var activeOwner: RecordingOwner?
/// True only while `startRecording()` is suspended in session acquire.
/// A second start is rejected instead of superseding the first one.
private var startInFlight = false
init(
sessionCoordinator: AudioSessionCoordinator = .shared,
recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(),
permissionGranted: (() -> Bool)? = nil,
paddingInterval: TimeInterval = 0.5,
maxRecordingDuration: TimeInterval = 120,
outputDirectory: URL? = nil,
testingHooks: TestingHooks = TestingHooks()
) {
self.sessionCoordinator = sessionCoordinator
self.recorderFactory = recorderFactory
self.permissionGranted = permissionGranted ?? Self.hasSystemPermission
self.paddingInterval = paddingInterval
self.maxRecordingDuration = maxRecordingDuration
self.outputDirectory = outputDirectory
self.testingHooks = testingHooks
}
// MARK: - Permissions
@@ -42,82 +115,130 @@ actor VoiceRecorder {
// MARK: - Recording Lifecycle
@discardableResult
func startRecording() throws -> URL {
if recorder?.isRecording == true {
func startRecording(owner: RecordingOwner) async throws -> URL {
if activeOwner != nil {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
guard permissionGranted() else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
activeOwner = owner
startInFlight = true
// The acquire suspends while the blocking session IPC runs on the
// coordinator's queue (never this actor's thread or main).
let token: AudioSessionCoordinator.Token
do {
token = try await sessionCoordinator.acquire(.capture) { [weak self] in
Task { await self?.handleSessionInterruption(for: owner) }
}
} catch {
guard activeOwner === owner else {
throw CancellationError()
}
startInFlight = false
activeOwner = nil
throw error
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
// Actor reentrancy: release/cancel may have ended this hold while the
// blocking session activation was still in progress.
guard activeOwner === owner, startInFlight else {
sessionCoordinator.release(token)
throw CancellationError()
}
startInFlight = false
sessionToken = token
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
var outputURL: URL?
do {
let newURL = try makeOutputURL()
outputURL = newURL
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
audioRecorder.isMeteringEnabled = true
guard audioRecorder.prepareToRecord() else {
throw RecorderError.failedToStartRecording
}
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
throw RecorderError.failedToStartRecording
}
recorder = audioRecorder
currentURL = outputURL
return outputURL
recorder = audioRecorder
currentURL = newURL
return newURL
} catch {
releaseSessionToken()
recorder = nil
currentURL = nil
activeOwner = nil
if let outputURL {
try? FileManager.default.removeItem(at: outputURL)
}
throw error
}
}
func stopRecording() async -> URL? {
guard let recorder, recorder.isRecording else {
return currentURL
func stopRecording(owner: RecordingOwner) async -> URL? {
guard activeOwner === owner else { return nil }
// `finish()` can race a still-suspended start on a direct caller even
// though the UI normally routes quick releases through cancel().
if startInFlight {
activeOwner = nil
startInFlight = false
return nil
}
guard let activeRecorder = recorder else {
let sessionURL = currentURL
releaseSessionToken()
currentURL = nil
activeOwner = nil
return sessionURL
}
let sessionURL = currentURL
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
recorder.stop()
// A new session may have started during the sleep don't touch its state
if self.recorder === recorder {
cleanupSession()
self.recorder = nil
currentURL = nil
if activeRecorder.isRecording, paddingInterval > 0 {
if let waitForStopPadding = testingHooks.waitForStopPadding {
await waitForStopPadding(paddingInterval)
} else {
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
}
}
// Cancellation or interruption may have run during the padding sleep.
// Only the recorder whose stop began here may be finalized by it.
guard activeOwner === owner,
let recorder = self.recorder,
recorder === activeRecorder
else { return nil }
if activeRecorder.isRecording {
activeRecorder.stop()
}
releaseSessionToken()
self.recorder = nil
currentURL = nil
activeOwner = nil
return sessionURL
}
func cancelRecording() {
func cancelRecording(owner: RecordingOwner) async {
guard activeOwner === owner else { return }
// Invalidate ownership before cleanup. An actor-reentrant start whose
// session acquire resumes later will observe the mismatch and release
// its token without opening the microphone.
activeOwner = nil
startInFlight = false
if let recorder, recorder.isRecording {
recorder.stop()
}
cleanupSession()
releaseSessionToken()
if let currentURL {
try? FileManager.default.removeItem(at: currentURL)
}
@@ -125,14 +246,45 @@ actor VoiceRecorder {
currentURL = nil
}
/// The audio session was interrupted (call, Siri) or reconfigured: stop
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
/// `stopRecording()` still returns the partial note.
private func handleSessionInterruption(for owner: RecordingOwner) async {
// A callback captured for a released token must never stop a newer
// recording. Conversely, an interruption delivered while acquire is
// still suspended invalidates that acquire before it can open the mic.
guard activeOwner === owner else { return }
if startInFlight {
activeOwner = nil
startInFlight = false
return
}
startInFlight = false
if let recorder, recorder.isRecording {
recorder.stop()
}
releaseSessionToken()
}
// MARK: - Helpers
private static func hasSystemPermission() -> Bool {
#if os(iOS)
AVAudioSession.sharedInstance().recordPermission == .granted
#elseif os(macOS)
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
#else
true
#endif
}
private func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "voice_\(formatter.string(from: Date())).m4a"
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
let baseDirectory = try outputDirectory
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName)
}
@@ -147,9 +299,11 @@ actor VoiceRecorder {
#endif
}
private func cleanupSession() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC
/// onto its own queue.
private func releaseSessionToken() {
guard let token = sessionToken else { return }
sessionToken = nil
sessionCoordinator.release(token)
}
}
-6
View File
@@ -56,12 +56,6 @@ final class WaveformCache {
}
}
func purgeAll() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAll()
}
}
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations
-7
View File
@@ -88,8 +88,6 @@ import BitFoundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity {
let peerID: PeerID // 8 random bytes
let sessionStart: Date
var handshakeState: HandshakeState
}
@@ -98,7 +96,6 @@ enum HandshakeState {
case initiated
case inProgress
case completed(fingerprint: String)
case failed(reason: String)
}
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
@@ -110,7 +107,6 @@ struct CryptographicIdentity: Codable {
// Optional Ed25519 signing public key (used to authenticate public messages)
var signingPublicKey: Data? = nil
let firstSeen: Date
let lastHandshake: Date?
}
/// Represents the social layer of identity - user-assigned names and trust relationships.
@@ -193,9 +189,6 @@ struct IdentityCache: Codable {
// Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Schema version for future migrations
var version: Int = 1
}
//
@@ -108,8 +108,6 @@ protocol SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management
func getFavorites() -> Set<String>
func setFavorite(_ fingerprint: String, isFavorite: Bool)
func isFavorite(fingerprint: String) -> Bool
// MARK: Blocked Users Management
@@ -123,8 +121,7 @@ protocol SecureIdentityStateManagerProtocol {
// MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
// MARK: Cleanup
func clearAllIdentityData()
func removeEphemeralSession(peerID: PeerID)
@@ -139,7 +136,6 @@ protocol SecureIdentityStateManagerProtocol {
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func validVouchers(for fingerprint: String) -> [VouchRecord]
func isVouched(fingerprint: String) -> Bool
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
@@ -322,21 +318,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
firstSeen: existing.firstSeen
)
self.cryptographicIdentities[fingerprint] = existing
} else {
// Update signing key and lastHandshake
// Update signing key
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
let updated = CryptographicIdentity(
fingerprint: existing.fingerprint,
publicKey: existing.publicKey,
signingPublicKey: existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
self.cryptographicIdentities[fingerprint] = existing
}
// Persist updated state (already assigned in branches above)
} else {
@@ -345,8 +333,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
firstSeen: now,
lastHandshake: now
firstSeen: now
)
self.cryptographicIdentities[fingerprint] = entry
}
@@ -511,11 +498,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID,
sessionStart: Date(),
handshakeState: handshakeState
)
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
}
}
+4 -2
View File
@@ -31,6 +31,8 @@
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
@@ -40,9 +42,9 @@
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<string>bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<string>bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key>
+8365 -3182
View File
File diff suppressed because it is too large Load Diff
@@ -13,13 +13,6 @@ extension BitchatMessage {
enum Media {
case voice(URL)
case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
}
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
+1 -3
View File
@@ -7,7 +7,6 @@ struct BitchatPeer: Equatable {
let peerID: PeerID // Hex-encoded peer ID
let noisePublicKey: Data
let nickname: String
let lastSeen: Date
let isConnected: Bool
let isReachable: Bool
@@ -77,14 +76,13 @@ struct BitchatPeer: Equatable {
peerID: PeerID,
noisePublicKey: Data,
nickname: String,
lastSeen: Date = Date(),
lastSeen _: Date = Date(),
isConnected: Bool = false,
isReachable: Bool = false
) {
self.peerID = peerID
self.noisePublicKey = noisePublicKey
self.nickname = nickname
self.lastSeen = lastSeen
self.isConnected = isConnected
self.isReachable = isReachable
+5 -1
View File
@@ -28,6 +28,7 @@ enum CommandInfo: String, Identifiable {
case unfavorite = "unfav"
case ping
case trace
case drop
var id: String { rawValue }
@@ -41,6 +42,8 @@ enum CommandInfo: String, Identifiable {
return "<" + String(localized: "content.input.group_placeholder") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
case .drop:
return "<" + String(localized: "content.input.note_placeholder") + ">"
case .clear, .help, .who:
return nil
}
@@ -62,11 +65,12 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace")
case .drop: String(localized: "content.commands.drop")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
// Cashu tokens are bearer instruments: in a public geohash any nearby
// stranger can redeem one, so don't *suggest* /pay there (the
// processor still allows it behind an explicit "public" confirm).
+1 -1
View File
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
return messageBuffer
}
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete
@@ -21,12 +21,6 @@ enum NoiseSecurityConstants {
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
-1
View File
@@ -14,5 +14,4 @@ enum NoiseSecurityError: Error {
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
+2 -8
View File
@@ -13,8 +13,6 @@ import BitFoundation
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
@@ -23,8 +21,6 @@ final class NoiseSessionManager {
var onSessionFailed: ((PeerID, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = { peerID, role in
SecureNoiseSession(
peerID: peerID,
@@ -37,12 +33,10 @@ final class NoiseSessionManager {
#if DEBUG
init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
keychain _: KeychainManagerProtocol,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = sessionFactory
}
#endif
+2 -10
View File
@@ -6,14 +6,12 @@ struct NostrIdentity: Codable {
let privateKey: Data
let publicKey: Data
let npub: String // Bech32-encoded public key
let createdAt: Date
/// Memberwise initializer
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
self.privateKey = privateKey
self.publicKey = publicKey
self.npub = npub
self.createdAt = createdAt
}
/// Generate a new Nostr identity
@@ -39,12 +37,6 @@ struct NostrIdentity: Codable {
self.privateKey = privateKeyData
self.publicKey = xOnlyPubkey
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
self.createdAt = Date()
}
/// Get signing key for event signatures
func signingKey() throws -> P256K.Signing.PrivateKey {
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
}
/// Get Schnorr signing key for Nostr event signatures
+12 -32
View File
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol = KeychainManager()) {
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
self.keychain = keychain
}
@@ -37,14 +37,6 @@ final class NostrIdentityBridge {
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// Get Nostr public key associated with a Noise public key
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
@@ -57,29 +49,10 @@ final class NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity
func clearAllAssociations() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
// Must go through the injected keychain, not raw SecItem calls:
// under test that keychain is in-memory, and a direct delete here
// would wipe the developer's real Nostr identity on every test run.
keychain.deleteAll(service: keychainService)
deviceSeedCache = nil
// Also drop the in-memory derived per-geohash identities. These hold the
@@ -113,6 +86,13 @@ final class NostrIdentityBridge {
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
/// geohash-chat identity for the same cell string.
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
try deriveIdentity(forGeohash: "bridge|" + cell)
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
+97 -35
View File
@@ -20,6 +20,10 @@ struct NostrProtocol {
case ephemeralEvent = 20000
case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
/// Sealed courier envelope parked on relays under its rotating
/// recipient tag (`#x`). Regular (stored) kind so it survives until
/// its NIP-40 expiration the whole point is store-and-forward.
case courierDrop = 1401
}
/// Create a NIP-17 private message
@@ -256,6 +260,94 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey)
}
// MARK: - Mesh bridge (rendezvous) events
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
/// channel subscriptions (which filter on `#g`); `m` is
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
/// parsers, which key their dedup on `m[1]` unconditionally and need it
/// per-message-unique. Current parsers key bridge rows by the authenticated
/// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh
/// coordinates are public and cannot authenticate the Nostr signer.
static func createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
meshSenderID: String? = nil,
meshTimestampMs: UInt64? = nil
) throws -> NostrEvent {
var tags = [["r", cell]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs {
let stableID = MeshMessageIdentity.stableID(
senderIDHex: meshSenderID,
timestampMs: meshTimestampMs,
content: content
)
tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
/// cell: empty content, `r` tag only the bridge analogue of geohash
/// presence, counted into "people across the bridge".
static func createBridgePresenceEvent(
cell: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["r", cell]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
/// parked on relays. `x` is the hex recipient tag the recipient (or a
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
/// the envelope expiry so honoring relays garbage-collect the drop. The
/// signing identity should be a throwaway the envelope authenticates
/// its sender internally via Noise-X, and linking drops to a stable
/// publisher key would leak courier traffic patterns.
static func createCourierDropEvent(
envelope: Data,
recipientTagHex: String,
expiresAt: Date,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let tags = [
["x", recipientTagHex],
["expiration", String(Int(expiresAt.timeIntervalSince1970))]
]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .courierDrop,
tags: tags,
content: envelope.base64EncodedString()
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
/// drop the note in step with a bridged board post's expiry.
@@ -264,7 +356,8 @@ struct NostrProtocol {
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
expiresAt: Date? = nil
expiresAt: Date? = nil,
urgent: Bool = false
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
@@ -273,6 +366,9 @@ struct NostrProtocol {
if let expiresAt {
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
}
if urgent {
tags.append(["t", "urgent"])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
@@ -548,37 +644,6 @@ struct NostrProtocol {
return sharedSecretData
}
// Direct version that doesn't try to add prefixes
private static func deriveSharedSecretDirect(
privateKey: P256K.Schnorr.PrivateKey,
publicKey: Data
) throws -> Data {
// Direct shared secret calculation
// Convert Schnorr private key to KeyAgreement private key
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: privateKey.dataRepresentation
)
// Use the public key as-is (should already have prefix)
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
dataRepresentation: publicKey,
format: .compressed
)
// Perform ECDH
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
with: keyAgreementPublicKey,
format: .compressed
)
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
}
private static func randomizedTimestamp() -> Date {
// Add random offset to current time for privacy
// This prevents timing correlation attacks while the actual message timestamp
@@ -708,11 +773,8 @@ struct NostrEvent: Codable {
enum NostrError: Error {
case invalidPublicKey
case invalidPrivateKey
case invalidEvent
case invalidCiphertext
case signingFailed
case encryptionFailed
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
+217 -16
View File
@@ -117,7 +117,6 @@ final class NostrRelayManager: ObservableObject {
let url: String
var isConnected: Bool = false
var lastError: Error?
var lastConnectedAt: Date?
var messagesSent: Int = 0
var messagesReceived: Int = 0
var reconnectAttempts: Int = 0
@@ -184,11 +183,26 @@ final class NostrRelayManager: ObservableObject {
}
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
// Track EOSE per subscription to signal when initial stored events are done
// Track EOSE per subscription to signal when initial stored events are
// done. Completion is scoped to relays the REQ actually reached: targets
// still mid-connect must not hold the callback hostage until the fallback
// timer (a dead relay of five used to pin "loading" for the full 10s).
private struct EOSETracker {
var pendingRelays: Set<String>
/// Targets the REQ has not been delivered to yet (still connecting).
var awaitingSend: Set<String>
/// Relays that received the REQ and have not sent EOSE yet.
var awaitingEOSE: Set<String>
/// True once any relay received the REQ (or answered with EOSE)
/// completion with zero sends would mean "done" without ever asking.
var didSend = false
var callback: () -> Void
let epoch: Int
/// Done when every relay that got the REQ has resolved, provided at
/// least one did or when every target dropped out entirely.
var isComplete: Bool {
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
}
}
private var eoseTrackers: [String: EOSETracker] = [:]
private var eoseTrackerEpoch = 0
@@ -202,6 +216,15 @@ final class NostrRelayManager: ObservableObject {
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
/// Non-queued sends whose callers require relay durability. A WebSocket
/// write only proves bytes left this process; NIP-20 OK is the relay's
/// accept/reject acknowledgment.
private struct ConfirmedSendState {
let token: UUID
var awaitingRelays: Set<String>
let completion: (Bool) -> Void
}
private var confirmedSends: [String: ConfirmedSendState] = [:]
// Total pending sends dropped at the queue cap; drives the sampled
// overflow warning (first + every Nth drop).
private var pendingSendDropCount = 0
@@ -298,6 +321,9 @@ final class NostrRelayManager: ObservableObject {
for (_, tracker) in trackers {
tracker.callback()
}
let confirmed = confirmedSends.values.map(\.completion)
confirmedSends.removeAll()
confirmed.forEach { $0(false) }
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
torReadyWaitAttempts = 0
@@ -331,6 +357,7 @@ final class NostrRelayManager: ObservableObject {
duplicateInboundEventDropCountBySubscription.removeAll()
inboundEventLogCount = 0
Self.pendingGiftWrapIDs.removeAll()
confirmedSends.removeAll()
messageQueueLock.lock()
messageQueue.removeAll()
@@ -347,7 +374,6 @@ final class NostrRelayManager: ObservableObject {
relays[index].nextReconnectTime = nil
if resetState {
relays[index].lastError = nil
relays[index].lastConnectedAt = nil
relays[index].lastDisconnectedAt = nil
relays[index].messagesSent = 0
relays[index].messagesReceived = 0
@@ -406,6 +432,97 @@ final class NostrRelayManager: ObservableObject {
}
}
/// Attempts an event only on currently connected target relays and
/// reports whether at least one relay explicitly accepted it via NIP-20
/// OK. A successful WebSocket write alone is not durable acceptance.
/// Unlike `sendEvent`, this never enters the process-local pending queue;
/// callers use it when success unlocks durable state or user-visible
/// delivery progress.
func sendEventImmediately(
_ event: NostrEvent,
to relayUrls: [String]? = nil,
completion: @escaping (Bool) -> Void
) {
guard dependencies.activationAllowed() else {
completion(false)
return
}
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
completion(false)
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in
guard let connection = connectedConnection(for: relayUrl) else { return nil }
return (relayUrl, connection)
}
guard !connectedTargets.isEmpty else {
completion(false)
return
}
let token = UUID()
let eventID = event.id
if let replaced = confirmedSends.removeValue(forKey: eventID) {
replaced.completion(false)
}
confirmedSends[eventID] = ConfirmedSendState(
token: token,
awaitingRelays: Set(connectedTargets.map(\.0)),
completion: completion
)
dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in
Task { @MainActor [weak self] in
self?.timeoutConfirmedSend(eventID: eventID, token: token)
}
}
for (relayUrl, connection) in connectedTargets {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in
guard let self else { return }
// Success only means the bytes reached the socket; wait for
// the matching relay OK. A failed write settles this target
// as rejected because no OK can arrive for it.
if !succeeded {
self.resolveConfirmedSend(
eventID: eventID,
relayURL: relayUrl,
accepted: false,
token: token
)
}
}
}
}
private func resolveConfirmedSend(
eventID: String,
relayURL: String,
accepted: Bool,
token: UUID? = nil
) {
guard var state = confirmedSends[eventID],
token == nil || state.token == token,
state.awaitingRelays.remove(relayURL) != nil else { return }
if accepted {
confirmedSends.removeValue(forKey: eventID)
state.completion(true)
} else if state.awaitingRelays.isEmpty {
confirmedSends.removeValue(forKey: eventID)
state.completion(false)
} else {
confirmedSends[eventID] = state
}
}
private func timeoutConfirmedSend(eventID: String, token: UUID) {
guard let state = confirmedSends[eventID], state.token == token else { return }
confirmedSends.removeValue(forKey: eventID)
state.completion(false)
}
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
@@ -795,7 +912,7 @@ final class NostrRelayManager: ObservableObject {
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackerEpoch += 1
let epoch = eoseTrackerEpoch
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch)
// Fallback timeout to avoid hanging if a relay never sends EOSE.
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in
@@ -910,6 +1027,7 @@ final class NostrRelayManager: ObservableObject {
// Send initial ping to verify connection
task.sendPing { [weak self] error in
DispatchQueue.main.async {
guard self?.connections[urlString] === task else { return }
if error == nil {
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true)
@@ -919,7 +1037,11 @@ final class NostrRelayManager: ObservableObject {
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
self?.handleDisconnection(
relayUrl: urlString,
error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil),
connection: task
)
}
}
}
@@ -935,8 +1057,19 @@ final class NostrRelayManager: ObservableObject {
toSend[id] = state.messageString
}
for (id, messageString) in toSend {
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
if self.subscriptions[relayUrl]?.contains(id) == true {
// Already subscribed on this relay (e.g. a tracker promoted
// after an earlier flush): its EOSE is coming, count it.
markEOSESubscribed(id: id, relayUrl: relayUrl)
continue
}
startPendingEOSETrackingIfNeeded(id: id)
// Mark at send *initiation*, not in the async completion: a fast
// relay's EOSE could otherwise complete the tracker while this
// relay REQ already on the wire still sat in awaitingSend.
// If the send fails the socket is going down with it, and the
// disconnect settle (or the fallback timer) releases the wait.
markEOSESubscribed(id: id, relayUrl: relayUrl)
connection.send(.string(messageString)) { [weak self, weak connection] error in
Task { @MainActor [weak self] in
guard let self else { return }
@@ -966,18 +1099,20 @@ final class NostrRelayManager: ObservableObject {
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
guard self.connections[relayUrl] === task else { return }
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Continue receiving
Task { @MainActor in
guard self.connections[relayUrl] === task else { return }
self.receiveMessage(from: task, relayUrl: relayUrl)
}
case .failure(let error):
DispatchQueue.main.async {
self.handleDisconnection(relayUrl: relayUrl, error: error)
self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task)
}
}
}
@@ -1018,8 +1153,12 @@ final class NostrRelayManager: ObservableObject {
}
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
// An EOSE proves the relay received the REQ even if the local
// send completion hasn't run yet.
tracker.awaitingSend.remove(relayUrl)
tracker.awaitingEOSE.remove(relayUrl)
tracker.didSend = true
if tracker.isComplete {
eoseTrackers.removeValue(forKey: subId)
tracker.callback()
} else {
@@ -1027,6 +1166,7 @@ final class NostrRelayManager: ObservableObject {
}
}
case .ok(let eventId, let success, let reason):
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
@@ -1043,7 +1183,12 @@ final class NostrRelayManager: ObservableObject {
}
}
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
private func sendToRelay(
event: NostrEvent,
connection: NostrRelayConnectionProtocol,
relayUrl: String,
completion: ((Bool) -> Void)? = nil
) {
let req = NostrRequest.event(event)
do {
@@ -1056,17 +1201,20 @@ final class NostrRelayManager: ObservableObject {
DispatchQueue.main.async {
if let error = error {
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
completion?(false)
} else {
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1
}
completion?(true)
}
}
}
} catch {
SecureLogger.error("Failed to encode event: \(error)", category: .session)
completion?(false)
}
}
@@ -1075,7 +1223,6 @@ final class NostrRelayManager: ObservableObject {
relays[index].isConnected = isConnected
relays[index].lastError = error
if isConnected {
relays[index].lastConnectedAt = dependencies.now()
relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil
} else {
@@ -1100,9 +1247,11 @@ final class NostrRelayManager: ObservableObject {
/// callbacks; treat it as done and let the remaining relays (or the
/// fallback timeout) drive completion.
private func settleEOSETrackers(droppingRelay relayUrl: String) {
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
for (id, var tracker) in eoseTrackers
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
tracker.awaitingSend.remove(relayUrl)
tracker.awaitingEOSE.remove(relayUrl)
if tracker.isComplete {
eoseTrackers.removeValue(forKey: id)
tracker.callback()
} else {
@@ -1111,9 +1260,38 @@ final class NostrRelayManager: ObservableObject {
}
}
private func handleDisconnection(relayUrl: String, error: Error) {
/// Whether any of `relayUrls` currently holds a live connection. Lets
/// subscribers distinguish "loaded, empty" from "never reached a relay"
/// when an EOSE fallback fires.
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
let targets = Set(relayUrls)
return relays.contains { targets.contains($0.url) && $0.isConnected }
}
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
/// this relay instead of the never-connected remainder.
private func markEOSESubscribed(id: String, relayUrl: String) {
guard var tracker = eoseTrackers[id],
tracker.awaitingSend.remove(relayUrl) != nil else { return }
tracker.awaitingEOSE.insert(relayUrl)
tracker.didSend = true
eoseTrackers[id] = tracker
}
private func handleDisconnection(
relayUrl: String,
error: Error,
connection: NostrRelayConnectionProtocol? = nil
) {
if let connection, connections[relayUrl] !== connection { return }
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
state.awaitingRelays.contains(relayUrl) ? eventID : nil
}
for eventID in awaitingConfirmation {
resolveConfirmedSend(eventID: eventID, relayURL: relayUrl, accepted: false)
}
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
// If networking is disallowed, do not schedule reconnection
@@ -1463,6 +1641,29 @@ struct NostrFilter: Encodable {
filter.limit = limit
return filter
}
// For the mesh bridge: rendezvous messages (kind 20000) and presence
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [20000, 20001]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["r": cells]
filter.limit = limit
return filter
}
// For courier drops: sealed envelopes (kind 1401) parked under rotating
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
// UTC days x recipients) in one filter.
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["x": recipientTagsHex]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+10
View File
@@ -77,6 +77,8 @@ enum NoisePayloadType: UInt8 {
// Private groups (0x04/0x05 reserved by other features)
case groupInvite = 0x06 // Creator-signed group state (invite)
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Live voice (push-to-talk)
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
@@ -90,6 +92,7 @@ enum NoisePayloadType: UInt8 {
case .delivered: return "delivered"
case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate"
case .voiceFrame: return "voiceFrame"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
@@ -127,6 +130,9 @@ protocol BitchatDelegate: AnyObject {
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
func didReceiveGroupMessage(payload: Data, timestamp: Date)
// Public live-voice burst packet (signature-verified by the transport)
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@@ -150,6 +156,10 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation
}
-8
View File
@@ -10,14 +10,6 @@ enum Geohash {
return map
}()
/// Validates a geohash string for building-level precision (8 characters).
/// - Parameter geohash: The geohash string to validate
/// - Returns: true if valid 8-character base32 geohash, false otherwise
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
guard geohash.count == 8 else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
/// Validates a geohash string at any channel precision (1-12 characters).
/// - Parameter geohash: The geohash string to validate
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
@@ -0,0 +1,32 @@
//
// MeshMessageIdentity.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Content-derived identity for public mesh messages.
///
/// The BLE wire carries no message ID for public broadcasts, so every device
/// recomputes the same stable ID from the signed wire fields (sender ID,
/// millisecond timestamp, content). That gives the mesh bridge a
/// cross-device-consistent radio identity with zero wire change. Bridge events
/// carry this value only as a hint for detecting a radio copy that is already
/// present: sender/timestamp/content are public, so a different Nostr signer
/// can copy them and must never be allowed to reserve the genuine event's
/// authenticated dedup slot.
enum MeshMessageIdentity {
/// Matches the wire truncation in `BLEService.sendMessage`.
static func millisecondTimestamp(_ date: Date) -> UInt64 {
UInt64(date.timeIntervalSince1970 * 1000)
}
static func stableID(senderIDHex: String, timestampMs: UInt64, content: String) -> String {
let input = senderIDHex.lowercased() + "|" + String(timestampMs) + "|" + content.trimmed
return String(Data(input.utf8).sha256Hex().prefix(32))
}
}
@@ -32,6 +32,14 @@ struct NostrCarrierPacket: Equatable {
enum Direction: UInt8 {
case toGateway = 0x01
case fromGateway = 0x02
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
/// publish its signed rendezvous event. Directed, like `toGateway`.
case toBridge = 0x03
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
/// event from a remote island. Broadcast, like `fromGateway`.
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
/// carrier quietly bridge traffic degrades to invisible, not junk.
case fromBridge = 0x04
}
let direction: Direction
+24 -2
View File
@@ -9,19 +9,25 @@ struct AnnouncementPacket {
let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
/// tagged rendezvous events without their own location fix.
let bridgeGeohash: String?
init(
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data,
directNeighbors: [Data]?,
capabilities: PeerCapabilities? = nil
capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
) {
self.nickname = nickname
self.noisePublicKey = noisePublicKey
self.signingPublicKey = signingPublicKey
self.directNeighbors = directNeighbors
self.capabilities = capabilities
self.bridgeGeohash = bridgeGeohash
}
private enum TLVType: UInt8 {
@@ -30,6 +36,7 @@ struct AnnouncementPacket {
case signingPublicKey = 0x03
case directNeighbors = 0x04
case capabilities = 0x05
case bridgeGeohash = 0x06
}
func encode() -> Data? {
@@ -74,6 +81,15 @@ struct AnnouncementPacket {
data.append(capabilityBytes)
}
// TLV for bridge rendezvous cell (optional; old clients skip it)
if let bridgeGeohash = bridgeGeohash,
let cellData = bridgeGeohash.data(using: .utf8),
!cellData.isEmpty, cellData.count <= 12 {
data.append(TLVType.bridgeGeohash.rawValue)
data.append(UInt8(cellData.count))
data.append(cellData)
}
return data
}
@@ -84,6 +100,7 @@ struct AnnouncementPacket {
var signingPublicKey: Data?
var directNeighbors: [Data]?
var capabilities: PeerCapabilities?
var bridgeGeohash: String?
while offset + 2 <= data.count {
let typeRaw = data[offset]
@@ -116,6 +133,10 @@ struct AnnouncementPacket {
}
case .capabilities:
capabilities = PeerCapabilities(encoded: Data(value))
case .bridgeGeohash:
if length <= 12 {
bridgeGeohash = String(data: value, encoding: .utf8)
}
}
} else {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -129,7 +150,8 @@ struct AnnouncementPacket {
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors,
capabilities: capabilities
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
}
}
+220
View File
@@ -0,0 +1,220 @@
//
// VoiceBurstPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Security
/// Audio codec of a live voice burst. START packets carry it so receivers can
/// reject bursts they can't decode instead of feeding garbage to the decoder.
enum VoiceBurstCodec: UInt8 {
/// AAC-LC, 16 kHz, mono, ~16 kbps matches the voice-note recorder, so
/// the finalized `.m4a` and the live frames come from the same encoder
/// settings.
case aacLC16kMono = 0x01
}
/// One packet of a live push-to-talk voice burst (the inner payload of
/// `NoisePayloadType.voiceFrame`, and for public mesh bursts the payload
/// of `MessageType.voiceFrame`).
///
/// Wire format:
/// ```
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload]
/// ```
/// - flags 0x01 (START): payload = [codec: UInt8]
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
struct VoiceBurstPacket: Equatable {
enum Kind: Equatable {
case start(codec: VoiceBurstCodec)
case frames([Data])
case end(totalDataPackets: UInt16, durationMs: UInt32)
case canceled
}
static let burstIDSize = 8
private static let headerSize = burstIDSize + 2 + 1
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
static let maxFramesPerPacket = 8
private enum Flags {
static let start: UInt8 = 0x01
static let end: UInt8 = 0x02
static let canceled: UInt8 = 0x04
}
let burstID: Data
let seq: UInt16
let kind: Kind
init?(burstID: Data, seq: UInt16, kind: Kind) {
guard burstID.count == Self.burstIDSize else { return nil }
if case .frames(let frames) = kind {
guard !frames.isEmpty,
frames.count <= Self.maxFramesPerPacket,
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
else { return nil }
}
self.burstID = burstID
self.seq = seq
self.kind = kind
}
func encode() -> Data {
var data = Data(capacity: Self.headerSize + payloadSize)
data.append(burstID)
data.append(UInt8((seq >> 8) & 0xFF))
data.append(UInt8(seq & 0xFF))
switch kind {
case .start(let codec):
data.append(Flags.start)
data.append(codec.rawValue)
case .frames(let frames):
data.append(0)
for frame in frames {
let length = UInt16(frame.count)
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(frame)
}
case .end(let totalDataPackets, let durationMs):
data.append(Flags.end)
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
data.append(UInt8(totalDataPackets & 0xFF))
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
}
case .canceled:
data.append(Flags.canceled)
}
return data
}
static func decode(_ data: Data) -> VoiceBurstPacket? {
// Work on a re-based copy so subscripting is offset-safe.
let data = Data(data)
guard data.count >= headerSize else { return nil }
let burstID = data.prefix(burstIDSize)
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
let flags = data[burstIDSize + 2]
let payload = data.dropFirst(headerSize)
let kind: Kind
switch flags {
case Flags.start:
guard let codecByte = payload.first,
let codec = VoiceBurstCodec(rawValue: codecByte)
else { return nil }
kind = .start(codec: codec)
case Flags.end:
guard payload.count >= 6 else { return nil }
let bytes = Array(payload)
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
kind = .end(totalDataPackets: total, durationMs: duration)
case Flags.canceled:
kind = .canceled
case 0:
var frames: [Data] = []
var offset = payload.startIndex
while offset < payload.endIndex {
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
offset = payload.index(offset, offsetBy: 2)
guard length > 0,
payload.distance(from: offset, to: payload.endIndex) >= length,
frames.count < maxFramesPerPacket
else { return nil }
let end = payload.index(offset, offsetBy: length)
frames.append(Data(payload[offset..<end]))
offset = end
}
guard !frames.isEmpty else { return nil }
kind = .frames(frames)
default:
return nil
}
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
}
static func makeBurstID() -> Data {
var bytes = Data(count: burstIDSize)
let result = bytes.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
}
guard result == errSecSuccess else {
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
}
return bytes
}
private var payloadSize: Int {
switch kind {
case .start: return 1
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
case .end: return 6
case .canceled: return 0
}
}
}
/// Greedy packetizer for outgoing bursts: batches encoded frames into
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
/// packet in a single BLE frame after Noise encryption and padding.
/// Not thread-safe confine to one queue.
struct VoiceBurstPacketizer {
let burstID: Data
private let budget: Int
private var pendingFrames: [Data] = []
private var pendingSize = 0
/// seq 0 is reserved for START; data packets start at 1.
private(set) var nextSeq: UInt16 = 1
private(set) var dataPacketCount: UInt16 = 0
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
self.burstID = burstID
self.budget = budget
}
/// Adds one encoded frame, returning any packets that became full.
/// Frames larger than the budget are dropped (the encoder's ~130-byte
/// frames never hit this; it guards against misconfiguration looping).
mutating func add(_ frame: Data) -> [Data] {
let frameCost = 2 + frame.count
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
var packets: [Data] = []
if !pendingFrames.isEmpty,
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
packets.append(contentsOf: flush())
}
pendingFrames.append(frame)
pendingSize += frameCost
return packets
}
/// Emits any buffered frames as a final data packet.
mutating func flush() -> [Data] {
guard !pendingFrames.isEmpty,
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
else {
pendingFrames = []
pendingSize = 0
return []
}
pendingFrames = []
pendingSize = 0
nextSeq &+= 1
dataPacketCount &+= 1
return [packet.encode()]
}
}
+1 -28
View File
@@ -11,14 +11,7 @@ import Foundation
/// Manages autocomplete functionality for chat
final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [
"/msg", "/who", "/clear",
"/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock"
]
/// Get autocomplete suggestions for current text
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
let textToPosition = String(text.prefix(cursorPosition))
@@ -73,26 +66,6 @@ final class AutocompleteService {
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
}
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
guard let regex = commandRegex else { return nil }
let nsText = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
guard let match = matches.last else { return nil }
let fullRange = match.range(at: 0)
let captureRange = match.range(at: 1)
let prefix = nsText.substring(with: captureRange).lowercased()
let suggestions = commands
.filter { $0.hasPrefix("/\(prefix)") }
.sorted()
.prefix(5)
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
}
private func needsArgument(command: String) -> Bool {
switch command {
case "/who", "/clear":
+24 -1
View File
@@ -20,6 +20,12 @@ struct BLEAnnounceHandlerEnvironment {
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Whether the link this packet arrived on is already bound to a
/// different peer ID (ingress-registry + BLE-queue read). Directness
/// rides on the unsigned TTL, so a replayed announce can look "direct"
/// on the replayer's link; that link must not shortcut an absent peer
/// into "connected".
let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool
/// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry.
@@ -135,6 +141,23 @@ final class BLEAnnounceHandler {
var isReconnectedPeer = false
let directLinkState = env.linkState(peerID)
let isDirectAnnounce = packet.ttl == env.messageTTL
// A "direct" announce arriving on a link that another peer already
// owns is either a rotation heal or a replay with its TTL restored;
// both are ambiguous, so only the rebind (which containment-checks
// the claimed identity) may promote it never this shortcut.
//
// Known limitation: denying the shortcut cannot prevent forged
// presence outright. A rebind that passes the containment checks
// promotes the claimed peer to connected it must, or a legitimate
// rotation on an open link would read as disconnected so a replay
// that wins the rebind (absent victim, cooldown clear) still forges
// presence. That residue is presence display only: DMs stay gated on
// canDeliverSecurely (no Noise session means retain + courier, see
// MessageRouter.sendPrivate). What this check buys: the ambiguous
// announce alone never flips presence forging requires winning the
// containment-checked rebind (never steals an identity that owns a
// live link; at most one rebind per link per cooldown window).
let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID)
env.withRegistryBarrier {
let hasPeripheralConnection = directLinkState.hasPeripheral
@@ -152,7 +175,7 @@ final class BLEAnnounceHandler {
let update = env.upsertVerifiedAnnounce(
peerID,
announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
now
)
isNewPeer = update.isNewPeer
+47 -11
View File
@@ -15,7 +15,10 @@ enum BLEFanoutSelector {
excludedLinks: Set<BLEIngressLinkID> = [],
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
preferredPeripheralPerPeer: [PeerID: String] = [:],
collapseDuplicatePeerLinks: Bool = true,
directedPeerHint: PeerID?,
requireDirectPeerLink: Bool = false,
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
@@ -31,10 +34,14 @@ enum BLEFanoutSelector {
to: directedPeerHint,
links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
) {
return directedSelection
}
if directedPeerHint != nil, requireDirectPeerLink {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
@@ -46,11 +53,20 @@ enum BLEFanoutSelector {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
let allowed = collapseDuplicateLinksPerPeer(
rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
// Direct announces are the packet that binds a link to its peer
// (BLEService's raw bind and verified rebind). Collapsing them per
// peer starves duplicate same-peer links of the announce they need to
// become bound the duplicates then look "pre-announce" forever and
// every broadcast sprays down all of them. Announces are small and
// throttled, so they go on every live link.
let allowed = collapseDuplicatePeerLinks
? collapseDuplicateLinksPerPeer(
rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
)
: rawAllowed
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
return BLEFanoutSelection(
@@ -97,7 +113,8 @@ enum BLEFanoutSelector {
to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
centralPeerBindings: [String: PeerID],
preferredPeripheralPerPeer: [PeerID: String]
) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer(
(
@@ -105,7 +122,8 @@ enum BLEFanoutSelector {
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer
)
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
@@ -140,7 +158,8 @@ enum BLEFanoutSelector {
private static func collapseDuplicateLinksPerPeer(
_ links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
centralPeerBindings: [String: PeerID],
preferredPeripheralPerPeer: [PeerID: String]
) -> (peripheralIDs: [String], centralIDs: [String]) {
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
return links
@@ -148,13 +167,30 @@ enum BLEFanoutSelector {
var seenPeers = Set<PeerID>()
var keptPeripheralIDs: [String] = []
// When a peer has several bound peripheral links (duplicate
// connections after a restore), collapse onto its preferred one (the
// most recently bound) instead of dictionary order an arbitrary
// pick could route a peer's single collapsed copy down a stale link.
for id in links.peripheralIDs {
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
continue
guard let peer = peripheralPeerBindings[id],
preferredPeripheralPerPeer[peer] == id,
seenPeers.insert(peer).inserted else { continue }
keptPeripheralIDs.append(id)
}
for id in links.peripheralIDs {
if let peer = peripheralPeerBindings[id] {
if preferredPeripheralPerPeer[peer] == id { continue }
if !seenPeers.insert(peer).inserted { continue }
}
keptPeripheralIDs.append(id)
}
// Known limitation: centrals collapse in subscription order (oldest
// first) there is no recency signal like the peripheral reverse
// map. A central-only peer with duplicate subscriptions rides the
// oldest one until the remote side (which owns those connections)
// consolidates on its next verified announce (bounded by its
// retirement cooldown).
var keptCentralIDs: [String] = []
for id in links.centralIDs {
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
@@ -14,6 +14,8 @@ struct BLEFileTransferHandlerEnvironment {
let localNickname: () -> String
/// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Verifies a packet's signature against a candidate signing key (registry path).
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast file packet for gossip sync.
@@ -44,25 +46,32 @@ final class BLEFileTransferHandler {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
/// Returns `false` when the packet fails sender authentication and must
/// not be relayed onward. Every other outcome returns `true`: files
/// directed to another peer are forwarded untouched, and local-only drops
/// (malformed payload, quota, save failure) don't affect multi-hop
/// delivery to nodes that may handle them fine.
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID) else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
return
return true
}
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = resolveSenderNickname(
packet: packet,
from: peerID,
isBroadcast: !deliveryPlan.isPrivateMessage,
peers: peersSnapshot,
env: env
) else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return false
}
if deliveryPlan.shouldTrackForSync {
env.trackPacketSeen(packet)
}
@@ -75,16 +84,16 @@ final class BLEFileTransferHandler {
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return
return true
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return
return true
case .failure(.unsupportedMime(let mimeType, let bytes)):
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
return
return true
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
return
return true
}
// BCH-01-002: Enforce storage quota before saving
@@ -97,7 +106,7 @@ final class BLEFileTransferHandler {
mime.defaultExtension,
mime.category.rawValue
) else {
return
return true
}
if deliveryPlan.isPrivateMessage {
@@ -113,11 +122,66 @@ final class BLEFileTransferHandler {
originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil,
senderPeerID: peerID
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap).
deliveryStatus: deliveryPlan.isPrivateMessage
? .delivered(to: env.localNickname(), at: ts)
: nil
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message)
return true
}
/// Resolves the authenticated display name for a file transfer's sender.
///
/// Directed (private) transfers are addressed to us specifically and keep
/// the lenient connected-peer path. Broadcast transfers carry an
/// attacker-controllable `senderID` exactly like public messages and public
/// voice frames registry membership alone is NOT proof of identity, so a
/// valid packet signature from the claimed sender is required before we
/// trust it. Without this, a peer that observed a public voice burst could
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
/// overwrite the signature-verified live bubble with attacker audio.
private func resolveSenderNickname(
packet: BitchatPacket,
from peerID: PeerID,
isBroadcast: Bool,
peers: [PeerID: BLEPeerInfo],
env: BLEFileTransferHandlerEnvironment
) -> String? {
guard isBroadcast else {
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID)
}
// Our own broadcasts replayed back via gossip sync (ttl==0) are
// trivially authentic and cannot be verified against the peer registry
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
// does. Verify against the signing key already in the
// (synchronously-updated) registry first, then fall back to the
// persisted-identity signature lookup for peers not yet cached there.
let isSelf = peerID == env.localPeerID()
let registrySigningKey = peers[peerID]?.signingPublicKey
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: false
) ?? signedDisplayName
}
}
@@ -61,7 +61,6 @@ struct BLEFragmentAssemblyBuffer {
}
private struct Metadata {
let type: UInt8
let total: Int
let timestamp: Date
let isBroadcast: Bool
@@ -150,7 +149,6 @@ struct BLEFragmentAssemblyBuffer {
fragmentsByKey[header.key] = [:]
metadataByKey[header.key] = Metadata(
type: header.originalType,
total: header.total,
timestamp: now,
isBroadcast: header.isBroadcastFragment,
@@ -5,7 +5,16 @@ import Foundation
struct BLEIncomingFileStore {
private static let quotaBytes: Int64 = 100 * 1024 * 1024
private let fileManager: FileManager
/// Name prefix of in-flight live voice captures (progressively written by
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
/// and kills playback and the coordinator's startup sweep deletes any
/// orphans a previous session left behind.
static let liveCapturePrefix = "voice_live_"
/// Exposed so callers that write progressively into the store's
/// directories (live voice captures) share the same file manager.
let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
@@ -15,6 +24,14 @@ struct BLEIncomingFileStore {
self.dateProvider = dateProvider
}
/// Resolves (and creates) an incoming-media directory for callers that
/// write progressively instead of via `save` (live voice captures).
func incomingDirectory(subdirectory: String) throws -> URL {
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory
}
func save(
data: Data,
preferredName: String?,
@@ -39,6 +56,11 @@ struct BLEIncomingFileStore {
}
}
/// Frees least-recently-modified incoming files until `reservingBytes`
/// fits under the quota. Files named `voice_live_*` (in-flight live
/// captures) are never evicted regardless of who triggers enforcement
/// a finalized transfer can arrive at quota while a burst is still
/// streaming but they still count toward usage.
func enforceQuota(reservingBytes: Int) {
do {
let base = try filesDirectory()
@@ -72,6 +94,7 @@ struct BLEIncomingFileStore {
var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break }
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
@@ -78,10 +78,21 @@ struct BLEIngressLinkRegistry {
return .failure(.selfLoopback(packetType: packet.type))
}
if let boundPeerID,
boundPeerID != claimedSenderID,
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
if let boundPeerID, boundPeerID != claimedSenderID {
if requiresDirectSenderBinding(packet) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
// A direct announce claiming a new sender on a bound link is either
// a spoof or a legitimate peer-ID rotation on a connection that
// outlived the old ID. Attribute it to the claimed sender and let
// it through: announces are self-authenticating, and only a
// signature-verified announce may rebind the link (BLEService).
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
return .success(BLEIngressPacketContext(
receivedFromPeerID: claimedSenderID,
validationPeerID: claimedSenderID
))
}
}
let receivedFromPeerID = boundPeerID ?? claimedSenderID
@@ -98,12 +109,15 @@ struct BLEIngressLinkRegistry {
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
}
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
// must be the link peer it elicits a full store replay, and the
// response is addressed to whoever the sender claims to be.
if packet.type == MessageType.requestSync.rawValue { return true }
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
packet.type == MessageType.requestSync.rawValue
}
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
}
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
+37 -5
View File
@@ -164,7 +164,11 @@ final class BLELinkStateStore {
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
if let peripheralUUID = peerToPeripheralUUID[peerID] {
// Scan all states rather than the 1:1 reverse map: after a state
// restoration the same device can hold several live peripheral links
// bound to one peer (it reappears under a fresh UUID while the
// restored connection lives on).
for (peripheralUUID, state) in peripherals where state.peerID == peerID {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
@@ -173,6 +177,13 @@ final class BLELinkStateStore {
return links
}
/// The peer's most recently bound peripheral link, per peer. Used to keep
/// duplicate-link fanout collapse deterministic (see BLEFanoutSelector).
var preferredPeripheralBindings: [PeerID: String] {
assertOwned()
return peerToPeripheralUUID
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
assertOwned()
return peripherals[peripheralID]?.peerID
@@ -203,16 +214,37 @@ final class BLELinkStateStore {
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
assertOwned()
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID
var previousPeerID: PeerID?
let updated = updatePeripheral(peripheralUUID) {
previousPeerID = $0.peerID
$0.peerID = peerID
}
guard updated != nil else { return }
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
// so the old peer no longer claims this link.
if let previousPeerID, previousPeerID != peerID,
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
}
peerToPeripheralUUID[peerID] = peripheralUUID
}
func removePeripheral(_ peripheralID: String) -> PeerID? {
assertOwned()
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID)
// Only clear (or repair) the reverse map when it points at the removed
// link: with duplicate links to one peer, removing a stale duplicate
// must not strand the peer's surviving bound link.
if let peerID, peerToPeripheralUUID[peerID] == peripheralID {
// Prefer a writable survivor: repairing onto a link that is
// mid-service-rediscovery would strand directed sends until the
// characteristic comes back.
let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected }
if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key {
peerToPeripheralUUID[peerID] = survivorUUID
} else {
peerToPeripheralUUID.removeValue(forKey: peerID)
}
}
return peerID
}
@@ -25,9 +25,4 @@ final class BLELogRateLimiter {
}
}
func removeAll() {
queue.sync {
lastLogTimeByKey.removeAll()
}
}
}
@@ -7,11 +7,54 @@ struct BLEOutboundFragmentTransferRequest {
let maxChunk: Int?
let directedPeer: PeerID?
let transferId: String?
let requireDirectPeerLink: Bool
let requireNoiseAuthenticatedPeerLink: Bool
init(
packet: BitchatPacket,
pad: Bool,
maxChunk: Int?,
directedPeer: PeerID?,
transferId: String?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) {
self.packet = packet
self.pad = pad
self.maxChunk = maxChunk
self.directedPeer = directedPeer
self.transferId = transferId
self.requireDirectPeerLink = requireDirectPeerLink
self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink
}
var resolvedTransferId: String? {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
return transferId ?? packet.payload.sha256Hex()
}
/// Content identity independent of the caller-chosen transfer ID: the
/// same file resent through another path (gossip-sync replay, retry)
/// arrives with a different explicit transferId but identical payload.
var contentKey: String? {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
return packet.payload.sha256Hex()
}
}
/// Transactional admission for strict fragment trains. Durable callers may
/// commit only when every fragment was accepted; the first rejection stops
/// the train and reports failure so the original remains retryable.
enum BLEStrictFragmentAdmission {
static func admitAll<Fragment>(
_ fragments: [Fragment],
accepting: (Fragment) -> Bool
) -> Bool {
for fragment in fragments where !accepting(fragment) {
return false
}
return true
}
}
struct BLEOutboundFragmentTransferScheduler {
@@ -23,6 +66,16 @@ struct BLEOutboundFragmentTransferScheduler {
enum SubmitResult {
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
/// Strict direct-link requests are transactional: returning false to
/// their durable owner must mean no process-local copy remains that
/// can transmit later. They are therefore start-or-reject, never
/// admitted to `pendingTransfers`.
case rejectedStrict(request: BLEOutboundFragmentTransferRequest, transferId: String?)
/// The same file is already being (or waiting to be) fragmented out
/// to an audience covering this request; sending it again would just
/// double the airtime (field-verified: one 41KB voice file went out
/// as two complete fragment streams).
case droppedDuplicate(request: BLEOutboundFragmentTransferRequest, activeTransferId: String?)
}
enum CancelResult {
@@ -38,14 +91,34 @@ struct BLEOutboundFragmentTransferScheduler {
}
private struct ActiveTransferState {
let totalFragments: Int
var totalFragments: Int
var sentFragments: Int
var workItems: [DispatchWorkItem]
var contentKey: String?
var directedPeer: PeerID?
}
private var activeTransfers: [String: ActiveTransferState] = [:]
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
/// A transfer of the same content whose audience covers `directedPeer`:
/// a broadcast covers every peer; a directed transfer covers only its
/// recipient. A directed resend to a peer NOT covered by what's in
/// flight (different recipient of a private file) is never a duplicate.
private func coveringDuplicate(contentKey: String, directedPeer: PeerID?) -> String? {
for (transferId, state) in activeTransfers where state.contentKey == contentKey {
if state.directedPeer == nil || state.directedPeer == directedPeer {
return transferId
}
}
for request in pendingTransfers where request.contentKey == contentKey {
if request.directedPeer == nil || request.directedPeer == directedPeer {
return request.resolvedTransferId
}
}
return nil
}
var activeCount: Int {
activeTransfers.count
}
@@ -69,17 +142,39 @@ struct BLEOutboundFragmentTransferScheduler {
return .start(request: request, reservedTransferId: nil)
}
// Only requests without an explicit transferId are dropped as
// duplicates: those are resend paths (gossip-sync replay, directed
// spool) with no UI waiting on them. An app-initiated send carries a
// transferId whose progress events the UI tracks, so it always runs.
if request.transferId == nil,
let contentKey = request.contentKey,
let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) {
return .droppedDuplicate(request: request, activeTransferId: coveringId)
}
guard activeTransfers.count < maxConcurrentTransfers else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.append(request)
return .queued(request: request, transferId: transferId, position: .back)
}
guard activeTransfers[transferId] == nil else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.insert(request, at: 0)
return .queued(request: request, transferId: transferId, position: .front)
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
activeTransfers[transferId] = ActiveTransferState(
totalFragments: 0,
sentFragments: 0,
workItems: [],
contentKey: request.contentKey,
directedPeer: request.directedPeer
)
return .start(request: request, reservedTransferId: transferId)
}
@@ -88,12 +183,11 @@ struct BLEOutboundFragmentTransferScheduler {
totalFragments: Int,
workItems: [DispatchWorkItem]
) -> Bool {
guard activeTransfers[transferId] != nil else { return false }
activeTransfers[transferId] = ActiveTransferState(
totalFragments: totalFragments,
sentFragments: 0,
workItems: workItems
)
guard var state = activeTransfers[transferId] else { return false }
state.totalFragments = totalFragments
state.sentFragments = 0
state.workItems = workItems
activeTransfers[transferId] = state
return true
}
@@ -149,13 +243,25 @@ struct BLEOutboundFragmentTransferScheduler {
while availableSlots > 0, !pendingTransfers.isEmpty {
let request = pendingTransfers.removeFirst()
availableSlots -= 1
guard let transferId = request.resolvedTransferId else {
availableSlots -= 1
results.append(.start(request: request, reservedTransferId: nil))
continue
}
// A queued duplicate of content that started while it waited
// must not resend the whole file once the slot frees up (same
// explicit-transferId exemption as submit).
if request.transferId == nil,
let contentKey = request.contentKey,
let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) {
results.append(.droppedDuplicate(request: request, activeTransferId: coveringId))
continue
}
availableSlots -= 1
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.insert(request, at: 0)
results.append(.queued(request: request, transferId: transferId, position: .front))
@@ -168,7 +274,13 @@ struct BLEOutboundFragmentTransferScheduler {
continue
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
activeTransfers[transferId] = ActiveTransferState(
totalFragments: 0,
sentFragments: 0,
workItems: [],
contentKey: request.contentKey,
directedPeer: request.directedPeer
)
results.append(.start(request: request, reservedTransferId: transferId))
}
@@ -20,22 +20,15 @@ enum BLEOutboundLinkPlanner {
excludedLinks: Set<BLEIngressLinkID>,
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
directedOnlyPeer: PeerID?
preferredPeripheralPerPeer: [PeerID: String] = [:],
directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault,
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false
) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: peripheralWriteLimits,
centralNotifyLimits: centralNotifyLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
shouldSpoolDirectedPacket: false
)
}
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
// Direct announces bypass the per-peer duplicate-link collapse so
// every live link gets bound (see BLEFanoutSelector.selectLinks).
let isDirectAnnounce = packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
let selectedLinks = BLEFanoutSelector.selectLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
@@ -43,11 +36,37 @@ enum BLEOutboundLinkPlanner {
excludedLinks: excludedLinks,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings,
preferredPeripheralPerPeer: preferredPeripheralPerPeer,
collapseDuplicatePeerLinks: !isDirectAnnounce,
directedPeerHint: directedPeerHint,
requireDirectPeerLink: requireDirectPeerLink,
packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
)
// Fragment only for links that this packet can actually use. Looking
// at every connected link before directed-peer selection lets an
// unrelated peer's MTU make an oversized directed send look routable,
// even though every resulting fragment will select zero target links.
let selectedPeripheralLimits = zip(peripheralIDs, peripheralWriteLimits).compactMap { id, limit in
selectedLinks.peripheralIDs.contains(id) ? limit : nil
}
let selectedCentralLimits = zip(centralIDs, centralNotifyLimits).compactMap { id, limit in
selectedLinks.centralIDs.contains(id) ? limit : nil
}
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: selectedPeripheralLimits,
centralNotifyLimits: selectedCentralLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: selectedLinks,
shouldSpoolDirectedPacket: false
)
}
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: nil,
@@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer<Target> {
guard !pending.isEmpty else { return }
notifications.insert(contentsOf: pending, at: 0)
}
/// Removes a disconnected target from target-specific retries. Broadcast
/// entries (`targets == nil`) remain valid for the surviving subscriber
/// set; an entry with no targets left is discarded entirely.
mutating func removeTarget(where matches: (Target) -> Bool) {
notifications = notifications.compactMap { notification in
guard let targets = notification.targets else { return notification }
let remaining = targets.filter { !matches($0) }
guard !remaining.isEmpty else { return nil }
return BLEPendingNotification(data: notification.data, targets: remaining)
}
}
}
@@ -12,12 +12,15 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
// voiceFrame is deliberately unpadded: padding to the 512 block would
// push every ~490-byte signed voice packet over the MTU into the
// fragment path.
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
return false
}
}
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
static func priority(for packet: BitchatPacket, data _: Data) -> BLEOutboundWritePriority {
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
switch messageType {
case .fragment:
@@ -46,8 +46,25 @@ struct BLEOutboundWriteBuffer {
priority: BLEOutboundWritePriority,
capBytes: Int
) -> EnqueueResult {
enqueueReportingAcceptance(
data: data,
for: peripheralID,
priority: priority,
capBytes: capBytes
).result
}
/// Enqueues while also reporting whether the newly offered write survived
/// priority trimming. `EnqueueResult.enqueued` alone cannot express that:
/// a full queue may immediately trim the new lowest-priority item.
mutating func enqueueReportingAcceptance(
data: Data,
for peripheralID: String,
priority: BLEOutboundWritePriority,
capBytes: Int
) -> (result: EnqueueResult, accepted: Bool) {
guard data.count <= capBytes else {
return .oversized(bytes: data.count)
return (.oversized(bytes: data.count), false)
}
var queue = writesByPeripheralID[peripheralID] ?? []
@@ -65,7 +82,8 @@ struct BLEOutboundWriteBuffer {
}
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
let accepted = insertIndex < queue.count
return (.enqueued(trimmedBytes: trimmedBytes, remainingBytes: total), accepted)
}
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
@@ -74,6 +92,13 @@ struct BLEOutboundWriteBuffer {
return items
}
/// Drops link-specific ciphertext when that physical link is gone. Keeping
/// the dictionary entry would let rotating peripheral UUIDs accumulate a
/// fresh per-link byte cap indefinitely.
mutating func discardAll(for peripheralID: String) {
writesByPeripheralID[peripheralID] = nil
}
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
guard !items.isEmpty else { return }
var existing = writesByPeripheralID[peripheralID] ?? []
+25 -10
View File
@@ -10,6 +10,8 @@ struct BLEPeerInfo: Equatable {
var isVerifiedNickname: Bool
var lastSeen: Date
var capabilities: PeerCapabilities = []
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
var bridgeGeohash: String?
}
struct BLEPeerAnnounceUpdate: Equatable {
@@ -117,6 +119,15 @@ struct BLEPeerRegistry {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
}
/// A rendezvous cell advertised by any bridge-capable peer, if one is
/// known lets location-less devices join the island's rendezvous.
func advertisedBridgeGeohash() -> String? {
peers.values
.filter { $0.capabilities.contains(.bridge) }
.compactMap(\.bridgeGeohash)
.first
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
@@ -141,20 +152,22 @@ struct BLEPeerRegistry {
}
}
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
} || selfNickname == info.nickname
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
}
mutating func markDisconnected(_ peerID: PeerID) {
guard var info = peers[peerID] else { return }
info.isConnected = false
peers[peerID] = info
}
/// Flips an already-known peer to connected. Returns false when the peer
/// is unknown or already connected (nothing changed).
@discardableResult
mutating func markConnected(_ peerID: PeerID) -> Bool {
guard var info = peers[peerID], !info.isConnected else { return false }
info.isConnected = true
peers[peerID] = info
return true
}
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
guard var peer = peers[peerID] else { return }
peer.lastSeen = date
@@ -168,7 +181,8 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date,
capabilities: PeerCapabilities = []
capabilities: PeerCapabilities = [],
bridgeGeohash: String? = nil
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate(
@@ -185,7 +199,8 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now,
capabilities: capabilities
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
return update
@@ -122,10 +122,18 @@ final class BLEPublicMessageHandler {
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil
let messageID: String?
if peerID == env.localPeerID() {
resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet)
messageID = env.takeSelfBroadcastMessageID(packet)
} else {
// The wire carries no message ID; derive the stable one every
// device agrees on so bridged copies dedup against the radio copy.
messageID = MeshMessageIdentity.stableID(
senderIDHex: peerID.id,
timestampMs: packet.timestamp,
content: content
)
}
env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID)
env.deliverPublicMessage(peerID, senderNickname, content, ts, messageID)
}
}
@@ -70,6 +70,7 @@ struct BLEReceivePipeline {
// announce-class TTL headroom so alerts travel the extra hop.
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
&& BoardWire.urgentFlag(in: packet.payload),
isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue,
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
@@ -0,0 +1,73 @@
import BitFoundation
import Foundation
/// Decides which central-role connections (peripheral links we own) are
/// redundant duplicates of a peer's live link.
///
/// One connection per role per peer is the normal dual-role topology (each
/// device is both central and peripheral). After a BLE state-restoration
/// relaunch, though, the same phone can reappear under a fresh peripheral
/// UUID while the restored connection lives on leaving several live
/// central-role connections to one peer, each carrying every packet
/// (field-verified: 2-3x airtime on all traffic). Only same-role duplicates
/// are retired; the peer's central-role subscription on our peripheral
/// manager is its own connection to manage, and it runs the same policy.
enum BLERedundantLinkPolicy {
struct PeripheralLink: Equatable {
let uuid: String
let peerID: PeerID?
let isConnected: Bool
/// Whether the link has a discovered characteristic (is writable).
/// A link mid-service-rediscovery (didModifyServices cleared it)
/// must never be kept over a writable duplicate.
let hasCharacteristic: Bool
init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) {
self.uuid = uuid
self.peerID = peerID
self.isConnected = isConnected
self.hasCharacteristic = hasCharacteristic
}
}
/// The link to keep when a peer has several connected bound peripheral
/// links, or nil when there is nothing to consolidate. Prefers the
/// ingress link of the verified direct announce that triggered the check
/// (the strongest liveness proof available), falling back to the peer's
/// most recently bound link but only among writable links while any
/// exist: keeping a characteristic-less link and cancelling the writable
/// duplicate would strand outbound traffic on the central link until
/// rediscovery finishes. When neither anchor is a viable candidate,
/// consolidation waits for a later announce rather than guessing.
static func keptPeripheralUUID(
ingressPeripheralUUID: String?,
mostRecentlyBoundUUID: String?,
links: [PeripheralLink],
peerID: PeerID
) -> String? {
let bound = links.filter { $0.peerID == peerID && $0.isConnected }
guard bound.count > 1 else { return nil }
let writable = bound.filter(\.hasCharacteristic)
let candidates = writable.isEmpty ? bound : writable
if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) {
return ingressPeripheralUUID
}
if let mostRecentlyBoundUUID, candidates.contains(where: { $0.uuid == mostRecentlyBoundUUID }) {
return mostRecentlyBoundUUID
}
return nil
}
/// Connected peripheral links bound to the peer other than the kept one.
static func peripheralUUIDsToRetire(
links: [PeripheralLink],
peerID: PeerID,
keeping keptUUID: String
) -> [String] {
links
.filter { $0.peerID == peerID && $0.isConnected && $0.uuid != keptUUID }
.map(\.uuid)
}
}
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -19,11 +19,10 @@ final class BoardManager: ObservableObject {
@Published private(set) var posts: [BoardPostPacket] = []
private let transport: Transport
private let store: BoardStore
/// Publishes a bridged kind-1 note (expiring with the board post via
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
/// was skipped.
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64) -> String?
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ urgent: Bool) -> String?
/// Requests NIP-09 deletion of a previously bridged note.
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
@@ -35,11 +34,10 @@ final class BoardManager: ObservableObject {
init(
transport: Transport,
store: BoardStore = .shared,
publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil,
deleteFromNostr: ((String, String) -> Void)? = nil
) {
self.transport = transport
self.store = store
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
cancellable = store.$postsSnapshot
@@ -128,7 +126,7 @@ final class BoardManager: ObservableObject {
// Nostr bridge: geohash posts also go out as kind-1 location notes so
// online users see them. Remember the event id for merged deletes.
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) {
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) {
bridgedEventIDs[postID] = eventID
}
return true
@@ -160,7 +158,7 @@ final class BoardManager: ObservableObject {
return true
}
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? {
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64, urgent: Bool) -> String? {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
@@ -173,7 +171,8 @@ final class BoardManager: ObservableObject {
geohash: geohash,
senderIdentity: identity,
nickname: nickname,
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000),
urgent: urgent
)
NostrRelayManager.shared.sendEvent(event, to: relays)
return event.id
-8
View File
@@ -146,14 +146,6 @@ final class BoardStore {
// MARK: - Maintenance
func pruneExpired() {
let nowMs = currentMs()
queue.sync {
pruneExpiredLocked(nowMs: nowMs)
persistLocked()
}
}
/// Panic wipe: drop all board data from memory and disk.
func wipe() {
queue.sync {
+8 -1
View File
@@ -23,6 +23,9 @@ struct NoticeItem: Identifiable, Equatable {
let content: String
let createdAt: Date
let isUrgent: Bool
/// When the notice fades (board expiry or a note's NIP-40 tag, as dead
/// drops carry). Nil means it only ages out of the relay window.
let expiresAt: Date?
let source: Source
var isBoardPost: Bool {
@@ -36,6 +39,9 @@ struct NoticeItem: Identifiable, Equatable {
content = post.content
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
isUrgent = post.isUrgent
expiresAt = post.expiresAt > 0
? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000)
: nil
source = .board(post)
}
@@ -46,7 +52,8 @@ struct NoticeItem: Identifiable, Equatable {
.first.map(String.init) ?? display
content = note.content
createdAt = note.createdAt
isUrgent = false
isUrgent = note.isUrgent
expiresAt = note.expiresAt
source = .nostr(note)
}
}
+36
View File
@@ -146,6 +146,8 @@ final class CommandProcessor {
return handleTrace(args)
case "/pay":
return handlePay(args)
case "/drop":
return handleDrop(args)
case "/help":
return .success(message: Self.helpText)
default:
@@ -171,9 +173,40 @@ final class CommandProcessor {
/ping @name measure round-trip time (mesh only)
/trace @name estimated mesh path (mesh only)
/pay <token> send a cashu ecash token in this chat
/drop <message> pin a note to this place for 24h (needs location)
/help this list
"""
/// /drop <text> a dead drop: pins a note to the current building-level
/// geohash with a 24h NIP-40 expiry. Anyone who passes through here and
/// looks at notices (or hits the empty-timeline "notes left here" hint)
/// reads it.
private func handleDrop(_ args: String) -> CommandResult {
guard LocationNotesSettings.enabled else {
return .error(message: "location notes are off — enable them in the info screen")
}
guard let content = args.trimmedOrNilIfEmpty else {
return .error(message: "usage: /drop <message>")
}
let location = LocationChannelManager.shared
guard location.permissionState == .authorized else {
return .error(message: "leaving a note needs location — enable it in the info screen")
}
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
location.refreshChannels()
return .error(message: "still finding this place — try again in a moment")
}
guard let nickname = contextProvider?.nickname,
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
return .error(message: "no geo relays reachable — note not left")
}
// Leaving a note is an explicit notes act: it unlocks the passive
// nearby-notes counter (tap-to-reveal) so the sender sees their own
// drop counted on the timeline.
NearbyNotesCounter.shared.reveal()
return .success(message: "📍 note left here — it fades in 24h")
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
@@ -336,6 +369,9 @@ final class CommandProcessor {
)
identityManager.updateSocialIdentity(blockedIdentity)
}
// Scrub their carried public messages now, while the peerID is
// resolvable, so they can't resurface as archived echoes.
meshService?.purgeArchivedPublicMessages(from: peerID)
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
+307 -36
View File
@@ -10,6 +10,9 @@ import BitFoundation
import BitLogger
import Combine
import Foundation
#if os(iOS)
import UIKit
#endif
/// Trust level of a courier deposit, decided by the caller's policy.
/// Favorites get the larger quota and are never evicted to make room for
@@ -42,6 +45,8 @@ final class CourierStore {
var sprayedTo: Set<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
/// Last publish of this envelope as a bridge courier drop on relays.
var lastBridgePublishAt: Date?
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
let prekeyID: UInt32?
@@ -59,6 +64,7 @@ final class CourierStore {
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil,
lastBridgePublishAt: Date? = nil,
prekeyID: UInt32? = nil
) {
self.recipientTag = recipientTag
@@ -70,6 +76,7 @@ final class CourierStore {
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
self.lastBridgePublishAt = lastBridgePublishAt
self.prekeyID = prekeyID
}
@@ -86,6 +93,7 @@ final class CourierStore {
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt)
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
}
}
@@ -115,13 +123,45 @@ final class CourierStore {
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
private let readData: (URL) throws -> Data
/// A protected file can be present but unreadable during an iOS
/// background restoration before first unlock. Keep that distinct from
/// an absent file: mutations may proceed in memory, but must not replace
/// the unreadable durable snapshot until it can be merged.
private var diskLoadDeferred = false
#if os(iOS)
private var protectedDataObserver: NSObjectProtocol?
#endif
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
init(
persistsToDisk: Bool = true,
fileURL: URL? = nil,
now: @escaping () -> Date = Date.init,
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) }
) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
self.readData = readData
loadFromDisk()
#if os(iOS)
protectedDataObserver = NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.retryDeferredPersistence()
}
#endif
}
deinit {
#if os(iOS)
if let protectedDataObserver {
NotificationCenter.default.removeObserver(protectedDataObserver)
}
#endif
}
// MARK: - Depositing (courier side)
@@ -149,10 +189,16 @@ final class CourierStore {
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently,
// keeping the larger spray budget (bounded by maxCopies either way).
// Identical ciphertext is the same envelope. Before any spray,
// a carry-only copy may legitimately arrive ahead of the original
// higher-budget copy, so keep the larger initial budget. Once a
// branch has sprayed, however, replaying the depositor's original
// packet must never replenish spent copies: that would defeat
// spray-and-wait and let `sprayedTo` grow without bound.
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
if envelopes[existing].sprayedTo.isEmpty {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
}
persistLocked()
return true
}
@@ -202,20 +248,52 @@ final class CourierStore {
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
/// the rotating recipient tag across adjacent days. This compatibility
/// helper accepts every offer; transport callers should use
/// `handoverEnvelopes(for:accepting:)` so failed sends remain durable.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
var handedOver: [CourierEnvelope] = []
handoverEnvelopes(for: noiseStaticKey) { envelope in
handedOver.append(envelope)
return true
}
return handedOver
}
/// Attempts direct handover without retiring the durable carried copy
/// until the transport accepts it onto the intended peer's physical link.
/// A failed encode, stale binding, or backpressure rejection leaves the
/// envelope unchanged for the next authenticated encounter.
@discardableResult
func handoverEnvelopes(
for noiseStaticKey: Data,
accepting: (CourierEnvelope) -> Bool
) -> Int {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
let offered = queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
return envelopes
.filter { candidates.contains($0.recipientTag) }
.map(\.envelope)
}
var acceptedCount = 0
for envelope in offered where accepting(envelope) {
// Do not hold the store queue while the acceptance closure enters
// BLE/collections queues. Commit in a second short critical
// section, rechecking that another handover did not win first.
let committed = queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
return false
}
envelopes.remove(at: index)
persistLocked()
return true
}
if committed { acceptedCount += 1 }
}
return acceptedCount
}
/// Envelopes addressed to a recipient we heard from via a *relayed*
@@ -242,6 +320,36 @@ final class CourierStore {
}
}
/// Envelopes eligible to park on relays as bridge courier drops. Merely
/// offering one does not start its cooldown: the caller commits that only
/// after a relay explicitly accepts the event via NIP-20 OK.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
return queue.sync {
pruneExpiredLocked(at: date)
return envelopes.compactMap { stored in
if let last = stored.lastBridgePublishAt,
date.timeIntervalSince(last) < cooldown {
return nil
}
// The relay copy carries no spray budget.
return stored.envelope.withCopies(1)
}
}
}
/// Starts the bridge-publish cooldown only for a relay-confirmed copy.
func markBridgePublished(_ envelope: CourierEnvelope) {
let date = now()
queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
return
}
envelopes[index].lastBridgePublishAt = date
persistLocked()
}
}
// MARK: - Spray-and-wait (on encountering another courier)
/// Envelopes to re-deposit with a courier we just encountered, each with
@@ -249,41 +357,69 @@ final class CourierStore {
/// deposited, envelopes addressed to them (those ride the handover path),
/// carry-only envelopes, and couriers already sprayed.
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
var sprayed: [CourierEnvelope] = []
transferSprayCopies(to: courierNoiseKey) { envelope in
sprayed.append(envelope)
return true
}
return sprayed
}
/// Offers binary-spray copies one at a time and commits the reduced local
/// budget plus `sprayedTo` marker only after the directed transport accepts
/// that copy. A false result is a rollback: retrying the same courier sees
/// the original budget and eligibility.
@discardableResult
func transferSprayCopies(
to courierNoiseKey: Data,
accepting: (CourierEnvelope) -> Bool
) -> Int {
let date = now()
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
return queue.sync {
let offered = queue.sync {
pruneExpiredLocked(at: date)
var sprayed: [CourierEnvelope] = []
for index in envelopes.indices {
let stored = envelopes[index]
return envelopes.compactMap { stored -> CourierEnvelope? in
guard stored.copies > 1,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else { continue }
let given = stored.copies / 2
envelopes[index].copies = stored.copies - given
envelopes[index].sprayedTo.insert(courierNoiseKey)
sprayed.append(stored.envelope.withCopies(given))
!courierTags.contains(stored.recipientTag) else { return nil }
return stored.envelope.withCopies(stored.copies / 2)
}
if !sprayed.isEmpty { persistLocked() }
return sprayed
}
var acceptedCount = 0
for copy in offered where accepting(copy) {
// As with direct handover, BLE acceptance runs outside the store
// queue. Revalidate and commit the exact budget that left this
// device; a competing successful transfer makes this a no-op.
let committed = queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == copy.ciphertext }) else {
return false
}
let stored = envelopes[index]
guard stored.copies > copy.copies,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else {
return false
}
envelopes[index].copies = stored.copies - copy.copies
envelopes[index].sprayedTo.insert(courierNoiseKey)
persistLocked()
return true
}
if committed { acceptedCount += 1 }
}
return acceptedCount
}
// MARK: - Maintenance
func pruneExpired() {
let date = now()
queue.sync {
pruneExpiredLocked(at: date)
persistLocked()
}
}
/// Panic wipe: drop all carried mail from memory and disk.
func wipe() {
queue.sync {
envelopes.removeAll()
diskLoadDeferred = false
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
@@ -291,6 +427,16 @@ final class CourierStore {
}
}
/// Retries a protected-data read and merges any envelopes accepted while
/// the file was unavailable. Internal so persistence tests can drive the
/// same transition as iOS's protected-data notification.
func retryDeferredPersistence() {
queue.sync {
guard diskLoadDeferred, resolveDeferredLoadLocked() else { return }
persistLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
@@ -311,6 +457,12 @@ final class CourierStore {
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
// Never turn a transient protected-data read failure into an
// authoritative empty/new file. Once readable, resolve first by
// merging the durable and in-memory snapshots.
if diskLoadDeferred, !resolveDeferredLoadLocked() {
return
}
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
@@ -323,7 +475,7 @@ final class CourierStore {
let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: fileURL, options: options)
} catch {
@@ -334,16 +486,135 @@ final class CourierStore {
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
diskLoadDeferred = false
return
}
envelopes = stored
do {
let data = try readData(fileURL)
do {
envelopes = try JSONDecoder().decode([StoredEnvelope].self, from: data)
diskLoadDeferred = false
pruneExpiredLocked(at: now())
publishCountLocked()
Self.migrateFileProtectionIfNeeded(at: fileURL)
} catch {
// The bytes were readable, so this is corruption/schema
// failure rather than protected-data unavailability.
diskLoadDeferred = false
SecureLogger.error("Failed to decode courier store: \(error)", category: .session)
}
} catch {
diskLoadDeferred = true
SecureLogger.warning("Courier store unavailable; deferring load until protected data is available: \(error)", category: .session)
}
}
}
/// Must be called on `queue`.
private func resolveDeferredLoadLocked() -> Bool {
guard diskLoadDeferred, let fileURL else { return true }
guard FileManager.default.fileExists(atPath: fileURL.path) else {
diskLoadDeferred = false
return true
}
do {
let data = try readData(fileURL)
let durable = try JSONDecoder().decode([StoredEnvelope].self, from: data)
envelopes = Self.merge(durable: durable, inMemory: envelopes)
diskLoadDeferred = false
pruneExpiredLocked(at: now())
publishCountLocked()
Self.migrateFileProtectionIfNeeded(at: fileURL)
return true
} catch let error as DecodingError {
// Readability returned but the snapshot is corrupt. Do not pin
// persistence forever; retain the valid in-memory snapshot.
diskLoadDeferred = false
SecureLogger.error("Failed to decode deferred courier store: \(error)", category: .session)
return true
} catch {
SecureLogger.warning("Courier store still unavailable: \(error)", category: .session)
return false
}
}
private static func merge(durable: [StoredEnvelope], inMemory: [StoredEnvelope]) -> [StoredEnvelope] {
var merged = durable
for candidate in inMemory {
if let index = merged.firstIndex(where: { $0.ciphertext == candidate.ciphertext }) {
// Union progress before deciding the budget. Once either copy
// has sprayed, the lower remaining budget is authoritative;
// an older durable/original snapshot must not replenish it.
let durableHasProgress = !merged[index].sprayedTo.isEmpty
let memoryHasProgress = !candidate.sprayedTo.isEmpty
let combinedSprayedTo = merged[index].sprayedTo.union(candidate.sprayedTo)
switch (durableHasProgress, memoryHasProgress) {
case (false, false):
merged[index].copies = max(merged[index].copies, candidate.copies)
case (true, false):
break // durable progress owns its remaining budget
case (false, true):
merged[index].copies = candidate.copies
case (true, true):
// Concurrent progress can only spend budget; choosing the
// lower branch prevents a merge from minting copies.
merged[index].copies = min(merged[index].copies, candidate.copies)
}
merged[index].sprayedTo = combinedSprayedTo
if candidate.tier == .favorite { merged[index].tier = .favorite }
merged[index].lastRemoteHandoverAt = [merged[index].lastRemoteHandoverAt, candidate.lastRemoteHandoverAt]
.compactMap { $0 }
.max()
merged[index].lastBridgePublishAt = [merged[index].lastBridgePublishAt, candidate.lastBridgePublishAt]
.compactMap { $0 }
.max()
} else {
merged.append(candidate)
}
}
// A long locked wake can accept new mail alongside a full durable
// store. Re-apply deposit quotas: existing per-depositor mail keeps
// its slot, while total-cap eviction sheds oldest verified mail first.
merged.sort { $0.storedAt < $1.storedAt }
var perDepositorCounts: [Data: Int] = [:]
merged = merged.filter { envelope in
let limit = envelope.tier == .favorite
? Limits.maxPerFavoriteDepositor
: Limits.maxPerVerifiedDepositor
let count = perDepositorCounts[envelope.depositorNoiseKey, default: 0]
guard count < limit else { return false }
perDepositorCounts[envelope.depositorNoiseKey] = count + 1
return true
}
while merged.filter({ $0.tier == .verified }).count > Limits.maxVerifiedEnvelopes {
guard let victim = merged.firstIndex(where: { $0.tier == .verified }) else { break }
merged.remove(at: victim)
}
while merged.count > Limits.maxEnvelopes {
if let victim = merged.firstIndex(where: { $0.tier == .verified }) {
merged.remove(at: victim)
} else {
merged.removeFirst()
}
}
return merged
}
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
#if os(iOS)
do {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
} catch {
SecureLogger.warning("Failed to migrate courier store file protection: \(error)", category: .session)
}
#endif
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
+729 -39
View File
@@ -11,6 +11,9 @@ import BitLogger
import CryptoKit
import Foundation
import Security
#if os(iOS)
import UIKit
#endif
/// Disk persistence for the MessageRouter outbox, so private messages queued
/// for an offline peer survive an app kill instead of silently evaporating.
@@ -60,76 +63,550 @@ final class MessageOutboxStore {
private static let keychainService = "chat.bitchat.outbox"
private static let keychainKey = "outbox-encryption-key"
typealias Snapshot = [PeerID: [QueuedMessage]]
private enum DiskState {
case unknown
case loaded
case deferred
}
private enum DiskReadResult {
case missing
case loaded(Snapshot)
case deferred(Error?)
case corrupt(Error)
}
private enum EncryptionKeyReadResult {
case available(SymmetricKey)
case missing
case invalid
case unavailable(Error?)
}
private struct RecoveredSnapshot {
let snapshot: Snapshot
let generation: UInt64
let unseenDurable: Snapshot
}
private let fileURL: URL?
private let keychain: KeychainManagerProtocol
private let readData: (URL) throws -> Data
private let writeData: (Data, URL, Data.WritingOptions) throws -> Void
private let beforeRecoveryNotification: () -> Void
private let lock = NSLock()
private var diskState: DiskState = .unknown
private var cachedSnapshot: Snapshot = [:]
/// Mutations made after a protected-data load failed. They are merged
/// with the durable snapshot once it becomes readable, never written on
/// top of an unreadable file.
private var pendingSnapshot: Snapshot?
/// True after a write failed after the durable baseline was already
/// loaded. That full-router snapshot includes removals and must replace,
/// rather than union with, the older disk contents on retry.
private var pendingSnapshotIsAuthoritative = false
/// Delivery/read acknowledgments received before a deferred cold-load
/// reveals the durable queue. Applied to every merge before persistence.
private var pendingRemovalMessageIDs = Set<String>()
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
/// Recovery loaded durable state that MessageRouter has not merged yet.
/// While true, router saves must union with `cachedSnapshot` instead of
/// replacing unseen durable messages.
private var recoveryDeliveryPending = false
/// Recovery read durable state and classified the unseen subset, but the
/// merged snapshot could not yet be persisted. Preserve that classification
/// across retries instead of treating the cached union as router-known.
private var unseenRecoveryPendingPersistence = false
/// MessageRouter's latest authoritative in-memory snapshot while a
/// recovery classification is awaiting persistence or delivery. This is
/// deliberately separate from `pendingSnapshot`, which may contain the
/// union of router-known and unseen durable work after a failed write.
private var recoveryRouterSnapshot: Snapshot = [:]
/// The durable messages absent from MessageRouter's locked-wake snapshot
/// when recovery completed. Only this subset is unioned into authoritative
/// router saves before the recovery callback is claimed.
private var unseenRecoveredSnapshot: Snapshot = [:]
/// Covers the narrow launch race where protected data becomes available
/// after `load()` returned but before MessageRouter installs its handler.
private var unreportedRecoveredSnapshot: RecoveredSnapshot?
/// Invalidates recovery callbacks already queued onto the main actor when
/// panic wipe begins.
private var lifecycleGeneration: UInt64 = 0
#if os(iOS)
private var protectedDataObserver: NSObjectProtocol?
#endif
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
init(
keychain: KeychainManagerProtocol,
fileURL: URL? = nil,
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) },
writeData: @escaping (Data, URL, Data.WritingOptions) throws -> Void = {
try $0.write(to: $1, options: $2)
},
beforeRecoveryNotification: @escaping () -> Void = {}
) {
self.keychain = keychain
self.fileURL = fileURL ?? Self.defaultFileURL()
self.readData = readData
self.writeData = writeData
self.beforeRecoveryNotification = beforeRecoveryNotification
#if os(iOS)
protectedDataObserver = NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.retryDeferredLoad()
}
#endif
}
deinit {
#if os(iOS)
if let protectedDataObserver {
NotificationCenter.default.removeObserver(protectedDataObserver)
}
#endif
}
// MARK: - API (call from the router's actor; IO is small and atomic)
func load() -> [PeerID: [QueuedMessage]] {
guard let fileURL,
let sealed = try? Data(contentsOf: fileURL),
let key = encryptionKey(createIfMissing: false),
let box = try? ChaChaPoly.SealedBox(combined: sealed),
let plaintext = try? ChaChaPoly.open(box, using: key),
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
return [:]
func load() -> Snapshot {
lock.lock()
defer { lock.unlock() }
if case .loaded = diskState {
return cachedSnapshot
}
var outbox: [PeerID: [QueuedMessage]] = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
// Once a deferred recovery has classified the durable baseline,
// `cachedSnapshot` is the only safe synchronous view. Re-reading via
// the generic load path would confuse its durable+router union with a
// fully router-known authoritative snapshot.
if unseenRecoveryPendingPersistence || recoveryDeliveryPending {
return cachedSnapshot
}
let wasDeferred = diskState == .deferred
switch readSnapshotLocked() {
case .loaded(let durable):
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshotIsAuthoritative
? (pendingSnapshot ?? [:])
: Self.merge(durable, pendingSnapshot ?? [:]))
diskState = .loaded
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
// The recovery callback is driven by `retryDeferredLoad`; `load`
// itself returns the recovered value synchronously to its caller.
return cachedSnapshot
case .missing:
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
diskState = .loaded
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
return cachedSnapshot
case .deferred(let error):
diskState = .deferred
if !wasDeferred {
SecureLogger.warning("Outbox unavailable; deferring load until protected data is available: \(String(describing: error))", category: .session)
}
return pendingSnapshot ?? [:]
case .corrupt(let error):
diskState = .loaded
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
return cachedSnapshot
}
return outbox
}
func save(_ outbox: [PeerID: [QueuedMessage]]) {
guard let fileURL else { return }
let flattened = outbox.filter { !$0.value.isEmpty }
guard !flattened.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
func save(_ outbox: Snapshot) {
var recovered: RecoveredSnapshot?
lock.lock()
let recoveryWasPending = recoveryDeliveryPending
let unseenClassificationWasPending = unseenRecoveryPendingPersistence
let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending
let flattened = applyingPendingRemovalsLocked(outbox.filter { !$0.value.isEmpty })
if recoveryStateWasPending {
// `save` is the router's complete current view. Keep it separate
// from the durable union retained for disk retry.
recoveryRouterSnapshot = flattened
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return
}
do {
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
switch diskState {
case .loaded:
cachedSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try sealed.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
if !persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
// Retain the latest complete router snapshot. Because its
// durable baseline was already loaded, it replaces the older
// disk file after protected data returns (preserving removals).
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .unknown, .deferred:
let wasDeferred = diskState == .deferred
// A non-authoritative deferred snapshot means the initial cold
// load never completed. An authoritative deferred snapshot with
// no recovery state is merely an ordinary post-load write retry.
let isRecoveryAttempt = recoveryStateWasPending ||
(wasDeferred && !pendingSnapshotIsAuthoritative)
switch readSnapshotLocked() {
case .loaded(let durable):
// `save` before a successful `load` is not authoritative over
// an unreadable snapshot. Union by message ID so neither the
// durable queue nor work accepted during the locked wake is
// lost.
let newlyUnseen = recoveryStateWasPending
? unseenRecoveredSnapshot
: (isRecoveryAttempt
? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: flattened))
: [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = newlyUnseen
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: (pendingSnapshotIsAuthoritative ? flattened : Self.merge(durable, flattened))
cachedSnapshot = applyingPendingRemovalsLocked(merged)
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: cachedSnapshot,
generation: lifecycleGeneration,
unseenDurable: newlyUnseen
)
}
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .missing:
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(
preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
cachedSnapshot = merged
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .deferred:
// `save` receives MessageRouter's complete current in-memory
// snapshot. Replace prior locked-wake state so delivery acks
// and expiry removals become tombstones for that state; only
// the still-unknown durable snapshot is unioned on recovery.
pendingSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
diskState = .deferred
case .corrupt(let error):
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(
preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
cachedSnapshot = merged
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
}
if let recovered {
unseenRecoveryPendingPersistence = false
recoveryDeliveryPending = true
unseenRecoveredSnapshot = recovered.unseenDurable
}
lock.unlock()
if let recovered {
beforeRecoveryNotification()
notifyRecovered(recovered.snapshot, generation: recovered.generation)
}
}
/// Installs the router-side merge hook used when a cold, locked launch
/// initially received an empty snapshot and protected data later becomes
/// readable.
func setRecoveryHandler(_ handler: @escaping @MainActor (Snapshot) -> Void) {
lock.lock()
recoveryHandler = handler
let unreported = unreportedRecoveredSnapshot
unreportedRecoveredSnapshot = nil
lock.unlock()
if let unreported {
Task { @MainActor [weak self] in
guard let latest = self?.claimPendingRecovery(generation: unreported.generation) else { return }
handler(latest)
}
}
}
/// Records an ack even when a locked cold-load has not revealed the
/// matching durable message yet. The next `save`/recovery applies this
/// tombstone before writing or notifying MessageRouter.
func recordRemoval(messageID: String) {
lock.lock()
pendingRemovalMessageIDs.insert(messageID)
cachedSnapshot = Self.removing([messageID], from: cachedSnapshot)
unseenRecoveredSnapshot = Self.removing([messageID], from: unseenRecoveredSnapshot)
recoveryRouterSnapshot = Self.removing([messageID], from: recoveryRouterSnapshot)
if let pendingSnapshot {
self.pendingSnapshot = Self.removing([messageID], from: pendingSnapshot)
}
lock.unlock()
}
/// Retries a deferred protected-data load. The returned snapshot includes
/// both durable messages and any messages queued during the locked wake.
@discardableResult
func retryDeferredLoad() -> Snapshot? {
var recovered: RecoveredSnapshot?
lock.lock()
guard diskState == .deferred else {
lock.unlock()
return nil
}
let recoveryWasPending = recoveryDeliveryPending
let unseenClassificationWasPending = unseenRecoveryPendingPersistence
let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending
// `pendingSnapshotIsAuthoritative` alone means a normal write failed
// after the router had already loaded its baseline. It must retry, but
// must not masquerade as cold-load recovery or schedule a callback.
let isRecoveryAttempt = recoveryStateWasPending || !pendingSnapshotIsAuthoritative
switch readSnapshotLocked() {
case .loaded(let durable):
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
let newlyUnseen = recoveryStateWasPending
? unseenRecoveredSnapshot
: (isRecoveryAttempt
? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: known))
: [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = newlyUnseen
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: newlyUnseen
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .missing:
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: known)
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .deferred:
break
case .corrupt(let error):
SecureLogger.error("Failed to decode encrypted outbox after protected-data recovery: \(error)", category: .session)
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: known)
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
if let recovered {
unseenRecoveryPendingPersistence = false
recoveryDeliveryPending = true
unseenRecoveredSnapshot = recovered.unseenDurable
}
lock.unlock()
if let recovered {
beforeRecoveryNotification()
notifyRecovered(recovered.snapshot, generation: recovered.generation)
}
return recovered?.snapshot
}
/// Panic wipe: drop the queued mail and the key that could ever read it.
func wipe() {
lock.lock()
diskState = .loaded
cachedSnapshot = [:]
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
pendingRemovalMessageIDs.removeAll()
recoveryDeliveryPending = false
unseenRecoveryPendingPersistence = false
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = [:]
unreportedRecoveredSnapshot = nil
lifecycleGeneration &+= 1
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
lock.unlock()
}
// MARK: - Internals
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
return SymmetricKey(data: data)
switch readEncryptionKey() {
case .available(let key):
return key
case .missing:
break
case .invalid:
guard createIfMissing else { return nil }
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
case .unavailable:
return nil
}
guard createIfMissing else { return nil }
let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data($0) }
// After-first-unlock so queued mail can flush from background BLE wakes.
@@ -139,9 +616,222 @@ final class MessageOutboxStore {
service: Self.keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
// The protocol's generic save predates result-bearing writes. Verify
// the item before sealing a file: otherwise a locked/full Keychain
// could drop the key while we successfully write unrecoverable mail.
guard case .success(let stored) = keychain.loadWithResult(
key: Self.keychainKey,
service: Self.keychainService
), stored == data else {
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
SecureLogger.error("Outbox encryption key was not retained by Keychain", category: .session)
return nil
}
return key
}
private func readEncryptionKey() -> EncryptionKeyReadResult {
switch keychain.loadWithResult(key: Self.keychainKey, service: Self.keychainService) {
case .success(let data):
guard data.count == 32 else { return .invalid }
return .available(SymmetricKey(data: data))
case .itemNotFound:
return .missing
case .deviceLocked, .authenticationFailed:
return .unavailable(nil)
case .accessDenied:
return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(errSecNotAvailable)))
case .otherError(let status):
return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(status)))
}
}
/// Must be called with `lock` held.
private func readSnapshotLocked() -> DiskReadResult {
guard let fileURL,
FileManager.default.fileExists(atPath: fileURL.path) else {
return .missing
}
let sealed: Data
do {
sealed = try readData(fileURL)
} catch {
return .deferred(error)
}
// A locked Keychain is transient; a genuine item-not-found next to an
// existing sealed file is permanent (notably after restoring onto a
// new device, because the key is ThisDeviceOnly). Remove that
// unrecoverable ciphertext before allowing a replacement key/file.
let key: SymmetricKey
switch readEncryptionKey() {
case .available(let availableKey):
key = availableKey
case .missing:
return discardOrphanedSnapshotLocked(reason: "encryption key is missing")
case .invalid:
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
return discardOrphanedSnapshotLocked(reason: "encryption key has an invalid length")
case .unavailable(let error):
return .deferred(error)
}
do {
let box = try ChaChaPoly.SealedBox(combined: sealed)
let plaintext = try ChaChaPoly.open(box, using: key)
let decoded = try JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext)
var outbox: Snapshot = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
}
Self.migrateFileProtectionIfNeeded(at: fileURL)
return .loaded(outbox)
} catch {
return .corrupt(error)
}
}
/// Must be called with `lock` held. A ciphertext whose ThisDeviceOnly key
/// is definitively absent can never become readable; removing it is safer
/// than deferring forever or overwriting it while pretending it loaded.
private func discardOrphanedSnapshotLocked(reason: String) -> DiskReadResult {
guard let fileURL else { return .missing }
do {
try FileManager.default.removeItem(at: fileURL)
SecureLogger.warning("Removed unrecoverable encrypted outbox because its \(reason)", category: .session)
return .missing
} catch {
SecureLogger.error("Could not remove unrecoverable encrypted outbox: \(error)", category: .session)
return .deferred(error)
}
}
/// Must be called with `lock` held.
@discardableResult
private func persistSnapshotLocked(_ snapshot: Snapshot) -> Bool {
guard let fileURL else { return true }
do {
if snapshot.isEmpty {
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
return true
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return false
}
let keyed = Dictionary(uniqueKeysWithValues: snapshot.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try writeData(sealed, fileURL, options)
return true
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
return false
}
}
/// Must be called with `lock` held.
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
guard persistSnapshotLocked(snapshot) else { return false }
pendingRemovalMessageIDs.removeAll()
return true
}
/// Must be called with `lock` held.
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
Self.removing(pendingRemovalMessageIDs, from: snapshot)
}
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
guard !messageIDs.isEmpty else { return snapshot }
var filtered: Snapshot = [:]
for (peerID, queue) in snapshot {
let remaining = queue.filter { !messageIDs.contains($0.messageID) }
if !remaining.isEmpty { filtered[peerID] = remaining }
}
return filtered
}
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) })
return removing(knownIDs, from: durable)
}
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
var merged = durable
for (peerID, pendingQueue) in pending {
var queue = merged[peerID] ?? []
for var candidate in pendingQueue {
if let index = queue.firstIndex(where: { $0.messageID == candidate.messageID }) {
candidate.sendAttempts = max(candidate.sendAttempts, queue[index].sendAttempts)
candidate.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys)
queue[index] = candidate
} else {
queue.append(candidate)
}
}
queue.sort { $0.timestamp < $1.timestamp }
if !queue.isEmpty { merged[peerID] = queue }
}
return merged.filter { !$0.value.isEmpty }
}
private func notifyRecovered(_ snapshot: Snapshot, generation: UInt64) {
lock.lock()
guard lifecycleGeneration == generation else {
lock.unlock()
return
}
let handler = recoveryHandler
if handler == nil {
unreportedRecoveredSnapshot = RecoveredSnapshot(
snapshot: snapshot,
generation: generation,
unseenDurable: unseenRecoveredSnapshot
)
}
lock.unlock()
guard let handler else { return }
Task { @MainActor [weak self] in
guard let latest = self?.claimPendingRecovery(generation: generation) else { return }
handler(latest)
}
}
private func claimPendingRecovery(generation: UInt64) -> Snapshot? {
lock.lock()
defer { lock.unlock() }
guard lifecycleGeneration == generation, recoveryDeliveryPending else { return nil }
let latest = cachedSnapshot
recoveryDeliveryPending = false
unseenRecoveryPendingPersistence = false
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = [:]
unreportedRecoveredSnapshot = nil
return latest
}
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
#if os(iOS)
do {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
} catch {
SecureLogger.warning("Failed to migrate outbox file protection: \(error)", category: .session)
}
#endif
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
@@ -58,12 +58,6 @@ final class StoreAndForwardMetrics {
SecureLogger.debug("📊 S&F \(event.rawValue)\(total)", category: .session)
}
func snapshot() -> [String: Int] {
lock.lock()
defer { lock.unlock() }
return counts
}
/// Included in the panic wipe alongside the stores it describes.
func reset() {
lock.lock()
@@ -34,23 +34,7 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService()
/// Default keychain for the `shared` singleton. Under test this is an
/// in-memory keychain so touching `shared` never blocks on securityd
/// (`SecItemCopyMatching` can hang in test environments) and never reads
/// or writes the developer's real keychain. Production behavior is
/// unchanged. Tests that need their own instance keep injecting a mock
/// via `init(keychain:)`.
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
// PreviewKeychainManager lives in _PreviewHelpers, a development
// asset excluded from archive builds release code must not
// reference it. Tests always run Debug, so the guard is lossless.
#if DEBUG
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
#endif
return KeychainManager()
}
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
self.keychain = keychain
loadFavorites()
@@ -0,0 +1,35 @@
//
// BoundedIDSet.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
/// evicted when full. Shared by the gateway and bridge loop-prevention
/// caches.
struct BoundedIDSet {
private var members: Set<String> = []
private var order: [String] = []
let capacity: Int
init(capacity: Int) {
self.capacity = capacity
}
func contains(_ id: String) -> Bool {
members.contains(id)
}
/// Returns false when the ID was already present.
@discardableResult
mutating func insert(_ id: String) -> Bool {
guard members.insert(id).inserted else { return false }
order.append(id)
if order.count > capacity {
members.remove(order.removeFirst())
}
return true
}
}
@@ -0,0 +1,565 @@
//
// BridgeCourierService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Foundation
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Courier delivery over the internet bridge: sealed courier envelopes are
/// parked on relays as kind-1401 "drops" tagged with their rotating
/// recipient tag, so delivery stops requiring a physical courier to bump
/// into the recipient.
///
/// Three duties, all gated on the bridge toggle:
/// - Sender: when the message router seals mail for an unreachable peer, a
/// copy is published as a drop (queued until relays connect). The drop is
/// signed with a fresh throwaway key per publish the envelope
/// authenticates its sender internally via Noise-X, and a stable publisher
/// key would leak courier traffic patterns to relays.
/// - Recipient: subscribes for its own candidate tags (adjacent UTC days)
/// and opens matching drops directly.
/// - Gateway (bridge + gateway toggles): additionally watches the tags of
/// verified local mesh peers and hands matching drops to them as directed
/// courier packets, so mesh-only recipients are served too.
///
/// Privacy: a drop reveals to relays only that "someone" is messaging "some
/// 16-byte day-rotating tag". Only parties who already know the recipient's
/// Noise static key can compute the tag; the payload is an opaque Noise-X
/// seal. Duplicate deliveries (drop + physical courier + direct link) are
/// absorbed downstream by message-ID dedup.
@MainActor
final class BridgeCourierService: ObservableObject {
enum Limits {
/// Drops waiting for relay connectivity (bounded, drop-oldest).
static let maxPendingDrops = 20
/// Republish cooldown for gateway-held envelopes.
static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60
/// Local peers a gateway watches drops for (x3 candidate tags each).
static let maxWatchedPeers = 16
/// Tag-set refresh cadence (also covers UTC day rollover).
static let refreshIntervalSeconds: TimeInterval = 30 * 60
/// Minimum spacing for announce-driven refreshes.
static let announceRefreshDebounceSeconds: TimeInterval = 60
/// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack).
static let maxDropEnvelopeBytes = 20 * 1024
static let maxTrackedIDs = 512
/// Coalescing window for dedup-record writes: a backlog re-fetch
/// mutates the seen set once per event, and each snapshot save is a
/// full JSON encode + atomic write on the main actor.
static let dedupPersistCoalesceSeconds: TimeInterval = 1.0
}
static let shared = BridgeCourierService()
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
var bridgeEnabled: (@MainActor () -> Bool)?
var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event directly to connected default (DM)
/// relays. Completion is true only after at least one relay explicitly
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
/// or merely "written to a socket".
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
/// (Re)opens the drop subscription for the given hex tags.
var openSubscription: (@MainActor ([String]) -> Void)?
var closeSubscription: (@MainActor () -> Void)?
/// Our own Noise static public key.
var myNoiseKey: (@MainActor () -> Data?)?
/// Verified reachable local peers with known Noise keys.
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
/// Seals content into a carry-only envelope for a recipient key.
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
/// Opens a drop addressed to us. True means the inner envelope was
/// delivered, deduplicated, or intentionally rejected after decryption;
/// false leaves the relay event retryable after a transient crypto/key
/// failure.
var openEnvelope: (@MainActor (CourierEnvelope) -> Bool)?
/// Hands a drop to a matching local peer as a directed courier packet.
/// Returns true only when the transport accepted the packet onto a live
/// physical link or its link-specific backpressure queue. Stale
/// reachability and process-local directed spooling return false so the
/// drop event stays retryable.
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Bool)?
/// Held envelopes eligible for (re)publish, honoring the cooldown.
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
/// Commits a held envelope's cooldown after confirmed relay acceptance.
var markHeldEnvelopePublished: (@MainActor (CourierEnvelope) -> Void)?
/// Timer injection for tests; nil arms a real `Task`.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
// MARK: State
private(set) var myTagsHex: Set<String> = []
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
private(set) var pendingDrops: [(
envelope: CourierEnvelope,
dedupKey: String?,
operationID: UUID?
)] = []
/// Message IDs already published as drops (sender-side dedup) and drop
/// event IDs already handled (multi-relay dedup). Both persist across
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
/// persisted outbox keeps re-depositing, so in-memory-only dedup meant
/// every relaunch republished the same message as a fresh drop and every
/// gateway relaunch re-delivered the whole backlog (field-verified
/// amplification storm). Entries age out with the 24h drop window.
private var publishedDropKeys: ExpiringIDSet
private var seenDropEventIDs: ExpiringIDSet
private var subscriptionOpen = false
private var lastSubscribedTags: Set<String> = []
private var refreshTimerArmed = false
private var announceRefreshTimerArmed = false
private var lastAnnounceRefresh = Date.distantPast
private struct ActiveDropOperation {
let id: UUID
let completion: @MainActor (Bool) -> Void
}
/// Sender operations queued locally or awaiting relay confirmation.
/// The per-attempt ID prevents a stale pre-wipe callback from completing
/// a newer attempt for the same message.
private var activeDropOperations: [String: ActiveDropOperation] = [:]
/// Held-envelope publishes have no sender message ID, but still need an
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
/// must not mint duplicate relay events for the same opaque envelope.
private var heldDropOperations: [Data: UUID] = [:]
/// Deterministically invalid envelopes are suppressed for this process,
/// but never persisted as if a relay accepted them. Bound and age them so
/// rotating oversize IDs cannot grow process memory forever.
private var rejectedDropKeys = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds
)
private let now: () -> Date
private let dedupStore: BridgeDropDedupStore
private var dedupPersistScheduled = false
init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) {
self.now = now
self.dedupStore = dedupStore ?? BridgeDropDedupStore(persistsToDisk: !TestEnvironment.isRunningTests)
let snapshot = self.dedupStore.load()
let date = now()
self.publishedDropKeys = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds,
entries: snapshot.publishedDropKeys,
now: date
)
self.seenDropEventIDs = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds,
entries: snapshot.seenDropEventIDs,
now: date
)
// A coalesced dedup write scheduled just before a background kill
// would be lost; flush when the app backgrounds or terminates.
#if os(iOS)
let flushNotifications = [UIApplication.didEnterBackgroundNotification, UIApplication.willTerminateNotification]
#else
let flushNotifications = [NSApplication.willTerminateNotification]
#endif
for name in flushNotifications {
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.flushDedupSnapshot() }
}
}
}
/// Schedules a coalesced write of the dedup record (see
/// `Limits.dedupPersistCoalesceSeconds`); lifecycle notifications flush
/// any scheduled write before a background kill could drop it.
private func persistDedup() {
guard !dedupPersistScheduled else { return }
dedupPersistScheduled = true
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Limits.dedupPersistCoalesceSeconds * 1_000_000_000))
guard let self else { return }
self.dedupPersistScheduled = false
self.flushDedupSnapshot()
}
}
/// Writes the dedup record now. `publishedDropKeys` contains only drops
/// a relay explicitly accepted; queued and in-flight keys
/// live in `activeDropOperations` and are intentionally process-local.
func flushDedupSnapshot() {
dedupStore.save(BridgeDropDedupStore.Snapshot(
publishedDropKeys: publishedDropKeys.entries,
seenDropEventIDs: seenDropEventIDs.entries
))
}
/// Panic wipe: forget queued drops and the persisted dedup record.
func wipe() {
cancelActivePublishes()
rejectedDropKeys = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds
)
publishedDropKeys = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
seenDropEventIDs = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
dedupStore.wipe()
}
// MARK: - Sender role
/// Parallel-deposit a sealed copy of an outbound private message as a
/// relay drop. Called by the message router alongside physical courier
/// deposits; idempotent per message ID. Completion becomes true only
/// after a real relay acceptance arrives, which is when the router may
/// show "carried".
func depositDrop(
content: String,
messageID: String,
recipientNoiseKey: Data,
completion: @escaping @MainActor (Bool) -> Void = { _ in }
) {
guard bridgeEnabled?() ?? false else {
completion(false)
return
}
guard !publishedDropKeys.contains(messageID, now: now()),
activeDropOperations[messageID] == nil,
!rejectedDropKeys.contains(messageID, now: now()) else {
completion(false)
return
}
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else {
completion(false)
return
}
// An envelope that can't encode within the drop size caps fails the
// same way on every attempt (size is a function of the content, not
// of the sealing); suppress it in-memory so the retry sweep does not
// churn, but never persist it as a published drop.
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
rejectedDropKeys.insert(messageID, now: now())
completion(false)
return
}
let operationID = UUID()
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
publishDrop(envelope, messageID: messageID, operationID: operationID)
}
/// Publishes held envelopes (mail we carry for others) as drops,
/// honoring the per-envelope cooldown.
func publishHeldEnvelopes() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
let key = envelope.ciphertext
guard heldDropOperations[key] == nil else { continue }
let operationID = UUID()
heldDropOperations[key] = operationID
publishDrop(envelope) { [weak self] succeeded in
guard let self, self.heldDropOperations[key] == operationID else { return }
self.heldDropOperations.removeValue(forKey: key)
if succeeded {
self.markHeldEnvelopePublished?(envelope)
}
}
}
}
/// Publishes a drop, or queues it when relays are down. `messageID` is the
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
/// it rides the pending queue so an evicted or failed drop can release its
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
private func publishDrop(
_ envelope: CourierEnvelope,
messageID: String? = nil,
operationID: UUID? = nil,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) {
guard let encoded = envelope.encode(),
encoded.count <= Limits.maxDropEnvelopeBytes,
!envelope.isExpired else {
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
guard relaysConnected?() ?? false else {
// Held mail remains in CourierStore and has no sender operation to
// recover after an in-memory queue loss. Leave its cooldown unset
// and let the next connected refresh offer it again.
guard messageID != nil else {
untrackedCompletion?(false)
return
}
pendingDrops.append((envelope, messageID, operationID))
while pendingDrops.count > Limits.maxPendingDrops {
let evicted = pendingDrops.removeFirst()
finishPublish(
messageID: evicted.dedupKey,
operationID: evicted.operationID,
succeeded: false
)
}
return
}
guard let identity = try? NostrIdentity.generate(),
let event = try? NostrProtocol.createCourierDropEvent(
envelope: encoded,
recipientTagHex: envelope.recipientTag.hexEncodedString(),
expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000),
senderIdentity: identity
) else {
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
guard let publishEvent else {
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
publishEvent(event) { [weak self] succeeded in
guard let self else { return }
guard self.finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: succeeded,
untrackedCompletion: untrackedCompletion
) else { return }
if succeeded {
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
} else {
SecureLogger.warning("📦🌉 No relay accepted courier drop", category: .session)
}
}
}
@discardableResult
private func finishPublish(
messageID: String?,
operationID: UUID?,
succeeded: Bool,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) -> Bool {
guard let messageID else {
untrackedCompletion?(succeeded)
return true
}
// Missing/mismatched means this callback was duplicated, invalidated
// by panic wipe, or belongs to an older attempt for the same key.
guard let operationID,
let operation = activeDropOperations[messageID],
operation.id == operationID else { return false }
activeDropOperations.removeValue(forKey: messageID)
if succeeded {
publishedDropKeys.insert(messageID, now: now())
persistDedup()
}
operation.completion(succeeded)
return true
}
/// Drops queued while relays were unreachable publish on reconnect.
func flushPendingDrops() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
let queued = pendingDrops
pendingDrops.removeAll()
for item in queued {
publishDrop(
item.envelope,
messageID: item.dedupKey,
operationID: item.operationID
)
}
}
// MARK: - Subscription (recipient + gateway watch)
/// Recomputes the watched tag set and (re)opens the subscription.
/// Call on toggle changes, relay connectivity changes, and periodically
/// (tags rotate daily); idempotent.
func refresh() {
armRefreshTimerIfNeeded()
guard bridgeEnabled?() ?? false else {
cancelActivePublishes()
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
}
return
}
guard relaysConnected?() ?? false else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
}
return
}
let date = now()
if let myKey = myNoiseKey?() {
myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() })
} else {
myTagsHex = []
}
// While bridging with internet, every device watches drops for its
// verified local peers the single-switch analogue of gateway duty.
let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers)
watchedPeerTags = peers.map { peer in
(peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() }))
}
let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex))
guard !allTags.isEmpty else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
lastSubscribedTags = []
}
return
}
// Resubscribe only when the watched set actually changed refresh
// fires on every verified announce (field logs showed the drop
// subscription rebuilt every ~60s for an unchanged tag set).
if !subscriptionOpen || allTags != lastSubscribedTags {
openSubscription?(allTags.sorted())
subscriptionOpen = true
lastSubscribedTags = allTags
}
flushPendingDrops()
publishHeldEnvelopes()
}
/// Announce-driven refresh, debounced a newly verified peer should be
/// watched promptly, but announce storms must not thrash subscriptions.
/// Calls inside the window coalesce into one trailing refresh so peers
/// learned after the leading edge are not omitted until the 30-minute
/// periodic timer.
func refreshAfterVerifiedAnnounce() {
guard bridgeEnabled?() ?? false else { return }
let date = now()
let elapsed = date.timeIntervalSince(lastAnnounceRefresh)
if elapsed >= Limits.announceRefreshDebounceSeconds {
lastAnnounceRefresh = date
refresh()
return
}
guard !announceRefreshTimerArmed else { return }
announceRefreshTimerArmed = true
let delay = max(0, Limits.announceRefreshDebounceSeconds - elapsed)
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.announceRefreshTimerArmed = false
guard self.bridgeEnabled?() ?? false else { return }
self.lastAnnounceRefresh = self.now()
self.refresh()
}
if let scheduleTimer {
scheduleTimer(delay, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
fire()
}
}
}
private func armRefreshTimerIfNeeded() {
guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return }
refreshTimerArmed = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.refreshTimerArmed = false
self.refresh()
}
if let scheduleTimer {
scheduleTimer(Limits.refreshIntervalSeconds, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000))
fire()
}
}
}
// MARK: - Inbound drops
/// Entry point for every drop event the subscription delivers (the relay
/// manager has already verified the event signature).
func handleDropEvent(_ event: NostrEvent) {
guard bridgeEnabled?() ?? false else { return }
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
// A resubscribe can still deliver an event from the old watch set.
// Do not durably consume it until it actually belongs to us/current
// local peer and is opened or accepted for physical delivery.
guard !seenDropEventIDs.contains(event.id, now: now()) else { return }
guard let data = Data(base64Encoded: event.content),
data.count <= Limits.maxDropEnvelopeBytes,
let envelope = CourierEnvelope.decode(data),
!envelope.isExpired else {
return
}
let tagHex = envelope.recipientTag.hexEncodedString()
// The envelope's own tag must match the event's filterable tag
// otherwise a mislabeled drop could ride a subscription it doesn't
// belong to.
guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return }
if myTagsHex.contains(tagHex) {
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
if openEnvelope?(envelope) == true {
seenDropEventIDs.insert(event.id, now: now())
persistDedup()
}
return
}
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))", category: .session)
if deliverToPeer?(envelope, match.peerID) == true {
seenDropEventIDs.insert(event.id, now: now())
persistDedup()
}
}
}
// MARK: - Helpers
/// Cancels queued and in-flight sender operations after bridge disable or
/// panic wipe. Invalidate first, then resolve false so callback re-entry
/// cannot be mistaken for an active operation; late relay callbacks no-op.
private func cancelActivePublishes() {
let invalidated = activeDropOperations.values.map(\.completion)
pendingDrops.removeAll()
activeDropOperations.removeAll()
// Untracked held publishes are invalidated too. Their late relay
// callbacks compare operation IDs and no-op after this reset.
heldDropOperations.removeAll()
invalidated.forEach { $0(false) }
}
/// A fresh random Nostr identity for signing one drop. Delegates to the
/// canonical generator (Schnorr key that can't fail validity) instead of
/// hand-rolling SecRandom + retry.
static func makeThrowawayIdentity() -> NostrIdentity? {
try? NostrIdentity.generate()
}
}

Some files were not shown because too many files have changed in this diff Show More