Compare commits

...
16 Commits
Author SHA1 Message Date
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
215 changed files with 17464 additions and 4938 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
+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:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
+14
View File
@@ -0,0 +1,14 @@
# 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
relative_results: true
baseline: .periphery.baseline.json
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.6.0
MARKETING_VERSION = 1.7.0
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
-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.
+11 -6
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)
-10
View File
@@ -796,16 +796,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,
+89
View File
@@ -0,0 +1,89 @@
//
// 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
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
init(locationManager: LocationChannelManager = .shared) {
self.locationManager = locationManager
}
/// 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() }
// 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
settingCancellable = nil
managerCancellable = nil
manager?.cancel()
manager = nil
noteCount = 0
}
private func retarget() {
guard activeHolders > 0,
LocationNotesSettings.enabled,
locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash
else {
managerCancellable = nil
manager?.cancel()
manager = nil
noteCount = 0
return
}
if let manager {
manager.setGeohash(geohash)
return
}
let fresh = LocationNotesManager(geohash: 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) {
+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
}
}
+144
View File
@@ -0,0 +1,144 @@
//
// PTTBurstPlayer.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
/// 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.
@MainActor
final class PTTBurstPlayer {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
private let decoder: PTTFrameDecoder
private var queuedBuffers: [AVAudioPCMBuffer] = []
private var queuedDuration: TimeInterval = 0
private var scheduledCount = 0
private var engineStarted = false
private var finished = false
private var stopped = false
private var deadlineTask: Task<Void, Never>?
private(set) var isPlaying = false
init?() {
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
self.decoder = decoder
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
deadlineTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
self?.startIfReady(force: true)
}
}
/// 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
stopIfDrained()
}
/// Immediate stop (cancel, another playback taking over, teardown).
func stop() {
guard !stopped else { return }
stopped = true
deadlineTask?.cancel()
queuedBuffers = []
if engineStarted {
node.stop()
engine.stop()
}
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
private func startIfReady(force: Bool) {
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
#if os(iOS)
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
}
#endif
engine.prepare()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
stopped = true
return
}
engineStarted = true
isPlaying = true
VoiceNotePlaybackCoordinator.shared.activate(self)
node.play()
let buffered = queuedBuffers
queuedBuffers = []
queuedDuration = 0
for buffer in buffered {
schedule(buffer)
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
scheduledCount += 1
node.scheduleBuffer(buffer) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.scheduledCount -= 1
self.stopIfDrained()
}
}
}
private func stopIfDrained() {
guard finished, scheduledCount <= 0 else { return }
stop()
}
}
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,183 @@
//
// 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
/// 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.
final class PTTCaptureEngine {
/// 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)
// 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 (main-actor
/// callers only; see `stopEngineIfStarted`).
private var engineStarted = false
/// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)?
enum CaptureError: Error {
case inputUnavailable
case audioSetupFailed
}
func start(outputURL: URL) throws {
#if os(iOS)
try Self.configureAudioSession()
#endif
// 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) }
}
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)
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).
func stop() -> (url: URL?, encodedFrames: Int) {
stopEngineIfStarted()
let result: (URL?, Int) = queue.sync {
let url = fileURL
let frames = encodedFrameCount
teardown(deleteFile: false)
return (url, frames)
}
#if os(iOS)
Self.deactivateAudioSession()
#endif
return result
}
func cancel() {
stopEngineIfStarted()
queue.sync { teardown(deleteFile: true) }
#if os(iOS)
Self.deactivateAudioSession()
#endif
}
/// 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.
private func stopEngineIfStarted() {
guard engineStarted else { return }
engineStarted = false
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
// MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer) {
guard 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
}
// MARK: - Audio session (iOS)
#if os(iOS)
private static func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
#if targetEnvironment(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)
}
private static func deactivateAudioSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
#endif
}
+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,183 @@
//
// 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 {
var isLive: Bool { false }
func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission()
}
func start() async throws {
try await VoiceRecorder.shared.startRecording()
}
func finish() async -> URL? {
await VoiceRecorder.shared.stopRecording()
}
func cancel() async {
await VoiceRecorder.shared.cancelRecording()
}
}
/// 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 = PTTCaptureEngine()
/// 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) {
self.burstID = VoiceBurstPacket.makeBurstID()
self.sendPacket = sendPacket
self.stream = StreamState(burstID: 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 capture.start(outputURL: outputURL)
} 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)
try capture.start(outputURL: outputURL)
}
startDate = Date()
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 { Date().timeIntervalSince($0) } ?? 0
let (url, encodedFrames) = capture.stop()
// stop() drained the capture queue, so touching `stream` is safe now.
guard elapsed >= 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((Double(encodedFrames) * PTTAudioFormat.frameDuration * 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 {
guard !completed else { return }
completed = true
capture.cancel()
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")
}
}
@@ -181,23 +181,35 @@ 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 weak var activeController: (any ExclusivePlayback)?
private init() {}
func activate(_ controller: VoiceNotePlaybackController) {
func activate(_ controller: any ExclusivePlayback) {
if activeController === controller {
return
}
activeController?.pause()
activeController?.pauseForExclusivity()
activeController = controller
}
func deactivate(_ controller: VoiceNotePlaybackController) {
func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller {
activeController = nil
}
@@ -5,7 +5,6 @@ import AVFoundation
actor VoiceRecorder {
enum RecorderError: Error {
case microphoneAccessDenied
case recorderInitializationFailed
case recordingInProgress
}
-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)
}
}
+8005 -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.
+87 -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,84 @@ 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` carries the original
/// mesh message ID so receivers dedup the bridged copy against the radio
/// copy by timeline ID.
static func createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
meshMessageID: String? = nil
) throws -> NostrEvent {
var tags = [["r", cell]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
tags.append(["m", meshMessageID])
}
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 +346,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 +356,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 +634,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 +763,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)
+82 -12
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
@@ -347,7 +361,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
@@ -795,7 +808,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
@@ -935,8 +948,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 }
@@ -1018,8 +1042,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 {
@@ -1075,7 +1103,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 +1127,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,6 +1140,24 @@ final class NostrRelayManager: ObservableObject {
}
}
/// 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) {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
@@ -1463,6 +1510,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
+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
@@ -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":
@@ -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,
@@ -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 {
+12 -2
View File
@@ -203,9 +203,19 @@ 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? {
@@ -25,9 +25,4 @@ final class BLELogRateLimiter {
}
}
func removeAll() {
queue.sync {
lastLogTimeByKey.removeAll()
}
}
}
@@ -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:
+15 -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,14 +152,6 @@ 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
@@ -168,7 +171,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 +189,8 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now,
capabilities: capabilities
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
return update
@@ -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
)
+423 -28
View File
@@ -10,7 +10,6 @@ import UIKit
/// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
/// - A lightweight `peerSnapshotPublisher` is provided for non-UI services.
final class BLEService: NSObject {
// MARK: - Constants
@@ -38,6 +37,10 @@ final class BLEService: NSObject {
// 1. Consolidated BLE link tracking for both central and peripheral roles.
private var linkStateStore = BLELinkStateStore()
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert.
private var lastLinkRebindAt: [String: Date] = [:]
// BCH-01-004: Rate-limiting for subscription-triggered announces.
private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter()
@@ -81,7 +84,11 @@ final class BLEService: NSObject {
// for every announce. `directedToUs` distinguishes an uplink deposit
// addressed to this device from a downlink broadcast.
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
/// Fired (off-main) when a signature-verified announce is processed
/// the bridge courier watch refreshes its tag set on new arrivals.
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)?
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
private var localBridgeGeohash: String? // collectionsQueue
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
@@ -489,13 +496,6 @@ final class BLEService: NSObject {
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
// MARK: Peer snapshots publisher (non-UI convenience)
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
peerSnapshotSubject.eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
collectionsQueue.sync {
@@ -712,6 +712,33 @@ final class BLEService: NSObject {
}
}
/// Reachable peers currently advertising the `.bridge` capability.
func reachableBridgePeers() -> [PeerID] {
let now = Date()
return collectionsQueue.sync {
peerRegistry.peers(advertising: .bridge)
.filter { peerRegistry.isReachable($0, now: now) }
}
}
/// A rendezvous cell advertised by a bridge-capable peer's announce.
func advertisedBridgeGeohash() -> String? {
collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() }
}
/// The rendezvous cell this device advertises in its own announces while
/// bridging with the gateway toggle on. Set from the main actor; the
/// value rides the next (forced) announce.
func setLocalBridgeGeohash(_ cell: String?) {
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
guard localBridgeGeohash != cell else { return false }
localBridgeGeohash = cell
return true
}
guard changed else { return }
sendAnnounce(forceSend: true)
}
func getPeerNicknames() -> [PeerID: String] {
return collectionsQueue.sync {
peerRegistry.displayNicknames(selfNickname: myNickname)
@@ -1228,7 +1255,57 @@ final class BLEService: NSObject {
return nil
}
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {
// MARK: - Archived public messages ("heard here earlier")
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
guard let sync = gossipSyncManager else {
Task { @MainActor in completion([]) }
return
}
sync.collectPublicMessagePackets { [weak self] packets in
guard let self = self else {
Task { @MainActor in completion([]) }
return
}
// Signature verification and registry lookups run on messageQueue
// like the live receive path.
self.messageQueue.async {
let decoded = packets
.compactMap { self.decodeArchivedPublicMessage($0) }
.sorted { $0.timestamp < $1.timestamp }
Task { @MainActor in completion(decoded) }
}
}
}
private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? {
guard packet.type == MessageType.message.rawValue,
let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty
else { return nil }
let senderPeerID = PeerID(hexData: packet.senderID)
let peers = collectionsQueue.sync { peerRegistry.snapshotByID }
// Archived senders are usually long gone, so the signature-derived
// identity is the best shot at a name; a live registry entry is
// next; anonymous fallback matches the live path.
let nickname = signedSenderDisplayName(for: packet, from: senderPeerID)
?? BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: senderPeerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peers,
allowConnectedUnverified: false
)
?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID)
return ArchivedPublicMessage(
packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(),
senderPeerID: senderPeerID,
senderNickname: nickname,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
)
}
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
fileTransferHandler.handle(packet, from: peerID)
}
@@ -1247,6 +1324,9 @@ final class BLEService: NSObject {
guard let self = self else { return [:] }
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
},
verifyPacketSignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
},
signedSenderDisplayName: { [weak self] packet, peerID in
self?.signedSenderDisplayName(for: packet, from: peerID)
},
@@ -1316,7 +1396,7 @@ final class BLEService: NSObject {
}
}
private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) {
private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
_ = collectionsQueue.sync(flags: .barrier) {
// Remove the peer when they leave
peerRegistry.remove(peerID)
@@ -1346,8 +1426,12 @@ final class BLEService: NSObject {
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync {
(peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities))
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
(
peerRegistry.connectedRoutingData,
PeerCapabilities.localSupported.union(runtimeCapabilities),
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
)
}
let announcement = AnnouncementPacket(
@@ -1355,7 +1439,8 @@ final class BLEService: NSObject {
noisePublicKey: noisePub,
signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs,
capabilities: advertisedCapabilities
capabilities: advertisedCapabilities,
bridgeGeohash: advertisedBridgeCell
)
guard let payload = announcement.encode() else {
@@ -1456,6 +1541,55 @@ final class BLEService: NSObject {
sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID)
}
// MARK: Live Voice (PTT)
/// Sends one live voice-burst packet inside the Noise session. Unlike
/// `sendNoisePayload` this never queues behind a handshake: live audio is
/// only useful now, so without an established session frames are dropped.
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {
messageQueue.async { [weak self] in
guard let self else { return }
guard self.noiseService.hasEstablishedSession(with: peerID) else {
SecureLogger.debug("PTT: dropping voice frame — no established session with \(peerID.id.prefix(8))", category: .session)
return
}
do {
let typedPayload = NoisePayload(type: .voiceFrame, data: burstContent).encode()
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
} catch {
SecureLogger.error("Failed to send voice frame: \(error)", category: .session)
}
}
}
/// Broadcasts one live voice-burst packet to the public mesh, signed like
/// a public message so receivers can authenticate the talker. Ephemeral:
/// never tracked for gossip sync (stale audio is worthless to replay).
func sendVoiceFrameBroadcast(_ burstContent: Data) {
guard !burstContent.isEmpty else { return }
messageQueue.async { [weak self] in
guard let self else { return }
let packet = BitchatPacket(
type: MessageType.voiceFrame.rawValue,
senderID: self.myPeerIDData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: burstContent,
signature: nil,
ttl: self.messageTTL
)
guard let signedPacket = self.noiseService.signPacket(packet) else {
SecureLogger.error("❌ Failed to sign voice frame", category: .security)
return
}
// Pre-mark our own broadcast as processed to avoid handling a
// relayed self copy.
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
self.messageDeduplicator.markProcessed(dedupID)
self.broadcastPacket(signedPacket)
}
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
// Appends to the encryption service's handler array, so this never
// displaces the callbacks installed by installNoiseSessionCallbacks.
@@ -1923,6 +2057,28 @@ extension BLEService {
recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID))
}
func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) {
bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) }
}
func _test_centralBinding(_ centralUUID: String) -> PeerID? {
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
}
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
peerID: peerID,
nickname: nickname,
isConnected: true,
noisePublicKey: nil,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: Date()
))
}
}
static func _test_shouldRediscoverBitChatService(
invalidatedServiceUUIDs: [CBUUID],
cachedServiceUUIDs: [CBUUID]?
@@ -2096,23 +2252,27 @@ extension BLEService: CBPeripheralDelegate {
}
}
private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) {
private func processNotificationPacket(_ packet: BitchatPacket, from _: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) {
let senderID = PeerID(hexData: packet.senderID)
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))", category: .session)
}
if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL {
if packet.type == MessageType.announce.rawValue,
packet.ttl == messageTTL {
// Only bind an unbound link here: this runs before signature
// verification, so a bound link must not be re-bound by a raw
// announce (spoofable). Rotation rebinds happen after the announce
// verifies (rebindLinkAfterVerifiedDirectAnnounce).
let boundPeerID = linkStateStore.peerID(forPeripheralID: peripheralUUID)
if boundPeerID == nil || boundPeerID == senderID {
linkStateStore.bindPeripheral(peripheralUUID, to: senderID)
refreshLocalTopology()
}
handleReceivedPacket(packet, from: peerID)
} else {
handleReceivedPacket(packet, from: peerID)
}
handleReceivedPacket(packet, from: peerID)
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
@@ -2474,8 +2634,13 @@ extension BLEService: CBPeripheralManagerDelegate {
if packet.type == MessageType.announce.rawValue,
packet.ttl == messageTTL {
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
refreshLocalTopology()
// Same rule as the peripheral path: raw announces only bind
// unbound links; rotation rebinds require a verified announce.
let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID)
if boundPeerID == nil || boundPeerID == claimedSenderID {
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
refreshLocalTopology()
}
}
guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
@@ -2937,6 +3102,92 @@ extension BLEService {
return true
}
// MARK: Courier over the bridge
/// Seals `content` into a courier envelope for relay parking (a bridge
/// courier drop). Same sealing rules as `sendCourierMessage` prekey
/// (v2) when a verified bundle is cached, static Noise X (v1) otherwise
/// but carry-only: a relay copy never sprays.
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? {
guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
return nil
}
do {
let now = Date()
let sealed: Data
let prekeyID: UInt32?
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
prekeyID = prekey.id
} else {
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
prekeyID = nil
}
return CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed,
copies: 1,
prekeyID: prekeyID
)
} catch {
SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption)
return nil
}
}
/// Opens a courier envelope that arrived as a bridge drop (relay fetch,
/// not a directed mesh packet). Returns false when the rotating tag does
/// not match our static key a drop for someone else, or a stale tag.
/// The inner Noise X seal authenticates the sender; there is no packet
/// signature to check on this path.
@discardableResult
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
guard !envelope.isExpired else { return false }
let myKey = noiseService.getStaticPublicKeyData()
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
return false
}
openCourierEnvelope(envelope)
return true
}
/// Hands a bridge-fetched envelope directly to the matching local peer
/// as a directed courier packet. Delivery-only by design: the recipient's
/// tag matched, so this never lands in a stranger's carry quota.
@discardableResult
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
guard isPeerConnected(peerID) || isPeerReachable(peerID) else { return false }
guard let payload = envelope.encode() else { return false }
let packet = makeCourierPacket(payload, to: peerID)
messageQueue.async { [weak self] in
self?.sendPacketDirected(packet, to: peerID)
}
return true
}
/// Our own Noise static public key (for computing our courier tags).
func myNoiseStaticPublicKey() -> Data {
noiseService.getStaticPublicKeyData()
}
/// Verified reachable peers with known Noise keys the set a bridge
/// gateway watches courier drops for.
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] {
let now = Date()
return collectionsQueue.sync {
peerRegistry.snapshotByID.values.compactMap { info in
guard info.isVerifiedNickname,
let key = info.noisePublicKey,
peerRegistry.isReachable(info.peerID, now: now) else { return nil }
return (info.peerID, key)
}
}
}
/// The prekey to seal a courier message with, or nil to fall back to
/// static sealing. The real signal is a verified, unexpired bundle with a
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
@@ -3335,7 +3586,7 @@ extension BLEService {
/// Transport-level handling for a received nostrCarrier packet; policy
/// (verification of the carried event, quotas, loop prevention) lives in
/// `GatewayService` behind `onNostrCarrierPacket`.
private func handleNostrCarrier(_ packet: BitchatPacket, from peerID: PeerID) {
private func handleNostrCarrier(_ packet: BitchatPacket, from _: PeerID) {
let senderID = PeerID(hexData: packet.senderID)
let directedToUs: Bool
if let recipientID = packet.recipientID {
@@ -3947,7 +4198,10 @@ extension BLEService {
handleFragment(packet, from: senderID)
case .fileTransfer:
handleFileTransfer(packet, from: senderID)
// Broadcast files that fail sender authentication must not spread
// to downstream (possibly older, ungated) nodes; skip the relay
// step below, like invalid board posts and voice frames.
guard handleFileTransfer(packet, from: senderID) else { return }
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
@@ -3964,6 +4218,11 @@ extension BLEService {
case .nostrCarrier:
handleNostrCarrier(packet, from: peerID)
case .voiceFrame:
// Rejected frames (unsigned/stale/spoofed) must not spread; skip
// the relay step below, like invalid board posts.
guard handleVoiceFrame(packet, from: senderID) else { return }
case .ping:
// Rate limiting must key on the ingress link (`peerID`), not the
// packet-claimed sender: pings are unsigned, so `senderID` is
@@ -4042,6 +4301,18 @@ extension BLEService {
drainPendingPrekeyBundles(for: result.peerID)
}
// A verified direct announce proves the sender owns the link it came
// in on: heal any stale binding left by a peer-ID rotation.
if let result, result.isVerified, result.isDirectAnnounce {
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
}
// Bridge courier watch: a verified announce may add a peer whose
// relay-parked drops we should start watching for.
if let result, result.isVerified {
onVerifiedPeerAnnounce?(result.peerID)
}
// Courier work: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them
// (or spray-able mail they could carry). Verified announces only.
@@ -4061,6 +4332,85 @@ extension BLEService {
}
}
/// When a peer relaunches it rotates its ephemeral peer ID, but an
/// already-open BLE connection keeps its old peripheral/centralpeerID
/// binding. Until that binding heals, the rotated peer shows up twice in
/// the peer list and its directed traffic on this link is dropped as
/// spoofed. A signature-verified direct announce proves the claimed
/// sender owns the link it arrived on, so rebind the link to the new ID
/// and retire the old identity.
private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) {
guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return }
bleQueue.async { [weak self] in
guard let self else { return }
let linkUUID: String
let previousPeerID: PeerID?
switch link {
case .peripheral(let peripheralUUID):
linkUUID = peripheralUUID
previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID)
case .central(let centralUUID):
linkUUID = centralUUID
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
}
guard let previousPeerID, previousPeerID != peerID else { return }
// The signature does not authenticate directness (TTL is excluded
// from signing because relays mutate it), so a "verified direct"
// announce can be a replay of another peer's fresh announce with
// its TTL restored. Contain what a forged rebind could do:
// never steal an identity another live link already owns, and
// allow at most one rebind per link per cooldown window so two
// identities can't fight over a link in a replay flip-flop.
guard self.linkStateStore.links(to: peerID).isEmpty else {
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security)
return
}
let now = Date()
self.lastLinkRebindAt = self.lastLinkRebindAt.filter {
now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds
}
guard self.lastLinkRebindAt[linkUUID] == nil else {
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security)
return
}
self.lastLinkRebindAt[linkUUID] = now
switch link {
case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
case .central(let centralUUID):
self.linkStateStore.bindCentral(centralUUID, to: peerID)
}
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))", category: .session)
self.refreshLocalTopology()
// Retire the rotated-away ID only once its last link is gone; a
// remaining stale link heals the same way or ages out.
guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return }
self.messageQueue.async { [weak self] in
self?.retireRotatedPeer(previousPeerID)
}
}
}
/// Rotation is an implicit leave of the old identity: drop it immediately
/// instead of letting a ghost duplicate linger for the reachability
/// retention window.
private func retireRotatedPeer(_ peerID: PeerID) {
let removed = collectionsQueue.sync(flags: .barrier) {
peerRegistry.remove(peerID) != nil
}
guard removed else { return }
gossipSyncManager?.removeAnnouncementForPeer(peerID)
refreshLocalTopology()
notifyUI { [weak self] in
guard let self else { return }
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
self.deliverTransportEvent(.peerDisconnected(peerID))
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
}
/// Builds the announce handler environment. All queue hops stay here so
/// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
@@ -4092,7 +4442,8 @@ extension BLEService {
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now,
capabilities: announcement.capabilities ?? []
capabilities: announcement.capabilities ?? [],
bridgeGeohash: announcement.bridgeGeohash
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
},
shouldEmitReconnectLog: { [weak self] peerID, now in
@@ -4296,7 +4647,7 @@ extension BLEService {
/// gossip backfill and hand the payload to the UI layer, where the group
/// coordinator decrypts and authenticates against the roster. Non-members
/// still relay (generic broadcast relay path) but never decode.
private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) {
private func handleGroupMessage(_ packet: BitchatPacket, from _: PeerID) {
let isBroadcastRecipient: Bool = {
guard let recipient = packet.recipientID else { return true }
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
@@ -4312,6 +4663,52 @@ extension BLEService {
}
}
/// Inbound public live-voice packet: broadcast-only, freshness-gated, and
/// signature-verified against the claimed sender's announce (mirrors the
/// public-message identity gate `senderID` is attacker-controlled, so a
/// valid packet signature is required before any audio reaches the UI).
/// Returns whether the packet was accepted; rejected packets must not be
/// relayed either, or spoofed 0x29 floods would still amplify.
private func handleVoiceFrame(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
guard peerID != myPeerID else { return false }
guard BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) else { return false }
guard !BLEPacketFreshnessPolicy.isStale(
timestampMilliseconds: packet.timestamp,
now: Date(),
maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds
) else { return false }
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false
let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID)
guard verifiedViaRegistry || signedDisplayName != nil else {
SecureLogger.warning("🚫 Dropping voice frame with missing/invalid signature for claimed sender \(peerID.id.prefix(8))", category: .security)
return false
}
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? signedDisplayName else {
return false
}
let payload = packet.payload
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.deliverTransportEvent(.publicVoiceFrameReceived(
peerID: peerID,
nickname: senderNickname,
payload: payload,
timestamp: timestamp
))
}
return true
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
noisePacketHandler.handleHandshake(packet, from: peerID)
}
@@ -4410,8 +4807,6 @@ extension BLEService {
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
peerRegistry.transportSnapshots(selfNickname: myNickname)
}
// Notify non-UI listeners
peerSnapshotSubject.send(transportPeers)
// Notify UI on MainActor via delegate
Task { @MainActor [weak self] in
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
+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)
}
}
+29
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,36 @@ 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")
}
return .success(message: "📍 note left here — it fades in 24h")
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
+29 -8
View File
@@ -42,6 +42,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 +61,7 @@ final class CourierStore {
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil,
lastBridgePublishAt: Date? = nil,
prekeyID: UInt32? = nil
) {
self.recipientTag = recipientTag
@@ -70,6 +73,7 @@ final class CourierStore {
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
self.lastBridgePublishAt = lastBridgePublishAt
self.prekeyID = prekeyID
}
@@ -86,6 +90,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)
}
}
@@ -242,6 +247,30 @@ final class CourierStore {
}
}
/// Envelopes to park on relays as bridge courier drops. Non-destructive
/// like remote handover the relay copy is speculative, so the carried
/// copy stays until direct handover or expiry. The per-envelope cooldown
/// keeps relay churn from republishing the same mail; publishing relays
/// dedup identical events by ID anyway.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
return queue.sync {
pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = []
for index in envelopes.indices {
if let last = envelopes[index].lastBridgePublishAt,
date.timeIntervalSince(last) < cooldown {
continue
}
envelopes[index].lastBridgePublishAt = date
// The relay copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
// MARK: - Spray-and-wait (on encountering another courier)
/// Envelopes to re-deposit with a courier we just encountered, each with
@@ -272,14 +301,6 @@ final class CourierStore {
// 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 {
@@ -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,284 @@
//
// 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
import Security
/// 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
}
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 to the default (DM) relays.
var publishEvent: (@MainActor (NostrEvent) -> 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 (tag verified inside).
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
/// Hands a drop to a matching local peer as a directed courier packet.
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)?
/// Held envelopes eligible for (re)publish, honoring the cooldown.
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
/// 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?)] = []
/// Message IDs already published as drops (sender-side dedup).
private var publishedDropKeys: BoundedIDSet
/// Drop event IDs already handled (multi-relay dedup).
private var seenDropEventIDs: BoundedIDSet
private var subscriptionOpen = false
private var lastSubscribedTags: Set<String> = []
private var refreshTimerArmed = false
private var lastAnnounceRefresh = Date.distantPast
private let now: () -> Date
init(now: @escaping () -> Date = Date.init) {
self.now = now
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs)
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs)
}
// 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. Returns true when a fresh drop
/// was sealed (published now or queued for the next relay connection)
/// the router marks the message "carried" so the sender sees progress.
@discardableResult
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
guard bridgeEnabled?() ?? false else { return false }
guard !publishedDropKeys.contains(messageID) else { return false }
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
publishedDropKeys.insert(messageID)
publishDrop(envelope)
return true
}
/// 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) ?? [] {
publishDrop(envelope)
}
}
private func publishDrop(_ envelope: CourierEnvelope) {
guard let encoded = envelope.encode(),
encoded.count <= Limits.maxDropEnvelopeBytes,
!envelope.isExpired else { return }
guard relaysConnected?() ?? false else {
pendingDrops.append((envelope, nil))
if pendingDrops.count > Limits.maxPendingDrops {
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops)
}
return
}
guard let identity = Self.makeThrowawayIdentity(),
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)
return
}
publishEvent?(event)
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
}
/// 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)
}
}
// 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, 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.
func refreshAfterVerifiedAnnounce() {
guard bridgeEnabled?() ?? false else { return }
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
lastAnnounceRefresh = now()
refresh()
}
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 }
guard seenDropEventIDs.insert(event.id) 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)
openEnvelope?(envelope)
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)
deliverToPeer?(envelope, match.peerID)
}
}
// MARK: - Helpers
/// A fresh random Nostr identity for signing one drop.
static func makeThrowawayIdentity() -> NostrIdentity? {
for _ in 0..<10 {
var bytes = Data(count: 32)
let status = bytes.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
guard status == errSecSuccess else { return nil }
if let identity = try? NostrIdentity(privateKeyData: bytes) {
return identity
}
}
return nil
}
}
@@ -0,0 +1,689 @@
//
// BridgeService.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 Combine
import Foundation
/// Policy engine for the mesh bridge: an opt-in stitcher of disjoint BLE
/// mesh islands that share a place. While the toggle is on, this device's
/// public mesh messages are additionally signed (with a derived, unlinkable
/// per-cell Nostr identity) as rendezvous events for the local geohash cell
/// and published to the cell's deterministic geo relays directly when we
/// have internet, or deposited with a bridge gateway peer over a directed
/// `toBridge` carrier when we are mesh-only. Inbound rendezvous events from
/// other islands render into the mesh timeline marked as bridged.
///
/// A device with BOTH this toggle and the gateway toggle on serves the
/// island: it accepts `toBridge` deposits, publishes them, and rebroadcasts
/// remote rendezvous events onto the mesh as `fromBridge` carriers so
/// mesh-only peers see across the bridge too.
///
/// Consent model:
/// - Nothing crosses a bridge unless its author signed it for the bridge:
/// gateways carry only finished, Schnorr-signed rendezvous events, so a
/// neighbor's gateway cannot exfiltrate radio-only traffic. The per-message
/// "nearby only" flag simply skips composing a rendezvous copy.
/// - Receiving over radio (`fromBridge` carriers) is always on it is
/// passive and leaks nothing. Subscribing over the internet (which reveals
/// the coarse cell to relays) and publishing both require the toggle.
///
/// Loop-prevention rules (adapted from `GatewayService`, unit-tested):
/// 1. An event learned from a `fromBridge` mesh broadcast is never published
/// and never rebroadcast (`meshBroadcastEventIDs`) a second bridge
/// gateway on the same island cannot echo mesh-carried traffic.
/// 2. An event this device published (own messages or uplinked deposits,
/// `publishedEventIDs`) is never downlink-rebroadcast: it originated on
/// this island, so our own relay subscription redelivering it must not
/// double BLE airtime.
/// 3. A subscription event is rebroadcast at most once
/// (`rebroadcastEventIDs`, marked after send), and never when the island
/// already holds the radio copy (`isMessageSeenLocally` on the event's
/// mesh message ID) remote islands' traffic is the only thing worth
/// airtime.
/// Receivers additionally dedup by timeline message ID: a bridged copy
/// carries the original mesh message ID in its `m` tag, so the store's
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order.
///
/// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays, radios, or CoreLocation.
@MainActor
final class BridgeService: ObservableObject {
enum Limits {
/// Uplink deposits held while relays are unreachable.
static let maxQueuedUplinks = 20
static let maxQueuedUplinksPerDepositor = 5
/// Uplink deposits accepted per depositor per minute.
static let uplinkEventsPerMinutePerDepositor = 10
/// Downlink mesh rebroadcasts per minute BLE airtime is precious,
/// and bridge traffic shares the radio with everything else.
static let downlinkEventsPerMinute = 20
static let maxPendingDownlinks = 30
/// Accepted clock skew for a rendezvous event.
static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512
/// Presence heartbeat cadence while the bridge is active.
static let presenceIntervalSeconds: TimeInterval = 4 * 60
/// A rendezvous participant counts toward "via bridge" for this long
/// after their last event.
static let participantFreshnessSeconds: TimeInterval = 10 * 60
/// Content cap, matching the public-message pipeline's own limit.
static let maxContentBytes = 16_000
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
static let cellPrecision = 6
}
struct QueuedUplink {
let depositor: PeerID
let cell: String
let event: NostrEvent
}
/// A validated rendezvous message ready for the timeline.
struct InboundBridgeMessage {
let messageID: String
let senderNickname: String
let senderPubkey: String
let content: String
let timestamp: Date
}
/// A person currently visible across the bridge (fresh, not attributed
/// to the local island), for the people sheet.
struct BridgedParticipant: Identifiable, Equatable {
let pubkey: String
let nickname: String?
let lastSeen: Date
var id: String { pubkey }
/// Geohash-chat convention: nickname#last-4-of-pubkey, so two remote
/// "anon"s stay distinguishable.
var displayName: String {
(nickname?.trimmedOrNilIfEmpty ?? "anon") + "#" + String(pubkey.suffix(4))
}
}
static let shared = BridgeService()
/// The user toggle. While true this device publishes its own public mesh
/// messages to the rendezvous and subscribes to it when online.
@Published private(set) var isEnabled: Bool
/// Distinct remote rendezvous participants seen within the freshness
/// window. Approximate by design: local participants are subtracted by
/// matching their events' mesh message IDs against the local timeline,
/// which cannot attribute silent (presence-only) local peers.
@Published private(set) var bridgedPeerCount: Int = 0
/// The people behind the count, newest activity first.
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
/// The rendezvous cell currently in use, when the bridge is active.
@Published private(set) var activeCell: String?
/// Per-session compose flag: while true, outgoing messages stay on the
/// radio no rendezvous copy is composed, so no gateway can carry them.
@Published var nearbyOnly: Bool = false
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
/// Publishes a signed event to the geo relays for a cell.
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
/// Opens the rendezvous subscription for (cell + neighbors); events are
/// fed back via `handleRendezvousEvent`.
var openSubscription: (@MainActor ([String]) -> Void)?
/// Closes the rendezvous subscription.
var closeSubscription: (@MainActor () -> Void)?
/// Whether any Nostr relay connection is currently working.
var relaysConnected: (@MainActor () -> Bool)?
/// The local neighborhood cell from CoreLocation, if permitted.
var locationCell: (@MainActor () -> String?)?
/// Asks the location layer for a fresh one-shot fix. The bridge must
/// pump location itself: channel data otherwise only flows while some
/// other feature (channels sheet, location notes) happens to be active
/// a field failure mode where the bridge silently never got a cell.
var requestLocationFix: (@MainActor () -> Void)?
/// A rendezvous cell advertised by a reachable mesh bridge peer's
/// announce lets a mesh-only, location-less device still compose
/// correctly tagged events.
var meshAdvertisedCell: (@MainActor () -> String?)?
/// Sends an encoded `toBridge` carrier directed to a bridge peer.
var sendToBridgePeer: (@MainActor (Data, PeerID) -> Bool)?
/// Reachable mesh peers advertising the `.bridge` capability.
var availableBridgePeers: (@MainActor () -> [PeerID])?
/// Broadcasts an encoded `fromBridge` carrier on the mesh.
var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Delivers a validated inbound bridge message to the mesh timeline.
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
/// True when the mesh timeline already holds this message ID (the radio
/// copy) used to skip pointless downlink airtime.
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
/// Derives the unlinkable per-cell rendezvous identity.
var deriveIdentity: (@MainActor (String) throws -> NostrIdentity)?
/// Local nickname for the `n` tag.
var myNickname: (@MainActor () -> String)?
/// Fired on toggle changes (advertise/withdraw `.bridge` + re-announce).
var onEnabledChanged: (@MainActor (Bool) -> Void)?
/// Fired when the active rendezvous cell changes (including to nil) so
/// the announce advertisement stays current.
var onActiveCellChanged: (@MainActor (String?) -> Void)?
/// Schedules a closure after a delay; nil arms a real `Task`. Injected so
/// timers are deterministic in tests.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
// MARK: State
/// Loop rule 1: event IDs seen in `fromBridge` mesh broadcasts.
private var meshBroadcastEventIDs: BoundedIDSet
/// Loop rule 2: event IDs this device published (own or deposited).
private var publishedEventIDs: BoundedIDSet
/// Loop rule 3: event IDs this device already rebroadcast.
private var rebroadcastEventIDs: BoundedIDSet
/// Timeline message IDs already injected (either arrival path).
private var injectedMessageIDs: BoundedIDSet
/// Cells the rendezvous subscription covers (own + neighbor ring).
private(set) var subscribedCells: Set<String> = []
private(set) var queuedUplinks: [QueuedUplink] = []
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
private var downlinkSendTimes: [Date] = []
private var pendingDownlinks: [(event: NostrEvent, cell: String)] = []
private var downlinkDrainScheduled = false
private var presenceTimerArmed = false
private var lastPresenceAt = Date.distantPast
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname).
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
private let defaults: UserDefaults
private let now: () -> Date
private static let enabledKey = "bridge.userEnabled"
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults
self.now = now
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
}
// MARK: - Toggle & lifecycle
func setEnabled(_ enabled: Bool) {
guard enabled != isEnabled else { return }
isEnabled = enabled
defaults.set(enabled, forKey: Self.enabledKey)
if !enabled {
queuedUplinks.removeAll()
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
participants.removeAll()
bridgedPeerCount = 0
bridgedParticipants = []
}
SecureLogger.info("🌉 Bridge mode \(enabled ? "enabled" : "disabled")", category: .session)
refreshRendezvous()
onEnabledChanged?(enabled)
}
/// Recomputes the active cell and (re)opens or closes the subscription.
/// Call on toggle changes, location updates, and relay connectivity
/// changes; idempotent.
func refreshRendezvous() {
let cell = isEnabled ? currentCell() : nil
// No cell yet: ask for a fix the availableChannels change re-enters
// here once it lands.
if isEnabled, cell == nil {
requestLocationFix?()
}
guard cell != activeCell else {
// The maintenance timer must run even cell-less: it is what
// retries the location fix (launch races the permission
// callback, so the first request can silently no-op).
if isEnabled { armPresenceTimerIfNeeded() }
return
}
if activeCell != nil {
closeSubscription?()
subscribedCells = []
}
activeCell = cell
onActiveCellChanged?(cell)
guard let cell else {
if isEnabled { armPresenceTimerIfNeeded() }
return
}
// Own cell + neighbors: islands straddling a cell edge still meet.
// Publishes go to the own cell only; symmetric because both sides
// subscribe to each other's cell via the neighbor ring.
let cells = [cell] + Geohash.neighbors(of: cell)
subscribedCells = Set(cells)
openSubscription?(cells)
SecureLogger.info("🌉 Bridge: rendezvous open for cell \(cell)", category: .session)
publishPresence()
armPresenceTimerIfNeeded()
}
/// The rendezvous cell: our own location when we have it, else the cell
/// a reachable bridge gateway advertises in its announce.
private func currentCell() -> String? {
if let own = locationCell?(), !own.isEmpty {
return String(own.prefix(Limits.cellPrecision))
}
if let advertised = meshAdvertisedCell?(), GatewayService.isValidGeohash(advertised) {
return String(advertised.prefix(Limits.cellPrecision))
}
return nil
}
// One switch does the right thing: while bridging, a device with
// internet automatically serves its island (accepts deposits, carries
// remote messages onto the radio). The marginal cost over bridging
// yourself is small the relay connections and subscription already
// exist for you and a separate "serve others" lever proved to be a
// silent trap for mesh-only neighbors.
// MARK: - Outgoing (sender role)
/// Composes and ships the bridged copy of an outgoing public mesh
/// message. Call after the radio send; no-op when the bridge is off,
/// no cell is known, or the message was flagged nearby-only upstream.
func bridgeOutgoing(content: String, messageID: String) {
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
guard content.utf8.count <= Limits.maxContentBytes else { return }
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgeMeshEvent(
content: content,
cell: cell,
senderIdentity: identity,
nickname: myNickname?(),
meshMessageID: messageID
) else {
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
return
}
publishedEventIDs.insert(event.id)
injectedMessageIDs.insert(messageID) // our own timeline already has it
if relaysConnected?() ?? false {
publishToRelays?(event, cell)
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
let payload = carrier.encode(),
let gateway = availableBridgePeers?().first {
if sendToBridgePeer?(payload, gateway) ?? false {
SecureLogger.debug("🌉 Bridge: uplinked own event via gateway \(gateway.id.prefix(8))", category: .session)
}
}
}
/// Publishes a presence heartbeat so silent participants still register
/// across the bridge. Throttled: several triggers (enable, cell change,
/// relay reconnect) can coincide, and same-second heartbeats are
/// byte-identical events anyway.
func publishPresence() {
guard isEnabled, let cell = activeCell, relaysConnected?() ?? false else { return }
guard now().timeIntervalSince(lastPresenceAt) >= 30 else { return }
lastPresenceAt = now()
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: identity) else { return }
publishedEventIDs.insert(event.id)
publishToRelays?(event, cell)
}
/// Maintenance heartbeat while bridging: presence, participant pruning,
/// and a location retry. Runs with or without a cell the cell-less
/// case is exactly when the location retry matters.
private func armPresenceTimerIfNeeded() {
guard isEnabled, !presenceTimerArmed else { return }
presenceTimerArmed = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.presenceTimerArmed = false
self.publishPresence()
self.pruneParticipants()
// Location refresh: migrates cells on a moving device and
// recovers a launch that raced the permission callback.
if self.activeCell == nil {
self.refreshRendezvous()
} else {
self.requestLocationFix?()
}
self.armPresenceTimerIfNeeded()
}
if let scheduleTimer {
scheduleTimer(Limits.presenceIntervalSeconds, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Limits.presenceIntervalSeconds * 1_000_000_000))
fire()
}
}
}
// MARK: - Subscription ingress (internet role)
/// Entry point for every event the rendezvous subscription delivers.
/// Handles presence accounting, timeline injection, and when acting as
/// the island's gateway downlink rebroadcast.
func handleRendezvousEvent(_ event: NostrEvent) {
guard isEnabled else { return }
// The subscription spans our cell + neighbors; trust only the
// event's own signed `r` tag, and only within that ring.
guard let cell = event.tags.first(where: { $0.count >= 2 && $0[0] == "r" })?[1],
subscribedCells.contains(cell) else {
return
}
guard let kind = classify(event, cell: cell) else { return }
// Events we published come back from our own subscription; they are
// presence-neutral (we never count ourselves) and never re-injected
// or rebroadcast. Two layers: the published-ID cache (this session)
// and pubkey self-recognition the rendezvous identity is derived
// deterministically, so even after a relaunch wipes the cache our
// own relay-backfilled events are recognized (field bug: own
// pre-restart messages re-rendered as bridged).
guard !publishedEventIDs.contains(event.id) else { return }
if isOwnRendezvousEvent(event, cell: cell) {
publishedEventIDs.insert(event.id) // never downlink it either
return
}
guard event.isValidSignature() else { return }
switch kind {
case .presence:
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
case .message(let message):
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
if isLocalRadioCopy {
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
}
recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname)
inject(message)
// Serving duty: carry remote islands' messages onto the radio for
// mesh-only peers. Local-origin events are skipped the island
// already heard them (loop rule 3). The drain is jitter-delayed:
// with every online bridger serving, the holdoff lets gateways
// hear each other's broadcasts and skip duplicates.
if !isLocalRadioCopy,
!meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id),
!pendingDownlinks.contains(where: { $0.event.id == event.id }) {
pendingDownlinks.append((event, cell))
if pendingDownlinks.count > Limits.maxPendingDownlinks {
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
}
scheduleDownlinkDrainIfNeeded(jitter: true)
}
}
}
// MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets with bridge
/// directions. `directedToUs` is true for `toBridge` deposits addressed
/// to this device; false for `fromBridge` broadcasts.
func handleMeshCarrier(_ carrier: NostrCarrierPacket, from peerID: PeerID, directedToUs: Bool) {
switch carrier.direction {
case .toBridge:
guard directedToUs else { return }
handleUplinkDeposit(carrier, from: peerID)
case .fromBridge:
guard !directedToUs else { return }
handleDownlinkBroadcast(carrier)
case .toGateway, .fromGateway:
return // GatewayService territory; routed there by the caller.
}
}
// MARK: - Uplink (gateway role: mesh peer -> internet)
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
guard isEnabled else { return }
// Cheap structural gates before any crypto, mirroring GatewayService.
guard let event = structurallyValidEvent(from: carrier) else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
return
}
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
return
}
guard allowUplinkDeposit(from: depositor) else {
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))", category: .session)
return
}
guard event.isValidSignature() else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
return
}
if relaysConnected?() ?? false {
publish(event, cell: carrier.geohash)
} else {
enqueueUplink(QueuedUplink(depositor: depositor, cell: carrier.geohash, event: event))
}
// No local injection: the depositor's radio broadcast already carried
// the message to this island, including us.
}
/// Publish everything queued while relays were unreachable.
func flushQueuedUplinks() {
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
let queued = queuedUplinks
queuedUplinks.removeAll()
for item in queued where !publishedEventIDs.contains(item.event.id) {
publish(item.event, cell: item.cell)
}
}
private func publish(_ event: NostrEvent, cell: String) {
publishedEventIDs.insert(event.id)
publishToRelays?(event, cell)
SecureLogger.info("🌉 Bridge: published carried event \(event.id.prefix(8))… for cell \(cell)", category: .session)
}
@discardableResult
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { return false }
if queuedUplinks.count >= Limits.maxQueuedUplinks {
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
}
queuedUplinks.append(item)
return true
}
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
let cutoff = now().addingTimeInterval(-60)
var times = uplinkDepositTimes[depositor, default: []]
times.removeAll { $0 < cutoff }
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
uplinkDepositTimes[depositor] = times
return false
}
times.append(now())
uplinkDepositTimes[depositor] = times
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
uplinkDepositTimes = uplinkDepositTimes.filter { $0.value.contains { $0 >= cutoff } }
}
return true
}
// MARK: - Downlink (gateway role: internet -> mesh)
private func drainPendingDownlinks() {
let cutoff = now().addingTimeInterval(-60)
downlinkSendTimes.removeAll { $0 < cutoff }
while !pendingDownlinks.isEmpty,
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
let (event, cell) = pendingDownlinks.removeFirst()
guard isFresh(event) else { continue }
// Suppression recheck at send time: another gateway may have
// broadcast this event during our jitter holdoff.
guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id) else { continue }
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
let payload = carrier.encode() else { continue }
broadcastToMesh?(payload)
SecureLogger.debug("🌉 Bridge: downlinked remote event \(event.id.prefix(8))… onto the mesh", category: .session)
// Mark-after-send (loop rule 3): a queue-overflow drop stays
// retryable on relay redelivery.
rebroadcastEventIDs.insert(event.id)
downlinkSendTimes.append(now())
}
scheduleDownlinkDrainIfNeeded()
}
private func scheduleDownlinkDrainIfNeeded(jitter: Bool = false) {
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
let delay: TimeInterval
if jitter {
// Multi-gateway suppression window: enough spread for another
// gateway's broadcast to land and mark the event mesh-carried.
delay = Double.random(in: 0.2...1.5)
} else {
let oldest = downlinkSendTimes.min() ?? now()
delay = max(0.05, 60 - now().timeIntervalSince(oldest))
}
downlinkDrainScheduled = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.downlinkDrainScheduled = false
self.drainPendingDownlinks()
}
if let scheduleTimer {
scheduleTimer(delay, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
fire()
}
}
}
// MARK: - Downlink (receiver role: carried event arrives over radio)
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
// Reception is deliberately NOT gated on the toggle: it is passive
// radio, and two phones side by side should not disagree about what
// the channel said. Publishing/subscribing remain opt-in.
guard let event = structurallyValidEvent(from: carrier),
!publishedEventIDs.contains(event.id),
!isOwnRendezvousEvent(event, cell: carrier.geohash),
event.isValidSignature() else {
return
}
// Mark after verification (a forged copy must not poison the cache),
// and use the marking as multi-path dedup.
guard meshBroadcastEventIDs.insert(event.id) else { return }
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
return
}
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
inject(message)
}
// MARK: - Injection & participants
private func inject(_ message: InboundBridgeMessage) {
guard injectedMessageIDs.insert(message.messageID) else { return }
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
injectInbound?(message)
}
private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
let previous = participants[pubkey]
// Local attribution is sticky: one radio-confirmed message marks the
// pubkey as an islander for as long as they stay fresh. Presence
// events carry no nickname, so a known name is never forgotten.
participants[pubkey] = (
lastSeen: now(),
isLocal: (previous?.isLocal ?? false) || isLocal,
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
)
recomputeBridgedCount()
}
private func pruneParticipants() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
participants = participants.filter { $0.value.lastSeen >= cutoff }
recomputeBridgedCount()
}
private func recomputeBridgedCount() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
let visible = participants
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
.sorted { $0.lastSeen > $1.lastSeen }
if visible.count != bridgedPeerCount {
bridgedPeerCount = visible.count
}
if visible != bridgedParticipants {
bridgedParticipants = visible
}
}
// MARK: - Validation
private enum RendezvousKind {
case message(InboundBridgeMessage)
case presence
}
/// Classifies a structurally acceptable rendezvous event; nil rejects.
private func classify(_ event: NostrEvent, cell: String) -> RendezvousKind? {
guard isFresh(event),
event.tags.contains(where: { $0.count >= 2 && $0[0] == "r" && $0[1] == cell }),
GatewayService.isValidGeohash(cell) else {
return nil
}
switch event.kind {
case NostrProtocol.EventKind.geohashPresence.rawValue:
return .presence
case NostrProtocol.EventKind.ephemeralEvent.rawValue:
let content = event.content
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1]
let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1]
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id
return .message(InboundBridgeMessage(
messageID: messageID,
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))",
senderPubkey: event.pubkey,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
))
default:
return nil
}
}
/// Parse + size + cell + kind + `r` tag + freshness, with NO signature
/// verification callers dedup/rate-limit first, Schnorr-verify last.
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
GatewayService.isValidGeohash(carrier.geohash),
let event = carrier.event(),
classify(event, cell: carrier.geohash) != nil else {
return nil
}
return event
}
private func isFresh(_ event: NostrEvent) -> Bool {
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
}
/// True when the event was signed by this device's own derived
/// rendezvous identity for the cell. Survives relaunches (unlike the
/// published-ID cache) because the derivation is deterministic; the
/// underlying identity cache makes this cheap.
private func isOwnRendezvousEvent(_ event: NostrEvent, cell: String) -> Bool {
guard let identity = try? deriveIdentity?(cell) else { return false }
return identity.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
}
+5 -29
View File
@@ -82,7 +82,6 @@ final class GatewayService: ObservableObject {
let depositor: PeerID
let geohash: String
let event: NostrEvent
let queuedAt: Date
}
static let shared = GatewayService()
@@ -179,6 +178,10 @@ final class GatewayService: ObservableObject {
// Downlink rides broadcast only; a directed fromGateway is malformed.
guard !directedToUs else { return }
handleDownlinkBroadcast(carrier)
case .toBridge, .fromBridge:
// Mesh-bridge carriers are BridgeService territory; the ingress
// router dispatches them there.
return
}
}
@@ -221,7 +224,7 @@ final class GatewayService: ObservableObject {
publish(event, geohash: carrier.geohash)
accepted = true
} else {
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event))
}
// Only render on our own timeline what we actually accepted for
@@ -463,30 +466,3 @@ final class GatewayService: ObservableObject {
&& geohash.allSatisfy { allowed.contains($0) }
}
}
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
/// evicted when full.
private 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,122 @@
//
// GeohashChatActivityTracker.swift
// bitchat
//
// Tracks actual chat-message activity per sampled geohash so the empty mesh
// timeline can point at a nearby channel where a conversation is happening
// not merely where participants are present.
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// A recent chat message observed in a sampled geohash channel.
struct GeohashChatPreview: Equatable, Sendable {
let senderName: String
let content: String
let timestamp: Date
init(senderName: String, content: String, timestamp: Date) {
self.senderName = senderName
self.content = content
self.timestamp = timestamp
}
}
/// The liveliest nearby conversation, resolved against the user's regional
/// channels.
struct NearbyConversation: Equatable, Sendable {
let channel: GeohashChannel
/// Chat messages seen within the activity window.
let messageCount: Int
let lastMessage: GeohashChatPreview
}
/// Records kind-20000 chat events seen by the background geohash sampling
/// subscriptions (blocked and self senders are filtered by the caller) and
/// answers "where nearby is a conversation actually happening?".
@MainActor
final class GeohashChatActivityTracker: ObservableObject {
static let shared = GeohashChatActivityTracker()
/// How far back a message still counts as "a conversation is happening".
private let window: TimeInterval
/// Per-geohash recent message timestamps (pruned to the window).
private var messageTimes: [String: [Date]] = [:]
/// Per-geohash newest message preview.
private var lastMessages: [String: GeohashChatPreview] = [:]
private let now: () -> Date
init(
window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds,
now: @escaping () -> Date = { Date() }
) {
self.window = window
self.now = now
}
func recordChatMessage(
geohash: String,
senderName: String,
content: String,
timestamp: Date
) {
let gh = geohash.lowercased()
let clamped = min(timestamp, now())
guard now().timeIntervalSince(clamped) < window else { return }
var times = messageTimes[gh] ?? []
times.append(clamped)
messageTimes[gh] = prune(times)
if let existing = lastMessages[gh], existing.timestamp > clamped {
// Keep the newer preview.
} else {
lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped)
}
objectWillChange.send()
}
/// Messages seen in the window for one geohash.
func messageCount(for geohash: String) -> Int {
prune(messageTimes[geohash.lowercased()] ?? []).count
}
func lastMessage(for geohash: String) -> GeohashChatPreview? {
let gh = geohash.lowercased()
guard messageCount(for: gh) > 0 else { return nil }
return lastMessages[gh]
}
/// The busiest channel with at least one chat message in the window.
/// Ties go to the more local (higher-precision) channel, so a lone
/// message on your block beats a lone message across the region.
func mostActiveConversation(among channels: [GeohashChannel]) -> NearbyConversation? {
var best: NearbyConversation?
for channel in channels {
let count = messageCount(for: channel.geohash)
guard count > 0, let last = lastMessage(for: channel.geohash) else { continue }
let candidate = NearbyConversation(channel: channel, messageCount: count, lastMessage: last)
if let current = best {
let better = count > current.messageCount
|| (count == current.messageCount
&& channel.level.precision > current.channel.level.precision)
if better { best = candidate }
} else {
best = candidate
}
}
return best
}
func clear() {
messageTimes.removeAll()
lastMessages.removeAll()
objectWillChange.send()
}
private func prune(_ times: [Date]) -> [Date] {
let cutoff = now().addingTimeInterval(-window)
return times.filter { $0 >= cutoff }
}
}
@@ -9,12 +9,12 @@
import Foundation
/// Represents a participant in a geohash channel
public struct GeoPerson: Identifiable, Equatable, Sendable {
public let id: String // pubkey hex (lowercased)
public let displayName: String
public let lastSeen: Date
struct GeoPerson: Identifiable, Equatable, Sendable {
let id: String // pubkey hex (lowercased)
let displayName: String
let lastSeen: Date
public init(id: String, displayName: String, lastSeen: Date) {
init(id: String, displayName: String, lastSeen: Date) {
self.id = id
self.displayName = displayName
self.lastSeen = lastSeen
@@ -23,7 +23,7 @@ public struct GeoPerson: Identifiable, Equatable, Sendable {
/// Protocol for resolving display names and checking block status
@MainActor
public protocol GeohashParticipantContext: AnyObject {
protocol GeohashParticipantContext: AnyObject {
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
func displayNameForPubkey(_ pubkeyHex: String) -> String
/// Returns true if the pubkey is blocked
@@ -32,16 +32,16 @@ public protocol GeohashParticipantContext: AnyObject {
/// Tracks participants across multiple geohash channels
@MainActor
public final class GeohashParticipantTracker: ObservableObject {
final class GeohashParticipantTracker: ObservableObject {
/// Activity cutoff duration (defaults to 5 minutes)
public let activityCutoff: TimeInterval
let activityCutoff: TimeInterval
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
private var participants: [String: [String: Date]] = [:]
/// Currently visible people for the active geohash
@Published public private(set) var visiblePeople: [GeoPerson] = []
@Published private(set) var visiblePeople: [GeoPerson] = []
/// The currently active geohash (if any)
private var activeGeohash: String?
@@ -52,17 +52,17 @@ public final class GeohashParticipantTracker: ObservableObject {
/// Timer for periodic refresh
private var refreshTimer: Timer?
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
init(activityCutoff: TimeInterval = -300) { // default 5 minutes
self.activityCutoff = activityCutoff
}
/// Configure with a context provider
public func configure(context: GeohashParticipantContext) {
func configure(context: GeohashParticipantContext) {
self.context = context
}
/// Set the currently active geohash
public func setActiveGeohash(_ geohash: String?) {
func setActiveGeohash(_ geohash: String?) {
activeGeohash = geohash
if geohash == nil {
visiblePeople = []
@@ -72,13 +72,13 @@ public final class GeohashParticipantTracker: ObservableObject {
}
/// Record activity from a participant in the current active geohash
public func recordParticipant(pubkeyHex: String) {
func recordParticipant(pubkeyHex: String) {
guard let gh = activeGeohash else { return }
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
}
/// Record activity from a participant in a specific geohash
public func recordParticipant(pubkeyHex: String, geohash: String) {
func recordParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = participants[geohash] ?? [:]
map[key] = Date()
@@ -94,7 +94,7 @@ public final class GeohashParticipantTracker: ObservableObject {
}
/// Remove a participant from all geohashes (used when blocking)
public func removeParticipant(pubkeyHex: String) {
func removeParticipant(pubkeyHex: String) {
let key = pubkeyHex.lowercased()
for (gh, var map) in participants {
map.removeValue(forKey: key)
@@ -104,14 +104,14 @@ public final class GeohashParticipantTracker: ObservableObject {
}
/// Get participant count for a specific geohash
public func participantCount(for geohash: String) -> Int {
func participantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = participants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
/// Get the visible people list for the active geohash (read-only query)
public func getVisiblePeople() -> [GeoPerson] {
func getVisiblePeople() -> [GeoPerson] {
guard let gh = activeGeohash, let context = context else { return [] }
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = (participants[gh] ?? [:])
@@ -126,12 +126,12 @@ public final class GeohashParticipantTracker: ObservableObject {
}
/// Refresh the visible people list
public func refresh() {
func refresh() {
visiblePeople = getVisiblePeople()
}
/// Start the periodic refresh timer
public func startRefreshTimer(interval: TimeInterval = 30.0) {
func startRefreshTimer(interval: TimeInterval = 30.0) {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in
@@ -141,19 +141,19 @@ public final class GeohashParticipantTracker: ObservableObject {
}
/// Stop the periodic refresh timer
public func stopRefreshTimer() {
func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
/// Clear all participant data
public func clear() {
func clear() {
participants.removeAll()
visiblePeople = []
}
/// Clear participant data for a specific geohash
public func clear(geohash: String) {
func clear(geohash: String) {
participants.removeValue(forKey: geohash)
if activeGeohash == geohash {
visiblePeople = []
@@ -406,7 +406,6 @@ enum GroupCryptoError: Error, Equatable {
case malformedPayload
case signingFailed
case sealFailed
case wrongEpoch
case decryptionFailed
case badSenderSignature
}
+53 -26
View File
@@ -12,6 +12,32 @@ import Foundation
import Security
final class KeychainManager: KeychainManagerProtocol {
/// Default keychain for components that construct their own rather than
/// having one injected. Under test this is an in-memory keychain: the
/// xctest runner's code signature changes every build, so any read of a
/// real login-keychain item triggers a macOS password prompt that
/// "Always Allow" can never satisfy and tests must never read or
/// mutate the developer's real keychain (`SecItemCopyMatching` can also
/// hang in test environments). Production behavior is unchanged.
static func makeDefault() -> 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 sharedTestKeychain }
#endif
return KeychainManager()
}
#if DEBUG
/// One store per process, mirroring the real keychain: separate
/// default-constructed components (e.g. two NostrIdentityBridge
/// instances in BoardManager's publish and delete paths) must see each
/// other's writes, or they would derive different Nostr identities
/// under test.
private static let sharedTestKeychain = PreviewKeychainManager()
#endif
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
@@ -255,11 +281,6 @@ final class KeychainManager: KeychainManagerProtocol {
// MARK: - Generic Operations
private func save(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
return saveData(data, forKey: key)
}
private func saveData(_ data: Data, forKey key: String) -> Bool {
// Delete any existing item first to ensure clean state
_ = delete(forKey: key)
@@ -305,11 +326,6 @@ final class KeychainManager: KeychainManagerProtocol {
return false
}
private func retrieve(forKey key: String) -> String? {
guard let data = retrieveData(forKey: key) else { return nil }
return String(data: data, encoding: .utf8)
}
private func retrieveData(forKey key: String) -> Data? {
// Base query
let base: [String: Any] = [
@@ -365,22 +381,7 @@ final class KeychainManager: KeychainManagerProtocol {
}
// MARK: - Cleanup
func deleteAllPasswords() -> Bool {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword
]
// Add service if not empty
if !service.isEmpty {
query[kSecAttrService as String] = service
}
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
// Delete ALL keychain data for panic mode
func deleteAllKeychainData() -> Bool {
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
@@ -565,4 +566,30 @@ final class KeychainManager: KeychainManagerProtocol {
SecItemDelete(query as CFDictionary)
}
/// Delete every item stored under a custom service
func deleteAll(service customService: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let items = result as? [[String: Any]] else {
return
}
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
}
}
+167 -8
View File
@@ -17,6 +17,10 @@ struct LocationNotesDependencies {
var now: () -> Date
// Fires when the geo relay directory refreshes; used to retry after "no relays".
var relayDirectoryUpdates: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher()
/// Whether any of the target relays has a live connection distinguishes
/// "loaded, empty" from "still connecting (Tor warming up)" when EOSE
/// fires without data. Defaults to true so tests keep legacy behavior.
var anyRelayConnected: @MainActor (_ relayUrls: [String]) -> Bool = { _ in true }
private static let idBridge = NostrIdentityBridge()
@@ -46,7 +50,10 @@ struct LocationNotesDependencies {
relayDirectoryUpdates: NotificationCenter.default
.publisher(for: .geoRelayDirectoryDidRefresh)
.map { _ in () }
.eraseToAnyPublisher()
.eraseToAnyPublisher(),
anyRelayConnected: { relayUrls in
NostrRelayManager.shared.isAnyRelayConnected(among: relayUrls)
}
)
}
@@ -57,6 +64,10 @@ final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
/// The initial fetch timed out with zero target relays connected
/// (usually Tor still bootstrapping): not "empty", just not there
/// yet. Retries automatically once a relay comes up.
case connecting
case ready
case noRelays
}
@@ -70,6 +81,30 @@ final class LocationNotesManager: ObservableObject {
/// The matched `g` tag: the cell the note was posted to, which can be
/// a neighbor of the subscribed geohash.
let geohash: String
/// NIP-40 expiration, when the note carries one (dead drops do).
let expiresAt: Date?
/// Carries a `["t","urgent"]` tag (parity with urgent board posts).
let isUrgent: Bool
init(
id: String,
pubkey: String,
content: String,
createdAt: Date,
nickname: String?,
geohash: String,
expiresAt: Date? = nil,
isUrgent: Bool = false
) {
self.id = id
self.pubkey = pubkey
self.content = content
self.createdAt = createdAt
self.nickname = nickname
self.geohash = geohash
self.expiresAt = expiresAt
self.isUrgent = isUrgent
}
var displayName: String {
let suffix = String(pubkey.suffix(4))
@@ -90,6 +125,8 @@ final class LocationNotesManager: ObservableObject {
private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable?
private var expiryPruneTimer: Timer?
private var connectivityRetryTimer: Timer?
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
@@ -123,6 +160,34 @@ final class LocationNotesManager: ObservableObject {
self.subscribe()
}
}
// NIP-40 notes can expire while displayed (a 24h dead drop crossing
// its boundary); ingest-time filtering alone would keep it visible
// until the subscription is recreated.
expiryPruneTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.pruneExpiredNotes()
}
}
}
deinit {
expiryPruneTimer?.invalidate()
connectivityRetryTimer?.invalidate()
}
/// Drops notes whose NIP-40 expiry has passed. Their ids stay in
/// `noteIDs` so a relay replay cannot resurrect them.
func pruneExpiredNotes() {
let now = dependencies.now()
let expired = notes.contains { note in
if let expiresAt = note.expiresAt { return expiresAt <= now }
return false
}
guard expired else { return }
notes.removeAll { note in
if let expiresAt = note.expiresAt { return expiresAt <= now }
return false
}
}
func setGeohash(_ newGeohash: String) {
@@ -168,6 +233,8 @@ final class LocationNotesManager: ObservableObject {
private func subscribe() {
state = .loading
errorMessage = nil
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = nil
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
@@ -202,10 +269,15 @@ final class LocationNotesManager: ObservableObject {
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
})?[1].lowercased() else { return }
guard !self.noteIDs.contains(event.id) else { return }
// NIP-40: relays are not required to enforce expiration drop
// expired notes client-side so 24h dead drops actually vanish.
let expiresAt = Self.expirationDate(of: event)
if let expiresAt, expiresAt <= self.dependencies.now() { return }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash)
let urgent = event.tags.contains { $0.count >= 2 && $0[0].lowercased() == "t" && $0[1].lowercased() == "urgent" }
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash, expiresAt: expiresAt, isUrgent: urgent)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.enforceMemoryCap()
@@ -213,14 +285,51 @@ final class LocationNotesManager: ObservableObject {
}, { [weak self] in
guard let self = self else { return }
self.initialLoadComplete = true
if self.state != .noRelays {
guard self.state != .noRelays else { return }
// EOSE with no data and zero connected target relays means the
// 10s fallback fired while Tor was still warming up showing
// "no notes" would be a lie. Wait visibly and retry.
if self.notes.isEmpty, !self.dependencies.anyRelayConnected(relays) {
self.state = .connecting
self.scheduleConnectivityRetry(relays: relays)
} else {
self.state = .ready
}
})
}
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
/// While `.connecting`, poll for a live target relay and re-subscribe as
/// soon as one appears (fresh REQ, fresh EOSE tracking). The poll dies
/// with the state: any subscribe/cancel invalidates it.
private func scheduleConnectivityRetry(relays: [String]) {
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = Timer.scheduledTimer(
withTimeInterval: TransportConfig.uiGeoNotesConnectivityRetrySeconds,
repeats: true
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.retryIfRelaysAvailable(relays: relays)
}
}
}
func retryIfRelaysAvailable(relays: [String]) {
guard state == .connecting else {
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = nil
return
}
guard dependencies.anyRelayConnected(relays) else { return }
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = nil
SecureLogger.debug("LocationNotesManager: relay came up, retrying notes fetch for \(geohash)", category: .session)
refresh()
}
/// Send a location note for the current geohash using the per-geohash
/// identity, optionally expiring via NIP-40 (dead drops pass 24h; the
/// composer's option passes nil) and optionally tagged urgent.
func send(content: String, nickname: String, expiresAt: Date? = nil, urgent: Bool = false) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
@@ -235,7 +344,9 @@ final class LocationNotesManager: ObservableObject {
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname
nickname: nickname,
expiresAt: expiresAt,
urgent: urgent
)
dependencies.sendEvent(event, relays)
// Optimistic local-echo
@@ -245,7 +356,9 @@ final class LocationNotesManager: ObservableObject {
content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname,
geohash: geohash
geohash: geohash,
expiresAt: expiresAt,
isUrgent: urgent
)
self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0)
@@ -288,6 +401,14 @@ final class LocationNotesManager: ObservableObject {
}
}
/// The NIP-40 `expiration` tag as a date, if the event carries one.
static func expirationDate(of event: NostrEvent) -> Date? {
guard let tag = event.tags.first(where: { $0.count >= 2 && $0[0].lowercased() == "expiration" }),
let seconds = TimeInterval(tag[1])
else { return nil }
return Date(timeIntervalSince1970: seconds)
}
/// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() {
if notes.count > maxNotesInMemory {
@@ -297,12 +418,50 @@ final class LocationNotesManager: ObservableObject {
}
}
/// Explicitly cancel subscription and release resources.
/// One-shot dead-drop publish without holding a subscription: pins a
/// note to `geohash` that expires via NIP-40. Returns false when no geo
/// relays are known or signing fails.
@MainActor
static func postDrop(
content: String,
nickname: String,
geohash: String,
expiry: TimeInterval = TransportConfig.locationDropExpirySeconds,
dependencies: LocationNotesDependencies = .live
) -> Bool {
guard let trimmed = content.trimmedOrNilIfEmpty else { return false }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
SecureLogger.warning("LocationNotesManager: drop blocked, no geo relays for geohash=\(geohash)", category: .session)
return false
}
do {
let identity = try dependencies.deriveIdentity(geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: identity,
nickname: nickname,
expiresAt: dependencies.now().addingTimeInterval(expiry)
)
dependencies.sendEvent(event, relays)
return true
} catch {
SecureLogger.error("LocationNotesManager: failed to post drop: \(error)", category: .session)
return false
}
}
/// Explicitly cancel the subscription. The prune timer stays alive (it
/// holds only a weak self) so a reused instance the notices sheet
/// cancels on tab switch and refreshes on return keeps pruning.
func cancel() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = nil
state = .idle
errorMessage = nil
}
@@ -0,0 +1,29 @@
//
// LocationNotesSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// User preference for location notes (dead drops): leaving notes pinned to
/// nearby places with /drop and surfacing notes others left here. On by
/// default, but everything it powers additionally requires location
/// permission the toggle in app info is the kill switch.
enum LocationNotesSettings {
private static let enabledKey = "locationNotes.enabled"
/// Fired on every toggle write so live consumers (the nearby-notes
/// counter) can drop or restart their relay subscription immediately.
static let didChangeNotification = Notification.Name("bitchat.locationNotesSettingsDidChange")
static var enabled: Bool {
get { UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true }
set {
UserDefaults.standard.set(newValue, forKey: enabledKey)
NotificationCenter.default.post(name: didChangeNotification, object: nil)
}
}
}
+1 -16
View File
@@ -101,9 +101,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private let cl: LocationStateManaging
private let geocoder: LocationStateGeocoding
private var lastLocation: CLLocation?
private var refreshTimer: Timer?
private var isGeocoding: Bool = false
// MARK: - Persistence Keys
@@ -268,7 +266,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
}
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
func beginLiveRefresh(interval _: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return }
refreshTimer?.invalidate()
refreshTimer = nil
@@ -385,7 +383,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
lastLocation = loc
computeChannels(from: loc.coordinate)
reverseGeocodeLocation(loc)
}
@@ -441,10 +438,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private func reverseGeocodeLocation(_ location: CLLocation) {
geocoder.cancelGeocode()
isGeocoding = true
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
guard let self = self else { return }
self.isGeocoding = false
if let pm = placemarks?.first {
let names = self.locationNamesByLevel(from: pm)
Task { @MainActor in self.locationNames = names }
@@ -632,15 +627,5 @@ extension LocationStateManager {
func toggle(_ geohash: String) {
toggleBookmark(geohash)
}
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
func add(_ geohash: String) {
addBookmark(geohash)
}
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
func remove(_ geohash: String) {
removeBookmark(geohash)
}
}
#endif
+27
View File
@@ -0,0 +1,27 @@
//
// MeshEchoSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Watermark for "heard here earlier" echoes: clearing the mesh timeline
/// (triple-tap or /clear) records the moment, and the next launch only
/// re-seeds archived messages heard after it. The archive itself is left
/// alone the device keeps carrying those messages for peers; the user
/// just doesn't want to see them again.
enum MeshEchoSettings {
private static let clearedThroughKey = "meshEchoes.clearedThrough"
static var clearedThrough: Date? {
get { UserDefaults.standard.object(forKey: clearedThroughKey) as? Date }
set { UserDefaults.standard.set(newValue, forKey: clearedThroughKey) }
}
static func reset() {
UserDefaults.standard.removeObject(forKey: clearedThroughKey)
}
}
+106
View File
@@ -0,0 +1,106 @@
//
// MeshSightingsTracker.swift
// bitchat
//
// Privacy-preserving daily tally of mesh peers that came within radio range,
// so an empty timeline can say "3 devices passed within range today" instead
// of feeling dead. Stores only a per-day salted hash per peer plus a count
// no identities, no history beyond today.
// This is free and unencumbered software released into the public domain.
//
import BitFoundation
import CryptoKit
import Foundation
@MainActor
final class MeshSightingsTracker: ObservableObject {
static let shared = MeshSightingsTracker()
private enum Keys {
static let dayKey = "meshSightings.dayKey"
static let salt = "meshSightings.salt"
static let hashes = "meshSightings.hashes"
static let lastSeenAt = "meshSightings.lastSeenAt"
}
/// Distinct devices seen within range today (rotating peer IDs may count
/// a long-lived neighbor more than once across rotations; that is fine
/// for an ambient stat).
@Published private(set) var todayCount: Int = 0
@Published private(set) var lastSightingAt: Date?
private let defaults: UserDefaults
private let now: () -> Date
private var seenHashes: Set<String> = []
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = { Date() }) {
self.defaults = defaults
self.now = now
restore()
}
func recordSighting(peerID: PeerID) {
rollOverIfNeeded()
let hash = saltedHash(peerID.id)
let seenAt = now()
lastSightingAt = seenAt
defaults.set(seenAt, forKey: Keys.lastSeenAt)
guard seenHashes.insert(hash).inserted else { return }
todayCount = seenHashes.count
defaults.set(Array(seenHashes), forKey: Keys.hashes)
}
func clear() {
seenHashes.removeAll()
todayCount = 0
lastSightingAt = nil
defaults.removeObject(forKey: Keys.dayKey)
defaults.removeObject(forKey: Keys.salt)
defaults.removeObject(forKey: Keys.hashes)
defaults.removeObject(forKey: Keys.lastSeenAt)
}
private func restore() {
rollOverIfNeeded()
seenHashes = Set(defaults.stringArray(forKey: Keys.hashes) ?? [])
todayCount = seenHashes.count
lastSightingAt = defaults.object(forKey: Keys.lastSeenAt) as? Date
}
/// Resets the tally when the local calendar day changes; the salt rotates
/// with it so hashes from different days can never be correlated.
private func rollOverIfNeeded() {
let today = Self.dayKey(for: now())
guard defaults.string(forKey: Keys.dayKey) != today else { return }
defaults.set(today, forKey: Keys.dayKey)
defaults.set(Self.randomSalt(), forKey: Keys.salt)
defaults.removeObject(forKey: Keys.hashes)
seenHashes.removeAll()
todayCount = 0
}
private func saltedHash(_ value: String) -> String {
let salt = defaults.data(forKey: Keys.salt) ?? {
let fresh = Self.randomSalt()
defaults.set(fresh, forKey: Keys.salt)
return fresh
}()
var digest = SHA256()
digest.update(data: salt)
digest.update(data: Data(value.utf8))
return digest.finalize().map { String(format: "%02x", $0) }.joined()
}
private static func dayKey(for date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar.current
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private static func randomSalt() -> Data {
Data((0..<16).map { _ in UInt8.random(in: .min ... .max) })
}
}
@@ -70,10 +70,6 @@ final class MessageFormattingEngine {
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
}()
}
// MARK: - Match Types
@@ -195,7 +191,7 @@ final class MessageFormattingEngine {
// MARK: - Private Helpers
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
private static func formatSystemMessage(_ message: BitchatMessage, isDark _: Bool) -> AttributedString {
var result = AttributedString()
let content = AttributedString("* \(message.content) *")
@@ -414,7 +410,7 @@ final class MessageFormattingEngine {
return AttributedString(text).mergingAttributes(style)
}
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
private static func formatMatch(_ text: String, type: MatchType, baseColor _: Color, isSelf _: Bool) -> AttributedString {
var style = AttributeContainer()
switch type {
+52 -7
View File
@@ -52,6 +52,53 @@ final class MessageRouter {
/// message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Parallel deposit into the internet bridge: park a sealed copy on
/// relays as a courier drop, so delivery stops requiring a physical
/// courier encounter. No-op unless the bridge is enabled. Runs alongside
/// (not instead of) mesh couriers; receivers dedup by message ID.
/// Returns true when a fresh drop was sealed, so the sender's message
/// can show the "carried" state instead of sitting on "sending" forever
/// (the delivery ack has no radio route back until the peers next share
/// a transport).
var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)?
/// Re-attempts bridge drops for retained messages whose recipient no
/// transport can promptly reach anymore. Covers sends that raced the BLE
/// reachability retention window: a peer stays "reachable" for a minute
/// after its radio disappears, so the original send trusted the mesh and
/// skipped the deposit and nothing else ever retried (field-found).
/// Safe to call often: the drop layer dedups by message ID.
func retryBridgeCourierDeposits() {
guard bridgeCourierDeposit != nil else { return }
for (peerID, queue) in outbox {
guard let recipientKey = courierDirectory.noiseKey(peerID) else { continue }
let promptlyDeliverable = transports.contains {
$0.isPeerReachable(peerID) && $0.canDeliverPromptly(to: peerID)
}
guard !promptlyDeliverable else { continue }
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true {
onMessageCarried?(message.messageID, peerID)
}
}
}
}
/// Arms the periodic sweep behind `retryBridgeCourierDeposits`. Called
/// once by the bootstrapper after the deposit closure is wired; separate
/// from init so tests drive the retry directly.
func startBridgeDepositSweep(interval: TimeInterval = 120) {
bridgeSweepTask?.cancel()
bridgeSweepTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
self?.retryBridgeCourierDeposits()
}
}
}
private var bridgeSweepTask: Task<Void, Never>?
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
@@ -160,6 +207,11 @@ final class MessageRouter {
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID),
let entry = queuedMessage(messageID, for: peerID) else { return }
// The bridge drop needs no connected courier only the recipient
// key so it runs before the courier-slot bookkeeping.
if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true {
onMessageCarried?(messageID, peerID)
}
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return }
@@ -311,13 +363,6 @@ final class MessageRouter {
}
}
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendDeliveryAck(for: messageID, to: peerID)
}
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
if let transport = connectedTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
@@ -153,11 +153,11 @@ enum EncryptionStatus: Equatable {
final class NoiseEncryptionService {
// Static identity key (persistent across sessions)
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
// Ed25519 signing key (persistent across sessions)
private let signingKey: Curve25519.Signing.PrivateKey
public let signingPublicKey: Curve25519.Signing.PublicKey
let signingPublicKey: Curve25519.Signing.PublicKey
// Session manager
private let sessionManager: NoiseSessionManager
+1 -10
View File
@@ -13,7 +13,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
/// Emits whether a relay that carries private messages is up
/// (fail-closed behind Tor). A connected geohash/custom relay alone
/// doesn't count: DM sends target the default relay set and would
@@ -42,7 +41,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
self.currentIdentity = currentIdentity
self.registerPendingGiftWrap = registerPendingGiftWrap
self.sendEvent = sendEvent
self.scheduleAfter = scheduleAfter
self.relayConnectivity = relayConnectivity
// Default pacer drives its throttle through the same injected
// scheduler, so tests that step scheduleAfter manually keep
@@ -130,8 +128,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
static let sharedAckPacer = AckPacer()
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
private let dependencies: Dependencies
private var favoriteStatusObserver: NSObjectProtocol?
@@ -145,12 +141,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
@MainActor
init(
keychain: KeychainManagerProtocol,
keychain _: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil
) {
self.keychain = keychain
self.idBridge = idBridge
self.dependencies = dependencies ?? .live(idBridge: idBridge)
setupObservers()
@@ -207,9 +201,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just([]).eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
var myPeerID: PeerID { senderPeerID }
+53 -3
View File
@@ -26,6 +26,10 @@ protocol NotificationRequestDelivering {
func add(_ request: UNNotificationRequest)
}
protocol NotificationCategoryRegistering {
func setCategories(_ categories: Set<UNNotificationCategory>)
}
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
private let center: UNUserNotificationCenter
@@ -55,6 +59,18 @@ private final class NotificationCenterRequestDelivererAdapter: NotificationReque
}
}
private final class NotificationCenterCategoryRegistrarAdapter: NotificationCategoryRegistering {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func setCategories(_ categories: Set<UNNotificationCategory>) {
center.setNotificationCategories(categories)
}
}
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
@@ -68,12 +84,21 @@ private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
func add(_ request: UNNotificationRequest) {}
}
private struct NoopNotificationCategoryRegistrar: NotificationCategoryRegistering {
func setCategories(_ categories: Set<UNNotificationCategory>) {}
}
final class NotificationService {
static let shared = NotificationService()
/// Category for the "bitchatters nearby" notification, carrying the wave quick action.
static let nearbyCategoryID = "chat.bitchat.category.nearby"
static let waveActionID = "chat.bitchat.action.wave"
private let isRunningTestsProvider: () -> Bool
private let authorizer: NotificationAuthorizing
private let requestDeliverer: NotificationRequestDelivering
private let categoryRegistrar: NotificationCategoryRegistering
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
private var isRunningTests: Bool {
@@ -92,25 +117,30 @@ final class NotificationService {
if isRunningTestsProvider() {
self.authorizer = NoopNotificationAuthorizer()
self.requestDeliverer = NoopNotificationRequestDeliverer()
self.categoryRegistrar = NoopNotificationCategoryRegistrar()
} else {
let center = UNUserNotificationCenter.current()
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
self.categoryRegistrar = NotificationCenterCategoryRegistrarAdapter(center: center)
}
}
internal init(
isRunningTestsProvider: @escaping () -> Bool,
authorizer: NotificationAuthorizing,
requestDeliverer: NotificationRequestDelivering
requestDeliverer: NotificationRequestDelivering,
categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar()
) {
self.isRunningTestsProvider = isRunningTestsProvider
self.authorizer = authorizer
self.requestDeliverer = requestDeliverer
self.categoryRegistrar = categoryRegistrar
}
func requestAuthorization() {
guard !isRunningTests else { return }
registerCategories()
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted {
// Permission granted
@@ -119,13 +149,29 @@ final class NotificationService {
}
}
}
private func registerCategories() {
let wave = UNNotificationAction(
identifier: Self.waveActionID,
title: "wave 👋",
options: []
)
let nearby = UNNotificationCategory(
identifier: Self.nearbyCategoryID,
actions: [wave],
intentIdentifiers: [],
options: []
)
categoryRegistrar.setCategories([nearby])
}
func sendLocalNotification(
title: String,
body: String,
identifier: String,
userInfo: [String: Any]? = nil,
interruptionLevel: UNNotificationInterruptionLevel = .active
interruptionLevel: UNNotificationInterruptionLevel = .active,
categoryIdentifier: String? = nil
) {
guard !isRunningTests else { return }
let content = UNMutableNotificationContent()
@@ -133,6 +179,9 @@ final class NotificationService {
content.body = body
content.sound = .default
content.interruptionLevel = interruptionLevel
if let categoryIdentifier = categoryIdentifier {
content.categoryIdentifier = categoryIdentifier
}
if let userInfo = userInfo {
content.userInfo = userInfo
@@ -183,7 +232,8 @@ final class NotificationService {
title: title,
body: body,
identifier: identifier,
interruptionLevel: .timeSensitive
interruptionLevel: .timeSensitive,
categoryIdentifier: Self.nearbyCategoryID
)
}
}
@@ -22,6 +22,11 @@ import Foundation
/// prekey-seal for recipients met long ago. Included in the panic wipe.
final class PrekeyBundleStore {
struct StoredBundle: Codable {
// noiseKey is read in loadFromDisk (dictionary keying), but the
// Periphery indexer intermittently misses that read and flakes CI
// with "assign-only" its USR is baselined (an in-source ignore
// can't work: strict mode flags it as superfluous on the runs where
// the indexer gets it right).
let noiseKey: Data
var generatedAt: UInt64
var prekeyIDs: [UInt32]
+1 -14
View File
@@ -28,7 +28,6 @@ final class PrivateChatManager: ObservableObject {
@Published private(set) var selectedPeer: PeerID? = nil
private var selectedPeerMirrorCancellable: AnyCancellable? = nil
private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
weak var meshService: Transport?
@@ -219,18 +218,13 @@ final class PrivateChatManager: ObservableObject {
/// Start a private chat with a peer. Selection is mutated through the
/// store's intent (the store owns it); the manager keeps its side
/// effects (fingerprint tracking, read receipts, unread clearing).
/// effects (read receipts, unread clearing).
@MainActor
func startChat(with peerID: PeerID) {
// Also creates the conversation if needed and updates the derived
// `selectedConversationID`; `selectedPeer` mirrors the change.
conversationStore?.setSelectedPrivatePeer(peerID)
// Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getFingerprint(for: peerID) {
selectedPeerFingerprint = fingerprint
}
// Mark messages as read
markAsRead(from: peerID)
}
@@ -239,15 +233,8 @@ final class PrivateChatManager: ObservableObject {
/// channel's conversation).
func endChat() {
conversationStore?.setSelectedPrivatePeer(nil)
selectedPeerFingerprint = nil
}
/// No-op since the `ConversationStore` cutover: the store maintains
/// chronological order and dedups by message ID on every insert, so the
/// per-append re-sort/dedup sweep this performed is no longer needed.
/// Kept only for API compatibility until step 5 removes the callers.
func sanitizeChat(for peerID: PeerID) {}
/// Mark messages from a peer as read
@MainActor
func markAsRead(from peerID: PeerID) {

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