Compare commits

..
Author SHA1 Message Date
jackandClaude Fable 5 3f677776bb Mark AckPacer @unchecked Sendable
Its mutable state is confined to the serial pacer queue; the annotation
silences the capture-of-non-Sendable warning in the scheduler callback
(NostrTransport.swift:123) under the app target's concurrency checking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:13:48 +02:00
jackandClaude Fable 5 5dffb04912 Bump version to 1.6.0
BLE background presence (wake-on-proximity pending connects), keychain
AfterFirstUnlock for locked-device operation, cross-launch gift-wrap dedup,
paced Nostr acks, GeoDM inbound dedup, and self-fragment sync fixes
(#1396-#1398).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:09:17 +02:00
81c45e9649 Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix (#1398)
* Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix

Remaining findings from the July 7 locked-phone test sessions, all rooted
in reconnect/relaunch behavior:

- All Nostr acks (READ and DELIVERED, direct and geohash) now flow through
  the paced queue that previously only throttled direct READ receipts.
  Reconnect redelivery produced 8 DELIVERED acks in under a second, which
  damus rejects ("noting too much").

- Processed gift-wrap event IDs persist across launches
  (NostrProcessedEventStore, wired through MessageDeduplicationService,
  debounced writes, wiped on the existing clear/panic paths). NIP-59
  randomizes gift-wrap timestamps, so the 24h-lookback DM subscriptions
  redeliver the same events every launch; without a cross-launch record
  each relaunch reprocessed old PMs and acks — the re-ack bursts and the
  "delivered ack for unknown mid" warnings (now debug: a stale ack is
  expected occasionally and not actionable).

- Own fragments handed back by sync replay (the deliberate RSR ttl=0
  restore path) now re-enter the gossip sync store before the self-drop.
  The fragment store is not archived, so after a relaunch our sync filter
  did not cover our own fragments and peers re-offered them every 30s
  round indefinitely; recording them stops the redelivery after one round
  while keeping assembly skipped.

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

* Address Codex review on #1398: shared ack pacer, transient clears keep disk record

- Geohash acks are sent through short-lived NostrTransport instances
  (makeGeohashNostrTransport creates one per ack), so the per-instance ack
  queue never paced a burst. Acks now flow through a pacer shared across
  instances: Dependencies.live wires the process-wide sharedAckPacer, and
  the default Dependencies init builds an isolated pacer from the same
  injected scheduleAfter so tests keep stepping the throttle manually.

- clearNostrCaches() runs on every geohash channel switch, so it no longer
  wipes the persisted gift-wrap record (that stays on the clearAll/panic
  path). Persistence is now append-merge instead of snapshot-overwrite —
  serialized on the store's IO queue — so a transient in-memory clear
  between debounced flushes can't shrink the on-disk record either.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:08:10 +02:00
963d3a089f Device-test findings: keychain lock access, GeoDM dedup, sync log clarity (#1397)
Three findings from the July 7 locked-phone test sessions:

- Keychain items move from WhenUnlocked to AfterFirstUnlock: the mesh keeps
  running while the device is locked (identity-cache saves failed with
  -25308 throughout testing), and a wake-on-proximity relaunch via BLE
  state restoration must read the noise keys before the user unlocks. A
  one-time SecItemUpdate migration upgrades existing items (retried on next
  launch if the device is locked); backup semantics unchanged.

- GeoDM inbound messages dedup by message ID at the handler: outbox retries
  re-wrap the same message in fresh gift-wrap events, so relay-level
  event-ID dedup can't catch them and every copy ran full processing
  (3-6x per message observed). DELIVERED acks still go through
  markGeoDeliveryAckSent first, so re-sent copies from a lost ack are
  still answered.

- Periodic gossip sync logs now name the type group ("message+fragment").
  The five per-type schedules log identical lines when several fire in one
  maintenance tick, which reads as duplicated sends (misdiagnosed twice
  during testing).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:09:01 +02:00
f79c2e3e9f BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1396)
* BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)

iOS cancels nothing for us: pending CBCentralManager connects never expire,
complete whenever the peer reappears in range, and relaunch the app via the
existing state-restoration path. Use that as the wake-on-proximity mechanism:

- BLERecentPeripheralCache: retains handles to recently seen/dropped
  peripherals (LRU 16, 15 min max age to respect BLE address rotation)
- On backgrounding, arm indefinite pending connects to cached peripherals
  within a slot budget (2 of 6 central slots reserved for live background
  discovery); armed entries carry lastConnectionAttempt == nil so a quick
  background/foreground bounce can't strand them as connecting
- The 8s app-level connect timeout defers while backgrounded so
  discovery-driven background connects also stay pending
- Foreground return cancels stale pending connects (including connecting
  entries rebuilt by state restoration after a relaunch) and hands control
  back to the scanner/scheduler
- A link dropped while backgrounded re-arms after the disconnect-settle
  window, so a peer walking away and returning wakes us again
- Packet ingress while backgrounded triggers a catch-up maintenance pass
  (announce/flush/drain) since the maintenance timer is suspended with the
  app; rate-limited to the normal 5s cadence

Battery cost ~0: pending connects live in the controller's allowlist (no
scanning, no app CPU), and the catch-up pass only runs inside wake windows
the radio already granted.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Restore: seed wake-on-proximity cache and resume service discovery

Field finding from on-device testing: after a state-restoration relaunch,
the recent-peripheral cache starts empty, so backgrounding shortly after a
restore armed no pending connects. Seed the cache from the restored
peripherals — they are the freshest proximity candidates we have.

Also resume service discovery for peripherals restored as connected with no
characteristic: the CBCharacteristic reference dies with the old process,
and without rediscovery the link sits connected-but-unusable until the peer
drops it.

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

* Defer restored-link service rediscovery until poweredOn

Field finding: CBPeripheral.discoverServices issued inside willRestoreState
fires before the central manager reaches poweredOn — CoreBluetooth drops the
command with an API MISUSE warning, leaving restored-connected links
characteristic-less after all. Move the rediscovery to
centralManagerDidUpdateState(.poweredOn), which restoration guarantees runs
afterwards.

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

* Let disconnect re-arms use the freed slot (Codex P2 on #1396)

The background-entry arm reserves 2 of 6 central slots for live discovery,
but the disconnect re-arm path shared that budget: with 4+ links remaining
the budget hit zero and the just-dropped peer was never armed — defeating
walk-away/walk-back re-arming in dense meshes. The disconnect path now arms
with no reserve, consuming the slot the disconnect itself freed.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:54:10 +02:00
jack 0c3136282e Revert "BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)"
This reverts commit b043324035.
2026-07-07 18:21:49 +02:00
b043324035 BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395)
iOS cancels nothing for us: pending CBCentralManager connects never expire,
complete whenever the peer reappears in range, and relaunch the app via the
existing state-restoration path. Use that as the wake-on-proximity mechanism:

- BLERecentPeripheralCache: retains handles to recently seen/dropped
  peripherals (LRU 16, 15 min max age to respect BLE address rotation)
- On backgrounding, arm indefinite pending connects to cached peripherals
  within a slot budget (2 of 6 central slots reserved for live background
  discovery); armed entries carry lastConnectionAttempt == nil so a quick
  background/foreground bounce can't strand them as connecting
- The 8s app-level connect timeout defers while backgrounded so
  discovery-driven background connects also stay pending
- Foreground return cancels stale pending connects (including connecting
  entries rebuilt by state restoration after a relaunch) and hands control
  back to the scanner/scheduler
- A link dropped while backgrounded re-arms after the disconnect-settle
  window, so a peer walking away and returning wakes us again
- Packet ingress while backgrounded triggers a catch-up maintenance pass
  (announce/flush/drain) since the maintenance timer is suspended with the
  app; rate-limited to the normal 5s cadence

Battery cost ~0: pending connects live in the controller's allowlist (no
scanning, no app CPU), and the catch-up pass only runs inside wake windows
the radio already granted.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:18:41 +02:00
bbe5e1ef4e Fix reachability-gate races flagged by Codex on #1389 (#1394)
P1: TorManager.shutdownCompletely() resets didStart asynchronously
(after Arti has actually stopped, up to ~5s later). A brief
offline->online flap could call startIfNeeded() inside that window;
the guard on didStart dropped it, and nothing reevaluated afterwards,
so Tor stayed down while activationAllowed was true. Track shutdowns
in flight and record a deferred start, honored when the last shutdown
finishes (still gated on allowAutoStart/foreground at that point).

P2: NWPathReachabilityMonitor.ingest() cancelled and rescheduled the
flush a full debounce interval from "now" on every observation, even
duplicates (e.g. interface detail changes while still unsatisfied).
ReachabilityDebounce already preserves the original pending.since, so
schedule the flush for the remaining time to the true deadline instead
of restarting the window.

Tests: debounce deadline preservation (pure) + a monitor-level timing
test that a mid-window duplicate does not postpone the offline commit.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:09:30 +02:00
28ffc53f3f i18n: fill all locale gaps so no string falls back to English (#1393)
Follow-up to #1391 — the Codex review flagged that the new feature-string
batch stopped at vi, omitting fil, pt-BR, zh-Hans, and zh-Hant.

- Translate the 137 feature strings into fil, pt-BR, zh-Hans, zh-Hant (548 entries)
- Translate fingerprint.message.vouched_by (plural) into the 16 locales it
  was missing, with CLDR-correct plural categories per language
- Add the 13 locales missing from the share extension catalog (78 entries),
  bringing it to the same 29 locales as the main app
- Mark the '#%@' channel-hashtag format as shouldTranslate=false like '%@'
- Add LocalizationCoverageTests: fails if any translatable key in either
  catalog is missing any supported locale, or if the share extension
  supports fewer locales than the main app

Machine translations marked needs_review, matching #1391. Verified no
existing translation was altered; full suite (1348 tests) green.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:57:05 +02:00
8415c52913 Unified notices: one pin, one sheet for board pins + location notes (#1392)
* Unified notices: merge board pins and location notes into one sheet

One pin icon in the header now opens a single Notices sheet with a
geo/mesh scope toggle, replacing the separate board, location-notes,
and mesh-only note buttons:

- geo tab: current geohash's notices — mesh-synced board posts merged
  and deduped with Nostr kind-1 location notes, with per-item mesh/net
  source badges. Scope follows the selected location channel, or the
  device's building geohash when chatting on mesh.
- mesh tab: mesh-local board only (fully offline).
- One composer: geo posts go to the board and bridge to Nostr (existing
  bridge), so mesh and internet see the same notice.
- Merged delete: tombstoning an own board post now also retracts the
  bridged Nostr copy via NIP-09 (new createDeleteEvent, bridged event
  ids tracked in BoardManager); own Nostr-only notes are deletable too.
- LocationNotesManager accepts any channel-precision geohash (1-12
  chars), not just building-level.

BoardView and LocationNotesView are superseded by NoticesView.

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

* Notices round 2: honest composer, friendlier copy, new-pin chat alerts

- Urgent + expiry controls now appear on the mesh tab only: the bridged
  Nostr copy of a geo post carries neither, so relay-side readers would
  never see them. Geo posts default to non-urgent with 7-day expiry, and
  the bridged note now gets a NIP-40 expiration tag so honoring relays
  drop it in step with the board copy.
- Geo tab explainer reuses the original location-notes description
  (keeps its 29 existing translations); mesh tab gets a new plain-
  language description.
- New-pin chat alerts, fully local (no wire traffic): BoardStore fires
  postArrivals for posts newly accepted from the wire; BoardAlertsModel
  filters own posts, dedups by postID, and for urgent pins created
  within the last 30 minutes emits one system line into the matching
  timeline (geo pin -> that geohash's chat, mesh pin -> mesh chat),
  collapsing simultaneous arrivals into a count line.
- Routine pins light up the header: the pin icon tints orange whenever
  the current scope has notices at all, and fills (pin.fill) while
  unseen new pins are waiting; opening the sheet clears them.

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

* i18n: translate the unified-notices strings into all 28 non-English locales

Adds the 13 new notices keys (sheet title, geo/mesh tabs, mesh
description, source badges, urgent alert lines, button tooltip and
accessibility strings) to the string catalog with translations for
every locale the app ships. The geo tab already reuses the fully
translated location_notes.description; this covers the rest. Insertion
preserves the catalog's case-insensitive key order, so the diff is
purely additive.

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

* Address Codex review: panic-wipe reset, scoped badge clear, geohash-aware dedupe

- BoardStore.wipe() now emits didWipe; BoardAlertsModel subscribes and
  resets, so a panic wipe drops pending urgent lines (which could
  otherwise re-append pre-wipe content into chat after the collapse
  flush), unseen badge scopes, and handled-post history.
- Opening the notices sheet clears unseen badges only for the scopes it
  actually shows (mesh + current geo scope); pins for other geohash
  channels keep their badge until visited.
- LocationNotesManager.Note now retains the matched g tag, and the
  bridged-copy dedupe requires the note's geohash to equal the board
  post's — a same-text note from a neighboring cell is no longer
  swallowed as a duplicate.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:40:12 +02:00
a0c517c018 i18n: machine-translate feature strings into all 28 non-English locales (#1391)
Fills the translation gaps for the strings the feature program added
(capability UI, /ping /trace, board, vouch, prekeys, gateway, groups,
Cashu, Wi-Fi bulk, Tor-offline). 3300 (key,language) pairs added across
28 locales; Spanish was already fully translated, the rest land as
`needs_review` for native-speaker review before shipping.

Purely additive: main's key set (336) and existing translations are
authoritative and untouched. Verified programmatically — 0 removed,
0 changed, exactly 3300 added. (The git diff-stat shows large deletion
counts, but that's line-alignment churn from inserting into a 38k-line
JSON; no content is removed.)

Machine translation only — every `needs_review` entry needs a native
speaker before it can be trusted.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:16:35 +02:00
81a10f73f0 Private groups: creator-managed encrypted group chat over the mesh (#1383)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

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

* Private groups: creator-managed encrypted group chat over the mesh

Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs:

Protocol
- MessageType.groupMessage = 0x25: broadcast packets with a cleartext
  16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as
  AEAD AAD), inner Ed25519 sender signature over
  "bitchat-group-msg-v1"|groupID|messageID|timestamp|content
- NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07:
  creator-signed group state (key, epoch, roster) 1:1 over Noise; signature
  over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise
  session peer must BE the creator
- SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens
  1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients
  ignore unknown bits and answer with types they know
- PeerCapabilities.localSupported now advertises .groups

Storage
- GroupStore: symmetric keys in the keychain, roster/name/epoch as
  protected JSON in Application Support; wiped in panicClearAllData()

Behavior
- Non-members relay 0x25 like any broadcast but cannot read it; group
  messages join gossip-sync backfill with the public-message window
- Receivers drop wrong-epoch envelopes, bad sender signatures, and
  senders missing from the creator-signed roster
- Fire-and-flood delivery (no per-member acks in v1)

UI
- Groups open as chat windows through the private-chat sheet (virtual
  "group_" peer IDs); groups section in the people sheet; /group
  create/invite/remove/leave/list commands; invitees get a system message
  + notification and the group appears in their people sheet

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

* Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes

Addresses the Codex review and adversarial-review findings on #1383:

- TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to
  65535 and truncating, so an oversize group message fails to seal and
  surfaces send_failed rather than shipping ciphertext recipients drop.
- Roster nicknames truncate on a Character boundary (never mid-scalar), so a
  multi-byte nickname can no longer make the whole signed roster undecodable.
- Invites now bump the epoch (rotate the key) like removals, giving every
  roster change a strictly-increasing epoch so out-of-order invite states no
  longer last-writer-wins a just-added member back out.
- Removing a member now sends them a creator-signed roster-without-them under
  a throwaway all-zero key (never the rotated key), so their client
  deactivates the group and surfaces "removed" instead of going silently dark.
- /block is enforced in the group receive path: a blocked member's messages
  are dropped from display and notifications, consistent with every other
  inbound path.
- Media affordances are disabled in group chats (both computed sites) so the
  composer can't strand a media placeholder that never sends; media-in-groups
  is a documented v2 item.
- Creator signature now covers the group name and the sender signature covers
  the epoch (wire-format-affecting; needs Android parity before ship).
- Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered
  receipts can never leak into group conversations under a future refactor.

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-07 15:59:55 +02:00
87910541ef Prekey bundles: forward-secret async first contact for courier mail (#1381)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

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

* Prekey bundles: forward-secret async first contact for courier mail

Courier envelopes were sealed with one-way Noise X to the recipient's
long-lived static key, so a later compromise of that key exposed every
envelope captured in transit. This adds one-time prekey bundles:

- PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys
  bound to the owner's Noise static key by an Ed25519 signature over
  "bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its
  own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness)
  and verified against the announce-bound signing key before caching.
- Sealed envelope v2: Noise X where the responder static is the one-time
  prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides
  encrypted inside and is authenticated exactly like v1 (blocked-sender
  check included). CourierEnvelope gains an optional prekeyID TLV that
  v1 decoders skip as unknown.
- Local prekeys live in the Keychain; consumed privates survive a 48h
  grace window for spray-and-wait redeliveries, then are deleted (the
  forward-secrecy clock starts at deletion). The batch tops back up and
  re-gossips when unconsumed count drops below 3, and everything is
  wiped in panic mode.
- Routing: courier sealing picks a cached verified bundle when one
  exists (one prekey per message, reused across deposit retries), with
  the advertised .prekeys capability as a veto for on-mesh peers, and
  falls back to static sealing otherwise.

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

* Prekeys: authenticate bundle packets, fix consume-republish, deflake CI

Fixes the prekey-bundle PR review + CI failure:

- CI root cause: the receive queue (mesh.message) is concurrent, so a
  gossiped prekey bundle can be processed before the announce that binds
  its owner's signing key. The old handler dropped such bundles outright,
  so under CI parallel load the bundle was permanently lost and the
  cache/gossip tests flaked (verifiedBundleEntersGossipStore,
  prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before
  their binding are now retained per-owner (bounded) and re-attempted when
  the verified announce lands, atomically to avoid a check-then-act race.

- Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM):
  require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the
  packet's Ed25519 signature (covers senderID + timestamp) against the
  owner's bound signing key, in addition to the inner bundle signature.
  Stops replay under a fresh timestamp / fake senderID.

- Key the gossip prekey-bundle store/dedup by the bundle's authenticated
  identity (noiseStaticPublicKey), not the unauthenticated packet
  senderID, so one valid bundle sprayed under many fabricated sender IDs
  can't multiply entries and exhaust the 200-owner cap.

- Bump published-bundle generatedAt strictly on consume (Codex P1):
  consuming a prekey shrinks the published bundle, so it now republishes
  with a strictly newer generatedAt and re-gossips, so peers replace the
  cached copy and stop assigning the consumed ID before its 48h grace.

- Guard the panic/clear detached Application Support tree-deletes behind
  TestEnvironment.isRunningTests: the SPM test process shares that tree,
  so the wipe could land mid-test and flake file-dependent tests.

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

* Update sync tests for prekeyBundle as bit 9 / default sync round

Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables
a prekey sync round by default. That broke tests authored by other PRs
that assumed bit 9 was phantom or that only their own sync round fires:

- SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+
  (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned.
- GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync
  round in configs that run maintenance (as they already do for message/
  fragment/fileTransfer), so they isolate the behavior under test.

Full app suite (1301 tests) green locally via SPM.

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-07 15:38:33 +02:00
f9032cf2b9 Add mesh diagnostics: /ping, /trace, and topology map (#1377)
* Add mesh diagnostics: /ping, /trace, and topology map

- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
  + origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
  normalization, layout

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

* Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing

Three fixes for PR #1377 review:

1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the
   panicClearAllData / clearCurrentPublicTimeline detached utility-priority
   tasks delete the real ~/Library/Application Support/files tree, which the
   test process shares. The wipe fires at a nondeterministic time and raced
   the sendImage test's JPEG in files/images/outgoing (write then re-read),
   so prepareImagePacket threw and the test timed out. Both wipes are now
   skipped under tests (existing TestEnvironment.isRunningTests pattern);
   this also stops test runs from deleting the developer's real media.

2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on
   packet.senderID let one connected peer rotate forged sender IDs to bypass
   the 5-per-10s budget. The limiter now keys on the ingress link (the
   directly connected peer that delivered the packet); the pong still goes
   to the claimed sender. Regression test proves rotating senders over one
   link exhaust one budget (fails 10 vs 5 pongs on the old code).

3. Codex P2: /ping output arrived up to 10s later and was routed from
   selectedPrivateChatPeer at callback time, misrouting the result after a
   chat switch. The origin conversation is now captured when the command is
   issued (CommandOutputDestination) and deferred output is routed there:
   a DM result lands in the origin chat's history even if deselected, and a
   mesh-timeline result pins to #mesh instead of the active channel.

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

* App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers

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

* Conform DiagnosticsMockContext to sendPublicMessage

CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after
this branch forked, so the diagnostics test mock no longer conformed once
main was merged in. Add the no-op stub.

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-07 15:08:22 +02:00
d4f0c49787 Gateway mode: opt-in mesh↔Nostr uplink for geohash channels (#1384)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

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

* Gateway mode: opt-in mesh↔Nostr uplink for geohash channels

An opt-in "internet gateway" toggle lets one connected phone bridge the
local geohash channel for mesh-only peers: signed kind-20000 events ride
a new nostrCarrier (0x28) packet — directed to the gateway for uplink,
broadcast with TTL for downlink — with Schnorr verification at every
hop, CourierStore-style quotas, and explicit loop-prevention rules.

- BitFoundation: MessageType.nostrCarrier = 0x28
- NostrCarrierPacket: 2-byte-length TLV codec (direction, geohash,
  signed event JSON), 16 KiB cap, tolerant decoder
- GatewayService: closure-injected policy layer — verify gates (sig,
  kind, #g tag, age, size), uplink quotas (10/min/depositor rate limit,
  offline queue of 20 total / 5 per depositor, drop-oldest, flush on
  reconnect), downlink budget (30/min, bounded drop-oldest backlog),
  bounded loop-prevention ID sets
- BLEService: runtime capability bits (advertise .gateway only while
  the toggle is on, re-announce on change), signed directed uplink
  sends, carrier ingress with depositor signature verification
- Mesh-only senders uplink automatically from sendGeohash when no relay
  is connected and a reachable peer advertises .gateway; once-per-
  channel "sent via mesh gateway" notice
- UI: gateway toggle beside the Tor toggle, globe header indicator,
  VoiceOver labels, xcstrings entries

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

* Gateway: harden downlink freshness, uplink verify ordering, and drain

Fixes the confirmed downlink/uplink defects from the PR #1384 review +
Codex findings:

- Downlink age + #g gate (Codex P2 / review #1): rebroadcastRelayEvent
  now drops events outside the same freshness window receivers enforce
  and whose #g tag mismatches the carrier geohash, BEFORE spending any
  budget — so a 1h/200-event channel-resubscribe backfill no longer
  burns the 30/min BLE budget on events every receiver drops.
- Rate-limit + dedup before Schnorr (review #2): handleUplinkDeposit now
  runs cheap structural checks + carried-ID dedup + rate-token consume
  before isValidSignature(), so a replay flood is bounded by cheap work
  instead of unbounded main-actor verifies.
- Quota-dropped deposits not rendered (review #3): enqueueUplink reports
  acceptance and injectInbound only fires for events actually
  published/queued, ending the local-timeline divergence.
- Drain timer + mark-after-send (Codex P2 / review #4): a burst beyond
  budget now arms a timer to drain when the window frees; rebroadcast
  IDs are marked only after an event is actually sent, so overflow-
  dropped events stay retryable.
- Symmetric publish path (review #5): the gateway publish closure now
  refuses when no geo relay is known, matching the local send path
  instead of publishing dead traffic to default relays.
- Loop-rule doc (review #7): softened to reflect that rule 3 is a
  call-site convention with unit-tested backstops; added tests for the
  publishedEventIDs backstop, downlink freshness/mismatch, drain timer,
  and quota-drop non-injection.

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

* Gateway: stop self-echo of uplinked events onto the mesh

Every event a gateway uplinks to the relays comes back through its own
geohash subscription. `rebroadcastRelayEvent` deduped against
`meshBroadcastEventIDs`, `rebroadcastEventIDs`, and `pendingDownlinks`,
but not `publishedEventIDs` — so an event this gateway just published
was downlink-rebroadcast onto the same mesh it originated from, doubling
BLE airtime per uplinked message and able to starve the 30/min downlink
budget on a busy channel (device-confirmed, filed on #1384).

Fix: also skip the downlink rebroadcast when the event id is in
`publishedEventIDs`. That set is already the bounded (drop-oldest,
capacity maxTrackedEventIDs) loop-rule-2 uplink cache, populated only by
`publish()`, so genuine inbound-from-internet events (never published
here) still rebroadcast normally. Reconciles cleanly with the existing
loop-prevention sets — no new state.

Adds a GatewayServiceTests case asserting an uplinked event that echoes
back via the subscription is not rebroadcast, while a genuine inbound
event still is.

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-07 14:55:17 +02:00
2360140760 Transitive verification: vouch for verified peers over Noise (#1380)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

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

* Transitive verification: vouch for verified peers over Noise

When a Noise session establishes with a peer I verified and that peer
advertises the .vouch capability, send signed attestations (up to 16,
most recently verified first, at most once per peer per 24h) for the
OTHER fingerprints I verified. Receivers accept vouches only from
senders they verified themselves, verify the Ed25519 signature against
the sender's announce-bound signing key, and surface the result as a
new derived trust tier: vouched (unfilled seal) between casual and
trusted.

Protocol:
- NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations:
  voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp
  (uint64 ms BE), Ed25519 signature over
  "bitchat-vouch-v1" | fingerprint | signingKey | timestamp.
  The voucher is implicit in the authenticated session.
- PeerCapabilities.localSupported now advertises .vouch.

Storage (SecureIdentityStateManager / IdentityCache):
- vouches keyed by vouchee, capped at 8 vouchers each; validity is
  recomputed on read (voucher still verified-by-me, < 30 days old), so
  unverifying a voucher retires their vouches without cascade deletes.
- New IdentityCache fields are Optional so pre-existing encrypted
  caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but
  raw values are strings, so persisted values are unaffected (and
  vouched itself is never persisted).
- Panic wipe clears vouch state with the rest of the identity cache.

UI: unfilled checkmark.seal badge in the mesh peer list (filled seal
stays exclusive to verified) and a "vouched for by N people you
verified" section with voucher names in FingerprintView; VoiceOver
labels and xcstrings entries included.

Tests: attestation encode/decode + signature (forged/tampered/expired),
accept-policy gates, batch cap, trust-level derivation incl. voucher
invalidation, persistence compat, and coordinator exchange/accept
policies. Full macOS suite: 1088 tests passing.

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

* Fix CI deadlock in vouch tests and live-refresh the fingerprint sheet on vouch acceptance

Two fixes for PR #1380 review findings:

1. CI "Run Swift Tests (app)" hang (exit 137): the new
   SecureIdentityStateManagerVouchTests suite was nonisolated, so Swift
   Testing ran its tests in parallel on the Swift Concurrency cooperative
   pool. Each test enqueues a queue.async(.barrier) write (setVerified)
   and immediately blocks in queue.sync / queue.sync(.barrier)
   (recordVouch / effectiveTrustLevel). On CI's few-core runners every
   cooperative-pool thread ended up parked behind a pending barrier that
   never got a dispatch worker, deadlocking the whole test process until
   the watchdog SIGKILLed it. The suite is now @MainActor, matching the
   production isolation of the vouch API (ChatVouchCoordinator is
   @MainActor) and keeping blocking syncs off the cooperative pool.

2. Codex P2: an open fingerprint sheet did not refresh its vouched badge
   when a vouch batch was accepted - VerificationModel.bind() never
   observed the trust-change signal. It now subscribes to the
   "peerStatusUpdated" notification that
   ChatVouchCoordinator.notifyPeerTrustChanged() posts (same source
   PeerListModel uses) and forwards it to objectWillChange. Added a
   regression test that pins VerificationModel's own subscription
   (verified to fail without the fix).

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

* Skip media-wipe detached tasks under tests (shared-filesystem race)

panicClearAllData and clearCurrentPublicTimeline delete the real
~/Library/Application Support/files tree in detached utility-priority
tasks. The SPM test process shares that tree and ChatViewModelTests
invoke both methods, so under parallel scheduling the wipe lands at a
nondeterministic time — deleting media a concurrently running test just
wrote (and the developer's real app data with it). Guard both with the
existing TestEnvironment.isRunningTests pattern, mirroring the same fix
on feat/mesh-diagnostics (#1377).

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

* Port vouch capability-race fix to feat/vouching (ports b8adcbe9)

Ports the on-device-confirmed fix from the integration test branch
(commit b8adcbe9) onto feat/vouching so PR #1380 is actually correct.
On-device testing confirmed the transitive vouch propagated once the
send was triggered on verify / announce arrival rather than auth alone.

Vouch attestations only ever sent from peerAuthenticated, gated on the
peer's .vouch capability. That capability arrives via the peer's announce,
processed independently of the Noise handshake, so at auth time the set was
usually empty -> gate failed -> vouch silently skipped and never retried.

- Refactor the send path into a reusable attemptVouch(to:fingerprint:now:).
- Trigger on peer-list updates (peersUpdated): fired after every verified
  announce, so the batch goes out once the .vouch bit actually arrives.
- Trigger on local verification (vouchToConnectedVerifiedPeers): verifying a
  peer runs a vouch pass over connected verified peers, covering the
  verify-while-connected case and propagating the new identity onward.
- Relax the capability gate: treat an empty/unknown set as eligible (the
  Noise 0x12 payload is ignored by non-supporting peers); only skip when a
  non-empty set explicitly lacks .vouch.

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-07 14:48:37 +02:00
70229f0be1 Originate v2 source routes and wire fragmentIdFilter targeted resync (#1378)
* Originate v2 source routes and wire fragmentIdFilter targeted resync

Part A — source-route origination policy:
- Gate route application (BLESourceRouteOriginationPolicy): only packets we
  author, directed at a single peer, with TTL headroom, whose recipient is
  not directly connected. Relays no longer attach routes to (and re-sign)
  packets they merely forward.
- Version-gate paths: MeshTopologyTracker records the highest protocol
  version observed per peer; BFS routes require every intermediate hop and
  the recipient to be v2-observed, capped at 4 intermediate hops.
- Degrade on failure: BLESourceRouteFailureCache marks a routed send that
  sees no inbound traffic from the recipient within 10s as failed and floods
  for 60s before retrying routes.

Part B — REQUEST_SYNC fragmentIdFilter (TLV 0x06):
- Requester: BLEFragmentAssemblyBuffer reports stalled broadcast
  reassemblies (no new fragment for 5s, retried at most every 10s); the
  maintenance pass sends a types=fragment REQUEST_SYNC naming the stalled
  8-byte fragment stream IDs to each connected peer.
- Responder: GossipSyncManager restricts the fragment diff to exactly the
  named streams, bypassing the since-cursor while the GCS filter still
  excludes pieces the requester holds; RSR/TTL-0/rate-limit semantics
  unchanged and REQUEST_SYNC stays link-local.
- Bounds: at most 60 IDs per request (60*17-1 = 1019 bytes <= the 1024-byte
  decoder cap); oversized 0x06 values are ignored, not fatal.

Docs: SOURCE_ROUTING.md gains the iOS origination policy (§8);
REQUEST_SYNC_MANAGER.md documents 0x05/0x06 as implemented.

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

* Fix stall-clock refresh on duplicates and overflow suppression in fragment resync

Two fixes to stalledBroadcastFragmentIDs bookkeeping in
BLEFragmentAssemblyBuffer:

- Duplicate fragments no longer reset the stall clock. Fragment packets
  bypass the packet deduplicator, so relayed duplicates of an
  already-held index arriving every few seconds kept lastFragmentAt
  fresh and suppressed the targeted REQUEST_SYNC indefinitely. Now
  lastFragmentAt only updates when the index is new (actual progress).

- Only the streams that will actually be encoded on the wire are
  rate-limited. Previously every stalled candidate got
  lastResyncRequestAt set, but encodeFragmentIdFilter serializes at most
  RequestSyncPacket.maxFragmentIdFilterCount (60) IDs, so overflow
  streams were suppressed for retryAfter without ever being requested.
  Selection now caps at that shared constant, oldest stall first, so
  overflow stays eligible and rotates fairly on the next pass.

Tests: duplicates arriving periodically still trigger the stall report;
70 stalled streams yield the 60 oldest on the first pass and the
remaining 10 on the next.

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-07 14:42:53 +02:00
cee2bcd535 Fix SyncTypeFlags tests: bit 8 (boardPost) is a known type (#1390)
#1379 (board) mapped bit 8 -> .boardPost in SyncTypeFlags, making it a
known bit that spills the encoded bitfield into a second byte. But the
phantom-bit tests (added by #1373) predate that change and still assert
bit 8 is unknown, so main went red once both landed. Neither PR's CI
caught it — each was green against a main without the other.

The impl is correct (board is a real sync type); the tests were stale.
Update them to treat bits 9+ as phantom, expect the all-known field to
serialize to 2 bytes, and add a regression test that the board bit
survives decode while the phantom high bits are stripped.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:35:51 +02:00
3c610a83cd Cashu ecash chips: detect, render, and redeem tokens + /pay command (#1376)
* Cashu ecash chips: detect, render, and redeem tokens + /pay command

Content-level Cashu support, no wire-protocol changes:

- CashuTokenDecoder: summarizes V3 (cashuA base64url-JSON) tokens —
  amount summed across proofs, unit, mint host, memo — and V4 (cashuB)
  via a minimal bounded CBOR reader. All input is treated as
  adversarial: size caps, depth/item budgets, overflow guards, display
  sanitization; malformed payloads fail closed to a generic chip.
- PaymentChipView: cashu chips now show "500 sat · mint.example.com"
  (+ memo) instead of a generic label; tap opens a cashu: wallet URL
  and falls back to https://redeem.cashu.me when no wallet handles it;
  context menu adds copy token / redeem in wallet / redeem on web.
- extractCashuLinks now returns bare deduplicated bearer strings so the
  chip can decode them (cashu: URIs still detected via the embedded
  token).
- /pay <token>: validates the token decodes, sends it as the message
  body; DMs send directly, public channels require an explicit
  "/pay <token> public" confirm since tokens are bearer instruments.
  Suggested everywhere except public geohash channels.
- Tests: decoder (V3/V4 decode, summation, URI forms, truncation/
  garbage/huge fuzzing, CBOR depth bounds) and /pay command flows.

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

* Cashu: strict decode on the /pay SEND path

The permissive decoder turned any non-empty cashuB… base64 that failed
CBOR parsing into a generic TokenInfo, so the /pay guard accepted base64
junk and truncated V4 tokens and relayed them with a success message.

Add a `strict` flag to CashuTokenDecoder.decode: in strict mode there is
no permissive V4 fallback and the token must resolve to a known version
with a positive amount, else it returns nil. Rendering keeps the
permissive path (an unknown chip is fine for display). /pay now decodes
with strict:true and surfaces "invalid cashu token" instead of sending.

Tests: /pay with truncated cashuB / base64 junk is rejected; valid V3
and valid definite-length V4 still send; decoder strict-mode unit tests.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:14:08 +02:00
60be88a4f5 Geohash bulletin board: persistent signed notices over mesh sync (#1379)
* Add geohash bulletin board: persistent signed notices over mesh sync

New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.

Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.

UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.

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

* Board: bound orphan tombstones and reject future-dated posts at ingest

Two hardening fixes from Codex review of the geohash bulletin board:

- Orphan tombstones (P1): retention was derived solely from the
  sender-chosen deletedAt, so self-signed tombstones for unseen post IDs
  with far-future deletedAt persisted and re-entered sync unboundedly.
  Retention is now also clamped to receive time (now + 7d + 1h skew --
  no post can outlive that), and orphans are capped at 100 globally and
  5 per author key with oldest-received evicted first. Matched
  tombstones and disk restores keep their existing behavior.

- Future-dated posts (P2): ingest only checked expiresAt > now, letting
  posts dated years ahead sort above honest posts and squat the 200
  global slots without ever pruning. The single ingest chokepoint
  (radio, sync, and disk restore all funnel through it) now rejects
  createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the
  decoder's span rule is unchanged.

Adds tests for the skew boundary, far-future expiry, receive-time
tombstone clamping, orphan caps/eviction, and matched-tombstone
exemption.

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-07 14:13:25 +02:00
ede6368296 NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders (#1382)
* NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders

Outgoing kind-20000 geohash messages mine a NIP-13 nonce tag (8 leading
zero bits, ~256 hashes, typically <1 ms) off the main actor before
signing. Mining is hard-capped at 2 s and cancellable (newer send or
channel switch): on cap/cancel the committed target steps down so the
message still ships promptly with an honest commitment - sending is
never blocked and nothing is dropped. The hot loop serializes the
canonical event once and rewrites only the fixed-width nonce bytes.

Inbound kind-20000 events are scored per NIP-13 commitment semantics
(committed target counts; the ID must actually meet it, extra work
earns nothing) and never hard-rejected: validated PoW >= 8 bits skips
the per-sender rate-limit bucket while the per-content flood bucket
still applies, so old non-mining clients keep working under today's
strict limits while bulk spam gets expensive.

Presence heartbeats (kind 20001), kind-1 notes, and DMs are unchanged;
no UI beyond a pow= field in an existing sampled debug log.

Reimplemented from scratch rather than cherry-picking the stale
feature/pow-geohash-mining-ui branch (unbounded loop, hard receive
filtering, mining UI, XCTest, force unwraps).

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

* Geohash: serialize PoW sends so order matches send order

Two location-channel sends back-to-back only cancelled the previous
mining task and started a new one. Cancellation merely *expedites* NIP-13
mining (the target is polled and steps down; it never aborts the send),
so the cancelled task still appended + relayed once mining returned. Both
tasks ran concurrently and the second (shorter to mine) could finish
first, reordering messages in the timeline and on relays.

Chain the mining tasks: each geohash send captures the previous send's
task, cancels it (to expedite, so delays never stack), and awaits its
completion before it echoes and relays. Order is now always send order.
The >2s mining cap is preserved: cancellation expedites the awaited task,
so a send is never blocked beyond NostrPoW.miningTimeCap.

Test: two rapid sends where the first mines longer (larger content) still
land in send order for both the local echo and the relayed events.

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-07 14:12:37 +02:00
276cde44e7 Gate Tor/relay startup on network reachability (#1389)
On a mesh-only/offline device the app used to bootstrap Tor and spin
Nostr relay reconnects forever ("connecting to Tor…"), wasting battery
even when there was provably no network path at all.

Add an NWPathMonitor-backed reachability signal (NetworkReachabilityMonitor)
and fold it into NetworkActivationService's activation gate:

- Tor bootstrap and relay connect/reconnect are now gated on the network
  path being usable. When the path is fully unsatisfied (no interface at
  all) we set autoStart off, shut Tor down, and disconnect relays instead
  of looping. When a usable path returns we resume.
- Conservative policy: only NWPath.Status.unsatisfied counts as offline.
  A flaky-but-present link stays "reachable" (Tor tolerates intermittent
  connectivity); we never tear down on the first hiccup.
- Transitions are debounced (ReachabilityDebounce, ~2.5s) so path flapping
  cannot thrash Tor/relay startup. The debounce is a pure value type,
  unit-tested without the Network framework or real timers.
- Starts optimistic (reachable) so nothing is suppressed before the first
  path evaluation arrives.
- BLE mesh never consults this gate and works fully offline.
- NWPathMonitor's background callback hops to the main actor before
  touching any state.

Surfaces NetworkActivationService.isNetworkReachable for UI to distinguish
"offline" from "connecting to Tor".

Tests: pure debounce (satisfied → allowed, unsatisfied → suppressed after
interval, flap debounced, recover-after-outage) plus service wiring
(unreachable suppresses Tor+relays, recovery resumes, loss disconnects).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:11:49 +02:00
201dbac49a docs: reconcile protocol docstrings with implementation (#1374)
- BitchatProtocol.swift: stop advertising "timing obfuscation prevents
  traffic analysis" — what exists is randomized relay jitter
  (RelayController, 10-220 ms) and PKCS#7-style padding to
  256/512/1024/2048-byte blocks (MessagePadding); there is no cover
  traffic or per-message timing obfuscation. Also update the stale
  Message Types list (Delivery/Read are Noise payloads, no Version
  negotiation type; add CourierEnvelope/RequestSync/FileTransfer).
- MessageType.swift: header said "6 essential" types; the enum has 9
  cases.

WHITEPAPER.md needed no changes: the #1372 rewrite already replaced the
old Bloom-filter and MessageRetryService claims, and its numbers
(dedup 1000/5min, jitter, outbox 100/peer 24h 8 attempts, courier
16 KiB/24h/40-20-5-2 quotas, spray 4/8, gossip 1000/15s/6h) all match
the code.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:10:58 +02:00
5fdc15d5af Add capability bits to announce TLV (#1375)
Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:07:52 +02:00
87 changed files with 29909 additions and 5202 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.4 MARKETING_VERSION = 1.6.0
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+19
View File
@@ -28,6 +28,7 @@ final class AppRuntime: ObservableObject {
let locationChannelsModel: LocationChannelsModel let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel let peerListModel: PeerListModel
let appChromeModel: AppChromeModel let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -91,6 +92,24 @@ final class AppRuntime: ObservableObject {
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel privateInboxModel: self.privateInboxModel
) )
let chatViewModel = self.chatViewModel
self.boardAlertsModel = BoardAlertsModel(
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { post in
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
return !key.isEmpty && key == post.authorSigningKey
},
emitSystemLine: { content, geohash in
if geohash.isEmpty {
chatViewModel.addMeshOnlySystemMessage(content)
} else {
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
}
}
)
)
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers() bindRuntimeObservers()
+1 -8
View File
@@ -8,9 +8,6 @@
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
#if DEBUG
import BitLogger
#endif
@main @main
struct BitchatApp: App { struct BitchatApp: App {
@@ -43,14 +40,10 @@ struct BitchatApp: App {
.environmentObject(runtime.locationChannelsModel) .environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel) .environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel) .environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel)
.onAppear { .onAppear {
appDelegate.runtime = runtime appDelegate.runtime = runtime
runtime.start() runtime.start()
#if DEBUG
// Arm the opt-in UDP log sink from persisted config (no-op
// until a collector host is set in the App Info sheet).
LogNetworkSink.shared.reloadConfiguration()
#endif
} }
.onOpenURL { url in .onOpenURL { url in
runtime.handleOpenURL(url) runtime.handleOpenURL(url)
-6
View File
@@ -33,18 +33,12 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBonjourServices</key>
<array>
<string>_bitchat-bulk._tcp</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string> <string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string> <string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
+25782 -491
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -74,11 +74,11 @@ enum CommandInfo: String, Identifiable {
if !isGeoPublic { if !isGeoPublic {
commands.append(.pay) commands.append(.pay)
} }
// The processor rejects favorites, groups and mesh diagnostics in // The processor rejects favorites, groups, and mesh diagnostics in
// geohash contexts, so only suggest them where they actually work: mesh. // geohash contexts, so only suggest them where they work: mesh.
if isGeoPublic || isGeoDM { if isGeoPublic || isGeoDM {
return commands return commands
} }
return commands + [.favorite, .unfavorite, .group, .ping, .trace] return commands + [.favorite, .unfavorite, .ping, .trace, .group]
} }
} }
+27 -2
View File
@@ -19,6 +19,7 @@ struct NostrProtocol {
case giftWrap = 1059 // NIP-59 gift wrap case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000 case ephemeralEvent = 20000
case geohashPresence = 20001 case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
} }
/// Create a NIP-17 private message /// Create a NIP-17 private message
@@ -256,16 +257,22 @@ struct NostrProtocol {
} }
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. /// 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.
static func createGeohashTextNote( static func createGeohashTextNote(
content: String, content: String,
geohash: String, geohash: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
nickname: String? = nil nickname: String? = nil,
expiresAt: Date? = nil
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if let expiresAt {
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
}
let event = NostrEvent( let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
@@ -276,7 +283,25 @@ struct NostrProtocol {
let schnorrKey = try senderIdentity.schnorrSigningKey() let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey) return try event.sign(with: schnorrKey)
} }
/// Create a NIP-09 deletion request for one of our own events. Relays that
/// honor NIP-09 drop the referenced event; it must be signed by the same
/// key that signed the original.
static func createDeleteEvent(
ofEventID eventID: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .deletion,
tags: [["e", eventID]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods // MARK: - Private Methods
private static func createSeal( private static func createSeal(
+7 -11
View File
@@ -28,14 +28,12 @@ struct BitchatFilePacket {
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length). /// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max). /// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass func encode() -> Data? {
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count) let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil } guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(limit) else { return nil } guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil } guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil } guard FileTransferLimits.isValidPayload(content.count) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) { func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian var big = value.bigEndian
@@ -68,9 +66,7 @@ struct BitchatFilePacket {
} }
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible. /// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass static func decode(_ data: Data) -> BitchatFilePacket? {
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
var cursor = data.startIndex var cursor = data.startIndex
let end = data.endIndex let end = data.endIndex
@@ -130,7 +126,7 @@ struct BitchatFilePacket {
for byte in value { for byte in value {
size = (size << 8) | UInt64(byte) size = (size << 8) | UInt64(byte)
} }
if size > UInt64(limit) { if size > UInt64(FileTransferLimits.maxPayloadBytes) {
return nil return nil
} }
fileSize = size fileSize = size
@@ -139,7 +135,7 @@ struct BitchatFilePacket {
mimeType = String(data: Data(value), encoding: .utf8) mimeType = String(data: Data(value), encoding: .utf8)
case .content: case .content:
let proposedSize = content.count + value.count let proposedSize = content.count + value.count
if proposedSize > limit { if proposedSize > FileTransferLimits.maxPayloadBytes {
return nil return nil
} }
content.append(contentsOf: value) content.append(contentsOf: value)
@@ -149,7 +145,7 @@ struct BitchatFilePacket {
} }
guard !content.isEmpty else { return nil } guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil } guard FileTransferLimits.isValidPayload(content.count) else { return nil }
return BitchatFilePacket( return BitchatFilePacket(
fileName: fileName, fileName: fileName,
fileSize: fileSize ?? UInt64(content.count), fileSize: fileSize ?? UInt64(content.count),
+1 -6
View File
@@ -74,10 +74,7 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered case delivered = 0x03 // Message was delivered
// Wi-Fi bulk transport negotiation (AWDL data plane for large media) // Private groups (0x04/0x05 reserved by other features)
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
// Private groups
case groupInvite = 0x06 // Creator-signed group state (invite) case groupInvite = 0x06 // Creator-signed group state (invite)
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Verification (QR-based OOB binding) // Verification (QR-based OOB binding)
@@ -91,8 +88,6 @@ enum NoisePayloadType: UInt8 {
case .privateMessage: return "privateMessage" case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt" case .readReceipt: return "readReceipt"
case .delivered: return "delivered" case .delivered: return "delivered"
case .bulkTransferOffer: return "bulkTransferOffer"
case .bulkTransferResponse: return "bulkTransferResponse"
case .groupInvite: return "groupInvite" case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate" case .groupKeyUpdate: return "groupKeyUpdate"
case .verifyChallenge: return "verifyChallenge" case .verifyChallenge: return "verifyChallenge"
+8
View File
@@ -18,6 +18,14 @@ enum Geohash {
return geohash.lowercased().allSatisfy { base32Map[$0] != nil } 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
static func isValidGeohash(_ geohash: String) -> Bool {
guard (1...12).contains(geohash.count) else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
/// Encodes the provided coordinates into a geohash string. /// Encodes the provided coordinates into a geohash string.
/// - Parameters: /// - Parameters:
/// - latitude: Latitude in degrees (-90...90) /// - latitude: Latitude in degrees (-90...90)
@@ -3,9 +3,5 @@ import BitFoundation
extension PeerCapabilities { extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets. /// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships. /// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = { static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
return caps
}()
} }
@@ -44,9 +44,7 @@ final class BLEFileTransferHandler {
self.environment = environment self.environment = environment
} }
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries func handle(_ packet: BitchatPacket, from peerID: PeerID) {
/// pass the ceiling that was enforced against the accepted offer.
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
let env = environment let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return } if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
@@ -71,7 +69,7 @@ final class BLEFileTransferHandler {
let filePacket: BitchatFilePacket let filePacket: BitchatFilePacket
let mime: MimeType let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) { switch BLEIncomingFileValidator.validate(payload: packet.payload) {
case .success(let acceptance): case .success(let acceptance):
filePacket = acceptance.filePacket filePacket = acceptance.filePacket
mime = acceptance.mime mime = acceptance.mime
@@ -42,17 +42,12 @@ enum BLEIncomingFileRejection: Error, Equatable {
} }
enum BLEIncomingFileValidator { enum BLEIncomingFileValidator {
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
/// pass the ceiling enforced against the accepted offer. guard let filePacket = BitchatFilePacket.decode(payload) else {
static func validate(
payload: Data,
limit: Int = FileTransferLimits.maxPayloadBytes
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
return .failure(.malformedPayload) return .failure(.malformedPayload)
} }
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else { guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
return .failure(.payloadTooLarge(bytes: filePacket.content.count)) return .failure(.payloadTooLarge(bytes: filePacket.content.count))
} }
+11 -3
View File
@@ -33,13 +33,21 @@ final class BLEFragmentHandler {
func handle(_ packet: BitchatPacket, from peerID: PeerID) { func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment let env = environment
// Don't process our own fragments guard let header = BLEFragmentHeader(packet: packet) else { return }
// Sync replay legitimately hands us our own fragments back (the RSR
// ttl=0 restore path): after a relaunch the fragment store starts
// empty, so our sync filter doesn't cover them and peers re-offer
// them. Record them as seen the next round's filter then covers
// them and the redelivery stops but skip assembly: we authored
// the original, there is nothing to reassemble.
if peerID == env.localPeerID() { if peerID == env.localPeerID() {
if header.isBroadcastFragment {
env.trackPacketSeen(packet)
}
return return
} }
guard let header = BLEFragmentHeader(packet: packet) else { return }
if header.isBroadcastFragment { if header.isBroadcastFragment {
env.trackPacketSeen(packet) env.trackPacketSeen(packet)
} }
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) { switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake: case .noiseEncrypted, .noiseHandshake:
return true return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong: case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
return false return false
} }
} }
@@ -51,16 +51,16 @@ struct BLEReceivePipeline {
// Courier envelopes are directed opaque ciphertext like DMs; a // Courier envelopes are directed opaque ciphertext like DMs; a
// remote handover toward a relayed announce rides this same // remote handover toward a relayed announce rides this same
// deterministic relay treatment instead of the broadcast clamp. // deterministic relay treatment instead of the broadcast clamp.
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need // Ping/pong diagnostics ride it too: probes need the same
// the same multi-hop treatment to reach a non-adjacent gateway.
// Ping/pong diagnostics also ride it: probes need the same
// deterministic multi-hop relay as DMs (always relay, jitter, // deterministic multi-hop relay as DMs (always relay, jitter,
// no TTL cap) so RTT and hop counts reflect the real path. // no TTL cap) so RTT and hop counts reflect the real path.
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
// the same multi-hop treatment to reach a non-adjacent gateway.
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|| packet.type == MessageType.courierEnvelope.rawValue || packet.type == MessageType.courierEnvelope.rawValue
|| packet.type == MessageType.nostrCarrier.rawValue
|| packet.type == MessageType.ping.rawValue || packet.type == MessageType.ping.rawValue
|| packet.type == MessageType.pong.rawValue) && packet.recipientID != nil, || packet.type == MessageType.pong.rawValue
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue, isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
@@ -0,0 +1,53 @@
import Foundation
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
/// links) so the service can arm pending background connections against them
/// when the app leaves the foreground. Generic over the peripheral type so
/// the eviction/expiry logic is testable without CoreBluetooth.
final class BLERecentPeripheralCache<Peripheral> {
private struct Entry {
let peripheral: Peripheral
var lastSeen: Date
}
private var entries: [String: Entry] = [:]
private let capacity: Int
private let maxAge: TimeInterval
init(
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
) {
self.capacity = capacity
self.maxAge = maxAge
}
var count: Int { entries.count }
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
guard entries.count > capacity else { return }
// Inserts overshoot capacity by at most one; evict the stalest entry
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
entries.removeValue(forKey: stalest.key)
}
}
/// Most-recently-seen peripherals eligible for a pending background
/// connect, freshest first, capped at `limit`. Expired entries are
/// pruned as a side effect.
func reconnectTargets(
now: Date,
limit: Int,
excluding: (String) -> Bool
) -> [(peripheralID: String, peripheral: Peripheral)] {
let cutoff = now.addingTimeInterval(-maxAge)
entries = entries.filter { $0.value.lastSeen >= cutoff }
guard limit > 0 else { return [] }
return entries
.filter { !excluding($0.key) }
.sorted { $0.value.lastSeen > $1.value.lastSeen }
.prefix(limit)
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
}
}
+187 -222
View File
@@ -90,6 +90,8 @@ final class BLEService: NSObject {
#endif #endif
private var selfBroadcastTracker = BLESelfBroadcastTracker() private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker() private let meshTopology = MeshTopologyTracker()
// Route health for originated source routes; guarded by collectionsQueue.
private var sourceRouteFailures = BLESourceRouteFailureCache()
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the // Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
// inbound ping budget keyed by the ingress link (the directly connected // inbound ping budget keyed by the ingress link (the directly connected
@@ -109,10 +111,6 @@ final class BLEService: NSObject {
window: TransportConfig.meshPingInboundWindowSeconds window: TransportConfig.meshPingInboundWindowSeconds
) )
// Route health for originated source routes; guarded by collectionsQueue.
private var sourceRouteFailures = BLESourceRouteFailureCache()
// 5. Fragment Reassembly (necessary for messages > MTU) // 5. Fragment Reassembly (necessary for messages > MTU)
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
@@ -190,8 +188,6 @@ final class BLEService: NSObject {
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment()) private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
// File-transfer orchestration (queue hops stay in the environment closures) // File-transfer orchestration (queue hops stay in the environment closures)
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment()) private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
// Wi-Fi bulk data plane: BLE/Noise negotiates, AWDL carries large media
private lazy var wifiBulkService = WifiBulkTransferService(environment: makeWifiBulkEnvironment())
// MARK: - Gossip Sync // MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager? private var gossipSyncManager: GossipSyncManager?
@@ -201,6 +197,7 @@ final class BLEService: NSObject {
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles private var maintenanceCounter = 0 // Track maintenance cycles
private var lastMaintenanceAt = Date.distantPast // bleQueue-confined; drives background-wake catch-up passes
/// Whether real CoreBluetooth managers were initialized. When false (unit /// Whether real CoreBluetooth managers were initialized. When false (unit
/// tests), periodic mesh background work is not started the maintenance /// tests), periodic mesh background work is not started the maintenance
/// timer and the gossip-sync timers only drain BLE writes/notifications, /// timer and the gossip-sync timers only drain BLE writes/notifications,
@@ -211,6 +208,9 @@ final class BLEService: NSObject {
// MARK: - Connection budget & scheduling (central role) // MARK: - Connection budget & scheduling (central role)
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>() private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
// Recently seen peripherals retained for background wake-on-proximity
// connects (bleQueue-confined, like the link state store)
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
// MARK: - Adaptive scanning duty-cycle // MARK: - Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer? private var scanDutyTimer: DispatchSourceTimer?
@@ -316,12 +316,6 @@ final class BLEService: NSObject {
// Initialize gossip sync manager // Initialize gossip sync manager
restartGossipManager() restartGossipManager()
// Force single-threaded instantiation: unlike the packet handlers
// (touched only from messageQueue), the Wi-Fi bulk service is reached
// from the main actor too (cancelTransfer/stopServices), and lazy
// vars are not thread-safe.
_ = wifiBulkService
} }
private func restartGossipManager() { private func restartGossipManager() {
@@ -570,9 +564,6 @@ final class BLEService: NSObject {
} }
func stopServices() { func stopServices() {
// Tear down any Wi-Fi bulk listeners/connections deterministically
wifiBulkService.stop()
// Send leave message synchronously to ensure delivery // Send leave message synchronously to ensure delivery
var leavePacket = BitchatPacket( var leavePacket = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
@@ -792,9 +783,6 @@ final class BLEService: NSObject {
// MARK: Messaging // MARK: Messaging
func cancelTransfer(_ transferId: String) { func cancelTransfer(_ transferId: String) {
// A transfer may be riding the Wi-Fi bulk channel instead of the BLE
// fragment scheduler; cancelling both is safe (each no-ops on miss).
wifiBulkService.cancelTransfer(transferId: transferId)
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -870,72 +858,10 @@ final class BLEService: NSObject {
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
// Encode with the Wi-Fi bulk ceiling; whether the payload also guard let payload = filePacket.encode() else {
// fits the BLE caps decides what a fallback may do.
guard let payload = filePacket.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes) else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
return return
} }
let fitsBLECaps = filePacket.encode() != nil
// Normalize to the short routing ID (SHA256-derived 16-hex) once.
// Stable/verified private chats address the peer by its full
// 64-hex Noise key, but the Noise session and the negotiation
// packet are keyed by the short ID so the capability/session
// eligibility checks (and the Wi-Fi offer) must all use it, or a
// 64-hex key makes `hasEstablishedSession` return false and Wi-Fi
// is never selected even when the peer advertises `.wifiBulk`.
let routingID = peerID.toShort()
let candidate = self.wifiBulkSendCandidate(payloadBytes: payload.count, to: routingID)
if WifiBulkPolicy.shouldOffer(candidate) {
self.wifiBulkService.sendFile(payload: payload, to: routingID, transferId: transferId) { [weak self] in
self?.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: fitsBLECaps)
}
return
}
guard fitsBLECaps else {
// Wi-Fi-only payload with no eligible Wi-Fi path.
SecureLogger.error("❌ File exceeds BLE caps and Wi-Fi bulk is unavailable", category: .session)
self.surfaceUndeliverableTransfer(transferId)
return
}
self.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: true)
}
}
/// Builds the Wi-Fi bulk eligibility candidate for a private send.
///
/// Normalizes `peerID` to its short routing ID first: stable/verified
/// private chats address the peer by its full 64-hex Noise key, but the
/// Noise session, advertised capabilities, and connection state are all
/// keyed by the short (SHA256-derived 16-hex) ID. Resolving them with the
/// 64-hex key would miss the session (`hasEstablishedSession` returns
/// false), so Wi-Fi would never be offered even to a directly-connected
/// `.wifiBulk` peer.
private func wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate {
let routingID = peerID.toShort()
return WifiBulkPolicy.SendCandidate(
payloadBytes: payloadBytes,
peerCapabilities: peerCapabilities(routingID),
isDirectlyConnected: isPeerConnected(routingID),
hasEstablishedNoiseSession: noiseService.hasEstablishedSession(with: routingID)
)
}
/// BLE fragmentation path for private file transfers; also the fallback
/// target when a Wi-Fi bulk attempt declines, times out, or errors.
/// May be invoked from the Wi-Fi bulk queue.
private func sendFilePrivateViaBLE(payload: Data, to peerID: PeerID, transferId: String, fitsBLECaps: Bool) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard fitsBLECaps else {
// A >1 MiB payload was negotiated for Wi-Fi and the channel
// failed: BLE cannot carry it, surface the normal failure path.
SecureLogger.error("❌ Wi-Fi bulk failed and payload exceeds BLE caps (\(payload.count) bytes)", category: .session)
self.surfaceUndeliverableTransfer(transferId)
return
}
// Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
// This ensures 64-hex Noise keys are converted to the canonical routing format // This ensures 64-hex Noise keys are converted to the canonical routing format
let targetID = peerID.toShort() let targetID = peerID.toShort()
@@ -964,13 +890,6 @@ final class BLEService: NSObject {
} }
} }
/// Drives the progress bus through startedcancelled so the UI clears a
/// transfer that no transport can carry.
private func surfaceUndeliverableTransfer(_ transferId: String) {
TransferProgressManager.shared.start(id: transferId, totalFragments: 1)
TransferProgressManager.shared.cancel(id: transferId)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID) let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
@@ -1356,57 +1275,6 @@ final class BLEService: NSObject {
) )
} }
/// Builds the Wi-Fi bulk service environment. Noise encryption and packet
/// dispatch stay on the message queue; incoming payloads re-enter the
/// normal file-transfer pipeline as if a fully assembled packet arrived.
private func makeWifiBulkEnvironment() -> WifiBulkTransferServiceEnvironment {
WifiBulkTransferServiceEnvironment(
sendNoisePayload: { [weak self] typedPayload, peerID in
guard let self = self, self.noiseService.hasEstablishedSession(with: peerID) else { return false }
self.messageQueue.async { [weak self] in
guard let self = self else { return }
do {
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
} catch {
SecureLogger.error("❌ Failed to send Wi-Fi bulk negotiation payload: \(error)", category: .session)
}
}
return true
},
isPeerConnected: { [weak self] peerID in
self?.isPeerConnected(peerID) ?? false
},
deliverReceivedFile: { [weak self] payload, peerID, payloadLimit in
self?.messageQueue.async { [weak self] in
guard let self = self else { return }
let packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: Data(hexString: peerID.toShort().id) ?? Data(),
recipientID: self.myPeerIDData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0,
version: 2
)
self.fileTransferHandler.handle(packet, from: peerID, payloadLimit: payloadLimit)
}
},
progressStart: { transferId, totalChunks in
TransferProgressManager.shared.start(id: transferId, totalFragments: totalChunks)
},
progressChunkSent: { transferId in
TransferProgressManager.shared.recordFragmentSent(id: transferId)
},
progressReset: { transferId in
TransferProgressManager.shared.reset(id: transferId)
},
progressCancel: { transferId in
TransferProgressManager.shared.cancel(id: transferId)
}
)
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session) SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
@@ -1651,6 +1519,14 @@ extension BLEService: CBCentralManagerDelegate {
assembler: assembler assembler: assembler
) )
linkStateStore.setPeripheralState(restoredState, for: identifier) linkStateStore.setPeripheralState(restoredState, for: identifier)
// Restored peripherals are the freshest wake-on-proximity
// candidates we have after a relaunch without this the cache
// starts empty and backgrounding right after a restore arms
// nothing. Service rediscovery for restored-connected links waits
// for poweredOn: CoreBluetooth drops commands issued during
// restoration (API MISUSE warnings).
recentPeripheralCache.record(peripheral, peripheralID: identifier, at: Date())
} }
captureBluetoothStatus(context: "central-restore") captureBluetoothStatus(context: "central-restore")
@@ -1666,6 +1542,17 @@ extension BLEService: CBCentralManagerDelegate {
switch central.state { switch central.state {
case .poweredOn: case .poweredOn:
// Links restored as connected have no characteristic in the new
// process; without rediscovery they sit connected-but-unusable
// until the peer disconnects. Runs here (not willRestoreState)
// because commands issued before poweredOn are dropped.
for state in linkStateStore.peripheralStates where state.isConnected
&& state.characteristic == nil
&& state.peripheral.state == .connected {
SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))", category: .session)
state.peripheral.discoverServices([BLEService.serviceUUID])
}
// Start scanning - use allow duplicates for faster discovery when active // Start scanning - use allow duplicates for faster discovery when active
startScanning() startScanning()
@@ -1745,6 +1632,9 @@ extension BLEService: CBCentralManagerDelegate {
isConnectable: isConnectable, isConnectable: isConnectable,
discoveredAt: Date() discoveredAt: Date()
) )
if isConnectable {
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
}
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init) let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
switch connectionScheduler.handleDiscovery( switch connectionScheduler.handleDiscovery(
@@ -1771,7 +1661,15 @@ extension BLEService: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
let peripheralID = peripheral.identifier.uuidString let peripheralID = peripheral.identifier.uuidString
#if os(iOS)
// A connect completing while backgrounded is the wake-on-proximity
// path doing its job worth an info line for field verification.
if !isAppActive {
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
}
#endif
// Update state to connected // Update state to connected
linkStateStore.markConnected(peripheral) linkStateStore.markConnected(peripheral)
@@ -1796,7 +1694,26 @@ extension BLEService: CBCentralManagerDelegate {
if error != nil { if error != nil {
connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date()) connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date())
} }
// Retain the handle: a dropped link is the best wake-on-proximity
// candidate if the app backgrounds before the peer returns.
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date())
#if os(iOS)
// Link lost while backgrounded (peer walked away): re-arm a pending
// connect during this wake window so the peer's return wakes us again.
// Delayed past the disconnect-settle window to avoid reconnect thrash
// at range edge.
if !isAppActive {
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
guard let self, !self.isAppActive else { return }
// Reserve 0: use the slot this disconnect freed even in a
// dense mesh, so the lost peer can wake us when it returns.
self.armPendingBackgroundConnects(slotReserve: 0)
}
}
#endif
// Clean up references and peer mappings // Clean up references and peer mappings
_ = linkStateStore.removePeripheral(peripheralID) _ = linkStateStore.removePeripheral(peripheralID)
if let peerID { if let peerID {
@@ -1924,6 +1841,18 @@ extension BLEService {
return return
} }
#if os(iOS)
if !self.isAppActive {
// Backgrounded: leave the connect pending. iOS never expires
// it the controller completes it whenever the peer comes
// back into range, waking the app (state restoration relaunches
// us if we were terminated). Foreground return cancels stale
// pendings via cancelStalePendingConnects().
SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session)
return
}
#endif
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
central.cancelPeripheralConnection(peripheral) central.cancelPeripheralConnection(peripheral)
_ = self.linkStateStore.removePeripheral(peripheralID) _ = self.linkStateStore.removePeripheral(peripheralID)
@@ -2003,34 +1932,6 @@ extension BLEService {
cachedServiceUUIDs: cachedServiceUUIDs cachedServiceUUIDs: cachedServiceUUIDs
) )
} }
/// The internal Noise service, so tests can drive a real handshake and
/// establish a session keyed by a chosen (short) routing ID.
var _test_noiseService: NoiseEncryptionService { noiseService }
/// Registers a directly-connected peer with the given advertised
/// capabilities, keyed by its short routing ID (as production does).
func _test_registerConnectedPeer(_ peerID: PeerID, capabilities: PeerCapabilities) {
let shortID = peerID.toShort()
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
peerID: shortID,
nickname: "TestPeer_\(shortID.id.prefix(4))",
isConnected: true,
noisePublicKey: peerID.noiseKey,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: Date(),
capabilities: capabilities
))
}
}
/// Runs the production Wi-Fi bulk eligibility resolution (including peer-ID
/// normalization) for the given payload size and recipient.
func _test_wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate {
wifiBulkSendCandidate(payloadBytes: payloadBytes, to: peerID)
}
} }
#endif #endif
@@ -2703,7 +2604,7 @@ extension BLEService {
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket { private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
let now = Date() let now = Date()
let decision = BLESourceRouteOriginationPolicy.decide( let route = BLESourceRouteOriginationPolicy.route(
for: packet, for: packet,
to: recipient, to: recipient,
localPeerIDData: myPeerIDData, localPeerIDData: myPeerIDData,
@@ -2715,19 +2616,7 @@ extension BLEService {
}, },
computeRoute: { self.computeRoute(to: $0) } computeRoute: { self.computeRoute(to: $0) }
) )
let route: [Data] guard let route else { return packet }
switch decision {
case .flood(let reason):
// Only log the interesting directed cases; a plain broadcast keeps
// flood by design and would spam the origination path.
if reason != .broadcast {
SecureLogger.info("[ROUTE] flood to \(recipient.id.prefix(8))… (\(reason.rawValue))", category: .mesh)
}
return packet
case .route(let hops):
SecureLogger.info("[ROUTE] source-route to \(recipient.id.prefix(8))… via \(hops.count) hop(s)", category: .mesh)
route = hops
}
// Create new packet with route applied and version upgraded to 2 // Create new packet with route applied and version upgraded to 2
let routedPacket = BitchatPacket( let routedPacket = BitchatPacket(
type: packet.type, type: packet.type,
@@ -2817,7 +2706,7 @@ extension BLEService {
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) { private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return } guard packet.recipientID == myPeerIDData else { return }
guard let ping = MeshPingPayload.decode(packet.payload) else { guard let ping = MeshPingPayload.decode(packet.payload) else {
SecureLogger.debug("[PING] malformed ping via \(linkPeerID.id.prefix(8))", category: .mesh) SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))", category: .session)
return return
} }
let allowed = collectionsQueue.sync(flags: .barrier) { let allowed = collectionsQueue.sync(flags: .barrier) {
@@ -2825,7 +2714,7 @@ extension BLEService {
} }
guard allowed else { guard allowed else {
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") { if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
SecureLogger.warning("[PING] rate-limiting pings via link \(linkPeerID.id.prefix(8))", category: .mesh) SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))", category: .security)
} }
return return
} }
@@ -2859,7 +2748,6 @@ extension BLEService {
rttMs: max(0, rttMs), rttMs: max(0, rttMs),
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl) hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
) )
SecureLogger.info("[PING] pong from \(peerID.id.prefix(8))… rtt=\(result.rttMs)ms hops=\(result.hops)", category: .mesh)
Task { @MainActor in pending.completion(result) } Task { @MainActor in pending.completion(result) }
} }
@@ -2868,15 +2756,11 @@ extension BLEService {
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
refreshLocalTopology() refreshLocalTopology()
if let route = computeRoute(to: peerID) { if let route = computeRoute(to: peerID) {
let path = route.compactMap { PeerID(routingData: $0) } return route.compactMap { PeerID(routingData: $0) }
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… via \(path.count) hop(s)", category: .mesh)
return path
} }
// Confirmed claims can lag a brand-new link (the peer's next announce // Confirmed claims can lag a brand-new link (the peer's next announce
// hasn't arrived yet); a live direct connection is still a known path. // hasn't arrived yet); a live direct connection is still a known path.
let direct = isPeerConnected(peerID) return isPeerConnected(peerID) ? [] : nil
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))\(direct ? "direct (0 hops)" : "no known path")", category: .mesh)
return direct ? [] : nil
} }
/// Mesh graph for the topology map. Edges are advisory: announces cap /// Mesh graph for the topology map. Edges are advisory: announces cap
@@ -3022,11 +2906,9 @@ extension BLEService {
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
prekeyID = prekey.id prekeyID = prekey.id
SecureLogger.info("[PREKEY] seal prekey (id=\(prekey.id)) id=\(messageID.prefix(8))", category: .session)
} else { } else {
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
prekeyID = nil prekeyID = nil
SecureLogger.info("[PREKEY] seal static (no bundle) id=\(messageID.prefix(8))", category: .session)
} }
let envelope = CourierEnvelope( let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag( recipientTag: CourierEnvelope.recipientTag(
@@ -3120,7 +3002,6 @@ extension BLEService {
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey) (typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
if opened.consumedPrekey { if opened.consumedPrekey {
let replenished = noiseService.replenishPrekeysIfNeeded() let replenished = noiseService.replenishPrekeysIfNeeded()
SecureLogger.info("[PREKEY] consume id=\(prekeyID), re-gossip bundle (replenished=\(replenished))", category: .session)
sendPrekeyBundle(force: replenished) sendPrekeyBundle(force: replenished)
} }
} else { } else {
@@ -3352,7 +3233,7 @@ extension BLEService {
return nil return nil
} }
guard let signingKey else { guard let signingKey else {
SecureLogger.debug("[PREKEY] bundle defer (no bound signing key) owner \(owner.id.prefix(8))", category: .session) SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8)))", category: .security)
return return
} }
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
@@ -3363,13 +3244,11 @@ extension BLEService {
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) { private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey), guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.info("[PREKEY] bundle reject (bad signature) owner \(owner.id.prefix(8))", category: .session) SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8)))", category: .security)
return return
} }
if prekeyBundleStore.ingest(bundle) { if prekeyBundleStore.ingest(bundle) {
SecureLogger.info("[PREKEY] bundle accept owner \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .session) SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
} else {
SecureLogger.debug("[PREKEY] bundle reject (not newer/invalid) owner \(owner.id.prefix(8))", category: .session)
} }
gossipSyncManager?.onPublicPacketSeen(packet) gossipSyncManager?.onPublicPacketSeen(packet)
} }
@@ -3593,11 +3472,12 @@ extension BLEService {
centralManager?.stopScan() centralManager?.stopScan()
startScanning() startScanning()
} }
cancelStalePendingConnects()
logBluetoothStatus("became-active") logBluetoothStatus("became-active")
scheduleBluetoothStatusSample(after: 5.0, context: "active-5s") scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
// No Local Name; nothing to refresh for advertising policy // No Local Name; nothing to refresh for advertising policy
} }
@objc private func appDidEnterBackground() { @objc private func appDidEnterBackground() {
isAppActive = false isAppActive = false
// Restart scanning without allow duplicates in background // Restart scanning without allow duplicates in background
@@ -3605,6 +3485,7 @@ extension BLEService {
centralManager?.stopScan() centralManager?.stopScan()
startScanning() startScanning()
} }
armPendingBackgroundConnects()
// Backgrounding may precede a kill; flush the public-history archive // Backgrounding may precede a kill; flush the public-history archive
// outside its 30s maintenance cadence. // outside its 30s maintenance cadence.
gossipSyncManager?.persistNow() gossipSyncManager?.persistNow()
@@ -3612,6 +3493,81 @@ extension BLEService {
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
// No Local Name; nothing to refresh for advertising policy // No Local Name; nothing to refresh for advertising policy
} }
/// Issue indefinite `connect()` requests to recently seen peripherals on
/// backgrounding. Pending connects live in the Bluetooth controller's
/// allowlist no scanning and no app CPU and complete whenever a peer
/// comes into range, waking (or relaunching) the app. A couple of central
/// slots stay reserved for connects driven by live background discovery
/// except on the disconnect re-arm path, which may consume the slot the
/// disconnect itself just freed (a dense mesh with 4+ remaining links
/// would otherwise compute a zero budget and never re-arm the lost peer).
private func armPendingBackgroundConnects(
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
) {
bleQueue.async { [weak self] in
guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
let budget = TransportConfig.bleMaxCentralLinks
- slotReserve
- self.linkStateStore.connectedOrConnectingPeripheralCount
let now = Date()
let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in
let state = self.linkStateStore.state(forPeripheralID: peripheralID)
return state?.isConnected == true || state?.isConnecting == true
}
guard !targets.isEmpty else { return }
for target in targets {
// lastConnectionAttempt stays nil: an indefinite pending connect
// has no attempt clock, and nil marks it always-stale so
// cancelStalePendingConnects() reclaims it on foreground even
// after a quick backgroundforeground bounce.
self.linkStateStore.setPeripheralState(
BLEPeripheralLinkState(
peripheral: target.peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: target.peripheralID
)
target.peripheral.delegate = self
central.connect(target.peripheral, options: [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
])
}
SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session)
}
}
/// Foreground restores normal connection management: pending connects
/// older than the connect timeout (including ones rebuilt by state
/// restoration after a relaunch) are cancelled so live scanning and the
/// scheduler take over. Anything still nearby is rediscovered within
/// seconds by the allow-duplicates foreground scan.
private func cancelStalePendingConnects() {
bleQueue.async { [weak self] in
guard let self, let central = self.centralManager else { return }
let now = Date()
var cancelled = 0
for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected {
let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
let peripheralID = state.peripheral.identifier.uuidString
central.cancelPeripheralConnection(state.peripheral)
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
if cancelled > 0 {
SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
self.tryConnectFromQueue()
}
}
}
#endif #endif
// MARK: Private Message Handling // MARK: Private Message Handling
@@ -3960,6 +3916,16 @@ extension BLEService {
} }
} }
#if os(iOS)
// The maintenance timer is suspended with the app, so a packet arriving
// while backgrounded means the radio woke us use the wake window to
// run the announce/flush/drain pass the timer would have run.
if !isAppActive {
bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() }
}
#endif
// Process by type // Process by type
switch context.messageType { switch context.messageType {
case .announce: case .announce:
@@ -3986,12 +3952,15 @@ extension BLEService {
case .courierEnvelope: case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID) handleCourierEnvelope(packet, from: peerID)
case .prekeyBundle:
handlePrekeyBundle(packet, from: senderID)
case .groupMessage: case .groupMessage:
handleGroupMessage(packet, from: senderID) handleGroupMessage(packet, from: senderID)
case .prekeyBundle:
handlePrekeyBundle(packet, from: senderID)
case .boardPost:
// Invalid or deleted posts must not spread; skip the relay step.
guard handleBoardPost(packet, from: senderID) else { return }
case .nostrCarrier: case .nostrCarrier:
handleNostrCarrier(packet, from: peerID) handleNostrCarrier(packet, from: peerID)
@@ -4004,10 +3973,6 @@ extension BLEService {
case .pong: case .pong:
handleMeshPong(packet, from: senderID) handleMeshPong(packet, from: senderID)
case .boardPost:
// Invalid or deleted posts must not spread; skip the relay step.
guard handleBoardPost(packet, from: senderID) else { return }
case .leave: case .leave:
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
@@ -4391,21 +4356,8 @@ extension BLEService {
self?.noiseService.clearSession(for: peerID) self?.noiseService.clearSession(for: peerID)
}, },
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
guard let self = self else { return }
// Wi-Fi bulk negotiation is a transport concern; consume it
// here instead of surfacing it to the UI layer.
switch type {
case .bulkTransferOffer:
self.wifiBulkService.handleOfferPayload(payload, from: peerID)
return
case .bulkTransferResponse:
self.wifiBulkService.handleResponsePayload(payload, from: peerID)
return
default:
break
}
// Single main-actor hop delivering `.noisePayloadReceived`. // Single main-actor hop delivering `.noisePayloadReceived`.
self.notifyUI { [weak self] in self?.notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived( self?.deliverTransportEvent(.noisePayloadReceived(
peerID: peerID, peerID: peerID,
type: type, type: type,
@@ -4470,6 +4422,7 @@ extension BLEService {
private func performMaintenance() { private func performMaintenance() {
maintenanceCounter += 1 maintenanceCounter += 1
lastMaintenanceAt = Date()
let now = Date() let now = Date()
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
@@ -4534,6 +4487,18 @@ extension BLEService {
} }
} }
#if os(iOS)
/// Catch-up maintenance for background wake windows (bleQueue-confined).
/// Rate-limited to the normal maintenance cadence so a burst of inbound
/// packets during one wake still runs at most one extra pass.
private func performBackgroundWakeMaintenanceIfStale() {
guard meshBackgroundEnabled,
!isAppActive,
Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return }
performMaintenance()
}
#endif
private func checkPeerConnectivity() { private func checkPeerConnectivity() {
let now = Date() let now = Date()
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs } let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
@@ -4,27 +4,8 @@ import Foundation
/// Decides whether an outbound directed packet should carry a v2 source /// Decides whether an outbound directed packet should carry a v2 source
/// route. Pure gating logic so BLEService's hot send path stays a thin wire. /// route. Pure gating logic so BLEService's hot send path stays a thin wire.
enum BLESourceRouteOriginationPolicy { enum BLESourceRouteOriginationPolicy {
/// Why a packet kept flood/direct-write behavior instead of routing. /// Returns the intermediate-hop route to attach, or nil to keep the
/// The `rawValue` doubles as the greppable `[ROUTE]` log reason. /// current flood/direct-write behavior unchanged.
enum FloodReason: String {
case relayedNotOriginator = "not originator"
case broadcast = "broadcast recipient"
case noTTLHeadroom = "link-local ttl"
case recipientDirect = "recipient direct"
case routeSuppressed = "route suppressed→flood"
case noPath = "no v2 path"
}
/// The routing decision for an originated packet.
enum Decision: Equatable {
/// Keep flood/direct-write behavior unchanged; carries the reason.
case flood(FloodReason)
/// Originate a v2 source route over these intermediate hops.
case route([Data])
}
/// Returns whether to originate a v2 source route (with its hops) or keep
/// flood/direct-write, with the reason for the latter.
/// ///
/// Routes are only originated when every gate passes: /// Routes are only originated when every gate passes:
/// - we authored the packet (relays must not rewrite and re-sign someone /// - we authored the packet (relays must not rewrite and re-sign someone
@@ -38,22 +19,22 @@ enum BLESourceRouteOriginationPolicy {
/// - routing to the recipient is not suppressed by a recent unconfirmed /// - routing to the recipient is not suppressed by a recent unconfirmed
/// routed send, and /// routed send, and
/// - the topology yields a complete path. /// - the topology yields a complete path.
static func decide( static func route(
for packet: BitchatPacket, for packet: BitchatPacket,
to recipient: PeerID, to recipient: PeerID,
localPeerIDData: Data, localPeerIDData: Data,
isRecipientConnected: (PeerID) -> Bool, isRecipientConnected: (PeerID) -> Bool,
shouldAttemptRoute: (PeerID) -> Bool, shouldAttemptRoute: (PeerID) -> Bool,
computeRoute: (PeerID) -> [Data]? computeRoute: (PeerID) -> [Data]?
) -> Decision { ) -> [Data]? {
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) } guard packet.senderID == localPeerIDData else { return nil }
guard let recipientData = packet.recipientID, guard let recipientData = packet.recipientID,
recipientData.count == 8, recipientData.count == 8,
!recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) } !recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) } guard packet.ttl > 1 else { return nil }
guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) } guard !isRecipientConnected(recipient) else { return nil }
guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) } guard shouldAttemptRoute(recipient) else { return nil }
guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) } guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
return .route(route) return route
} }
} }
@@ -0,0 +1,160 @@
//
// BoardAlertsModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Combine
import Foundation
/// Turns newly arriving board posts into local, scope-matched chat alerts.
/// Everything here is derived from posts the mesh already synced no extra
/// wire traffic, nothing another peer can't already see.
///
/// - Urgent, recent pins get one system line in the matching chat (geo pin
/// that geohash's timeline, mesh pin mesh chat), collapsed when several
/// arrive together.
/// - Every other new pin just marks the header's pin icon until the notices
/// sheet is opened.
@MainActor
final class BoardAlertsModel: ObservableObject {
struct Dependencies {
/// Own posts never alert; the author already knows.
var isOwnPost: @MainActor (BoardPostPacket) -> Bool
/// Appends a local system line to a scope's chat timeline
/// (geohash, or "" for mesh chat).
var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void
var now: () -> Date = Date.init
/// Schedules the collapsed flush of pending urgent alerts; tests
/// inject a synchronous hook.
var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000))
flush()
}
}
}
/// Posts older than this at arrival are backfilled history carried in by
/// a peer, not something happening now; they badge but never line the chat.
static let inlineRecencyWindow: TimeInterval = 30 * 60
/// Urgent arrivals within this window collapse into one line.
static let collapseDelaySeconds: TimeInterval = 4
private static let alertContentMaxChars = 120
/// Unseen new pins by postID (hex) geohash scope, cleared when the
/// notices sheet opens.
@Published private(set) var unseenPostScopes: [String: String] = [:]
/// PostIDs already handled this session, so store eviction/re-sync churn
/// can't re-alert. Bounded by session wire volume (32-byte strings).
private var handledPostIDs = Set<String>()
private var pendingUrgent: [String: [BoardPostPacket]] = [:]
private var flushScheduled = false
private let dependencies: Dependencies
private var cancellable: AnyCancellable?
private var wipeCancellable: AnyCancellable?
private enum Strings {
static func urgentSingle(author: String, content: String) -> String {
String(
format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"),
locale: .current,
author, content
)
}
static func urgentCollapsed(_ count: Int) -> String {
String(
format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"),
locale: .current,
count
)
}
}
init(
arrivals: AnyPublisher<BoardPostPacket, Never>,
wipes: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher(),
dependencies: Dependencies
) {
self.dependencies = dependencies
cancellable = arrivals
.receive(on: DispatchQueue.main)
.sink { [weak self] post in
self?.handleArrival(post)
}
wipeCancellable = wipes
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.reset()
}
}
func unseenCount(forGeohash geohash: String) -> Int {
unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) }
}
/// Marks pins in the given scopes as seen only the scopes the notices
/// sheet actually shows, so unseen pins for other geohash channels keep
/// their badge until visited.
func markSeen(forScopes scopes: Set<String>) {
guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return }
unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) }
}
/// Panic wipe: drop everything derived from pre-wipe posts, including
/// urgent lines still waiting on the collapse flush.
func reset() {
pendingUrgent.removeAll()
handledPostIDs.removeAll()
guard !unseenPostScopes.isEmpty else { return }
unseenPostScopes.removeAll()
}
func handleArrival(_ post: BoardPostPacket) {
let postID = post.postID.hexEncodedString()
guard !handledPostIDs.contains(postID) else { return }
handledPostIDs.insert(postID)
guard !dependencies.isOwnPost(post) else { return }
unseenPostScopes[postID] = post.geohash
let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
guard post.isUrgent,
dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else {
return
}
pendingUrgent[post.geohash, default: []].append(post)
if !flushScheduled {
flushScheduled = true
dependencies.scheduleFlush { [weak self] in
self?.flushPendingUrgent()
}
}
}
private func flushPendingUrgent() {
flushScheduled = false
let pending = pendingUrgent
pendingUrgent.removeAll()
for (geohash, posts) in pending {
guard let first = posts.first else { continue }
let line: String
if posts.count == 1 {
let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon"
line = Strings.urgentSingle(author: author, content: Self.truncated(first.content))
} else {
line = Strings.urgentCollapsed(posts.count)
}
dependencies.emitSystemLine(line, geohash)
}
}
private static func truncated(_ content: String) -> String {
guard content.count > alertContentMaxChars else { return content }
return content.prefix(alertContentMaxChars) + ""
}
}
+41 -9
View File
@@ -20,17 +20,28 @@ final class BoardManager: ObservableObject {
private let transport: Transport private let transport: Transport
private let store: BoardStore private let store: BoardStore
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void /// 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?
/// 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:
/// after a relaunch a delete still tombstones the board copy, but the
/// Nostr copy is left to expire with relay retention.
private var bridgedEventIDs: [Data: String] = [:]
private var cancellable: AnyCancellable? private var cancellable: AnyCancellable?
init( init(
transport: Transport, transport: Transport,
store: BoardStore = .shared, store: BoardStore = .shared,
publishToNostr: ((String, String, String) -> Void)? = nil publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
deleteFromNostr: ((String, String) -> Void)? = nil
) { ) {
self.transport = transport self.transport = transport
self.store = store self.store = store
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
cancellable = store.$postsSnapshot cancellable = store.$postsSnapshot
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] snapshot in .sink { [weak self] snapshot in
@@ -115,10 +126,10 @@ final class BoardManager: ObservableObject {
) )
transport.sendBoardPayload(BoardWire.post(post).encode()) transport.sendBoardPayload(BoardWire.post(post).encode())
// One-way Nostr bridge (v1): geohash posts also go out as kind-1 // Nostr bridge: geohash posts also go out as kind-1 location notes so
// location notes so online users see them. No inbound merge yet. // online users see them. Remember the event id for merged deletes.
if !geohash.isEmpty { if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) {
publishToNostr(trimmed, geohash, cleanNickname) bridgedEventIDs[postID] = eventID
} }
return true return true
} }
@@ -140,14 +151,20 @@ final class BoardManager: ObservableObject {
signature: signature signature: signature
) )
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
// Merged delete: also retract the bridged Nostr copy when we still
// know its event id.
if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) {
deleteFromNostr(eventID, post.geohash)
}
return true return true
} }
private static func livePublishToNostr(content: String, geohash: String, nickname: String) { private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else { guard !relays.isEmpty else {
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session) SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
return return nil
} }
do { do {
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
@@ -155,11 +172,26 @@ final class BoardManager: ObservableObject {
content: content, content: content,
geohash: geohash, geohash: geohash,
senderIdentity: identity, senderIdentity: identity,
nickname: nickname nickname: nickname,
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
) )
NostrRelayManager.shared.sendEvent(event, to: relays) NostrRelayManager.shared.sendEvent(event, to: relays)
return event.id
} catch { } catch {
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session) SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
return nil
}
}
private static func liveDeleteFromNostr(eventID: String, geohash: String) {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else { return }
do {
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity)
NostrRelayManager.shared.sendEvent(deletion, to: relays)
} catch {
SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session)
} }
} }
} }
+18
View File
@@ -78,6 +78,16 @@ final class BoardStore {
/// Live posts, published on the main thread for the board UI. /// Live posts, published on the main thread for the board UI.
@Published private(set) var postsSnapshot: [BoardPostPacket] = [] @Published private(set) var postsSnapshot: [BoardPostPacket] = []
/// Fires on the main thread for each post newly accepted from the wire
/// (radio, sync, or local echo) not for disk restores. Drives the
/// local new-pin chat alerts; duplicates never fire twice because the
/// store rejects them.
let postArrivals = PassthroughSubject<BoardPostPacket, Never>()
/// Fires on the main thread after a panic wipe so derived state (pending
/// alerts, unseen badges) is dropped along with the posts themselves.
let didWipe = PassthroughSubject<Void, Never>()
private var posts: [StoredPost] = [] private var posts: [StoredPost] = []
private var tombstones: [StoredTombstone] = [] private var tombstones: [StoredTombstone] = []
private let queue = DispatchQueue(label: "chat.bitchat.board.store") private let queue = DispatchQueue(label: "chat.bitchat.board.store")
@@ -104,6 +114,11 @@ final class BoardStore {
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs) let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
if result == .accepted { if result == .accepted {
persistLocked() persistLocked()
if case .post(let post) = wire {
DispatchQueue.main.async { [weak self] in
self?.postArrivals.send(post)
}
}
} }
return result return result
} }
@@ -149,6 +164,9 @@ final class BoardStore {
} }
publishSnapshotLocked() publishSnapshotLocked()
} }
DispatchQueue.main.async { [weak self] in
self?.didWipe.send()
}
} }
// MARK: - Internals (call only on `queue`) // MARK: - Internals (call only on `queue`)
@@ -0,0 +1,88 @@
//
// UnifiedNotices.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// One row in the unified notices sheet: a mesh board post or a Nostr
/// location note, normalized for display.
struct NoticeItem: Identifiable, Equatable {
enum Source: Equatable {
/// Signed board post carried by the mesh.
case board(BoardPostPacket)
/// Kind-1 location note seen on geo relays.
case nostr(LocationNotesManager.Note)
}
let id: String
let author: String
let content: String
let createdAt: Date
let isUrgent: Bool
let source: Source
var isBoardPost: Bool {
if case .board = source { return true }
return false
}
init(post: BoardPostPacket) {
id = post.postID.hexEncodedString()
author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
content = post.content
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
isUrgent = post.isUrgent
source = .board(post)
}
init(note: LocationNotesManager.Note) {
id = note.id
let display = note.displayName
author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
.first.map(String.init) ?? display
content = note.content
createdAt = note.createdAt
isUrgent = false
source = .nostr(note)
}
}
/// Merges mesh board posts and Nostr location notes into one deduplicated
/// list for the notices sheet's geo tab.
enum UnifiedNotices {
/// Board posts on geohash channels are bridged to Nostr as kind-1 notes at
/// post time, so the same notice arrives twice. The copies share content
/// and nickname but are signed by unlinkable keys; match them
/// heuristically by content + author within a time window.
static let bridgeDedupeWindow: TimeInterval = 15 * 60
/// Returns board posts and notes as one list, urgent posts first, then
/// newest first. Notes that look like bridged copies of a board post are
/// dropped the board copy wins because it carries urgency and supports
/// merged deletion. The geohash must match exactly: the notes
/// subscription also surfaces neighboring cells, and a same-text note
/// from a neighbor is not the bridged copy.
static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] {
var items = posts.map(NoticeItem.init(post:))
for note in notes {
let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon"
let isBridgedCopy = posts.contains { post in
post.geohash == note.geohash
&& post.content == note.content
&& (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname
&& abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow
}
if !isBridgedCopy {
items.append(NoticeItem(note: note))
}
}
return items.sorted {
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
return $0.createdAt > $1.createdAt
}
}
}
+22 -26
View File
@@ -38,7 +38,11 @@ import Foundation
/// original broadcast packet is the TTL relay's job, not ours. /// original broadcast packet is the TTL relay's job, not ours.
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and /// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so /// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
/// repeat deposits and relay echoes are absorbed. /// repeat deposits and relay echoes are absorbed. An event this gateway
/// itself uplinked (`publishedEventIDs`) is additionally never
/// downlink-rebroadcast: it originated on this mesh, so echoing it back
/// when our own relay subscription redelivers it would double BLE airtime
/// (the device-confirmed self-echo bug).
/// 3. Uplink is only attempted for locally composed events at the send site /// 3. Uplink is only attempted for locally composed events at the send site
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the /// (`GeohashSubscriptionManager.sendGeohash`); events received over the
/// carrier never re-enter the uplink path. This is a call-site convention; /// carrier never re-enter the uplink path. This is a call-site convention;
@@ -152,7 +156,7 @@ final class GatewayService: ObservableObject {
pendingDownlinks.removeAll() pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll() uplinkDepositTimes.removeAll()
} }
SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway) SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
onEnabledChanged?(enabled) onEnabledChanged?(enabled)
} }
@@ -163,7 +167,7 @@ final class GatewayService: ObservableObject {
/// for broadcasts (downlink rebroadcasts from a gateway). /// for broadcasts (downlink rebroadcasts from a gateway).
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) { func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
guard let carrier = NostrCarrierPacket.decode(payload) else { guard let carrier = NostrCarrierPacket.decode(payload) else {
SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))", category: .gateway) SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))", category: .session)
return return
} }
switch carrier.direction { switch carrier.direction {
@@ -186,7 +190,7 @@ final class GatewayService: ObservableObject {
// age) no crypto so junk and stale replays are dropped before we // age) no crypto so junk and stale replays are dropped before we
// ever pay for a MainActor Schnorr verify. // ever pay for a MainActor Schnorr verify.
guard let event = structurallyValidEvent(from: carrier) else { guard let event = structurallyValidEvent(from: carrier) else {
SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))", category: .gateway) SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8)) (failed validation)", category: .security)
return return
} }
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a // Dedup by the carried event ID BEFORE verification. Loop rule 1: a
@@ -197,19 +201,18 @@ final class GatewayService: ObservableObject {
guard !meshBroadcastEventIDs.contains(event.id), guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id), !publishedEventIDs.contains(event.id),
!queuedUplinks.contains(where: { $0.event.id == event.id }) else { !queuedUplinks.contains(where: { $0.event.id == event.id }) else {
SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))", category: .gateway)
return return
} }
// Consume the per-depositor rate token BEFORE the expensive verify so // Consume the per-depositor rate token BEFORE the expensive verify so
// a flood of distinct forged/junk deposits is bounded by cheap work, // a flood of distinct forged/junk deposits is bounded by cheap work,
// not by main-actor Schnorr verifications. // not by main-actor Schnorr verifications.
guard allowUplinkDeposit(from: depositor) else { guard allowUplinkDeposit(from: depositor) else {
SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))", category: .gateway) SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))", category: .session)
return return
} }
// Only now pay for cryptographic verification; receivers verify again. // Only now pay for cryptographic verification; receivers verify again.
guard event.isValidSignature() else { guard event.isValidSignature() else {
SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))", category: .gateway) SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8)) (bad signature)", category: .security)
return return
} }
@@ -219,9 +222,6 @@ final class GatewayService: ObservableObject {
accepted = true accepted = true
} else { } else {
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
if accepted {
SecureLogger.info("[GW] uplink accept→queue \(event.id.prefix(8))… (relays down)", category: .gateway)
}
} }
// Only render on our own timeline what we actually accepted for // Only render on our own timeline what we actually accepted for
@@ -247,7 +247,7 @@ final class GatewayService: ObservableObject {
private func publish(_ event: NostrEvent, geohash: String) { private func publish(_ event: NostrEvent, geohash: String) {
publishedEventIDs.insert(event.id) publishedEventIDs.insert(event.id)
publishToRelays?(event, geohash) publishToRelays?(event, geohash)
SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway) SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
} }
/// Returns true when the item was actually stored for later publish. /// Returns true when the item was actually stored for later publish.
@@ -255,7 +255,7 @@ final class GatewayService: ObservableObject {
private func enqueueUplink(_ item: QueuedUplink) -> Bool { private func enqueueUplink(_ item: QueuedUplink) -> Bool {
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))", category: .gateway) SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))", category: .session)
return false return false
} }
if queuedUplinks.count >= Limits.maxQueuedUplinks { if queuedUplinks.count >= Limits.maxQueuedUplinks {
@@ -298,26 +298,26 @@ final class GatewayService: ObservableObject {
// event's own `#g` tag to match the carrier geohash. // event's own `#g` tag to match the carrier geohash.
guard isFresh(event), guard isFresh(event),
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else { event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
SecureLogger.debug("[GW] downlink drop (stale/mismatch) \(event.id.prefix(8))", category: .gateway)
return return
} }
// Loop rule 1: never rebroadcast mesh-carried events back onto the // Loop rule 1: never rebroadcast mesh-carried events back onto the
// mesh. Loop rule 2: rebroadcast each relay event at most once but // mesh. Loop rule 2 (self-echo): never rebroadcast an event this
// mark only AFTER it is actually sent (in `drainPendingDownlinks`), so // gateway itself uplinked (`publishedEventIDs`) it originated on this
// an event dropped by the queue overflow stays retryable on relay // very mesh, so our own relay subscription echoing it back must not
// double the BLE airtime by pushing it out again. Loop rule 2
// (downlink): rebroadcast each genuine inbound relay event at most once
// but mark only AFTER it is actually sent (in `drainPendingDownlinks`),
// so an event dropped by the queue overflow stays retryable on relay
// redelivery. Guard against a redelivery re-queueing an event that is // redelivery. Guard against a redelivery re-queueing an event that is
// still waiting to be sent. // still waiting to be sent.
guard !meshBroadcastEventIDs.contains(event.id), guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id), !rebroadcastEventIDs.contains(event.id),
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else { !pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))", category: .gateway)
return return
} }
// Verify before spending BLE airtime; receivers verify again. // Verify before spending BLE airtime; receivers verify again.
guard event.isValidSignature() else { guard event.isValidSignature() else { return }
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))", category: .gateway)
return
}
pendingDownlinks.append((event, geohash)) pendingDownlinks.append((event, geohash))
if pendingDownlinks.count > Limits.maxPendingDownlinks { if pendingDownlinks.count > Limits.maxPendingDownlinks {
@@ -325,7 +325,6 @@ final class GatewayService: ObservableObject {
// dropped event is not yet in `rebroadcastEventIDs`, so a later // dropped event is not yet in `rebroadcastEventIDs`, so a later
// relay redelivery can still carry it. // relay redelivery can still carry it.
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway)
} }
drainPendingDownlinks() drainPendingDownlinks()
} }
@@ -342,7 +341,6 @@ final class GatewayService: ObservableObject {
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
let payload = carrier.encode() else { continue } let payload = carrier.encode() else { continue }
broadcastToMesh?(payload) broadcastToMesh?(payload)
SecureLogger.info("[GW] downlink rebroadcast \(event.id.prefix(8))… to mesh for #\(geohash)", category: .gateway)
// Mark-after-send: only now is the relay event definitively // Mark-after-send: only now is the relay event definitively
// rebroadcast (loop rule 2). // rebroadcast (loop rule 2).
rebroadcastEventIDs.insert(event.id) rebroadcastEventIDs.insert(event.id)
@@ -362,11 +360,9 @@ final class GatewayService: ObservableObject {
let oldest = downlinkSendTimes.min() ?? now() let oldest = downlinkSendTimes.min() ?? now()
let delay = max(0.05, 60 - now().timeIntervalSince(oldest)) let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
downlinkDrainScheduled = true downlinkDrainScheduled = true
SecureLogger.info("[GW] drain-timer armed (\(String(format: "%.1f", delay))s, \(pendingDownlinks.count) pending)", category: .gateway)
let fire: @MainActor () -> Void = { [weak self] in let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return } guard let self else { return }
self.downlinkDrainScheduled = false self.downlinkDrainScheduled = false
SecureLogger.info("[GW] drain-timer fired", category: .gateway)
self.drainPendingDownlinks() self.drainPendingDownlinks()
} }
if let scheduleDrainTimer { if let scheduleDrainTimer {
@@ -421,7 +417,7 @@ final class GatewayService: ObservableObject {
return false return false
} }
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false } guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))", category: .gateway) SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))", category: .session)
return true return true
} }
+46 -3
View File
@@ -15,7 +15,50 @@ final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = BitchatApp.bundleID private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)" private let appGroup = "group.\(BitchatApp.bundleID)"
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
// device locked (identity-cache saves failed with -25308 throughout
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
// restoration must be able to read the noise keys before the user
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
init() {
#if os(iOS)
migrateAccessibilityIfNeeded()
#endif
}
#if os(iOS)
/// One-time upgrade of items created under WhenUnlocked. New saves get
/// the right class on their own (saves are delete-then-add), but the
/// long-lived identity keys are written once and would otherwise stay
/// unreadable while the device is locked.
private func migrateAccessibilityIfNeeded() {
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
guard !UserDefaults.standard.bool(forKey: flag) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let update: [String: Any] = [
kSecAttrAccessible as String: Self.itemAccessibility
]
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
switch status {
case errSecSuccess, errSecItemNotFound:
// Nothing to migrate on a fresh install; both are terminal.
UserDefaults.standard.set(true, forKey: flag)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
default:
// Likely errSecInteractionNotAllowed (relaunched while locked)
// leave the flag unset so the next launch retries.
SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain)
}
}
#endif
// MARK: - Identity Keys // MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
@@ -62,7 +105,7 @@ final class KeychainManager: KeychainManagerProtocol {
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecValueData as String: data, kSecValueData as String: data,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecAttrAccessible as String: Self.itemAccessibility,
kSecAttrLabel as String: "bitchat-\(key)" kSecAttrLabel as String: "bitchat-\(key)"
] ]
#if os(macOS) #if os(macOS)
@@ -227,7 +270,7 @@ final class KeychainManager: KeychainManagerProtocol {
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecValueData as String: data, kSecValueData as String: data,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, kSecAttrAccessible as String: Self.itemAccessibility,
kSecAttrLabel as String: "bitchat-\(key)" kSecAttrLabel as String: "bitchat-\(key)"
] ]
#if os(macOS) #if os(macOS)
+46 -10
View File
@@ -67,6 +67,9 @@ final class LocationNotesManager: ObservableObject {
let content: String let content: String
let createdAt: Date let createdAt: Date
let nickname: String? let nickname: String?
/// The matched `g` tag: the cell the note was posted to, which can be
/// a neighbor of the subscribed geohash.
let geohash: String
var displayName: String { var displayName: String {
let suffix = String(pubkey.suffix(4)) let suffix = String(pubkey.suffix(4))
@@ -82,6 +85,8 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var initialLoadComplete: Bool = false @Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading @Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String? @Published private(set) var errorMessage: String?
/// Public key of our per-geohash Nostr identity; identifies our own notes.
private var ownPubkey: String?
private var subscriptionID: String? private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection private var noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable? private var directoryUpdateCancellable: AnyCancellable?
@@ -104,10 +109,10 @@ final class LocationNotesManager: ObservableObject {
let norm = geohash.lowercased() let norm = geohash.lowercased()
self.geohash = norm self.geohash = norm
self.dependencies = dependencies self.dependencies = dependencies
// Validate geohash (building-level precision: 8 chars) if !Geohash.isValidGeohash(norm) {
if !Geohash.isValidBuildingGeohash(norm) { SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
} }
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
subscribe() subscribe()
// The relay directory may load after init (remote fetch over Tor); // The relay directory may load after init (remote fetch over Tor);
// retry automatically instead of staying stuck on "no relays". // retry automatically instead of staying stuck on "no relays".
@@ -123,9 +128,8 @@ final class LocationNotesManager: ObservableObject {
func setGeohash(_ newGeohash: String) { func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased() let norm = newGeohash.lowercased()
guard norm != geohash else { return } guard norm != geohash else { return }
// Validate geohash (building-level precision: 8 chars) guard Geohash.isValidGeohash(norm) else {
guard Geohash.isValidBuildingGeohash(norm) else { SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return return
} }
if let sub = subscriptionID { if let sub = subscriptionID {
@@ -137,6 +141,7 @@ final class LocationNotesManager: ObservableObject {
initialLoadComplete = false initialLoadComplete = false
errorMessage = nil errorMessage = nil
geohash = norm geohash = norm
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
notes.removeAll() notes.removeAll()
noteIDs.removeAll() noteIDs.removeAll()
subscribe() subscribe()
@@ -193,14 +198,14 @@ final class LocationNotesManager: ObservableObject {
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag - accept any of our 9 geohashes // Ensure matching tag - accept any of our 9 geohashes
guard event.tags.contains(where: { tag in guard let matchedGeohash = event.tags.first(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
}) else { return } })?[1].lowercased() else { return }
guard !self.noteIDs.contains(event.id) else { return } guard !self.noteIDs.contains(event.id) else { return }
self.noteIDs.insert(event.id) self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash)
self.notes.append(note) self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt } self.notes.sort { $0.createdAt > $1.createdAt }
self.enforceMemoryCap() self.enforceMemoryCap()
@@ -239,7 +244,8 @@ final class LocationNotesManager: ObservableObject {
pubkey: id.publicKeyHex, pubkey: id.publicKeyHex,
content: trimmed, content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname nickname: nickname,
geohash: geohash
) )
self.noteIDs.insert(event.id) self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0) self.notes.insert(echo, at: 0)
@@ -252,6 +258,36 @@ final class LocationNotesManager: ObservableObject {
} }
} }
/// Whether the note was published by this device's identity for the
/// current geohash (and can therefore be deleted with NIP-09).
func isOwnNote(_ note: Note) -> Bool {
guard let ownPubkey else { return false }
return note.pubkey == ownPubkey
}
/// Requests NIP-09 deletion of one of our own notes and removes it locally.
@discardableResult
func delete(note: Note) -> Bool {
guard isOwnNote(note) else { return false }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = Strings.noRelays
return false
}
do {
let identity = try dependencies.deriveIdentity(geohash)
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity)
dependencies.sendEvent(deletion, relays)
// Keep the id in noteIDs so a relay replay can't resurrect it.
notes.removeAll { $0.id == note.id }
return true
} catch {
SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session)
return false
}
}
/// Enforces defensive memory cap on notes array (keeps newest). /// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() { private func enforceMemoryCap() {
if notes.count > maxNotesInMemory { if notes.count > maxNotesInMemory {
@@ -172,17 +172,37 @@ final class MessageDeduplicationService {
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format) /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
private let nostrAckCache: LRUDeduplicationCache<Bool> private let nostrAckCache: LRUDeduplicationCache<Bool>
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
/// relays redeliver the same events on every launch; without this record
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
/// that don't opt in) keeps the cache purely in-memory.
private let nostrEventStore: NostrProcessedEventStore?
private let nostrEventCapacity: Int
private var persistScheduled = false
private var pendingPersistIDs: [String] = []
/// Creates a new deduplication service with specified capacities. /// Creates a new deduplication service with specified capacities.
/// - Parameters: /// - Parameters:
/// - contentCapacity: Max entries for content cache /// - contentCapacity: Max entries for content cache
/// - nostrEventCapacity: Max entries for Nostr event cache /// - nostrEventCapacity: Max entries for Nostr event cache
/// - nostrEventStore: Optional disk store preloading and persisting
/// processed Nostr event IDs across launches
init( init(
contentCapacity: Int = TransportConfig.contentLRUCap, contentCapacity: Int = TransportConfig.contentLRUCap,
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap,
nostrEventStore: NostrProcessedEventStore? = nil
) { ) {
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity) self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity) self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity) self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
self.nostrEventStore = nostrEventStore
self.nostrEventCapacity = nostrEventCapacity
if let nostrEventStore {
for eventID in nostrEventStore.load() {
nostrEventCache.record(eventID, value: true)
}
}
} }
// MARK: - Content Deduplication // MARK: - Content Deduplication
@@ -239,6 +259,26 @@ final class MessageDeduplicationService {
/// - Parameter eventId: The event ID /// - Parameter eventId: The event ID
func recordNostrEvent(_ eventId: String) { func recordNostrEvent(_ eventId: String) {
nostrEventCache.record(eventId, value: true) nostrEventCache.record(eventId, value: true)
if nostrEventStore != nil {
pendingPersistIDs.append(eventId)
schedulePersistIfNeeded()
}
}
/// Debounced persistence: bursts of inbound events (reconnect redelivery)
/// collapse into one append. Append-merge rather than snapshot, so a
/// transient in-memory clear between flushes can't shrink the disk record.
private func schedulePersistIfNeeded() {
guard let nostrEventStore, !persistScheduled else { return }
persistScheduled = true
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard let self else { return }
self.persistScheduled = false
let newIDs = self.pendingPersistIDs
self.pendingPersistIDs.removeAll()
nostrEventStore.append(newIDs, cap: self.nostrEventCapacity)
}
} }
// MARK: - Nostr ACK Deduplication // MARK: - Nostr ACK Deduplication
@@ -263,14 +303,20 @@ final class MessageDeduplicationService {
// MARK: - Clear // MARK: - Clear
/// Clears all caches /// Clears all caches. This is the wipe/panic path: the persisted
/// gift-wrap record goes with everything else.
func clearAll() { func clearAll() {
contentCache.clear() contentCache.clear()
nostrEventCache.clear() nostrEventCache.clear()
nostrAckCache.clear() nostrAckCache.clear()
pendingPersistIDs.removeAll()
nostrEventStore?.wipe()
} }
/// Clears only the Nostr caches (events and ACKs) /// Clears only the in-memory Nostr caches (events and ACKs). Runs on
/// every geohash channel switch, so the disk record deliberately
/// survives wiping it here would forfeit cross-launch gift-wrap dedup
/// each time the user changes channels (flagged by Codex on #1398).
func clearNostrCaches() { func clearNostrCaches() {
nostrEventCache.clear() nostrEventCache.clear()
nostrAckCache.clear() nostrAckCache.clear()
@@ -25,14 +25,20 @@ extension NostrRelayManager: NetworkActivationRelayControlling {}
extension TorURLSession: NetworkActivationProxyControlling {} extension TorURLSession: NetworkActivationProxyControlling {}
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays. /// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR /// Policy: permit start when (location permissions are authorized OR there
/// there exists at least one mutual favorite. Otherwise, do not start. /// exists at least one mutual favorite) AND the device has a usable network
/// path. When there is provably no network at all we do not bootstrap Tor or
/// spin relay reconnects that only wastes battery on a mesh-only/offline
/// device. BLE mesh is entirely independent of this gate.
@MainActor @MainActor
final class NetworkActivationService: ObservableObject { final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService() static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false @Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true @Published private(set) var userTorEnabled: Bool = true
/// Coarse, debounced network reachability. `false` only when the OS reports
/// no usable interface at all. Surfaced for UI ("offline" vs "connecting").
@Published private(set) var isNetworkReachable: Bool = true
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var started = false private var started = false
@@ -43,6 +49,7 @@ final class NetworkActivationService: ObservableObject {
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never> private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
private let permissionProvider: () -> LocationChannelManager.PermissionState private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data> private let mutualFavoritesProvider: () -> Set<Data>
private let reachabilityMonitor: NetworkReachabilityMonitoring
private let torController: NetworkActivationTorControlling private let torController: NetworkActivationTorControlling
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
// (via its live dependencies), so capturing NostrRelayManager.shared here would // (via its live dependencies), so capturing NostrRelayManager.shared here would
@@ -58,6 +65,7 @@ final class NetworkActivationService: ObservableObject {
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
permissionProvider = { LocationChannelManager.shared.permissionState } permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
reachabilityMonitor = NWPathReachabilityMonitor()
torController = TorManager.shared torController = TorManager.shared
relayControllerProvider = { NostrRelayManager.shared } relayControllerProvider = { NostrRelayManager.shared }
proxyController = TorURLSession.shared proxyController = TorURLSession.shared
@@ -70,6 +78,7 @@ final class NetworkActivationService: ObservableObject {
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>, mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
permissionProvider: @escaping () -> LocationChannelManager.PermissionState, permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
mutualFavoritesProvider: @escaping () -> Set<Data>, mutualFavoritesProvider: @escaping () -> Set<Data>,
reachabilityMonitor: NetworkReachabilityMonitoring,
torController: NetworkActivationTorControlling, torController: NetworkActivationTorControlling,
relayController: NetworkActivationRelayControlling, relayController: NetworkActivationRelayControlling,
proxyController: NetworkActivationProxyControlling, proxyController: NetworkActivationProxyControlling,
@@ -80,6 +89,7 @@ final class NetworkActivationService: ObservableObject {
self.mutualFavoritesPublisher = mutualFavoritesPublisher self.mutualFavoritesPublisher = mutualFavoritesPublisher
self.permissionProvider = permissionProvider self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider self.mutualFavoritesProvider = mutualFavoritesProvider
self.reachabilityMonitor = reachabilityMonitor
self.torController = torController self.torController = torController
self.relayControllerProvider = { relayController } self.relayControllerProvider = { relayController }
self.proxyController = proxyController self.proxyController = proxyController
@@ -96,8 +106,12 @@ final class NetworkActivationService: ObservableObject {
userTorEnabled = true userTorEnabled = true
} }
// Begin (idempotent) reachability monitoring and seed initial state.
reachabilityMonitor.start()
isNetworkReachable = reachabilityMonitor.isReachable
// Initial compute // Initial compute
let allowed = basePolicyAllowed() let allowed = effectiveAllowed()
activationAllowed = allowed activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled torAutoStartDesired = allowed && userTorEnabled
torController.setAutoStartAllowed(torAutoStartDesired) torController.setAutoStartAllowed(torAutoStartDesired)
@@ -123,6 +137,21 @@ final class NetworkActivationService: ObservableObject {
self?.reevaluate() self?.reevaluate()
} }
.store(in: &cancellables) .store(in: &cancellables)
// React to network reachability changes (debounced, unsatisfied-only).
reachabilityMonitor.reachabilityPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] reachable in
guard let self else { return }
guard reachable != self.isNetworkReachable else { return }
self.isNetworkReachable = reachable
SecureLogger.info(
"NetworkActivationService: isNetworkReachable -> \(reachable)",
category: .session
)
self.reevaluate()
}
.store(in: &cancellables)
} }
func setUserTorEnabled(_ enabled: Bool) { func setUserTorEnabled(_ enabled: Bool) {
@@ -138,7 +167,7 @@ final class NetworkActivationService: ObservableObject {
} }
private func reevaluate() { private func reevaluate() {
let allowed = basePolicyAllowed() let allowed = effectiveAllowed()
let torDesired = allowed && userTorEnabled let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired let torChanged = torDesired != torAutoStartDesired
@@ -163,12 +192,20 @@ final class NetworkActivationService: ObservableObject {
} }
} }
/// Base policy: who is allowed to use the network at all (permission or a
/// mutual favorite), ignoring current link state.
private func basePolicyAllowed() -> Bool { private func basePolicyAllowed() -> Bool {
let permOK = permissionProvider() == .authorized let permOK = permissionProvider() == .authorized
let hasMutual = !mutualFavoritesProvider().isEmpty let hasMutual = !mutualFavoritesProvider().isEmpty
return permOK || hasMutual return permOK || hasMutual
} }
/// Effective gate: base policy AND a usable network path. When there is
/// provably no network, Tor bootstrap and relay reconnects are suppressed.
private func effectiveAllowed() -> Bool {
basePolicyAllowed() && reachabilityMonitor.isReachable
}
private func applyTorState(torDesired: Bool) { private func applyTorState(torDesired: Bool) {
proxyController.setProxyMode(useTor: torDesired) proxyController.setProxyMode(useTor: torDesired)
if torDesired { if torDesired {
@@ -0,0 +1,179 @@
import Foundation
import Combine
import BitLogger
#if canImport(Network)
import Network
#endif
/// Coarse, conservative network-reachability signal used to gate Tor bootstrap
/// and Nostr relay connections.
///
/// Policy (deliberately conservative):
/// - Reports `false` only when the OS says there is *no* usable interface at
/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays
/// `true` because Tor tolerates intermittent connectivity, and tearing down
/// on the first hiccup would cost more battery/latency than it saves.
/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping
/// does not thrash Tor/relay startup.
/// - Starts optimistic (`true`) so nothing is ever suppressed before the first
/// path evaluation arrives.
///
/// BLE mesh must never consult this monitor the mesh works fully offline.
@MainActor
protocol NetworkReachabilityMonitoring: AnyObject {
/// Current debounced coarse reachability.
var isReachable: Bool { get }
/// Emits the debounced reachability whenever it changes (main-actor).
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
/// Begin monitoring. Idempotent.
func start()
}
/// Pure debounce/decision logic for reachability, split out so it can be
/// unit-tested without the Network framework or real timers.
///
/// A candidate state only becomes the committed state once it has been stable
/// (uninterrupted) for `interval`. Any observation matching the committed state
/// cancels a pending opposite change, which is what makes flapping a no-op.
struct ReachabilityDebounce {
let interval: TimeInterval
private(set) var committed: Bool
private var pending: (value: Bool, since: Date)?
init(interval: TimeInterval, initial: Bool) {
self.interval = interval
self.committed = initial
}
/// Whether a change is currently waiting out the debounce window.
var hasPendingChange: Bool { pending != nil }
/// Time left before the pending change may commit, or `nil` when nothing
/// is pending. Lets callers schedule a flush at the true deadline instead
/// of a full interval from "now" (duplicate observations must not push
/// the deadline out).
func pendingRemaining(at now: Date) -> TimeInterval? {
guard let pending else { return nil }
return max(0, interval - now.timeIntervalSince(pending.since))
}
/// Feed a raw observation. Returns the new committed value if it changed,
/// otherwise `nil`.
mutating func observe(reachable: Bool, at now: Date) -> Bool? {
if reachable == committed {
// Already in this state cancel any pending opposite change.
pending = nil
return nil
}
// Differs from committed: (re)arm the pending change, preserving the
// timestamp if we're already waiting on this same target value.
if pending?.value != reachable {
pending = (reachable, now)
}
return commitIfAged(at: now)
}
/// Called from a timer to commit a pending change once it has aged past
/// `interval`. Returns the new committed value if it changed, else `nil`.
mutating func flush(at now: Date) -> Bool? {
commitIfAged(at: now)
}
private mutating func commitIfAged(at now: Date) -> Bool? {
guard let pending else { return nil }
guard now.timeIntervalSince(pending.since) >= interval else { return nil }
committed = pending.value
self.pending = nil
return committed
}
}
/// Always-reachable stub. Used as the default in tests and as the fallback on
/// platforms without the Network framework, so reachability never suppresses
/// startup by itself.
@MainActor
final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
var isReachable: Bool { true }
var reachabilityPublisher: AnyPublisher<Bool, Never> {
Empty(completeImmediately: false).eraseToAnyPublisher()
}
func start() {}
}
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
/// background path callback hops here before touching the debounce.
@MainActor
final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
private let subject: CurrentValueSubject<Bool, Never>
private var debounce: ReachabilityDebounce
private var flushWorkItem: DispatchWorkItem?
private var started = false
private let now: () -> Date
#if canImport(Network)
private var monitor: NWPathMonitor?
private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability")
#endif
init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) {
self.now = now
self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true)
self.subject = CurrentValueSubject(true)
}
var isReachable: Bool { subject.value }
var reachabilityPublisher: AnyPublisher<Bool, Never> {
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
}
func start() {
guard !started else { return }
started = true
#if canImport(Network)
let monitor = NWPathMonitor()
self.monitor = monitor
monitor.pathUpdateHandler = { [weak self] path in
// Conservative: only "no interface at all" counts as unreachable.
let reachable = path.status != .unsatisfied
Task { @MainActor in
self?.ingest(reachable: reachable)
}
}
monitor.start(queue: monitorQueue)
#else
// No Network framework: never suppress startup.
#endif
}
/// Feed an observation into the debounce and publish committed changes.
/// Exposed internally so higher layers/tests could drive it if needed.
func ingest(reachable: Bool) {
flushWorkItem?.cancel()
flushWorkItem = nil
if let committed = debounce.observe(reachable: reachable, at: now()) {
publish(committed)
} else if debounce.hasPendingChange {
scheduleFlush()
}
}
private func scheduleFlush() {
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
if let committed = self.debounce.flush(at: self.now()) {
self.publish(committed)
}
}
flushWorkItem = work
// Fire at the pending change's real deadline (pending.since + interval):
// duplicate path updates re-enter here and must not restart the window.
let delay = debounce.pendingRemaining(at: now()) ?? debounce.interval
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}
private func publish(_ reachable: Bool) {
SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session)
subject.send(reachable)
}
}
@@ -463,7 +463,6 @@ final class NoiseEncryptionService {
/// so the caller can re-gossip the shrunken bundle only when it changed. /// so the caller can re-gossip the shrunken bundle only when it changed.
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) { func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else { guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
SecureLogger.info("[PREKEY] open failed (unknown prekey id=\(prekeyID))", category: .session)
throw NoiseEncryptionError.unknownPrekey throw NoiseEncryptionError.unknownPrekey
} }
let handshake = NoiseHandshakeState( let handshake = NoiseHandshakeState(
@@ -0,0 +1,106 @@
//
// NostrProcessedEventStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
/// and relays redeliver the same events on every launch without a
/// cross-launch record, each relaunch reprocesses old PMs and acks
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
///
/// Contents are event IDs already visible to every relay, so
/// until-first-unlock file protection is the right at-rest posture the
/// file must also load during a locked-background restoration relaunch.
/// Wiped on panic via the dedup service's clear paths.
final class NostrProcessedEventStore {
private let fileURL: URL?
// All file access is serialized here: appends are read-modify-write, and
// overlapping debounced flushes would otherwise race and drop IDs.
private let ioQueue = DispatchQueue(label: "chat.bitchat.nostr-processed-events", qos: .utility)
init(fileURL: URL? = nil) {
self.fileURL = fileURL ?? Self.defaultFileURL()
}
/// Processed event IDs, oldest first (insertion order).
func load() -> [String] {
ioQueue.sync { loadLocked() }
}
/// Merge new IDs onto the persisted record, oldest-first, trimming from
/// the front past `cap`. Append-merge (not snapshot-overwrite) so the
/// in-memory cache being cleared transiently (channel switches) can
/// never shrink the on-disk record.
func append(_ newIDs: [String], cap: Int) {
guard !newIDs.isEmpty else { return }
ioQueue.async { [self] in
var merged = loadLocked()
var known = Set(merged)
for id in newIDs where !known.contains(id) {
merged.append(id)
known.insert(id)
}
if merged.count > cap {
merged.removeFirst(merged.count - cap)
}
saveLocked(merged)
}
}
func wipe() {
ioQueue.async { [self] in
guard let fileURL else { return }
try? FileManager.default.removeItem(at: fileURL)
}
}
private func loadLocked() -> [String] {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let ids = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return ids
}
private func saveLocked(_ eventIDs: [String]) {
guard let fileURL else { return }
guard !eventIDs.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
}
do {
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(eventIDs)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist processed Nostr events: \(error)", category: .session)
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("nostr", isDirectory: true)
.appendingPathComponent("processed-events.json")
}
}
+133 -62
View File
@@ -19,6 +19,36 @@ final class NostrTransport: Transport, @unchecked Sendable {
/// doesn't count: DM sends target the default relay set and would /// doesn't count: DM sends target the default relay set and would
/// still queue. /// still queue.
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never> let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
/// Paces outbound acks. Defaults to an isolated pacer so tests don't
/// serialize behind each other; `live` passes the process-wide one.
let ackPacer: AckPacer
init(
notificationCenter: NotificationCenter,
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
ackPacer: AckPacer? = nil
) {
self.notificationCenter = notificationCenter
self.loadFavorites = loadFavorites
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
self.favoriteStatusForPeerID = favoriteStatusForPeerID
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
// control of the ack cadence.
self.ackPacer = ackPacer ?? AckPacer(scheduleAfter: scheduleAfter)
}
@MainActor @MainActor
static func live(idBridge: NostrIdentityBridge) -> Dependencies { static func live(idBridge: NostrIdentityBridge) -> Dependencies {
@@ -33,7 +63,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}, },
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() } relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
ackPacer: NostrTransport.sharedAckPacer
) )
} }
} }
@@ -41,14 +72,64 @@ final class NostrTransport: Transport, @unchecked Sendable {
// Provide BLE short peer ID for BitChat embedding // Provide BLE short peer ID for BitChat embedding
var senderPeerID = PeerID(str: "") var senderPeerID = PeerID(str: "")
// Throttle READ receipts to avoid relay rate limits // Throttle outbound acks READ receipts and DELIVERED acks, direct and
private struct QueuedRead { // geohash to avoid relay rate limits. Reconnect redelivery produces a
let receipt: ReadReceipt // burst of acks at once: 8 DELIVERED in under a second tripped damus's
let peerID: PeerID // "noting too much" during July 2026 device testing.
private enum QueuedAck {
case readDirect(ReadReceipt, PeerID)
case deliveredDirect(messageID: String, peerID: PeerID)
case deliveredGeohash(messageID: String, recipientHex: String, identity: NostrIdentity)
case readGeohash(messageID: String, recipientHex: String, identity: NostrIdentity)
} }
private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false /// Ack pacing shared across transport instances. Geohash acks are sent
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval /// through short-lived transports created per ack
/// (`makeGeohashNostrTransport()`), so a per-instance queue would only
/// ever hold one item and never pace a burst (flagged by Codex on
/// #1398). Production wires `sharedAckPacer` via `Dependencies.live`;
/// tests get an isolated instance per `Dependencies` by default.
/// @unchecked Sendable: all mutable state (`pending`, `isSending`) is
/// confined to the serial `queue`; the class is only touched via
/// `enqueue` and the scheduler callback, both of which hop onto it.
final class AckPacer: @unchecked Sendable {
typealias Scheduler = @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
private let queue = DispatchQueue(label: "chat.bitchat.nostr-ack-pacer")
private var pending: [() -> Void] = []
private var isSending = false
private let interval: TimeInterval = TransportConfig.nostrReadAckInterval
private let scheduleAfter: Scheduler
init(scheduleAfter: @escaping Scheduler = { delay, action in
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: action)
}) {
self.scheduleAfter = scheduleAfter
}
func enqueue(_ send: @escaping () -> Void) {
queue.async {
self.pending.append(send)
self.processNext()
}
}
/// Must be called on `queue`.
private func processNext() {
guard !isSending, !pending.isEmpty else { return }
isSending = true
let send = pending.removeFirst()
send()
scheduleAfter(interval) { [weak self] in
guard let self else { return }
self.queue.async {
self.isSending = false
self.processNext()
}
}
}
}
static let sharedAckPacer = AckPacer()
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private let dependencies: Dependencies private let dependencies: Dependencies
@@ -187,12 +268,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits enqueueAck(.readDirect(receipt, peerID))
// Use barrier to synchronize access to readQueue }
queue.async(flags: .barrier) {
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) /// Enqueue an ack for paced sending. Captures self strongly on purpose:
self.processReadQueueIfNeeded() /// geohash acks ride throwaway transport instances that must stay alive
} /// until their ack leaves the queue.
private func enqueueAck(_ ack: QueuedAck) {
dependencies.ackPacer.enqueue { self.sendAckItem(ack) }
} }
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -212,17 +295,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) { func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in enqueueAck(.deliveredDirect(messageID: messageID, peerID: peerID))
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
} }
} }
@@ -232,19 +305,11 @@ extension NostrTransport {
// MARK: Geohash ACK helpers // MARK: Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in enqueueAck(.deliveredGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
} }
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in enqueueAck(.readGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
} }
// MARK: Geohash DMs (per-geohash identity) // MARK: Geohash DMs (per-geohash identity)
@@ -290,36 +355,42 @@ extension NostrTransport {
dependencies.sendEvent(event) dependencies.sendEvent(event)
} }
/// Must be called within a barrier on `queue`
private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return }
isSendingReadAcks = true
let item = readQueue.removeFirst()
sendReadAckItem(item)
}
/// Sends a single read ack item (called after extraction from queue within barrier) /// Sends a single ack item (invoked by the pacer, one per interval)
private func sendReadAckItem(_ item: QueuedRead) { private func sendAckItem(_ item: QueuedAck) {
Task { @MainActor in Task { @MainActor in
defer { scheduleNextReadAck() } switch item {
guard let recipientNpub = resolveRecipientNpub(for: item.peerID), case .readDirect(let receipt, let peerID):
let recipientHex = npubToHex(recipientNpub), guard let recipientNpub = resolveRecipientNpub(for: peerID),
let senderIdentity = try? dependencies.currentIdentity() else { return } let recipientHex = npubToHex(recipientNpub),
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))", category: .session) let senderIdentity = try? dependencies.currentIdentity() else { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { SecureLogger.debug("NostrTransport: preparing READ ack id=\(receipt.originalMessageID.prefix(8))", category: .session)
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
return SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
} return
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) }
} sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
private func scheduleNextReadAck() { case .deliveredDirect(let messageID, let peerID):
dependencies.scheduleAfter(readAckInterval) { [weak self] in guard let recipientNpub = resolveRecipientNpub(for: peerID),
self?.queue.async(flags: .barrier) { [weak self] in let recipientHex = npubToHex(recipientNpub),
self?.isSendingReadAcks = false let senderIdentity = try? dependencies.currentIdentity() else { return }
self?.processReadQueueIfNeeded() SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
case .deliveredGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
case .readGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
} }
} }
} }
+8 -8
View File
@@ -167,6 +167,10 @@ protocol Transport: AnyObject {
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data) func broadcastGroupMessage(_ envelope: Data)
// Bulletin board (mesh transports only): broadcast a pre-signed board
// payload (post or tombstone) so it spreads over relay and gossip sync.
func sendBoardPayload(_ payload: Data)
// Mesh diagnostics (optional for transports). Defaults are inert so // Mesh diagnostics (optional for transports). Defaults are inert so
// queue-backed transports (e.g. NostrTransport) stay untouched. // queue-backed transports (e.g. NostrTransport) stay untouched.
/// Sends a directed ping probe; the completion fires exactly once on the /// Sends a directed ping probe; the completion fires exactly once on the
@@ -178,10 +182,6 @@ protocol Transport: AnyObject {
/// Current mesh graph for the topology map; nil when unsupported. /// Current mesh graph for the topology map; nil when unsupported.
func currentMeshTopology() -> MeshTopologySnapshot? func currentMeshTopology() -> MeshTopologySnapshot?
// Bulletin board (mesh transports only): broadcast a pre-signed board
// payload (post or tombstone) so it spreads over relay and gossip sync.
func sendBoardPayload(_ payload: Data)
// QR verification (optional for transports) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -223,13 +223,14 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {} func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {} func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendBoardPayload(_ payload: Data) {}
// Mesh diagnostics are mesh-transport-only; other transports report // Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything. // "no reply"/"no path" rather than pretending to measure anything.
@@ -238,7 +239,6 @@ extension Transport {
} }
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil } func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
func currentMeshTopology() -> MeshTopologySnapshot? { nil } func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendBoardPayload(_ payload: Data) {}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
+10 -15
View File
@@ -242,6 +242,16 @@ enum TransportConfig {
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9 static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0 static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
// Background wake-on-proximity (iOS). Pending connects issued on
// backgrounding never expire at the OS level: the Bluetooth controller
// completes them whenever the peer reappears in range and relaunches the
// app via state restoration. Entries older than the BLE address-rotation
// window no longer map to a reachable address, so the cache prunes them.
static let bleRecentPeripheralCacheCap: Int = 16
static let bleRecentPeripheralMaxAgeSeconds: TimeInterval = 15 * 60
// Central slots kept free for connects driven by live background discovery
static let bleBackgroundPendingConnectSlotReserve: Int = 2
// Weak-link cooldown after connection timeouts // Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90 static let bleWeakLinkRSSICutoff: Int = -90
@@ -305,21 +315,6 @@ enum TransportConfig {
static let syncResponseRateLimitMaxResponses: Int = 8 static let syncResponseRateLimitMaxResponses: Int = 8
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0 static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
// Wi-Fi bulk transport (peer-to-peer AWDL data plane for large media).
// BLE stays the control plane: offers/responses ride the Noise session,
// only the sealed chunk stream moves to TCP over AWDL.
static let wifiBulkEnabled: Bool = true
// Below this size BLE fragmentation is fast enough that negotiation
// overhead isn't worth it.
static let wifiBulkMinPayloadBytes: Int = 64 * 1024
static let wifiBulkChunkBytes: Int = 64 * 1024
// Offer unanswered for this long fall back to BLE fragmentation.
static let wifiBulkOfferTimeoutSeconds: TimeInterval = 10.0
// Hard ceiling on how long the Bonjour listener/connection may live.
static let wifiBulkTransferWindowSeconds: TimeInterval = 60.0
static let wifiBulkServiceType: String = "_bitchat-bulk._tcp"
static let wifiBulkMaxConcurrentIncoming: Int = 4
// Courier store-and-forward // Courier store-and-forward
// Initial spray-and-wait budget per deposited envelope: each courier may // Initial spray-and-wait budget per deposited envelope: each courier may
// hand half its remaining copies to another courier on encounter, so a // hand half its remaining copies to another courier on encounter, so a
@@ -1,466 +0,0 @@
//
// WifiBulkChannel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import CryptoKit
import Foundation
import Network
/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on
/// the connection's dispatch queue.
enum WifiBulkStream {
/// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead.
static func maxFrameBodyBytes(chunkBytes: Int) -> Int {
chunkBytes + WifiBulkCrypto.frameOverhead
}
/// Reads frames until `onFrame` returns false (stop) or the stream
/// errors/closes. `onFrame` returning true keeps the loop alive.
static func readFrames(
on connection: NWConnection,
buffer: WifiBulkFrameBuffer,
maxFrameBodyBytes: Int,
onFrame: @escaping (Data) -> Bool,
onError: @escaping (String) -> Void
) {
// Drain any frames already buffered before touching the socket.
do {
while let body = try buffer.nextFrameBody() {
guard onFrame(body) else { return }
}
} catch {
onError("frame decode failed: \(error)")
return
}
connection.receive(
minimumIncompleteLength: 1,
maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength
) { data, _, isComplete, error in
if let data, !data.isEmpty {
buffer.append(data)
}
if let error {
onError("receive failed: \(error)")
return
}
if isComplete {
// Peer closed: hand over whatever complete frames remain, then
// report the close (sessions that already got what they need
// will have stopped the loop from inside onFrame).
do {
while let body = try buffer.nextFrameBody() {
guard onFrame(body) else { return }
}
} catch {
onError("frame decode failed: \(error)")
return
}
onError("connection closed by peer")
return
}
readFrames(
on: connection,
buffer: buffer,
maxFrameBodyBytes: maxFrameBodyBytes,
onFrame: onFrame,
onError: onError
)
}
}
}
/// Sender side of the bulk channel: publishes the per-transfer Bonjour
/// listener, requires the first inbound frame to prove knowledge of the
/// Noise-exchanged channel key, then streams sealed chunks and waits for the
/// receiver's verified receipt.
///
/// The listener starts at offer time (Bonjour registration takes a moment)
/// but data can only flow after `activate(key:)` supplies the channel key
/// derived from the accepted response.
final class WifiBulkSenderSession {
private let queue: DispatchQueue
private let payload: Data
private let transferID: Data
private let payloadHash: Data
private let chunkBytes: Int
private let parameters: NWParameters
private let service: NWListener.Service?
private let maxCandidateConnections = 4
private var key: SymmetricKey?
private var listener: NWListener?
/// Connections that have not yet produced a valid auth frame.
private var candidates: [NWConnection] = []
private var authenticated: NWConnection?
private var finished = false
let totalChunks: Int
/// Test hook: fires once the listener is ready, with its bound port.
var onListenerReady: ((UInt16) -> Void)?
var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)?
var onCompleted: (() -> Void)?
var onFailed: ((String) -> Void)?
init(
payload: Data,
transferID: Data,
chunkBytes: Int,
parameters: NWParameters,
service: NWListener.Service?,
queue: DispatchQueue
) {
self.payload = payload
self.transferID = transferID
self.payloadHash = Data(SHA256.hash(data: payload))
self.chunkBytes = chunkBytes
self.parameters = parameters
self.service = service
self.queue = queue
self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes
}
deinit {
cancelNetworkResources()
}
/// Starts the listener. Returns false when the listener cannot be created
/// (caller falls back to BLE immediately).
func start() -> Bool {
let listener: NWListener
do {
listener = try NWListener(using: parameters)
} catch {
SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport)
return false
}
listener.service = service
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
switch state {
case .ready:
if let port = listener.port?.rawValue {
self.onListenerReady?(port)
}
case .failed(let error):
self.fail("listener failed: \(error)")
default:
break
}
}
listener.newConnectionHandler = { [weak self] connection in
self?.acceptCandidate(connection)
}
self.listener = listener
listener.start(queue: queue)
return true
}
/// Supplies the channel key once the receiver accepted the offer; begins
/// authenticating any connections that raced ahead of the response.
func activate(key: SymmetricKey) {
guard !finished, self.key == nil else { return }
self.key = key
for candidate in candidates {
beginAuthentication(on: candidate, key: key)
}
}
func cancel() {
finished = true
cancelNetworkResources()
}
// MARK: - Connection handling
private func acceptCandidate(_ connection: NWConnection) {
guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else {
connection.cancel()
return
}
candidates.append(connection)
connection.stateUpdateHandler = { [weak self, weak connection] state in
guard let self, let connection else { return }
if case .failed = state {
self.dropCandidate(connection)
}
}
connection.start(queue: queue)
if let key {
beginAuthentication(on: connection, key: key)
}
}
private func dropCandidate(_ connection: NWConnection) {
if let index = candidates.firstIndex(where: { $0 === connection }) {
candidates.remove(at: index)
connection.cancel()
}
}
private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) {
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
WifiBulkStream.readFrames(
on: connection,
buffer: buffer,
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
onFrame: { [weak self, weak connection] body in
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
// Bonjour-level gatecrasher: no channel key, no service.
SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security)
self.dropCandidate(connection)
return false
}
self.promoteAuthenticated(connection, key: key, residualBuffer: buffer)
return false
},
onError: { [weak self, weak connection] _ in
guard let self, let connection, self.authenticated !== connection else { return }
self.dropCandidate(connection)
}
)
}
private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) {
authenticated = connection
// One authenticated peer is all a transfer needs: stop advertising and
// shed the other candidates.
listener?.cancel()
listener = nil
for candidate in candidates where candidate !== connection {
candidate.cancel()
}
candidates.removeAll()
SecureLogger.info("[WIFI] AWDL connected (sender), streaming \(totalChunks) chunk(s)", category: .transport)
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
}
// MARK: - Streaming
private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) {
guard !finished else { return }
guard index < totalChunks else {
awaitReceipt(on: connection, key: key, buffer: receiptBuffer)
return
}
let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes)
let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex)))
let chunk = Data(payload[start..<end])
let body: Data
do {
body = try WifiBulkCrypto.sealFrameBody(chunk, direction: .senderToReceiver, counter: UInt64(index), key: key)
} catch {
fail("chunk seal failed: \(error)")
return
}
connection.send(content: WifiBulkCrypto.frameData(body: body), completion: .contentProcessed { [weak self] error in
guard let self, !self.finished else { return }
if let error {
self.fail("send failed: \(error)")
return
}
self.onChunkSent?(index + 1, self.totalChunks)
self.streamChunk(at: index + 1, over: connection, key: key, receiptBuffer: receiptBuffer)
})
}
private func awaitReceipt(on connection: NWConnection, key: SymmetricKey, buffer: WifiBulkFrameBuffer) {
WifiBulkStream.readFrames(
on: connection,
buffer: buffer,
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
onFrame: { [weak self] body in
guard let self, !self.finished else { return false }
guard WifiBulkCrypto.validateReceiptFrameBody(body, payloadHash: self.payloadHash, key: key) else {
self.fail("invalid receipt frame")
return false
}
self.finished = true
self.cancelNetworkResources()
self.onCompleted?()
return false
},
onError: { [weak self] reason in
self?.fail("receipt wait failed: \(reason)")
}
)
}
// MARK: - Teardown
private func fail(_ reason: String) {
guard !finished else { return }
finished = true
cancelNetworkResources()
onFailed?(reason)
}
private func cancelNetworkResources() {
listener?.cancel()
listener = nil
authenticated?.cancel()
authenticated = nil
for candidate in candidates {
candidate.cancel()
}
candidates.removeAll()
}
}
/// Receiver side of the bulk channel: connects to the sender's per-transfer
/// endpoint, proves knowledge of the channel key with the first frame, then
/// reassembles sealed chunks, verifies the offer hash, and returns a receipt.
final class WifiBulkReceiverSession {
private let queue: DispatchQueue
private let connection: NWConnection
private let key: SymmetricKey
private let transferID: Data
private let payloadHash: Data
private let chunkBytes: Int
private let assembler: WifiBulkPayloadAssembler
private var finished = false
var onCompleted: ((Data) -> Void)?
var onFailed: ((String) -> Void)?
/// Fails (returns nil) when the offer exceeds `sizeCap` the receiver
/// enforces the cap it advertised, not the sender's word.
init?(
endpoint: NWEndpoint,
parameters: NWParameters,
key: SymmetricKey,
transferID: Data,
expectedSize: UInt64,
expectedHash: Data,
sizeCap: Int,
chunkBytes: Int,
queue: DispatchQueue
) {
guard let assembler = WifiBulkPayloadAssembler(
key: key,
expectedSize: expectedSize,
expectedHash: expectedHash,
sizeCap: sizeCap
) else {
return nil
}
self.assembler = assembler
self.connection = NWConnection(to: endpoint, using: parameters)
self.key = key
self.transferID = transferID
self.payloadHash = expectedHash
self.chunkBytes = chunkBytes
self.queue = queue
}
deinit {
connection.cancel()
}
func start() {
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
switch state {
case .ready:
SecureLogger.info("[WIFI] AWDL connect established (receiver)", category: .transport)
self.sendAuthFrameAndReceive()
case .failed(let error):
SecureLogger.info("[WIFI] AWDL connect failed: \(error)", category: .transport)
self.fail("connect failed: \(error)")
case .waiting(let error):
// .waiting can resolve on its own, but a per-transfer channel
// has a peer actively listening; treat unreachable as fatal so
// the sender's fallback isn't left to the window timeout alone.
self.fail("connection waiting: \(error)")
default:
break
}
}
connection.start(queue: queue)
}
func cancel() {
finished = true
connection.cancel()
}
private func sendAuthFrameAndReceive() {
guard !finished else { return }
let authBody: Data
do {
authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
} catch {
fail("auth frame seal failed: \(error)")
return
}
connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in
guard let self, !self.finished else { return }
if let error {
self.fail("auth frame send failed: \(error)")
return
}
self.receiveChunks()
})
}
private func receiveChunks() {
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
WifiBulkStream.readFrames(
on: connection,
buffer: buffer,
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
onFrame: { [weak self] body in
guard let self, !self.finished else { return false }
do {
guard let payload = try self.assembler.consume(frameBody: body) else {
return true // keep reading
}
self.sendReceiptAndComplete(payload)
return false
} catch {
self.fail("chunk rejected: \(error)")
return false
}
},
onError: { [weak self] reason in
self?.fail(reason)
}
)
}
private func sendReceiptAndComplete(_ payload: Data) {
let receiptBody: Data
do {
receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key)
} catch {
fail("receipt seal failed: \(error)")
return
}
connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in
// Receipt is best-effort from the receiver's perspective: the
// payload is already verified. Close the channel either way.
guard let self, !self.finished else { return }
self.finished = true
self.connection.cancel()
self.onCompleted?(payload)
})
}
private func fail(_ reason: String) {
guard !finished else { return }
finished = true
connection.cancel()
onFailed?(reason)
}
}
@@ -1,215 +0,0 @@
//
// WifiBulkCrypto.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
/// Channel security for the Wi-Fi bulk data plane.
///
/// The TCP stream is encrypted and authenticated independently of TLS: both
/// endpoints exchanged random 32-byte tokens inside the established Noise
/// session, so only they can derive the ChaChaPoly channel key via
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
/// gatecrasher that connects to the listener cannot produce a single valid
/// frame and is disconnected.
///
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
/// combined form (12-byte nonce ciphertext 16-byte tag). Nonces are
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
/// and the reader requires the exact expected nonce for each frame, so frames
/// cannot be replayed, reordered, or reflected across directions.
enum WifiBulkCryptoError: Error, Equatable {
case invalidParameters
case frameTooLarge
case truncatedFrame
case nonceMismatch
case authenticationFailed
case emptyChunk
case payloadOverflow
case hashMismatch
}
enum WifiBulkFrameDirection: UInt8 {
/// Data chunks: counters 0, 1, 2,
case senderToReceiver = 0x00
/// Counter 0 = client auth frame, counter 1 = final receipt.
case receiverToSender = 0x01
}
enum WifiBulkCrypto {
static let keyDomain = "bitchat-bulk-v1"
static let nonceLength = 12
static let tagLength = 16
/// AEAD overhead per frame body (nonce + tag).
static let frameOverhead = nonceLength + tagLength
/// 4-byte big-endian length prefix per frame.
static let framePrefixLength = 4
// MARK: Key derivation
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
/// Deterministic: same tokens + transferID always yield the same key.
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
guard senderToken.count == WifiBulkWire.tokenLength,
receiverToken.count == WifiBulkWire.tokenLength,
transferID.count == WifiBulkWire.transferIDLength else {
return nil
}
var inputKeyMaterial = Data()
inputKeyMaterial.append(senderToken)
inputKeyMaterial.append(receiverToken)
return HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
salt: transferID,
info: Data(keyDomain.utf8),
outputByteCount: 32
)
}
// MARK: Frame sealing
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
var nonce = Data(count: nonceLength)
nonce[0] = direction.rawValue
var counterBE = counter.bigEndian
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
return nonce
}
/// Seals one frame body (nonce ciphertext tag), without length prefix.
static func sealFrameBody(
_ plaintext: Data,
direction: WifiBulkFrameDirection,
counter: UInt64,
key: SymmetricKey
) throws -> Data {
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
}
/// Opens one frame body, enforcing the exact expected nonce.
static func openFrameBody(
_ body: Data,
direction: WifiBulkFrameDirection,
counter: UInt64,
key: SymmetricKey
) throws -> Data {
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
throw WifiBulkCryptoError.nonceMismatch
}
do {
let box = try ChaChaPoly.SealedBox(combined: body)
return try ChaChaPoly.open(box, using: key)
} catch {
throw WifiBulkCryptoError.authenticationFailed
}
}
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
static func frameData(body: Data) -> Data {
var framed = Data(capacity: framePrefixLength + body.count)
var lengthBE = UInt32(body.count).bigEndian
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
framed.append(body)
return framed
}
// MARK: Control frames
/// First frame on the wire, receiver sender: proves the connecting
/// client holds the Noise-exchanged secret before any data flows.
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
}
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
}
/// Final frame, receiver sender: acknowledges the fully verified payload.
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
}
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
}
}
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
final class WifiBulkFrameBuffer {
private var buffer = Data()
private let maxBodyBytes: Int
init(maxBodyBytes: Int) {
self.maxBodyBytes = maxBodyBytes
}
func append(_ data: Data) {
buffer.append(data)
}
/// Extracts the next complete frame body, or nil when more bytes are needed.
func nextFrameBody() throws -> Data? {
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
return body
}
}
/// Receiver-side reassembly: opens sequential data frames, enforces the size
/// negotiated in the accepted offer, and verifies the final SHA-256.
final class WifiBulkPayloadAssembler {
private let key: SymmetricKey
private let expectedSize: Int
private let expectedHash: Data
private var received = Data()
private var counter: UInt64 = 0
/// Fails when the offer exceeds the receiver-enforced cap.
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
guard expectedSize > 0,
expectedSize <= UInt64(sizeCap),
expectedHash.count == WifiBulkWire.hashLength else {
return nil
}
self.key = key
self.expectedSize = Int(expectedSize)
self.expectedHash = expectedHash
}
var isComplete: Bool { received.count == expectedSize }
/// Consumes one sealed data frame body. Returns the verified payload when
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
func consume(frameBody: Data) throws -> Data? {
let chunk = try WifiBulkCrypto.openFrameBody(
frameBody,
direction: .senderToReceiver,
counter: counter,
key: key
)
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
counter += 1
guard received.count + chunk.count <= expectedSize else {
throw WifiBulkCryptoError.payloadOverflow
}
received.append(chunk)
guard isComplete else { return nil }
guard Data(SHA256.hash(data: received)) == expectedHash else {
throw WifiBulkCryptoError.hashMismatch
}
return received
}
}
@@ -1,191 +0,0 @@
//
// WifiBulkMessages.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established
/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`).
///
/// Both messages ride the encrypted Noise channel, so every field including
/// the session tokens and the random Bonjour instance name is only visible
/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type,
/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward
/// compatibility.
enum WifiBulkWire {
static let transferIDLength = 16
static let tokenLength = 32
static let hashLength = 32
/// Bonjour instance names are capped at 63 UTF-8 bytes.
static let maxServiceNameBytes = 63
static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) {
data.append(type)
var length = UInt16(value.count).bigEndian
withUnsafeBytes(of: &length) { data.append(contentsOf: $0) }
data.append(value)
}
/// Iterates well-formed TLVs, handing each (type, value) to `visit`.
/// Returns false when the buffer is structurally malformed.
static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool {
var cursor = data.startIndex
let end = data.endIndex
while cursor < end {
let type = data[cursor]
cursor = data.index(after: cursor)
guard data.distance(from: cursor, to: end) >= 2 else { return false }
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
cursor = data.index(cursor, offsetBy: 2)
guard data.distance(from: cursor, to: end) >= length else { return false }
let valueEnd = data.index(cursor, offsetBy: length)
visit(type, Data(data[cursor..<valueEnd]))
cursor = valueEnd
}
return true
}
}
/// Sender receiver: proposal to move an already-encoded file payload over
/// a peer-to-peer Wi-Fi (AWDL) TCP channel instead of BLE fragmentation.
struct WifiBulkOffer: Equatable {
/// Random per-transfer identifier; also the HKDF salt.
let transferID: Data
/// Exact byte count of the payload that will cross the channel.
let fileSize: UInt64
/// SHA-256 over the payload bytes as they cross the channel, verified by
/// the receiver after reassembly.
let payloadHash: Data
/// Sender's random half of the channel secret.
let token: Data
/// Random Bonjour instance name the sender publishes for this transfer.
/// Never derived from nickname or peer ID.
let serviceName: String
private enum TLVType: UInt8 {
case transferID = 0x01
case fileSize = 0x02
case payloadHash = 0x03
case token = 0x04
case serviceName = 0x05
}
func encode() -> Data? {
guard transferID.count == WifiBulkWire.transferIDLength,
payloadHash.count == WifiBulkWire.hashLength,
token.count == WifiBulkWire.tokenLength else { return nil }
let nameData = Data(serviceName.utf8)
guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil }
var encoded = Data()
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
var sizeBE = fileSize.bigEndian
WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded)
WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded)
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded)
return encoded
}
static func decode(_ data: Data) -> WifiBulkOffer? {
var transferID: Data?
var fileSize: UInt64?
var payloadHash: Data?
var token: Data?
var serviceName: String?
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
switch TLVType(rawValue: type) {
case .transferID where value.count == WifiBulkWire.transferIDLength:
transferID = value
case .fileSize where value.count == 8:
fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .payloadHash where value.count == WifiBulkWire.hashLength:
payloadHash = value
case .token where value.count == WifiBulkWire.tokenLength:
token = value
case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes:
serviceName = String(data: value, encoding: .utf8)
default:
break // Unknown or malformed field: ignore; required checks below.
}
}
guard wellFormed,
let transferID, let fileSize, let payloadHash, let token, let serviceName else {
return nil
}
return WifiBulkOffer(
transferID: transferID,
fileSize: fileSize,
payloadHash: payloadHash,
token: token,
serviceName: serviceName
)
}
}
/// Receiver sender: accept (with the receiver's token half) or decline.
struct WifiBulkResponse: Equatable {
let transferID: Data
let accepted: Bool
/// Receiver's random half of the channel secret; present iff accepted.
let token: Data?
private enum TLVType: UInt8 {
case transferID = 0x01
case accepted = 0x02
case token = 0x03
}
static func accept(transferID: Data, token: Data) -> WifiBulkResponse {
WifiBulkResponse(transferID: transferID, accepted: true, token: token)
}
static func decline(transferID: Data) -> WifiBulkResponse {
WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
}
func encode() -> Data? {
guard transferID.count == WifiBulkWire.transferIDLength else { return nil }
if accepted {
guard token?.count == WifiBulkWire.tokenLength else { return nil }
}
var encoded = Data()
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded)
if accepted, let token {
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
}
return encoded
}
static func decode(_ data: Data) -> WifiBulkResponse? {
var transferID: Data?
var accepted: Bool?
var token: Data?
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
switch TLVType(rawValue: type) {
case .transferID where value.count == WifiBulkWire.transferIDLength:
transferID = value
case .accepted where value.count == 1:
accepted = value.first == 1
case .token where value.count == WifiBulkWire.tokenLength:
token = value
default:
break
}
}
guard wellFormed, let transferID, let accepted else { return nil }
if accepted {
guard let token else { return nil }
return WifiBulkResponse(transferID: transferID, accepted: true, token: token)
}
return WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
}
}
@@ -1,57 +0,0 @@
//
// WifiBulkPolicy.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that
/// fails these gates rides BLE fragmentation exactly as before the BLE
/// fallback is the common case and must stay bulletproof.
enum WifiBulkPolicy {
struct SendCandidate {
let payloadBytes: Int
let peerCapabilities: PeerCapabilities
/// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL
/// only reaches direct neighbors, and relays can't proxy the channel.
let isDirectlyConnected: Bool
/// The offer rides the Noise session, so one must already exist.
let hasEstablishedNoiseSession: Bool
}
static func shouldOffer(
_ candidate: SendCandidate,
enabled: Bool = TransportConfig.wifiBulkEnabled,
minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes,
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
) -> Bool {
enabled
&& candidate.payloadBytes > minPayloadBytes
&& candidate.payloadBytes <= maxPayloadBytes
&& candidate.peerCapabilities.contains(.wifiBulk)
&& candidate.isDirectlyConnected
&& candidate.hasEstablishedNoiseSession
}
/// Receiver-side gate. Field lengths were validated at decode; this
/// enforces the size cap (from the local ceiling, not the sender's word)
/// and local enablement.
static func shouldAccept(
offer: WifiBulkOffer,
senderIsDirectlyConnected: Bool,
activeIncomingTransfers: Int,
enabled: Bool = TransportConfig.wifiBulkEnabled,
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes,
maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
) -> Bool {
enabled
&& senderIsDirectlyConnected
&& activeIncomingTransfers < maxConcurrentIncoming
&& offer.fileSize > 0
&& offer.fileSize <= UInt64(maxPayloadBytes)
}
}
@@ -1,479 +0,0 @@
//
// WifiBulkTransferService.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 CryptoKit
import Foundation
import Network
/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue
/// hops live inside the closures supplied by `BLEService`, keeping this
/// service independently testable.
struct WifiBulkTransferServiceEnvironment {
/// Sends a typed payload inside the established Noise session with the
/// peer. Returns false when no established session exists (the caller
/// falls back to BLE).
let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool
/// Whether the peer is on a direct BLE link right now.
let isPeerConnected: (PeerID) -> Bool
/// Delivers a fully received, hash-verified payload (encoded
/// `BitchatFilePacket` TLV) into the normal incoming-file pipeline.
let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void
/// Progress bus hooks mirroring the BLE fragmentation path so the UI is
/// unchanged (chunks report as "fragments").
let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void
let progressChunkSent: (_ transferId: String) -> Void
/// Silently forgets progress state ahead of a BLE fallback re-start.
let progressReset: (_ transferId: String) -> Void
/// Emits the cancelled event for user-cancelled transfers.
let progressCancel: (_ transferId: String) -> Void
}
/// Knobs with test overrides; production values come from `TransportConfig`.
struct WifiBulkTransferServiceConfig {
var serviceType: String = TransportConfig.wifiBulkServiceType
var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes
var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds
var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds
var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
/// Tests disable peer-to-peer so loopback interfaces stay usable.
var usePeerToPeer: Bool = true
/// Tests disable Bonjour publication (unit-test hosts may lack mDNS access).
var publishBonjourService: Bool = true
}
/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and
/// response (control plane), then the payload crosses a per-transfer TCP
/// channel over AWDL, sealed with a key both sides derived from the
/// Noise-exchanged tokens. Any failure at any stage falls back to BLE
/// fragmentation exactly once; the receiver side fails silently and lets the
/// sender's timeout drive that fallback.
final class WifiBulkTransferService {
private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated)
private let environment: WifiBulkTransferServiceEnvironment
private let config: WifiBulkTransferServiceConfig
private final class OutgoingTransfer {
let transferID: Data
let transferId: String
let peerID: PeerID
let token: Data
let fallback: () -> Void
var session: WifiBulkSenderSession?
var offerTimeout: DispatchWorkItem?
var windowTimeout: DispatchWorkItem?
var accepted = false
var finished = false
init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) {
self.transferID = transferID
self.transferId = transferId
self.peerID = peerID
self.token = token
self.fallback = fallback
}
}
private final class IncomingTransfer {
let offer: WifiBulkOffer
let peerID: PeerID
let key: SymmetricKey
var browser: NWBrowser?
var session: WifiBulkReceiverSession?
var windowTimeout: DispatchWorkItem?
init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) {
self.offer = offer
self.peerID = peerID
self.key = key
}
}
private var outgoing: [Data: OutgoingTransfer] = [:]
private var incoming: [Data: IncomingTransfer] = [:]
init(
environment: WifiBulkTransferServiceEnvironment,
config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig()
) {
self.environment = environment
self.config = config
}
// MARK: - Sender
/// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at
/// most once, on decline, timeout, or any mid-transfer error.
func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
queue.async { [weak self] in
self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE)
}
}
/// Handles a decrypted `bulkTransferResponse` Noise payload.
func handleResponsePayload(_ payload: Data, from peerID: PeerID) {
queue.async { [weak self] in
self?.processResponse(payload, from: peerID)
}
}
/// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`).
func cancelTransfer(transferId: String) {
queue.async { [weak self] in
guard let self,
let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return }
self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user")
}
}
/// Tears down every transfer (service shutdown / emergency disconnect).
/// In-flight outgoing transfers do NOT fall back the transport is going away.
func stop() {
queue.async { [weak self] in
guard let self else { return }
for transfer in self.outgoing.values {
transfer.finished = true
transfer.offerTimeout?.cancel()
transfer.windowTimeout?.cancel()
transfer.session?.cancel()
}
self.outgoing.removeAll()
for transfer in self.incoming.values {
self.tearDownIncomingResources(transfer)
}
self.incoming.removeAll()
}
}
private enum OutgoingOutcome {
case completed
case fallback
case cancelled
}
private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
let transferID = Self.randomData(WifiBulkWire.transferIDLength)
let token = Self.randomData(WifiBulkWire.tokenLength)
// Random per-transfer instance name never the nickname or peer ID.
let serviceName = Self.randomData(16).hexEncodedString()
let offer = WifiBulkOffer(
transferID: transferID,
fileSize: UInt64(payload.count),
payloadHash: Data(SHA256.hash(data: payload)),
token: token,
serviceName: serviceName
)
guard let offerData = offer.encode() else {
SecureLogger.info("[WIFI] fallback→BLE to \(peerID.id.prefix(8))… (offer encode failed)", category: .transport)
fallbackToBLE()
return
}
let transfer = OutgoingTransfer(
transferID: transferID,
transferId: transferId,
peerID: peerID,
token: token,
fallback: fallbackToBLE
)
let session = WifiBulkSenderSession(
payload: payload,
transferID: transferID,
chunkBytes: config.chunkBytes,
parameters: makeParameters(),
service: config.publishBonjourService
? NWListener.Service(name: serviceName, type: config.serviceType)
: nil,
queue: queue
)
if let onListenerReady = _test_onListenerReady {
session.onListenerReady = { port in onListenerReady(transferID, port) }
}
session.onChunkSent = { [weak self, weak transfer] sent, total in
guard let self, let transfer, !transfer.finished else { return }
// Hold the final tick until the receipt confirms delivery, so the
// progress bus only emits .completed for verified transfers.
if sent < total {
self.environment.progressChunkSent(transfer.transferId)
}
}
session.onCompleted = { [weak self, weak transfer] in
guard let self, let transfer, !transfer.finished else { return }
self.environment.progressChunkSent(transfer.transferId)
self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified")
}
session.onFailed = { [weak self, weak transfer] reason in
guard let self, let transfer else { return }
self.finishOutgoing(transfer, outcome: .fallback, reason: reason)
}
transfer.session = session
outgoing[transferID] = transfer
guard session.start() else {
finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable")
return
}
guard environment.sendNoisePayload(
BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData),
peerID
) else {
finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session")
return
}
SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))", category: .transport)
environment.progressStart(transferId, session.totalChunks)
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
guard let self, let transfer, !transfer.accepted else { return }
self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out")
}
transfer.offerTimeout = offerTimeout
queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout)
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
guard let self, let transfer else { return }
self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired")
}
transfer.windowTimeout = windowTimeout
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
}
private func processResponse(_ payload: Data, from peerID: PeerID) {
guard let response = WifiBulkResponse.decode(payload),
let transfer = outgoing[response.transferID],
transfer.peerID.toShort() == peerID.toShort(),
!transfer.accepted, !transfer.finished else {
return
}
guard response.accepted, let receiverToken = response.token else {
finishOutgoing(transfer, outcome: .fallback, reason: "offer declined")
return
}
guard let key = WifiBulkCrypto.deriveKey(
senderToken: transfer.token,
receiverToken: receiverToken,
transferID: transfer.transferID
) else {
finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed")
return
}
transfer.accepted = true
transfer.offerTimeout?.cancel()
transfer.offerTimeout = nil
SecureLogger.info("[WIFI] offer accepted by \(peerID.id.prefix(8))…, activating channel", category: .transport)
transfer.session?.activate(key: key)
}
private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) {
guard !transfer.finished else { return }
transfer.finished = true
transfer.offerTimeout?.cancel()
transfer.windowTimeout?.cancel()
transfer.session?.cancel()
outgoing.removeValue(forKey: transfer.transferID)
switch outcome {
case .completed:
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport)
case .fallback:
SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport)
environment.progressReset(transfer.transferId)
transfer.fallback()
case .cancelled:
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport)
environment.progressCancel(transfer.transferId)
}
}
// MARK: - Receiver
/// Handles a decrypted `bulkTransferOffer` Noise payload.
func handleOfferPayload(_ payload: Data, from peerID: PeerID) {
queue.async { [weak self] in
self?.processOffer(payload, from: peerID)
}
}
private func processOffer(_ payload: Data, from peerID: PeerID) {
guard let offer = WifiBulkOffer.decode(payload) else { return }
guard incoming[offer.transferID] == nil else { return }
guard WifiBulkPolicy.shouldAccept(
offer: offer,
senderIsDirectlyConnected: environment.isPeerConnected(peerID),
activeIncomingTransfers: incoming.count,
maxPayloadBytes: config.maxIncomingPayloadBytes,
maxConcurrentIncoming: config.maxConcurrentIncoming
) else {
decline(offer: offer, peerID: peerID)
return
}
let token = Self.randomData(WifiBulkWire.tokenLength)
guard let key = WifiBulkCrypto.deriveKey(
senderToken: offer.token,
receiverToken: token,
transferID: offer.transferID
),
let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else {
decline(offer: offer, peerID: peerID)
return
}
guard environment.sendNoisePayload(
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
peerID
) else {
return // No session to answer on; the sender's timeout handles fallback.
}
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
incoming[offer.transferID] = transfer
SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .transport)
startBrowsing(for: transfer)
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
guard let self, let transfer else { return }
SecureLogger.info("[WIFI] incoming window expired", category: .transport)
self.tearDownIncoming(transfer)
}
transfer.windowTimeout = windowTimeout
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
}
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .transport)
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
_ = environment.sendNoisePayload(
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
peerID
)
}
private func startBrowsing(for transfer: IncomingTransfer) {
let browser = NWBrowser(
for: .bonjour(type: config.serviceType, domain: nil),
using: makeParameters()
)
transfer.browser = browser
browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in
guard let self, let transfer, transfer.session == nil else { return }
let match = results.first { result in
if case .service(let name, _, _, _) = result.endpoint {
return name == transfer.offer.serviceName
}
return false
}
guard let match else { return }
self.connect(transfer, to: match.endpoint)
}
browser.stateUpdateHandler = { [weak self, weak transfer] state in
guard let self, let transfer else { return }
if case .failed(let error) = state {
SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport)
self.tearDownIncoming(transfer)
}
}
browser.start(queue: queue)
}
/// Test hook: connects an accepted incoming transfer straight to an
/// endpoint, standing in for Bonjour discovery on hosts without mDNS.
func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) {
queue.async { [weak self] in
guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return }
self.connect(transfer, to: endpoint)
}
}
private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) {
transfer.browser?.cancel()
transfer.browser = nil
guard let session = WifiBulkReceiverSession(
endpoint: endpoint,
parameters: makeParameters(),
key: transfer.key,
transferID: transfer.offer.transferID,
expectedSize: transfer.offer.fileSize,
expectedHash: transfer.offer.payloadHash,
sizeCap: config.maxIncomingPayloadBytes,
chunkBytes: config.chunkBytes,
queue: queue
) else {
tearDownIncoming(transfer)
return
}
session.onCompleted = { [weak self, weak transfer] payload in
guard let self, let transfer else { return }
SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))", category: .transport)
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
self.tearDownIncoming(transfer)
}
session.onFailed = { [weak self, weak transfer] reason in
guard let self, let transfer else { return }
SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport)
self.tearDownIncoming(transfer)
}
transfer.session = session
session.start()
}
private func tearDownIncoming(_ transfer: IncomingTransfer) {
tearDownIncomingResources(transfer)
incoming.removeValue(forKey: transfer.offer.transferID)
}
private func tearDownIncomingResources(_ transfer: IncomingTransfer) {
transfer.windowTimeout?.cancel()
transfer.windowTimeout = nil
transfer.browser?.cancel()
transfer.browser = nil
transfer.session?.cancel()
transfer.session = nil
}
// MARK: - Helpers
private func makeParameters() -> NWParameters {
let parameters = NWParameters.tcp
if config.usePeerToPeer {
parameters.includePeerToPeer = true
// Keep the channel off infrastructure-independent radios we never
// want (cellular/wired); AWDL rides on the peer-to-peer flag.
parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback]
}
return parameters
}
/// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed).
private static func randomData(_ count: Int) -> Data {
Data((0..<count).map { _ in UInt8.random(in: .min ... .max) })
}
// MARK: - Test observability
/// Test hook: reports each outgoing listener's bound port, standing in
/// for Bonjour resolution on hosts without mDNS. Set before `sendFile`.
var _test_onListenerReady: ((_ transferID: Data, _ port: UInt16) -> Void)?
var _test_activeOutgoingCount: Int {
queue.sync { outgoing.count }
}
var _test_activeIncomingCount: Int {
queue.sync { incoming.count }
}
}
+33 -55
View File
@@ -212,10 +212,10 @@ final class GossipSyncManager {
// messages get the long town-crier window; fragments, file transfers and // messages get the long town-crier window; fragments, file transfers and
// announces keep the short one. // announces keep the short one.
private func isPacketFresh(_ packet: BitchatPacket) -> Bool { private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
let maxAgeSeconds: TimeInterval
switch packet.type {
// Group messages share the whole-message window: members off the mesh // Group messages share the whole-message window: members off the mesh
// for a while should backfill their crew's history like public chat. // for a while should backfill their crew's history like public chat.
let maxAgeSeconds: TimeInterval
switch packet.type {
case MessageType.message.rawValue, MessageType.groupMessage.rawValue: case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
maxAgeSeconds = config.publicMessageMaxAgeSeconds maxAgeSeconds = config.publicMessageMaxAgeSeconds
case MessageType.prekeyBundle.rawValue: case MessageType.prekeyBundle.rawValue:
@@ -275,6 +275,13 @@ final class GossipSyncManager {
guard isPacketFresh(packet) else { return } guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
case .groupMessage:
// Opaque ciphertext to non-members; carried and served like any
// other broadcast so members get backfill from any relay.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
case .prekeyBundle: case .prekeyBundle:
// Callers only feed verified bundles here (own bundles at send // Callers only feed verified bundles here (own bundles at send
// time, peers' after signature verification), so gossip never // time, peers' after signature verification), so gossip never
@@ -298,13 +305,6 @@ final class GossipSyncManager {
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return } || latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet) latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
case .groupMessage:
// Opaque ciphertext to non-members; carried and served like any
// other broadcast so members get backfill from any relay.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
default: default:
break break
} }
@@ -313,6 +313,7 @@ final class GossipSyncManager {
private func sendPeriodicSync(for types: SyncTypeFlags) { private func sendPeriodicSync(for types: SyncTypeFlags) {
// Unicast sync to connected peers to allow RSR attribution // Unicast sync to connected peers to allow RSR attribution
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty { if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
SecureLogger.debug("Sending periodic sync (\(types.logDescription)) to \(connectedPeers.count) connected peers", category: .sync)
for peerID in connectedPeers { for peerID in connectedPeers {
sendRequestSync(to: peerID, types: types) sendRequestSync(to: peerID, types: types)
} }
@@ -349,7 +350,7 @@ final class GossipSyncManager {
private func _requestMissingFragments(_ fragmentIDs: [Data]) { private func _requestMissingFragments(_ fragmentIDs: [Data]) {
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return } guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return } guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh) SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync)
for peerID in connectedPeers { for peerID in connectedPeers {
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter) sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
} }
@@ -390,7 +391,7 @@ final class GossipSyncManager {
// A response can replay the whole store, so bound how often one peer // A response can replay the whole store, so bound how often one peer
// can trigger a diff pass regardless of how fast it asks. // can trigger a diff pass regardless of how fast it asks.
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))", category: .sync) SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))", category: .sync)
return return
} }
let requestedTypes = (request.types ?? .publicMessages) let requestedTypes = (request.types ?? .publicMessages)
@@ -398,9 +399,6 @@ final class GossipSyncManager {
// older packets are outside the filter but not missing, and without // older packets are outside the filter but not missing, and without
// the cursor they would be re-sent every round. // the cursor they would be re-sent every round.
let since = request.sinceTimestamp let since = request.sinceTimestamp
// One concise per-request summary (RSR is already rate-limited per
// peer), never per-packet spam.
var servedCount = 0
// Decode GCS into sorted set and prepare membership checker // Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool { func mightContain(_ id: Data) -> Bool {
@@ -420,7 +418,6 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -435,7 +432,6 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -460,7 +456,6 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -475,26 +470,6 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
}
}
}
// Like announces, prekey bundles are exempt from the since-cursor:
// there is at most one per owner (newer replaces older), so the
// resend cost is bounded and a joining peer must be able to learn
// bundles generated long before it arrived.
if requestedTypes.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
@@ -509,11 +484,26 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
// Like announces, prekey bundles are exempt from the since-cursor:
// there is at most one per owner (newer replaces older), so the
// resend cost is bounded and a joining peer must be able to learn
// bundles generated long before it arrived.
if requestedTypes.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.boardPost) { if requestedTypes.contains(.boardPost) {
// The board store already filters to live posts and tombstones; // The board store already filters to live posts and tombstones;
// no freshness window applies (posts sync until their own expiry). // no freshness window applies (posts sync until their own expiry).
@@ -526,14 +516,9 @@ final class GossipSyncManager {
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
} }
} }
} }
if servedCount > 0 {
SecureLogger.info("[SYNC] served \(servedCount) packet(s) [\(requestedTypes.debugShortLabel)] to \(peerID.id.prefix(8))", category: .sync)
}
} }
// Build REQUEST_SYNC payload using current candidates and GCS params // Build REQUEST_SYNC payload using current candidates and GCS params
@@ -553,14 +538,14 @@ final class GossipSyncManager {
if types.contains(.fileTransfer) { if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
} }
if types.contains(.groupMessage) {
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
}
if types.contains(.prekeyBundle) { if types.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) { for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
candidates.append(pair.packet) candidates.append(pair.packet)
} }
} }
if types.contains(.groupMessage) {
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
}
if types.contains(.boardPost) { if types.contains(.boardPost) {
candidates.append(contentsOf: boardPacketsProvider?() ?? []) candidates.append(contentsOf: boardPacketsProvider?() ?? [])
} }
@@ -625,10 +610,10 @@ final class GossipSyncManager {
} }
fragments.removeExpired(isFresh: isPacketFresh) fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh)
groupMessages.removeExpired(isFresh: isPacketFresh)
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
isPacketFresh(pair.packet) isPacketFresh(pair.packet)
} }
groupMessages.removeExpired(isFresh: isPacketFresh)
} }
// MARK: - Archive (public message persistence) // MARK: - Archive (public message persistence)
@@ -677,7 +662,6 @@ final class GossipSyncManager {
// One request per due schedule rather than a union filter: each type // One request per due schedule rather than a union filter: each type
// group gets the full GCS capacity and its own since-cursor, so heavy // group gets the full GCS capacity and its own since-cursor, so heavy
// fragment traffic can't crowd messages out of the filter. // fragment traffic can't crowd messages out of the filter.
var dueLabels: [String] = []
for index in syncSchedules.indices { for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue } guard syncSchedules[index].interval > 0 else { continue }
// No board source wired up means nothing to offer or store; // No board source wired up means nothing to offer or store;
@@ -686,14 +670,8 @@ final class GossipSyncManager {
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now syncSchedules[index].lastSent = now
sendPeriodicSync(for: syncSchedules[index].types) sendPeriodicSync(for: syncSchedules[index].types)
dueLabels.append(syncSchedules[index].types.debugShortLabel)
} }
} }
// One concise summary per maintenance cycle, not per packet.
if !dueLabels.isEmpty {
let peerCount = delegate?.getConnectedPeers().count ?? 0
SecureLogger.info("[SYNC] cycle: request [\(dueLabels.joined(separator: " "))] to \(peerCount) peer(s)", category: .sync)
}
} }
private func cleanupStaleAnnouncementsIfNeeded(now: Date) { private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
+18 -24
View File
@@ -36,29 +36,29 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5 case .fragment: return 5
case .requestSync: return 6 case .requestSync: return 6
case .fileTransfer: return 7 case .fileTransfer: return 7
case .boardPost: return 8
// Extended bits are compat-safe by construction: `toData()` encodes // Extended bits are compat-safe by construction: `toData()` encodes
// the bitfield little-endian with trailing zero bytes trimmed (bit 10 // the bitfield little-endian with trailing zero bytes trimmed (bit 10
// widens the wire form from 1 to 2 bytes inside the length-prefixed // widens the wire form from 1 to 2 bytes inside the length-prefixed
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while // REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
// `type(forBit:)` maps unknown bits to nil so old clients simply // `type(forBit:)` maps unknown bits to nil so old clients simply
// ignore the group bit and answer with the types they know. // ignore the group bit and answer with the types they know.
case .boardPost: return 8
case .groupMessage: return 10 case .groupMessage: return 10
// Courier envelopes are directed deposits between trusted peers and // Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync. // must never spread via gossip sync.
case .courierEnvelope: return nil case .courierEnvelope: return nil
// The bitfield is a wire-tolerant little-endian UInt64 (1-8 bytes, // Ping/pong are ephemeral directed probes; replaying them via gossip
// unknown high bits ignored by `type(forBit:)`), so bits 8+ need no // sync would only produce stale, unanswerable echoes.
// format change: old clients decode the wider flags and simply never case .ping, .pong: return nil
// match the new bits.
case .prekeyBundle: return 9
// Gateway carriers are ephemeral live traffic (uplinks are directed, // Gateway carriers are ephemeral live traffic (uplinks are directed,
// downlinks are rate-budgeted rebroadcasts); replaying them via sync // downlinks are rate-budgeted rebroadcasts); replaying them via sync
// would waste airtime and extend their lifetime. // would waste airtime and extend their lifetime.
case .nostrCarrier: return nil case .nostrCarrier: return nil
// Ping/pong are ephemeral directed probes; replaying them via gossip // Prekey bundles gossip like board posts. The bitfield is a
// sync would only produce stale, unanswerable echoes. // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
case .ping, .pong: return nil // ignored by `type(forBit:)`), so bits 8+ need no format change: old
// clients decode the wider flags and simply never match the new bits.
case .prekeyBundle: return 9
} }
} }
@@ -115,6 +115,15 @@ struct SyncTypeFlags: OptionSet {
SyncTypeFlags(rawValue: rawValue & other.rawValue) SyncTypeFlags(rawValue: rawValue & other.rawValue)
} }
/// Compact form for logs, e.g. "message+fragment". Without this, the
/// per-schedule periodic sync rounds log identical lines and read as
/// duplicated sends (misdiagnosed twice during July 2026 device testing).
var logDescription: String {
let types = toMessageTypes()
guard !types.isEmpty else { return "none" }
return types.map { String(describing: $0) }.joined(separator: "+")
}
func toMessageTypes() -> [MessageType] { func toMessageTypes() -> [MessageType] {
guard rawValue != 0 else { return [] } guard rawValue != 0 else { return [] }
var types: [MessageType] = [] var types: [MessageType] = []
@@ -150,19 +159,4 @@ struct SyncTypeFlags: OptionSet {
} }
return SyncTypeFlags(rawValue: raw) return SyncTypeFlags(rawValue: raw)
} }
/// Compact human label for the `[SYNC]` observability logs, e.g. "msg,frag,pre".
/// (Referenced only from DEBUG log lines, but kept unconditional so the
/// log-call arguments compile in release too, where the log itself no-ops.)
var debugShortLabel: String {
var parts: [String] = []
if contains(.announce) { parts.append("ann") }
if contains(.message) { parts.append("msg") }
if contains(.fragment) { parts.append("frag") }
if contains(.fileTransfer) { parts.append("file") }
if contains(.prekeyBundle) { parts.append("pre") }
if contains(.groupMessage) { parts.append("grp") }
if contains(.boardPost) { parts.append("board") }
return parts.isEmpty ? "none" : parts.joined(separator: ",")
}
} }
@@ -227,10 +227,29 @@ extension ChatViewModel: ChatPrivateConversationContext {
final class ChatPrivateConversationCoordinator { final class ChatPrivateConversationCoordinator {
private unowned let context: any ChatPrivateConversationContext private unowned let context: any ChatPrivateConversationContext
// Outbox retries re-wrap the same message in fresh gift-wrap events, so
// relay-level event-ID dedup can't catch them; track inbound GeoDM
// message IDs so each copy past the first costs one (already-deduped)
// ack check and nothing else.
private var seenInboundGeoDMIDs: Set<String> = []
private var seenInboundGeoDMOrder: [String] = []
private static let seenInboundGeoDMCap = 512
init(context: any ChatPrivateConversationContext) { init(context: any ChatPrivateConversationContext) {
self.context = context self.context = context
} }
/// Returns `false` if this GeoDM message ID was already handled.
private func markInboundGeoDMSeen(_ messageId: String) -> Bool {
guard !seenInboundGeoDMIDs.contains(messageId) else { return false }
seenInboundGeoDMIDs.insert(messageId)
seenInboundGeoDMOrder.append(messageId)
if seenInboundGeoDMOrder.count > Self.seenInboundGeoDMCap {
seenInboundGeoDMIDs.remove(seenInboundGeoDMOrder.removeFirst())
}
return true
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) { func sendPrivateMessage(_ content: String, to peerID: PeerID) {
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
@@ -408,10 +427,15 @@ final class ChatPrivateConversationCoordinator {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID let messageId = pm.messageID
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session) // Ack before the dedup guard: a re-sent copy means the sender may not
// have our DELIVERED yet, and markGeoDeliveryAckSent dedups the
// actual sends.
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
guard markInboundGeoDMSeen(messageId) else { return }
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session)
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
return return
} }
@@ -490,7 +514,10 @@ final class ChatPrivateConversationCoordinator {
category: .session category: .session
) )
} else { } else {
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) // A stale ack for a message this device no longer tracks (dropped
// outbox entry, cleared chat, or a peer re-acking after losing our
// receipt) expected occasionally, not actionable.
SecureLogger.debug("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
} }
} }
@@ -71,11 +71,11 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Verification payloads // MARK: Verification payloads
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
func handleVouchPayload(from peerID: PeerID, payload: Data)
// MARK: Group payloads (creator-signed state over Noise) // MARK: Group payloads (creator-signed state over Noise)
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
func handleVouchPayload(from peerID: PeerID, payload: Data)
} }
extension ChatViewModel: ChatTransportEventContext { extension ChatViewModel: ChatTransportEventContext {
@@ -135,10 +135,6 @@ extension ChatViewModel: ChatTransportEventContext {
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
} }
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
}
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload) groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
} }
@@ -146,6 +142,10 @@ extension ChatViewModel: ChatTransportEventContext {
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload) groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
} }
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
}
} }
final class ChatTransportEventCoordinator { final class ChatTransportEventCoordinator {
@@ -389,19 +389,14 @@ private extension ChatTransportEventCoordinator {
case .verifyResponse: case .verifyResponse:
context.handleVerifyResponsePayload(from: peerID, payload: payload) context.handleVerifyResponsePayload(from: peerID, payload: payload)
case .vouch:
context.handleVouchPayload(from: peerID, payload: payload)
case .groupInvite: case .groupInvite:
context.handleGroupInvitePayload(from: peerID, payload: payload) context.handleGroupInvitePayload(from: peerID, payload: payload)
case .groupKeyUpdate: case .groupKeyUpdate:
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload) context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
case .bulkTransferOffer, .bulkTransferResponse: case .vouch:
// Wi-Fi bulk negotiation is consumed inside the mesh transport context.handleVouchPayload(from: peerID, payload: payload)
// (BLEService); it never reaches the UI layer.
break
} }
} }
@@ -24,6 +24,10 @@ protocol ChatVerificationContext: AnyObject {
func setStoredVerified(_ fingerprint: String, verified: Bool) func setStoredVerified(_ fingerprint: String, verified: Bool)
func isVerifiedFingerprint(_ fingerprint: String) -> Bool func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func saveIdentityState() func saveIdentityState()
/// After a fingerprint becomes verified, run a transitive-vouch pass over
/// currently connected peers (so verifying a peer you're already connected
/// to sends vouches immediately, and the new identity propagates onward).
func vouchToConnectedVerifiedPeers()
// MARK: Encryption status // MARK: Encryption status
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
@@ -86,6 +90,10 @@ extension ChatViewModel: ChatVerificationContext {
peerIdentityStore.setVerified(fingerprint, verified: verified) peerIdentityStore.setVerified(fingerprint, verified: verified)
} }
func vouchToConnectedVerifiedPeers() {
vouchCoordinator.vouchToConnectedVerifiedPeers()
}
var unifiedPeers: [BitchatPeer] { var unifiedPeers: [BitchatPeer] {
unifiedPeerService.peers unifiedPeerService.peers
} }
@@ -148,6 +156,9 @@ final class ChatVerificationCoordinator {
context.saveIdentityState() context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true) context.setStoredVerified(fingerprint, verified: true)
context.updateEncryptionStatus(for: peerID) context.updateEncryptionStatus(for: peerID)
// Verifying a peer is a vouch trigger: push attestations to my other
// connected verified peers (and to this one if already connected).
context.vouchToConnectedVerifiedPeers()
} }
func unverifyFingerprint(for peerID: PeerID) { func unverifyFingerprint(for peerID: PeerID) {
@@ -340,6 +351,8 @@ final class ChatVerificationCoordinator {
} }
context.updateEncryptionStatus(for: peerID) context.updateEncryptionStatus(for: peerID)
// QR verification just completed same vouch trigger as manual verify.
context.vouchToConnectedVerifiedPeers()
} }
} }
+28 -9
View File
@@ -179,8 +179,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
lazy var groupCoordinator = ChatGroupCoordinator(context: self) lazy var groupCoordinator = ChatGroupCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
// Computed properties for compatibility // Computed properties for compatibility
@MainActor @MainActor
@@ -1215,14 +1215,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
GossipMessageArchive.wipeDefault() GossipMessageArchive.wipeDefault()
StoreAndForwardMetrics.shared.reset() StoreAndForwardMetrics.shared.reset()
// Drop private group keys and rosters (keychain + disk)
groupStore.wipe()
// Drop cached peers' prekey bundles (who we could write to is // Drop cached peers' prekey bundles (who we could write to is
// metadata too). Our own prekey privates are keychain-backed and go // metadata too). Our own prekey privates are keychain-backed and go
// with deleteAllKeychainData above plus the identity reset below. // with deleteAllKeychainData above plus the identity reset below.
PrekeyBundleStore.shared.wipe() PrekeyBundleStore.shared.wipe()
// Drop private group keys and rosters (keychain + disk)
groupStore.wipe()
// Drop bulletin-board posts and tombstones (memory and disk); board // Drop bulletin-board posts and tombstones (memory and disk); board
// posts are signed with our identity key and persist for days. // posts are signed with our identity key and persist for days.
BoardStore.shared.wipe() BoardStore.shared.wipe()
@@ -1295,10 +1293,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Delete ALL media files (incoming and outgoing) in background // Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
// The SPM test process shares the real Application Support tree, so // Skipped under tests: the test process shares the user's real
// this detached tree-delete can land mid-test under parallel // ~/Library/Application Support/files tree, and this detached
// scheduling and flake a file-dependent test; tests never need the // utility-priority wipe fires at a nondeterministic time
// on-disk media wiped. // deleting media that concurrently running tests (e.g. the
// sendImage flow) just wrote there, and the developer's real
// app data with it.
guard !TestEnvironment.isRunningTests else { return } guard !TestEnvironment.isRunningTests else { return }
do { do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
@@ -1650,6 +1650,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func didUpdatePeerList(_ peers: [PeerID]) { func didUpdatePeerList(_ peers: [PeerID]) {
peerListCoordinator.didUpdatePeerList(peers) peerListCoordinator.didUpdatePeerList(peers)
// A peer-list update follows every verified announce, which is where a
// peer's `.vouch` capability actually arrives retry vouching now that
// capabilities may finally be known (closes the auth-time capability race).
Task { @MainActor [weak self] in
self?.vouchCoordinator.peersUpdated(peers)
}
} }
@MainActor @MainActor
@@ -1738,6 +1744,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func addGeohashOnlySystemMessage(_ content: String) { func addGeohashOnlySystemMessage(_ content: String) {
publicConversationCoordinator.addGeohashOnlySystemMessage(content) publicConversationCoordinator.addGeohashOnlySystemMessage(content)
} }
/// Add a local system message to one specific geohash timeline, active or
/// not. Used by the board's new-pin alerts to scope-match the pin's channel.
@MainActor
func addGeohashSystemMessage(_ content: String, geohash: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash)
}
// Send a public message without adding a local user echo. // Send a public message without adding a local user echo.
// Used for emotes where we want a local system-style confirmation instead. // Used for emotes where we want a local system-style confirmation instead.
@MainActor @MainActor
@@ -41,7 +41,11 @@ struct ChatViewModelServiceBundle {
self.privateChatManager = privateChatManager self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService self.unifiedPeerService = unifiedPeerService
self.autocompleteService = AutocompleteService() self.autocompleteService = AutocompleteService()
self.deduplicationService = MessageDeduplicationService() // Persist processed gift-wrap event IDs: NIP-59 randomizes their
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
// events on every launch and only a cross-launch record stops the
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
self.publicMessagePipeline = PublicMessagePipeline() self.publicMessagePipeline = PublicMessagePipeline()
} }
} }
+70 -8
View File
@@ -26,6 +26,9 @@ protocol ChatVouchContext: AnyObject {
// MARK: Transport // MARK: Transport
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities func peerCapabilities(for peerID: PeerID) -> PeerCapabilities
/// PeerIDs with a currently established mesh session (used to run a vouch
/// pass over peers we are already connected to when we verify someone).
func connectedPeerIDs() -> [PeerID]
/// Appends a session-established observer (additive; never displaces the /// Appends a session-established observer (additive; never displaces the
/// verification coordinator's callbacks). /// verification coordinator's callbacks).
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
@@ -74,6 +77,10 @@ extension ChatViewModel: ChatVouchContext {
meshService.peerCapabilities(peerID) meshService.peerCapabilities(peerID)
} }
func connectedPeerIDs() -> [PeerID] {
Array(connectedPeers)
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
meshService.addPeerAuthenticatedObserver(handler) meshService.addPeerAuthenticatedObserver(handler)
} }
@@ -120,16 +127,70 @@ final class ChatVouchCoordinator {
} }
} }
/// Exchange policy: on session establishment with a peer I verified that /// Trigger session established: on a Noise session coming up with a peer
/// advertises the `.vouch` capability, send attestations for up to /// I verified, attempt to send a vouch batch. Kept as the historical entry
/// `VouchAttestation.maxBatchCount` *other* verified fingerprints (most /// point; the real work lives in `attemptVouch`.
/// recently verified first), at most once per peer per `batchInterval`.
func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) { func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
guard context.isVerifiedFingerprint(fingerprint) else { return } attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
guard context.peerCapabilities(for: peerID).contains(.vouch) else { return } }
/// Trigger verified announce processed: a peer's `.vouch` capability
/// arrives on its *announce*, which is handled independently of the Noise
/// handshake. This is invoked on every peer-list update (fired after each
/// verified announce), so it closes the capability race the batch that
/// `peerAuthenticated` couldn't send (capabilities not yet known) goes out
/// once the capability-bearing announce lands. Throttled per peer.
func peersUpdated(_ peerIDs: [PeerID], now: Date = Date()) {
for peerID in peerIDs {
guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
}
}
/// Trigger local verification completed: the user just verified a peer.
/// Run a vouch pass over every currently connected peer I verified. This
/// makes vouching fire when verifying someone already connected (whose
/// session is authenticated, so `peerAuthenticated` never re-fires), and it
/// propagates the newly-verified identity to my other verified peers.
/// Throttled per peer by `batchInterval`, so it can't spam.
func vouchToConnectedVerifiedPeers(now: Date = Date()) {
var sentCount = 0
for peerID in context.connectedPeerIDs() {
guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
if attemptVouch(to: peerID, fingerprint: fingerprint, now: now) {
sentCount += 1
}
}
if sentCount > 0 {
SecureLogger.info(
"🪪 verify-triggered vouch pass sent to \(sentCount) connected peer(s)",
category: .security
)
}
}
/// Exchange policy shared by every trigger: to a peer I verified, send
/// attestations for up to `VouchAttestation.maxBatchCount` *other* verified
/// fingerprints (most recently verified first), at most once per peer per
/// `batchInterval`. Returns whether a batch was actually sent.
@discardableResult
func attemptVouch(to peerID: PeerID, fingerprint: String, now: Date = Date()) -> Bool {
guard context.isVerifiedFingerprint(fingerprint) else { return false }
// Capability gate, race-tolerant: a peer's `.vouch` bit is carried on
// its announce, processed independently of the Noise handshake, so at
// authentication time the capability set is frequently still empty.
// Treat an empty/unknown set as eligible the payload is a Noise
// `0x12` (`NoisePayloadType.vouch`) that non-supporting peers harmlessly
// ignore, so sending on an unknown set is safe and avoids the race
// dropping the batch. Only skip when the peer advertised a non-empty
// capability set that explicitly lacks `.vouch`.
let capabilities = context.peerCapabilities(for: peerID)
if !capabilities.isEmpty, !capabilities.contains(.vouch) { return false }
if let lastSent = context.lastVouchBatchSent(to: fingerprint), if let lastSent = context.lastVouchBatchSent(to: fingerprint),
now.timeIntervalSince(lastSent) < Self.batchInterval { now.timeIntervalSince(lastSent) < Self.batchInterval {
return return false
} }
let candidates = context.recentlyVerifiedFingerprints( let candidates = context.recentlyVerifiedFingerprints(
@@ -156,13 +217,14 @@ final class ChatVouchCoordinator {
} }
guard !attestations.isEmpty, guard !attestations.isEmpty,
let payload = VouchAttestation.encodeList(attestations) else { return } let payload = VouchAttestation.encodeList(attestations) else { return false }
context.sendVouchAttestations(payload, to: peerID) context.sendVouchAttestations(payload, to: peerID)
context.markVouchBatchSent(to: fingerprint, at: now) context.markVouchBatchSent(to: fingerprint, at: now)
SecureLogger.debug( SecureLogger.debug(
"🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))", "🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))",
category: .security category: .security
) )
return true
} }
/// Accept policy: process inbound vouches only from a sender I verified, /// Accept policy: process inbound vouches only from a sender I verified,
+6 -13
View File
@@ -304,10 +304,8 @@ final class NostrInboundPipeline {
case .readReceipt: case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
// Group state travels only over mesh Noise sessions in v1; anything // Group state travels only over mesh Noise sessions in v1; anything
// claiming to be group traffic over Nostr is ignored. Wi-Fi bulk // claiming to be group traffic over Nostr is ignored.
// negotiation is mesh-proximity only; it never rides Nostr either. case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
.bulkTransferOffer, .bulkTransferResponse:
break break
} }
} }
@@ -360,10 +358,8 @@ final class NostrInboundPipeline {
case .readReceipt: case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
// Group state travels only over mesh Noise sessions in v1; anything // Group state travels only over mesh Noise sessions in v1; anything
// claiming to be group traffic over Nostr is ignored. Wi-Fi bulk // claiming to be group traffic over Nostr is ignored.
// negotiation is mesh-proximity only; it never rides Nostr either. case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
.bulkTransferOffer, .bulkTransferResponse:
break break
} }
} }
@@ -443,11 +439,8 @@ final class NostrInboundPipeline {
case .readReceipt: case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
// Group state travels only over mesh Noise sessions // Group state travels only over mesh Noise sessions
// in v1; group traffic over Nostr is ignored. Wi-Fi // in v1; group traffic over Nostr is ignored.
// bulk negotiation is mesh-proximity only; it never case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
// rides Nostr either.
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
.bulkTransferOffer, .bulkTransferResponse:
break break
} }
} }
+21 -148
View File
@@ -1,12 +1,4 @@
import SwiftUI import SwiftUI
#if DEBUG
import BitLogger
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
#endif
struct AppInfoView: View { struct AppInfoView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@@ -18,14 +10,6 @@ struct AppInfoView: View {
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
@State private var showTopology = false @State private var showTopology = false
#if DEBUG
// Opt-in LAN log streaming + in-app export. DEBUG-only; empty host = off.
@AppStorage(LogNetworkSink.hostDefaultsKey) private var logSinkHost = ""
@AppStorage(LogNetworkSink.portDefaultsKey) private var logSinkPort = LogNetworkSink.defaultPort
@State private var exportedLogURL: URL?
@State private var isPresentingLogShare = false
#endif
private var selectedTheme: AppTheme { private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix AppTheme(rawValue: appThemeRawValue) ?? .matrix
} }
@@ -239,6 +223,27 @@ struct AppInfoView: View {
.foregroundColor(textColor) .foregroundColor(textColor)
} }
// Network diagnostics
if topologyProvider != nil {
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Network.title)
Button {
showTopology = true
} label: {
HStack(spacing: 0) {
FeatureRow(info: Strings.Network.topology)
Image(systemName: "chevron.right")
.font(.bitchatSystem(size: 12))
.foregroundColor(secondaryTextColor)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(Text("app_info.network.topology.hint"))
}
}
// Features // Features
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title) SectionHeader(Strings.Features.title)
@@ -278,27 +283,6 @@ struct AppInfoView: View {
} }
} }
// Network diagnostics
if topologyProvider != nil {
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Network.title)
Button {
showTopology = true
} label: {
HStack(spacing: 0) {
FeatureRow(info: Strings.Network.topology)
Image(systemName: "chevron.right")
.font(.bitchatSystem(size: 12))
.foregroundColor(secondaryTextColor)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(Text("app_info.network.topology.hint"))
}
}
// Privacy // Privacy
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title) SectionHeader(Strings.Privacy.title)
@@ -309,122 +293,11 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Privacy.panic) FeatureRow(info: Strings.Privacy.panic)
} }
#if DEBUG
debugSection
#endif
} }
.padding() .padding()
} }
#if DEBUG
// Tester-only observability panel: live LAN log streaming + a logs export.
// Never compiled into release (privacy-first: release ships no such UI).
@ViewBuilder
private var debugSection: some View {
VStack(alignment: .leading, spacing: 12) {
SectionHeader("debug & logs")
Text("Stream sanitized logs over the LAN to a collector (nc -lu \(logSinkPort)). Empty host = off.")
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
TextField("collector host (e.g. 192.168.1.5)", text: $logSinkHost)
.textFieldStyle(.roundedBorder)
.bitchatFont(size: 13)
#if os(iOS)
.keyboardType(.numbersAndPunctuation)
.autocapitalization(.none)
.disableAutocorrection(true)
#endif
.accessibilityLabel(Text("log collector host"))
TextField("port", value: $logSinkPort, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
.bitchatFont(size: 13)
.frame(width: 70)
#if os(iOS)
.keyboardType(.numberPad)
#endif
.accessibilityLabel(Text("log collector port"))
}
.onChange(of: logSinkHost) { _ in LogNetworkSink.shared.reloadConfiguration() }
.onChange(of: logSinkPort) { _ in LogNetworkSink.shared.reloadConfiguration() }
Button(action: exportLogs) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "square.and.arrow.up")
.font(.bitchatSystem(size: 20))
.foregroundColor(textColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text("Export Logs")
.bitchatFont(size: 14, weight: .semibold)
.foregroundColor(textColor)
Text("Share the recent in-memory log buffer as a .txt file.")
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Export logs"))
.accessibilityHint(Text("Shares the recent log buffer as a text file"))
}
#if os(iOS)
.sheet(isPresented: $isPresentingLogShare) {
if let exportedLogURL {
LogShareSheet(activityItems: [exportedLogURL])
}
}
#endif
}
/// Write the buffered log to a temp `.txt` and present the platform share.
private func exportLogs() {
let text = LogExportBuffer.shared.snapshot()
let stamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("bitchat-logs-\(stamp).txt")
do {
try text.data(using: .utf8)?.write(to: url)
} catch {
return
}
#if os(iOS)
exportedLogURL = url
isPresentingLogShare = true
#elseif os(macOS)
let panel = NSSavePanel()
panel.nameFieldStringValue = url.lastPathComponent
panel.allowedContentTypes = [.plainText]
if panel.runModal() == .OK, let dest = panel.url {
try? text.data(using: .utf8)?.write(to: dest)
}
#endif
}
#endif
} }
#if DEBUG && os(iOS)
/// Minimal UIActivityViewController bridge for the Export Logs share sheet.
private struct LogShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
#endif
struct AppInfoFeatureInfo { struct AppInfoFeatureInfo {
let icon: String let icon: String
let title: LocalizedStringKey let title: LocalizedStringKey
-270
View File
@@ -1,270 +0,0 @@
//
// BoardView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// The bulletin board for one context: a geohash channel, or the mesh-local
/// board when `geohash` is empty. Urgent posts pin to the top; own posts can
/// be swipe-deleted, which broadcasts a signed tombstone.
struct BoardView: View {
/// Empty string = mesh-local board.
let geohash: String
let senderNickname: String
@ObservedObject var board: BoardManager
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
@State private var urgent = false
@State private var expiryDays = 7
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) }
private enum Strings {
static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet")
static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title")
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button")
static func expiryDaysOption(_ days: Int) -> String {
String(
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
locale: .current,
days
)
}
static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
let base = String(
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
locale: .current,
author, content
)
return urgent ? "\(urgentBadge), \(base)" : base
}
}
var body: some View {
VStack(spacing: 0) {
headerSection
postList
composer
}
.themedSurface()
#if os(macOS)
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
#endif
.themedSheetBackground()
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)")
.bitchatFont(size: 18)
Spacer()
SheetCloseButton { dismiss() }
.accessibilityLabel(Strings.closeHint)
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.themedSurface()
}
private var postList: some View {
Group {
if posts.isEmpty {
ScrollView {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
} else {
List {
ForEach(posts, id: \.postID) { post in
postRow(post)
.listRowBackground(palette.background)
.listRowSeparatorTint(palette.divider)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
private func postRow(_ post: BoardPostPacket) -> some View {
let isOwn = board.isOwnPost(post)
let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if post.isUrgent {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 11))
.foregroundColor(palette.alertRed)
Text(Strings.urgentBadge)
.bitchatFont(size: 11, weight: .semibold)
.foregroundColor(palette.alertRed)
}
Text(verbatim: "@\(author)")
.bitchatFont(size: 12, weight: .semibold)
Text(timestampText(forMs: post.createdAt))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
Spacer()
if isOwn {
Button {
board.deletePost(post)
} label: {
Image(systemName: "trash")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.deleteAction)
}
}
Text(post.content)
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent))
.accessibilityActions {
if isOwn {
Button(Strings.deleteAction) { board.deletePost(post) }
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if isOwn {
Button(role: .destructive) {
board.deletePost(post)
} label: {
Label(Strings.deleteAction, systemImage: "trash")
}
}
}
}
private var composer: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.placeholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendEnabled ? palette.accent : .secondary)
}
.padding(.top, 2)
.buttonStyle(.plain)
.disabled(!sendEnabled)
.accessibilityLabel(Strings.send)
}
HStack(spacing: 12) {
Toggle(isOn: $urgent) {
Text(Strings.urgentToggle)
.bitchatFont(size: 12)
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
}
.toggleStyle(.switch)
.fixedSize()
.accessibilityLabel(Strings.urgentToggle)
Spacer()
Text(Strings.expiryLabel)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Picker(Strings.expiryLabel, selection: $expiryDays) {
ForEach([1, 3, 7], id: \.self) { days in
Text(Strings.expiryDaysOption(days)).tag(days)
}
}
.pickerStyle(.segmented)
.fixedSize()
.accessibilityLabel(Strings.expiryLabel)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
private var sendEnabled: Bool {
let trimmed = draft.trimmed
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
}
private func send() {
guard let content = draft.trimmedOrNilIfEmpty else { return }
let sent = board.createPost(
content: content,
geohash: geohash,
urgent: urgent,
expiryDays: expiryDays,
nickname: senderNickname
)
if sent {
draft = ""
urgent = false
}
}
private func timestampText(forMs ms: UInt64) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
}
return Self.absDateFormatter.string(from: date)
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
}
+67 -108
View File
@@ -1,21 +1,17 @@
import SwiftUI import SwiftUI
#if os(iOS)
import UIKit
#endif
struct ContentHeaderView: View { struct ContentHeaderView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
@EnvironmentObject private var boardAlertsModel: BoardAlertsModel
@Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette @ThemedPalette private var palette
@Binding var showSidebar: Bool @Binding var showSidebar: Bool
@Binding var showVerifySheet: Bool @Binding var showVerifySheet: Bool
@Binding var showLocationNotes: Bool
@Binding var notesGeohash: String?
var isNicknameFieldFocused: FocusState<Bool>.Binding var isNicknameFieldFocused: FocusState<Bool>.Binding
let headerHeight: CGFloat let headerHeight: CGFloat
@@ -25,8 +21,13 @@ struct ContentHeaderView: View {
/// Courier envelopes this device is carrying for offline third parties. /// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0 @State private var carriedMailCount = 0
/// Bulletin board sheet for the current channel context. /// Unified notices sheet (board posts + location notes) for the current
@State private var showBoard = false /// channel context.
@State private var showNotices = false
/// Board posts mirrored from the store so the pin icon can show when the
/// current scope has notices.
@State private var boardPosts: [BoardPostPacket] = []
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -137,36 +138,40 @@ struct ContentHeaderView: View {
) )
} }
if case .mesh = locationChannelsModel.selectedChannel, Button(action: {
locationChannelsModel.permissionState == .authorized { var scopes: Set<String> = [""]
Button(action: { if let geoScope = noticesGeoScope {
locationChannelsModel.enableAndRefresh() scopes.insert(geoScope)
notesGeohash = locationChannelsModel.currentBuildingGeohash
showLocationNotes = true
}) {
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8))
.headerTapTarget()
} }
.buttonStyle(.plain) boardAlertsModel.markSeen(forScopes: scopes)
.accessibilityLabel( showNotices = true
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") }) {
) // Fill marks unseen new pins; the tint says the current
} // scope has notices at all.
Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin")
Button(action: { showBoard = true }) {
Image(systemName: "pin")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.9)) .foregroundColor(
scopeHasNotices || unseenNoticesCount > 0
? Color.orange.opacity(0.8)
: palette.secondary.opacity(0.9)
)
.headerTapTarget() .headerTapTarget()
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button") String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices button")
)
.accessibilityValue(
unseenNoticesCount > 0
? String(
format: String(localized: "content.accessibility.notices_new", defaultValue: "%lld new", comment: "Accessibility value for the notices button when unseen pins arrived"),
locale: .current,
unseenNoticesCount
)
: ""
) )
.help( .help(
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button") String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button")
) )
if case .location(let channel) = locationChannelsModel.selectedChannel { if case .location(let channel) = locationChannelsModel.selectedChannel {
@@ -267,54 +272,21 @@ struct ContentHeaderView: View {
.onReceive(CourierStore.shared.$carriedCount) { count in .onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count carriedMailCount = count
} }
.onReceive(BoardStore.shared.$postsSnapshot) { posts in
boardPosts = posts
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel) .environmentObject(locationChannelsModel)
.environmentObject(peerListModel) .environmentObject(peerListModel)
} }
.sheet(isPresented: $showBoard) { .sheet(isPresented: $showNotices) {
BoardView( NoticesView(
geohash: boardGeohash,
senderNickname: appChromeModel.nickname, senderNickname: appChromeModel.nickname,
board: appChromeModel.boardManager board: appChromeModel.boardManager,
initialTab: initialNoticesTab
) )
} .environmentObject(locationChannelsModel)
.sheet(isPresented: $showLocationNotes, onDismiss: {
notesGeohash = nil
}) {
Group {
if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
LocationNotesView(
geohash: geohash,
senderNickname: appChromeModel.nickname
)
.environmentObject(locationChannelsModel)
} else {
ContentLocationNotesUnavailableView(
showLocationNotes: $showLocationNotes,
headerHeight: headerHeight
)
.environmentObject(locationChannelsModel)
}
}
.onAppear {
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
}
.onDisappear {
locationChannelsModel.endLiveRefresh()
}
.onChange(of: locationChannelsModel.availableChannels) { channels in
if let current = channels.first(where: { $0.level == .building })?.geohash,
notesGeohash != current {
notesGeohash = current
#if os(iOS)
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
#endif
}
}
} }
.onAppear { .onAppear {
locationChannelsModel.refreshMeshChannelsIfNeeded() locationChannelsModel.refreshMeshChannelsIfNeeded()
@@ -348,13 +320,34 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1 dynamicTypeSize.isAccessibilitySize ? 2 : 1
} }
/// The board scope for the current channel: the geohash channel's board, /// Open the notices sheet on the tab matching the current channel: the
/// or the mesh-local board ("") in mesh chat. /// geohash channel's notices, or the mesh-local board in mesh chat.
var boardGeohash: String { var initialNoticesTab: NoticesView.Tab {
if case .location = locationChannelsModel.selectedChannel {
return .geo
}
return .mesh
}
/// The geo scope the notices sheet would open on: the selected location
/// channel, or the device's building geohash when chatting on mesh.
var noticesGeoScope: String? {
if case .location(let channel) = locationChannelsModel.selectedChannel { if case .location(let channel) = locationChannelsModel.selectedChannel {
return channel.geohash return channel.geohash
} }
return "" return locationChannelsModel.currentBuildingGeohash
}
/// Whether either tab of the notices sheet currently has content.
var scopeHasNotices: Bool {
boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope }
}
/// New pins in either visible scope since the sheet was last opened.
var unseenNoticesCount: Int {
let meshCount = boardAlertsModel.unseenCount(forGeohash: "")
let geoCount = noticesGeoScope.map { boardAlertsModel.unseenCount(forGeohash: $0) } ?? 0
return meshCount + geoCount
} }
/// Whether anyone is actually reachable on the current channel the /// Whether anyone is actually reachable on the current channel the
@@ -380,37 +373,3 @@ private extension ContentHeaderView {
} }
} }
} }
private struct ContentLocationNotesUnavailableView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var showLocationNotes: Bool
let headerHeight: CGFloat
var body: some View {
VStack(spacing: 12) {
HStack {
Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold)
Spacer()
SheetCloseButton { showLocationNotes = false }
.foregroundColor(palette.primary)
}
.frame(minHeight: headerHeight)
.padding(.horizontal, 12)
.themedChromePanel(edge: .top)
Text("content.notes.location_unavailable")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
Button("content.location.enable") {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
Spacer()
}
.themedSheetBackground()
.foregroundColor(palette.primary)
}
}
-4
View File
@@ -49,8 +49,6 @@ struct ContentView: View {
@State private var isAtBottomPrivate = true @State private var isAtBottomPrivate = true
@State private var autocompleteDebounceTimer: Timer? @State private var autocompleteDebounceTimer: Timer?
@State private var showVerifySheet = false @State private var showVerifySheet = false
@State private var showLocationNotes = false
@State private var notesGeohash: String?
@State private var imagePreviewURL: URL? @State private var imagePreviewURL: URL?
#if os(iOS) #if os(iOS)
@State private var showImagePicker = false @State private var showImagePicker = false
@@ -269,8 +267,6 @@ struct ContentView: View {
ContentHeaderView( ContentHeaderView(
showSidebar: $showSidebar, showSidebar: $showSidebar,
showVerifySheet: $showVerifySheet, showVerifySheet: $showVerifySheet,
showLocationNotes: $showLocationNotes,
notesGeohash: $notesGeohash,
isNicknameFieldFocused: $isNicknameFieldFocused, isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight, headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize, headerPeerIconSize: headerPeerIconSize,
-304
View File
@@ -1,304 +0,0 @@
import SwiftUI
struct LocationNotesView: View {
@StateObject private var manager: LocationNotesManager
let geohash: String
let senderNickname: String
let onNotesCountChanged: ((Int) -> Void)?
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
init(
geohash: String,
senderNickname: String,
onNotesCountChanged: ((Int) -> Void)? = nil,
manager: LocationNotesManager? = nil
) {
let gh = geohash.lowercased()
self.geohash = gh
self.senderNickname = senderNickname
self.onNotesCountChanged = onNotesCountChanged
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
}
private var backgroundColor: Color { palette.background }
private var accentGreen: Color { palette.accent }
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings {
static let description: LocalizedStringKey = "location_notes.description"
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
static let retry: LocalizedStringKey = "location_notes.action.retry"
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
}
var body: some View {
#if os(macOS)
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
headerSection
notesContent
}
}
.themedSurface()
inputSection
}
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.themedSheetBackground()
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
}
.onAppear { onNotesCountChanged?(manager.notes.count) }
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
#else
NavigationView {
VStack(spacing: 0) {
headerSection
ScrollView {
notesContent
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
inputSection
}
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
#else
.navigationTitle("")
#endif
}
.themedSheetBackground()
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
}
.onAppear { onNotesCountChanged?(manager.notes.count) }
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
#endif
}
private var closeButton: some View {
SheetCloseButton { dismiss() }
}
private var headerSection: some View {
let count = manager.notes.count
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(headerTitle(for: count))
.bitchatFont(size: 18)
Spacer()
closeButton
}
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
Text(building)
.bitchatFont(size: 12)
.foregroundColor(accentGreen)
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
Text(block)
.bitchatFont(size: 12)
.foregroundColor(accentGreen)
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays {
Text(Strings.relaysPaused)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.themedSurface()
}
private func headerTitle(for count: Int) -> String {
String(
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
locale: .current,
"\(geohash) ± 1", count
)
}
private var notesContent: some View {
LazyVStack(alignment: .leading, spacing: 12) {
if manager.state == .noRelays {
noRelaysRow
} else if manager.state == .loading && !manager.initialLoadComplete {
loadingRow
} else if manager.notes.isEmpty {
emptyRow
} else {
ForEach(manager.notes) { note in
noteRow(note)
}
}
if let error = manager.errorMessage, manager.state != .noRelays {
errorRow(message: error)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
let ts = timestampText(for: note.createdAt)
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(verbatim: "@\(baseName)")
.bitchatFont(size: 12, weight: .semibold)
if !ts.isEmpty {
Text(ts)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
Spacer()
}
Text(note.content)
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
}
private var noRelaysRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.noRelaysNearby)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Button(Strings.retry) { manager.refresh() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var loadingRow: some View {
HStack(spacing: 10) {
ProgressView()
Text(Strings.loadingNotes)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Spacer()
}
.padding(.vertical, 8)
}
private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
.padding(.vertical, 6)
}
private func errorRow(message: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.bitchatFont(size: 12)
Text(message)
.bitchatFont(size: 12)
Spacer()
}
Button(Strings.dismissError) { manager.clearError() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var inputSection: some View {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
}
.padding(.top, 2)
.buttonStyle(.plain)
.disabled(!sendButtonEnabled)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
private func send() {
guard let content = draft.trimmedOrNilIfEmpty else { return }
manager.send(content: content, nickname: senderNickname)
draft = ""
}
private var sendButtonEnabled: Bool {
!draft.trimmed.isEmpty && manager.state != .noRelays
}
// MARK: - Timestamp Formatting
private func timestampText(for date: Date) -> String {
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
} else {
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
return fmt.string(from: date)
}
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
private static let absDateYearFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d, y")
return f
}()
}
+560
View File
@@ -0,0 +1,560 @@
//
// NoticesView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// The unified notices sheet behind the header's pin icon: one place for
/// everything pinned around you, with a scope toggle.
///
/// - geo: the current geohash's notices mesh-synced board posts merged and
/// deduped with Nostr kind-1 location notes, so you also see notices from
/// people who aren't on your mesh.
/// - mesh: the mesh-local board only (empty geohash, fully offline).
struct NoticesView: View {
enum Tab: Hashable {
case geo
case mesh
}
let senderNickname: String
@ObservedObject var board: BoardManager
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@State private var tab: Tab
@State private var draft: String = ""
@State private var urgent = false
@State private var expiryDays = 7
/// Injected notes manager for tests; live use derives one per geohash.
private let notesManager: LocationNotesManager?
init(
senderNickname: String,
board: BoardManager,
initialTab: Tab,
notesManager: LocationNotesManager? = nil
) {
self.senderNickname = senderNickname
self.board = board
self.notesManager = notesManager
_tab = State(initialValue: initialTab)
}
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
/// The geohash the geo tab is scoped to: the selected location channel,
/// or the device's building geohash when chatting on mesh.
private var geoGeohash: String? {
if case .location(let channel) = locationChannelsModel.selectedChannel {
return channel.geohash
}
return locationChannelsModel.currentBuildingGeohash
}
/// The geo scope comes from device location only when no location channel
/// is selected; that's the case that needs the location machinery.
private var geoTabNeedsDeviceLocation: Bool {
if case .location = locationChannelsModel.selectedChannel { return false }
return true
}
private var activeGeohash: String? {
switch tab {
case .geo: return geoGeohash
case .mesh: return ""
}
}
enum Strings {
static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet")
static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices")
static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices")
static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle")
// The pre-merge location-notes explainer, reused so its existing
// translations carry over.
static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet")
static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet")
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button")
static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh")
static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays")
static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices")
static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices")
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
static let retry: LocalizedStringKey = "location_notes.action.retry"
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
static func expiryDaysOption(_ days: Int) -> String {
String(
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
locale: .current,
days
)
}
static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
let base = String(
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
locale: .current,
author, content
)
return urgent ? "\(urgentBadge), \(base)" : base
}
}
var body: some View {
VStack(spacing: 0) {
headerSection
contentSection
if activeGeohash != nil {
composer
}
}
.themedSurface()
#if os(macOS)
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
#endif
.themedSheetBackground()
.onAppear { beginGeoLocationIfNeeded() }
.onChange(of: tab) { newTab in
if newTab == .geo {
beginGeoLocationIfNeeded()
} else {
locationChannelsModel.endLiveRefresh()
}
}
// Catches permission granted from the geo tab's enable button.
.onChange(of: locationChannelsModel.permissionState) { _ in
beginGeoLocationIfNeeded()
}
.onDisappear { locationChannelsModel.endLiveRefresh() }
}
/// The geo tab tracks the device's building geohash while on mesh; keep
/// location fresh only in that case (a selected location channel already
/// fixes the scope).
private func beginGeoLocationIfNeeded() {
guard tab == .geo, geoTabNeedsDeviceLocation,
locationChannelsModel.permissionState == .authorized else { return }
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(verbatim: scopeTitle)
.bitchatFont(size: 18)
Spacer()
SheetCloseButton { dismiss() }
.accessibilityLabel(Strings.closeHint)
}
Picker(Strings.scopePicker, selection: $tab) {
Text(Strings.geoTab).tag(Tab.geo)
Text(Strings.meshTab).tag(Tab.mesh)
}
.pickerStyle(.segmented)
.accessibilityLabel(Strings.scopePicker)
Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.themedSurface()
}
private var scopeTitle: String {
switch tab {
case .mesh:
return "\(Strings.title) @ #mesh"
case .geo:
if let geohash = geoGeohash {
return "\(Strings.title) @ #\(geohash)"
}
return Strings.title
}
}
@ViewBuilder
private var contentSection: some View {
switch tab {
case .mesh:
NoticesList(
items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []),
showsSource: false,
board: board,
notesManager: nil
)
case .geo:
if let geohash = geoGeohash {
GeoNoticesList(geohash: geohash, board: board, manager: notesManager)
} else {
locationUnavailableSection
}
}
}
private var locationUnavailableSection: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(Strings.locationUnavailable)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
Button(Strings.enableLocation) {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
private var composer: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.placeholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendEnabled ? palette.accent : .secondary)
}
.padding(.top, 2)
.buttonStyle(.plain)
.disabled(!sendEnabled)
.accessibilityLabel(Strings.send)
}
// Urgency and expiry only travel with the mesh copy the bridged
// Nostr note carries neither, so relay-side readers would never
// see them. Offer the controls only where they fully apply.
if tab == .mesh {
HStack(spacing: 12) {
Toggle(isOn: $urgent) {
Text(Strings.urgentToggle)
.bitchatFont(size: 12)
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
}
.toggleStyle(.switch)
.fixedSize()
.accessibilityLabel(Strings.urgentToggle)
Spacer()
Text(Strings.expiryLabel)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Picker(Strings.expiryLabel, selection: $expiryDays) {
ForEach([1, 3, 7], id: \.self) { days in
Text(Strings.expiryDaysOption(days)).tag(days)
}
}
.pickerStyle(.segmented)
.fixedSize()
.accessibilityLabel(Strings.expiryLabel)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
private var sendEnabled: Bool {
let trimmed = draft.trimmed
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
}
private func send() {
guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return }
// Geo posts go to the board and are bridged to Nostr by BoardManager,
// so mesh and internet see the same notice. They always use the
// defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy).
let sent = board.createPost(
content: content,
geohash: geohash,
urgent: tab == .mesh && urgent,
expiryDays: tab == .mesh ? expiryDays : 7,
nickname: senderNickname
)
if sent {
draft = ""
urgent = false
}
}
}
/// The geo tab's list: owns the Nostr notes subscription for the scope
/// geohash and merges it with the board posts for the same geohash.
private struct GeoNoticesList: View {
let geohash: String
@ObservedObject var board: BoardManager
@StateObject private var notesManager: LocationNotesManager
init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) {
let gh = geohash.lowercased()
self.geohash = gh
self.board = board
_notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
}
var body: some View {
NoticesList(
items: UnifiedNotices.merge(
posts: board.posts(forGeohash: geohash),
notes: notesManager.notes
),
showsSource: true,
board: board,
notesManager: notesManager
)
.onChange(of: geohash) { newValue in
notesManager.setGeohash(newValue)
}
.onDisappear { notesManager.cancel() }
}
}
/// Renders merged notices with per-source affordances: swipe-delete for own
/// items and a mesh/net badge when sources mix.
private struct NoticesList: View {
let items: [NoticeItem]
let showsSource: Bool
let board: BoardManager
let notesManager: LocationNotesManager?
@ThemedPalette private var palette
private typealias Strings = NoticesView.Strings
var body: some View {
Group {
if items.isEmpty {
ScrollView {
VStack(alignment: .leading, spacing: 4) {
statusRows
if showEmptyState {
Text(Strings.emptyTitle)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
} else {
List {
statusRows
.listRowBackground(palette.background)
.listRowSeparatorTint(palette.divider)
ForEach(items) { item in
row(item)
.listRowBackground(palette.background)
.listRowSeparatorTint(palette.divider)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
/// Notes may still be loading or unreachable; only claim "no notices yet"
/// once the sources settled.
private var showEmptyState: Bool {
guard let notesManager else { return true }
return notesManager.initialLoadComplete && notesManager.state != .loading
}
@ViewBuilder
private var statusRows: some View {
if let notesManager {
if notesManager.state == .loading && !notesManager.initialLoadComplete {
HStack(spacing: 10) {
ProgressView()
Text(Strings.loadingNotes)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Spacer()
}
.padding(.vertical, 8)
} else if notesManager.state == .noRelays {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.noRelaysNearby)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Button(Strings.retry) { notesManager.refresh() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.bottom, 8)
} else if let error = notesManager.errorMessage {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.bitchatFont(size: 12)
Text(error)
.bitchatFont(size: 12)
Spacer()
}
Button(Strings.dismissError) { notesManager.clearError() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.bottom, 8)
}
}
}
private func canDelete(_ item: NoticeItem) -> Bool {
switch item.source {
case .board(let post):
return board.isOwnPost(post)
case .nostr(let note):
return notesManager?.isOwnNote(note) ?? false
}
}
private func delete(_ item: NoticeItem) {
switch item.source {
case .board(let post):
// Tombstones the board post and retracts the bridged Nostr copy.
board.deletePost(post)
case .nostr(let note):
notesManager?.delete(note: note)
}
}
private func row(_ item: NoticeItem) -> some View {
let isOwn = canDelete(item)
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if item.isUrgent {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 11))
.foregroundColor(palette.alertRed)
Text(Strings.urgentBadge)
.bitchatFont(size: 11, weight: .semibold)
.foregroundColor(palette.alertRed)
}
Text(verbatim: "@\(item.author)")
.bitchatFont(size: 12, weight: .semibold)
Text(Self.timestampText(for: item.createdAt))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
Spacer()
if showsSource {
sourceBadge(item)
}
if isOwn {
Button {
delete(item)
} label: {
Image(systemName: "trash")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.deleteAction)
}
}
Text(item.content)
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent))
.accessibilityActions {
if isOwn {
Button(Strings.deleteAction) { delete(item) }
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if isOwn {
Button(role: .destructive) {
delete(item)
} label: {
Label(Strings.deleteAction, systemImage: "trash")
}
}
}
}
private func sourceBadge(_ item: NoticeItem) -> some View {
HStack(spacing: 3) {
Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe")
.font(.bitchatSystem(size: 10))
Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
.bitchatFont(size: 10)
}
.foregroundColor(palette.secondary.opacity(0.8))
.accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
}
// MARK: - Timestamp Formatting
private static func timestampText(for date: Date) -> String {
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
}
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date)
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
private static let absDateYearFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d, y")
return f
}()
}
File diff suppressed because it is too large Load Diff
-45
View File
@@ -217,51 +217,6 @@ struct BLEServiceCoreTests {
)) ))
} }
// Regression: a stable/verified private chat addresses the peer by its
// full 64-hex Noise key, but the Noise session (and capabilities/
// connection) are keyed by the short routing ID. The Wi-Fi bulk send path
// must normalize the ID first, or `hasEstablishedSession` misses and
// Wi-Fi is never offered for a large payload to a direct `.wifiBulk` peer.
@Test
func wifiBulkEligibility_resolvesWhenAddressedBy64HexNoiseKey() throws {
let ble = makeService()
let noiseKey = Data((0..<32).map { UInt8(($0 &* 7) &+ 3) })
let fullKey = PeerID(str: noiseKey.hexEncodedString()) // 64-hex Noise key
#expect(fullKey.noiseKey != nil)
let shortID = fullKey.toShort() // 16-hex routing ID
#expect(shortID != fullKey)
// Establish a real Noise session in the service's noise engine, keyed
// by the short routing ID (exactly as a completed handshake would).
let peer = NoiseEncryptionService(keychain: MockKeychain())
let noise = ble._test_noiseService
let m1 = try noise.initiateHandshake(with: shortID)
let m2 = try #require(try peer.processHandshakeMessage(from: shortID, message: m1))
let m3 = try #require(try noise.processHandshakeMessage(from: shortID, message: m2))
_ = try peer.processHandshakeMessage(from: shortID, message: m3)
#expect(noise.hasEstablishedSession(with: shortID))
// The bug in raw form: querying by the 64-hex key misses the session.
#expect(!noise.hasEstablishedSession(with: fullKey))
// Register the peer as a directly-connected `.wifiBulk` neighbor.
ble._test_registerConnectedPeer(fullKey, capabilities: [.wifiBulk])
let bigPayload = FileTransferLimits.maxWifiBulkPayloadBytes
// The send path normalizes first, so eligibility + offer resolve for
// both the short ID and the full 64-hex Noise key.
let viaShort = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: shortID)
let viaFull = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: fullKey)
#expect(viaShort.hasEstablishedNoiseSession)
#expect(viaFull.hasEstablishedNoiseSession)
#expect(viaFull.isDirectlyConnected)
#expect(viaFull.peerCapabilities.contains(.wifiBulk))
#expect(WifiBulkPolicy.shouldOffer(viaShort, enabled: true))
#expect(WifiBulkPolicy.shouldOffer(viaFull, enabled: true))
}
/// Pings are unsigned, so their claimed sender is attacker-controlled. /// Pings are unsigned, so their claimed sender is attacker-controlled.
/// The pong budget must be keyed on the ingress link (the directly /// The pong budget must be keyed on the ingress link (the directly
/// connected peer that delivered the packet): rotating forged sender IDs /// connected peer that delivered the packet): rotating forged sender IDs
@@ -157,12 +157,6 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
verifyResponsePayloads.append((peerID, payload)) verifyResponsePayloads.append((peerID, payload))
} }
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchPayloads.append((peerID, payload))
}
// Group payloads // Group payloads
private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = [] private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = [] private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
@@ -174,6 +168,12 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
groupKeyUpdatePayloads.append((peerID, payload)) groupKeyUpdatePayloads.append((peerID, payload))
} }
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchPayloads.append((peerID, payload))
}
} }
// MARK: - Helpers // MARK: - Helpers
@@ -51,6 +51,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
func saveIdentityState() { saveIdentityStateCount += 1 } func saveIdentityState() { saveIdentityStateCount += 1 }
private(set) var vouchToConnectedVerifiedPeersCount = 0
func vouchToConnectedVerifiedPeers() { vouchToConnectedVerifiedPeersCount += 1 }
// Encryption status // Encryption status
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
private(set) var updatedEncryptionStatusPeers: [PeerID] = [] private(set) var updatedEncryptionStatusPeers: [PeerID] = []
@@ -60,8 +60,12 @@ private final class MockChatVouchContext: ChatVouchContext {
private(set) var installedObservers: [(PeerID, String) -> Void] = [] private(set) var installedObservers: [(PeerID, String) -> Void] = []
private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = [] private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
var connectedPeerIDList: [PeerID] = []
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] } func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] }
func connectedPeerIDs() -> [PeerID] { connectedPeerIDList }
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
installedObservers.append(handler) installedObservers.append(handler)
} }
@@ -135,14 +139,78 @@ struct ChatVouchCoordinatorContextTests {
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
#expect(context.sentVouchPayloads.isEmpty) #expect(context.sentVouchPayloads.isEmpty)
// Verified but no .vouch capability: nothing. // Verified but advertises a non-empty capability set lacking .vouch:
// nothing. (An *empty*/unknown set is race-tolerant and still sends
// see `attemptVouch_sendsWhenCapabilitiesUnknown`.)
context.verifiedFingerprints.insert(peerFingerprint) context.verifiedFingerprints.insert(peerFingerprint)
context.capabilitiesByPeerID[peerID] = [] context.capabilitiesByPeerID[peerID] = [.prekeys]
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
#expect(context.sentVouchPayloads.isEmpty) #expect(context.sentVouchPayloads.isEmpty)
#expect(context.markedBatchSent.isEmpty) #expect(context.markedBatchSent.isEmpty)
} }
// MARK: Capability race tolerance & new triggers
@Test @MainActor
func attemptVouch_sendsWhenCapabilitiesUnknown() {
// Capability set still empty at attempt time (the peer's .vouch bit
// arrives on a later announce): the batch must still go out.
let (context, coordinator) = makeVerifiedCapablePeer()
context.capabilitiesByPeerID[peerID] = []
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
#expect(context.sentVouchPayloads.count == 1)
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
}
@Test @MainActor
func vouchToConnectedVerifiedPeers_sendsToConnectedVerifiedCapablePeer() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.connectedPeerIDList = [peerID]
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
// Session is already up (no peerAuthenticated re-fire); the verify pass
// is what makes the batch go out.
coordinator.vouchToConnectedVerifiedPeers()
let sent = context.sentVouchPayloads
#expect(sent.count == 1)
#expect(sent.first?.peerID == peerID)
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
}
@Test @MainActor
func vouchToConnectedVerifiedPeers_skipsUnverifiedConnectedPeers() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.connectedPeerIDList = [peerID]
context.verifiedFingerprints.remove(peerFingerprint)
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
coordinator.vouchToConnectedVerifiedPeers()
#expect(context.sentVouchPayloads.isEmpty)
}
@Test @MainActor
func peersUpdated_sendsOnceCapabilityBearingAnnounceArrives() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
// First announce before the .vouch bit is known: empty set is
// race-tolerant, so it already sends and stamps the throttle.
context.capabilitiesByPeerID[peerID] = []
coordinator.peersUpdated([peerID])
#expect(context.sentVouchPayloads.count == 1)
// A later announce carrying .vouch must not double-send (throttled).
context.capabilitiesByPeerID[peerID] = [.vouch]
coordinator.peersUpdated([peerID])
#expect(context.sentVouchPayloads.count == 1)
}
@Test @MainActor @Test @MainActor
func peerAuthenticated_rateLimitsPerPeerPer24Hours() { func peerAuthenticated_rateLimitsPerPeerPer24Hours() {
let (context, coordinator) = makeVerifiedCapablePeer() let (context, coordinator) = makeVerifiedCapablePeer()
+24 -27
View File
@@ -370,7 +370,6 @@ struct GossipSyncManagerTests {
config.messageSyncIntervalSeconds = 0 config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0
// Silence the prekey round so the maintenance barrier below emits nothing.
config.prekeyBundleSyncIntervalSeconds = 0 config.prekeyBundleSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager() let requestSyncManager = RequestSyncManager()
@@ -545,7 +544,6 @@ struct GossipSyncManagerTests {
config.messageSyncIntervalSeconds = 0 config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0
// Silence the prekey round so the maintenance barrier isolates fragments.
config.prekeyBundleSyncIntervalSeconds = 0 config.prekeyBundleSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager() let requestSyncManager = RequestSyncManager()
@@ -588,6 +586,30 @@ struct GossipSyncManagerTests {
#expect(sent.isRSR) #expect(sent.isRSR)
} }
@Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws {
var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")]
manager.delegate = delegate
let stalledID = try #require(Data(hexString: "0102030405060708"))
manager.requestMissingFragments(fragmentIDs: [stalledID])
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let sent = try #require(delegate.packets.first)
#expect(sent.type == MessageType.requestSync.rawValue)
#expect(sent.ttl == 0)
let request = try #require(RequestSyncPacket.decode(from: sent.payload))
#expect(request.types == .fragment)
let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter))
#expect(ids == Set([stalledID]))
}
@Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws { @Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws {
var config = GossipSyncManager.Config() var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0 config.messageSyncIntervalSeconds = 0
@@ -675,31 +697,6 @@ struct GossipSyncManagerTests {
#expect(manager._hasPrekeyBundle(for: ownerPeer)) #expect(manager._hasPrekeyBundle(for: ownerPeer))
} }
@Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws {
var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")]
manager.delegate = delegate
let stalledID = try #require(Data(hexString: "0102030405060708"))
manager.requestMissingFragments(fragmentIDs: [stalledID])
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let sent = try #require(delegate.packets.first)
#expect(sent.type == MessageType.requestSync.rawValue)
#expect(sent.ttl == 0)
let request = try #require(RequestSyncPacket.decode(from: sent.payload))
#expect(request.types == .fragment)
let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter))
#expect(ids == Set([stalledID]))
}
// MARK: - Archive persistence // MARK: - Archive persistence
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
@@ -0,0 +1,74 @@
import Testing
import Foundation
/// Guards against locale gaps in the string catalogs: every translatable key
/// must have a localization for every supported locale, so no user ever sees
/// an English fallback (see PR #1391 review).
struct LocalizationCoverageTests {
private static let repoRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // bitchatTests
.deletingLastPathComponent() // repo root
private struct Catalog {
let sourceLanguage: String
/// key -> set of locales with a localization entry
let coverage: [String: Set<String>]
/// all locales appearing anywhere in the catalog
var allLocales: Set<String> { coverage.values.reduce(into: []) { $0.formUnion($1) } }
}
private static func loadCatalog(_ relativePath: String) throws -> Catalog {
let url = repoRoot.appendingPathComponent(relativePath)
let data = try Data(contentsOf: url)
let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
let strings = try #require(root["strings"] as? [String: Any])
let sourceLanguage = try #require(root["sourceLanguage"] as? String)
var coverage: [String: Set<String>] = [:]
for (key, value) in strings {
guard let entry = value as? [String: Any] else { continue }
if entry["shouldTranslate"] as? Bool == false { continue }
let localizations = entry["localizations"] as? [String: Any] ?? [:]
var locales: Set<String> = []
for (locale, loc) in localizations {
guard let loc = loc as? [String: Any] else { continue }
// A localization counts if it has a non-empty stringUnit value
// or uses variations/substitutions (plural forms).
if let unit = loc["stringUnit"] as? [String: Any],
let unitValue = unit["value"] as? String, !unitValue.isEmpty {
locales.insert(locale)
} else if loc["variations"] != nil || loc["substitutions"] != nil {
locales.insert(locale)
}
}
coverage[key] = locales
}
return Catalog(sourceLanguage: sourceLanguage, coverage: coverage)
}
@Test func mainCatalogCoversAllLocalesForEveryKey() throws {
let catalog = try Self.loadCatalog("bitchat/Localizable.xcstrings")
let expected = catalog.allLocales
#expect(expected.count > 1, "catalog should declare more locales than the source language")
for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) {
let missing = expected.subtracting(locales).sorted()
#expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))")
}
}
@Test func shareExtensionCatalogCoversAllLocalesForEveryKey() throws {
let catalog = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
let expected = catalog.allLocales
for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) {
let missing = expected.subtracting(locales).sorted()
#expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))")
}
}
@Test func shareExtensionSupportsSameLocalesAsMainApp() throws {
let main = try Self.loadCatalog("bitchat/Localizable.xcstrings")
let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
let missing = main.allLocales.subtracting(shareExt.allLocales).sorted()
#expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))")
}
}
@@ -40,14 +40,17 @@ struct BLEFragmentHandlerTests {
} }
@Test @Test
func ownFragmentIsIgnored() { func ownFragmentIsTrackedForSyncButNotAssembled() {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder) let handler = makeHandler(recorder: recorder)
let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2) let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2)
handler.handle(packet, from: localPeerID) handler.handle(packet, from: localPeerID)
#expect(recorder.trackedPackets.isEmpty) // Sync replay hands own fragments back after a relaunch; they must
// re-enter the sync store (so the next round's filter covers them
// and redelivery stops) without being reassembled.
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.appendedHeaders.isEmpty) #expect(recorder.appendedHeaders.isEmpty)
#expect(recorder.reinjectedPackets.isEmpty) #expect(recorder.reinjectedPackets.isEmpty)
} }
@@ -0,0 +1,99 @@
//
// BLERecentPeripheralCacheTests.swift
// bitchatTests
//
// Eviction, expiry, and reconnect-target selection for the background
// wake-on-proximity peripheral cache.
//
import Testing
import Foundation
@testable import bitchat
struct BLERecentPeripheralCacheTests {
private let base = Date(timeIntervalSince1970: 1_700_000_000)
private func makeCache(capacity: Int = 4, maxAge: TimeInterval = 900) -> BLERecentPeripheralCache<String> {
BLERecentPeripheralCache<String>(capacity: capacity, maxAge: maxAge)
}
@Test
func recordUpsertsByPeripheralID() {
let cache = makeCache()
cache.record("p1", peripheralID: "A", at: base)
cache.record("p1-updated", peripheralID: "A", at: base.addingTimeInterval(10))
#expect(cache.count == 1)
let targets = cache.reconnectTargets(now: base.addingTimeInterval(11), limit: 10) { _ in false }
#expect(targets.map(\.peripheral) == ["p1-updated"])
}
@Test
func overCapacityEvictsStalestEntry() {
let cache = makeCache(capacity: 2)
cache.record("p1", peripheralID: "A", at: base)
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1))
cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(2))
#expect(cache.count == 2)
let targets = cache.reconnectTargets(now: base.addingTimeInterval(3), limit: 10) { _ in false }
#expect(targets.map(\.peripheralID) == ["C", "B"])
}
@Test
func refreshingAnEntryProtectsItFromEviction() {
let cache = makeCache(capacity: 2)
cache.record("p1", peripheralID: "A", at: base)
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1))
// A becomes the freshest again; adding C must evict B, not A
cache.record("p1", peripheralID: "A", at: base.addingTimeInterval(2))
cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(3))
let targets = cache.reconnectTargets(now: base.addingTimeInterval(4), limit: 10) { _ in false }
#expect(targets.map(\.peripheralID) == ["C", "A"])
}
@Test
func expiredEntriesArePruned() {
let cache = makeCache(maxAge: 100)
cache.record("p1", peripheralID: "A", at: base)
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(50))
let targets = cache.reconnectTargets(now: base.addingTimeInterval(120), limit: 10) { _ in false }
#expect(targets.map(\.peripheralID) == ["B"])
#expect(cache.count == 1)
}
@Test
func targetsAreFreshestFirstAndCappedAtLimit() {
let cache = makeCache(capacity: 8)
for (index, id) in ["A", "B", "C", "D"].enumerated() {
cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index)))
}
let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { _ in false }
#expect(targets.map(\.peripheralID) == ["D", "C"])
}
@Test
func excludedPeripheralsAreSkippedWithoutConsumingTheLimit() {
let cache = makeCache(capacity: 8)
for (index, id) in ["A", "B", "C"].enumerated() {
cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index)))
}
// C (freshest) is already connected; the two slots go to B and A
let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { $0 == "C" }
#expect(targets.map(\.peripheralID) == ["B", "A"])
}
@Test
func nonPositiveLimitReturnsNothing() {
let cache = makeCache()
cache.record("p1", peripheralID: "A", at: base)
#expect(cache.reconnectTargets(now: base, limit: 0) { _ in false }.isEmpty)
#expect(cache.reconnectTargets(now: base, limit: -3) { _ in false }.isEmpty)
}
}
@@ -32,13 +32,13 @@ struct BLESourceRouteOriginationPolicyTests {
) )
} }
private func decide( private func route(
packet: BitchatPacket, packet: BitchatPacket,
isRecipientConnected: Bool = false, isRecipientConnected: Bool = false,
shouldAttemptRoute: Bool = true, shouldAttemptRoute: Bool = true,
computedRoute: [Data]? = nil computedRoute: [Data]? = nil
) -> BLESourceRouteOriginationPolicy.Decision { ) -> [Data]? {
BLESourceRouteOriginationPolicy.decide( BLESourceRouteOriginationPolicy.route(
for: packet, for: packet,
to: recipient, to: recipient,
localPeerIDData: localPeerIDData, localPeerIDData: localPeerIDData,
@@ -49,38 +49,38 @@ struct BLESourceRouteOriginationPolicyTests {
} }
@Test func routesWhenAllGatesPass() { @Test func routesWhenAllGatesPass() {
#expect(decide(packet: makePacket()) == .route([hop])) #expect(route(packet: makePacket()) == [hop])
} }
@Test func relayedPacketNeverGetsRoute() { @Test func relayedPacketNeverGetsRoute() {
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011")) let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
#expect(decide(packet: relayed) == .flood(.relayedNotOriginator)) #expect(route(packet: relayed) == nil)
} }
@Test func broadcastRecipientNeverGetsRoute() { @Test func broadcastRecipientNeverGetsRoute() {
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8)) let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
#expect(decide(packet: broadcast) == .flood(.broadcast)) #expect(route(packet: broadcast) == nil)
let noRecipient = makePacket(recipientID: nil) let noRecipient = makePacket(recipientID: nil)
#expect(decide(packet: noRecipient) == .flood(.broadcast)) #expect(route(packet: noRecipient) == nil)
} }
@Test func linkLocalTTLNeverGetsRoute() { @Test func linkLocalTTLNeverGetsRoute() {
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops. // TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
#expect(decide(packet: makePacket(ttl: 0)) == .flood(.noTTLHeadroom)) #expect(route(packet: makePacket(ttl: 0)) == nil)
#expect(decide(packet: makePacket(ttl: 1)) == .flood(.noTTLHeadroom)) #expect(route(packet: makePacket(ttl: 1)) == nil)
} }
@Test func directlyConnectedRecipientNeverGetsRoute() { @Test func directlyConnectedRecipientNeverGetsRoute() {
#expect(decide(packet: makePacket(), isRecipientConnected: true) == .flood(.recipientDirect)) #expect(route(packet: makePacket(), isRecipientConnected: true) == nil)
} }
@Test func suppressedRecipientFallsBackToFlood() { @Test func suppressedRecipientFallsBackToFlood() {
#expect(decide(packet: makePacket(), shouldAttemptRoute: false) == .flood(.routeSuppressed)) #expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil)
} }
@Test func missingOrEmptyRouteFallsBackToFlood() { @Test func missingOrEmptyRouteFallsBackToFlood() {
var sawComputeRoute = false var sawComputeRoute = false
let result = BLESourceRouteOriginationPolicy.decide( let result = BLESourceRouteOriginationPolicy.route(
for: makePacket(), for: makePacket(),
to: recipient, to: recipient,
localPeerIDData: localPeerIDData, localPeerIDData: localPeerIDData,
@@ -91,8 +91,8 @@ struct BLESourceRouteOriginationPolicyTests {
return nil return nil
} }
) )
#expect(result == .flood(.noPath)) #expect(result == nil)
#expect(sawComputeRoute) #expect(sawComputeRoute)
#expect(decide(packet: makePacket(), computedRoute: []) == .flood(.noPath)) #expect(route(packet: makePacket(), computedRoute: []) == nil)
} }
} }
@@ -0,0 +1,215 @@
//
// BoardAlertsModelTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Combine
import Foundation
import Testing
@testable import bitchat
@MainActor
struct BoardAlertsModelTests {
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
private let ownKey = Data(repeating: 7, count: 32)
private final class Harness {
var lines: [(content: String, geohash: String)] = []
var pendingFlushes: [@MainActor () -> Void] = []
@MainActor
func flushAll() {
let flushes = pendingFlushes
pendingFlushes = []
for flush in flushes { flush() }
}
}
private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel {
let fixedNow = now ?? baseDate
return BoardAlertsModel(
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey },
emitSystemLine: { content, geohash in
harness.lines.append((content, geohash))
},
now: { fixedNow },
scheduleFlush: { flush in
harness.pendingFlushes.append(flush)
}
)
)
}
private func makePost(
content: String = "hello",
geohash: String = "9q8yy",
nickname: String = "alice",
createdAt: UInt64? = nil,
urgent: Bool = false,
authorKey: Data = Data(repeating: 1, count: 32),
postID: Data? = nil
) -> BoardPostPacket {
BoardPostPacket(
postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
geohash: geohash,
content: content,
authorSigningKey: authorKey,
authorNickname: nickname,
createdAt: createdAt ?? baseMs,
expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
flags: urgent ? BoardPostPacket.urgentFlag : 0,
signature: Data(repeating: 2, count: 64)
)
}
@Test
func ownPosts_neverBadgeOrAlert() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(urgent: true, authorKey: ownKey))
harness.flushAll()
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
#expect(harness.lines.isEmpty)
}
@Test
func routinePost_badgesWithoutChatLine() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(geohash: ""))
harness.flushAll()
#expect(model.unseenCount(forGeohash: "") == 1)
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
#expect(harness.lines.isEmpty)
}
@Test
func urgentRecentPost_emitsLineInMatchingScope() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true))
#expect(harness.lines.isEmpty)
harness.flushAll()
#expect(harness.lines.count == 1)
#expect(harness.lines[0].geohash == "9q8yy")
#expect(harness.lines[0].content.contains("road closed"))
#expect(harness.lines[0].content.contains("@alice"))
}
@Test
func urgentBackfilledPost_badgesOnly() {
let harness = Harness()
let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120)
let model = makeModel(harness: harness, now: arrivalTime)
model.handleArrival(makePost(createdAt: baseMs, urgent: true))
harness.flushAll()
#expect(model.unseenCount(forGeohash: "9q8yy") == 1)
#expect(harness.lines.isEmpty)
}
@Test
func simultaneousUrgentPosts_collapseIntoOneLine() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(content: "one", urgent: true))
model.handleArrival(makePost(content: "two", urgent: true))
model.handleArrival(makePost(content: "three", urgent: true))
harness.flushAll()
#expect(harness.lines.count == 1)
#expect(harness.lines[0].content.contains("3"))
#expect(harness.pendingFlushes.isEmpty)
}
@Test
func urgentPostsInDifferentScopes_alertEachScope() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true))
model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true))
harness.flushAll()
#expect(harness.lines.count == 2)
#expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""])
}
@Test
func duplicateArrival_isHandledOnce() {
let harness = Harness()
let model = makeModel(harness: harness)
let id = Data(repeating: 3, count: 16)
model.handleArrival(makePost(urgent: true, postID: id))
model.handleArrival(makePost(urgent: true, postID: id))
harness.flushAll()
#expect(model.unseenCount(forGeohash: "9q8yy") == 1)
#expect(harness.lines.count == 1)
#expect(!harness.lines[0].content.contains("2"))
}
@Test
func markSeen_clearsOnlyVisibleScopes() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(geohash: ""))
model.handleArrival(makePost(geohash: "9q8yy"))
model.handleArrival(makePost(geohash: "u4pruyd"))
// Opening the sheet on mesh + 9q8yy must not eat the badge for the
// never-shown u4pruyd channel.
model.markSeen(forScopes: ["", "9q8yy"])
#expect(model.unseenCount(forGeohash: "") == 0)
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
#expect(model.unseenCount(forGeohash: "u4pruyd") == 1)
}
@Test
func reset_dropsPendingUrgentLinesAndBadges() {
let harness = Harness()
let model = makeModel(harness: harness)
model.handleArrival(makePost(content: "pre-wipe secret", urgent: true))
#expect(harness.pendingFlushes.count == 1)
// Panic wipe lands before the collapse flush fires.
model.reset()
harness.flushAll()
#expect(harness.lines.isEmpty)
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
}
@Test
func longUrgentContent_isTruncatedInLine() {
let harness = Harness()
let model = makeModel(harness: harness)
let long = String(repeating: "a", count: 400)
model.handleArrival(makePost(content: long, urgent: true))
harness.flushAll()
#expect(harness.lines.count == 1)
#expect(harness.lines[0].content.count < 200)
#expect(harness.lines[0].content.contains(""))
}
}
@@ -337,6 +337,33 @@ struct GatewayServiceTests {
#expect(decoded.event()?.id == event.id) #expect(decoded.event()?.id == event.id)
} }
@Test("never downlink-rebroadcasts an event it uplinked and saw echo back")
func uplinkedEventNotSelfEchoed() throws {
let fixture = Fixture()
let event = try makeEvent()
// A mesh-only peer deposits an event; the gateway uplinks (publishes)
// it to the relays (loop rule 2 records it in publishedEventIDs).
try deposit(event, into: fixture)
#expect(fixture.published.map(\.event.id) == [event.id])
// The gateway's own geohash subscription now delivers that same event
// right back (~0.15s later on device). It originated on this mesh, so
// rebroadcasting it would double BLE airtime the device-confirmed
// self-echo. It must be suppressed.
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
#expect(fixture.broadcasts.isEmpty)
// Sanity: a genuine inbound-from-internet event (never uplinked here)
// still downlink-rebroadcasts normally.
let inbound = try makeEvent()
fixture.service.rebroadcastRelayEvent(inbound, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1)
let payload = try #require(fixture.broadcasts.first)
let decoded = try #require(NostrCarrierPacket.decode(payload))
#expect(decoded.event()?.id == inbound.id)
}
@Test("a forged broadcast cannot poison the loop-prevention set") @Test("a forged broadcast cannot poison the loop-prevention set")
func forgedBroadcastDoesNotPoison() throws { func forgedBroadcastDoesNotPoison() throws {
let fixture = Fixture() let fixture = Fixture()
@@ -277,14 +277,14 @@ private final class DiagnosticsMockContext: CommandContextProvider {
func clearPrivateChat(_ peerID: PeerID) {} func clearPrivateChat(_ peerID: PeerID) {}
func sendPublicRaw(_ content: String) {} func sendPublicRaw(_ content: String) {}
func sendPublicMessage(_ content: String) {} func sendPublicMessage(_ content: String) {}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
func addPublicSystemMessage(_ content: String) {}
func toggleFavorite(peerID: PeerID) {}
func groupCreate(named name: String) -> CommandResult { .handled } func groupCreate(named name: String) -> CommandResult { .handled }
func groupInvite(nickname: String) -> CommandResult { .handled } func groupInvite(nickname: String) -> CommandResult { .handled }
func groupRemove(nickname: String) -> CommandResult { .handled } func groupRemove(nickname: String) -> CommandResult { .handled }
func groupLeave() -> CommandResult { .handled } func groupLeave() -> CommandResult { .handled }
func groupList() -> CommandResult { .handled } func groupList() -> CommandResult { .handled }
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
func addPublicSystemMessage(_ content: String) {}
func toggleFavorite(peerID: PeerID) {}
func currentCommandDestination() -> CommandOutputDestination { func currentCommandDestination() -> CommandOutputDestination {
if let peerID = selectedPrivateChatPeer { if let peerID = selectedPrivateChatPeer {
@@ -111,6 +111,7 @@ final class NetworkActivationServiceTests: XCTestCase {
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
permissionProvider: { permissionSubject.value }, permissionProvider: { permissionSubject.value },
mutualFavoritesProvider: { favoritesSubject.value }, mutualFavoritesProvider: { favoritesSubject.value },
reachabilityMonitor: AlwaysReachableMonitor(),
torController: torController, torController: torController,
relayController: relayController, relayController: relayController,
proxyController: proxyController, proxyController: proxyController,
@@ -0,0 +1,240 @@
import Combine
import XCTest
@testable import bitchat
/// Covers the reachability-gate decision logic (pure debounce) and the
/// `NetworkActivationService` wiring that suppresses Tor/relay startup when
/// there is provably no network.
@MainActor
final class NetworkReachabilityGateTests: XCTestCase {
// MARK: - Pure debounce logic
func test_debounce_satisfiedStaysReachable() {
var d = ReachabilityDebounce(interval: 2.5, initial: true)
let t0 = Date()
// An interface remains present: no change, no pending.
XCTAssertNil(d.observe(reachable: true, at: t0))
XCTAssertTrue(d.committed)
XCTAssertFalse(d.hasPendingChange)
}
func test_debounce_unsatisfiedSuppressesAfterInterval() {
var d = ReachabilityDebounce(interval: 2.5, initial: true)
let t0 = Date()
// Path drops: not committed immediately (within debounce window).
XCTAssertNil(d.observe(reachable: false, at: t0))
XCTAssertTrue(d.committed)
XCTAssertTrue(d.hasPendingChange)
// Still within window.
XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0)))
XCTAssertTrue(d.committed)
// Past the window: commit unreachable.
XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false)
XCTAssertFalse(d.committed)
XCTAssertFalse(d.hasPendingChange)
}
func test_debounce_flapIsIgnored() {
var d = ReachabilityDebounce(interval: 2.5, initial: true)
let t0 = Date()
// Drop then recover well within the window must never commit a change.
XCTAssertNil(d.observe(reachable: false, at: t0))
XCTAssertTrue(d.hasPendingChange)
XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5)))
XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop")
// A late flush after the original deadline is a no-op (nothing pending).
XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0)))
XCTAssertTrue(d.committed)
}
func test_debounce_recoverAfterOutageCommitsAfterInterval() {
var d = ReachabilityDebounce(interval: 2.5, initial: false)
let t0 = Date()
XCTAssertNil(d.observe(reachable: true, at: t0))
XCTAssertTrue(d.hasPendingChange)
XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true)
XCTAssertTrue(d.committed)
}
func test_debounce_duplicateObservationsPreservePendingDeadline() {
var d = ReachabilityDebounce(interval: 2.5, initial: true)
let t0 = Date()
XCTAssertNil(d.observe(reachable: false, at: t0))
// Duplicate unsatisfied updates mid-window keep the original deadline.
XCTAssertNil(d.observe(reachable: false, at: t0.addingTimeInterval(1.0)))
XCTAssertEqual(d.pendingRemaining(at: t0.addingTimeInterval(1.0)), 1.5)
// A duplicate arriving past the deadline commits immediately.
XCTAssertEqual(d.observe(reachable: false, at: t0.addingTimeInterval(2.5)), false)
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
}
func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async {
let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0)
var received: [Bool] = []
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
defer { cancellable.cancel() }
let start = Date()
monitor.ingest(reachable: false)
try? await Task.sleep(nanoseconds: 500_000_000)
// Duplicate unsatisfied update mid-window (e.g. interface detail change
// while still offline) must not restart the debounce window.
monitor.ingest(reachable: false)
let committed = await waitUntil(timeout: 2.0) { !received.isEmpty }
XCTAssertTrue(committed)
XCTAssertEqual(received, [false])
// The flush must fire at the original ~1.0s deadline, not ~1.5s
// (a full interval after the duplicate).
XCTAssertLessThan(Date().timeIntervalSince(start), 1.4)
}
// MARK: - Service gating
func test_start_whenUnreachable_suppressesTorAndRelays() {
let ctx = makeService(permission: .authorized, reachable: false)
ctx.service.start()
XCTAssertFalse(ctx.service.activationAllowed)
XCTAssertFalse(ctx.service.isNetworkReachable)
XCTAssertTrue(ctx.reachability.startCalled)
XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0)
XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false])
XCTAssertEqual(ctx.relayController.connectCallCount, 0)
XCTAssertEqual(ctx.relayController.disconnectCallCount, 1)
}
func test_start_whenReachable_allowsTorAndRelays() {
let ctx = makeService(permission: .authorized, reachable: true)
ctx.service.start()
XCTAssertTrue(ctx.service.activationAllowed)
XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1)
XCTAssertEqual(ctx.relayController.connectCallCount, 1)
}
func test_reachabilityRecovery_resumesTorAndRelays() async {
let ctx = makeService(permission: .authorized, reachable: false)
ctx.service.start()
XCTAssertFalse(ctx.service.activationAllowed)
ctx.reachability.set(true)
let resumed = await waitUntil { ctx.service.activationAllowed }
XCTAssertTrue(resumed)
XCTAssertTrue(ctx.service.isNetworkReachable)
XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1)
XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1)
}
func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async {
let ctx = makeService(permission: .authorized, reachable: true)
ctx.service.start()
XCTAssertTrue(ctx.service.activationAllowed)
let disconnectsBefore = ctx.relayController.disconnectCallCount
ctx.reachability.set(false)
let suppressed = await waitUntil { !ctx.service.activationAllowed }
XCTAssertTrue(suppressed)
XCTAssertFalse(ctx.service.isNetworkReachable)
XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore)
XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false))
XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1)
}
// MARK: - Harness
private func makeService(
permission: LocationChannelManager.PermissionState,
reachable: Bool
) -> Context {
let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName)!
storage.removePersistentDomain(forName: suiteName)
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>([])
let reachability = ControllableReachabilityMonitor(initial: reachable)
let torController = GateMockTorController()
let relayController = GateMockRelayController()
let proxyController = GateMockProxyController()
let service = NetworkActivationService(
storage: storage,
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
permissionProvider: { permissionSubject.value },
mutualFavoritesProvider: { favoritesSubject.value },
reachabilityMonitor: reachability,
torController: torController,
relayController: relayController,
proxyController: proxyController,
notificationCenter: NotificationCenter()
)
return Context(
service: service,
reachability: reachability,
torController: torController,
relayController: relayController
)
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() { return true }
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
@MainActor
private struct Context {
let service: NetworkActivationService
let reachability: ControllableReachabilityMonitor
let torController: GateMockTorController
let relayController: GateMockRelayController
}
@MainActor
private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring {
private let subject: CurrentValueSubject<Bool, Never>
private(set) var startCalled = false
init(initial: Bool) {
subject = CurrentValueSubject(initial)
}
var isReachable: Bool { subject.value }
var reachabilityPublisher: AnyPublisher<Bool, Never> {
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
}
func start() { startCalled = true }
func set(_ reachable: Bool) { subject.send(reachable) }
}
@MainActor
private final class GateMockTorController: NetworkActivationTorControlling {
private(set) var autoStartAllowedValues: [Bool] = []
private(set) var startIfNeededCallCount = 0
private(set) var shutdownCompletelyCallCount = 0
func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) }
func startIfNeeded() { startIfNeededCallCount += 1 }
func shutdownCompletely() { shutdownCompletelyCallCount += 1 }
}
@MainActor
private final class GateMockRelayController: NetworkActivationRelayControlling {
private(set) var connectCallCount = 0
private(set) var disconnectCallCount = 0
func connect() { connectCallCount += 1 }
func disconnect() { disconnectCallCount += 1 }
}
private final class GateMockProxyController: NetworkActivationProxyControlling {
private(set) var proxyModes: [Bool] = []
func setProxyMode(useTor: Bool) { proxyModes.append(useTor) }
}
@@ -0,0 +1,142 @@
//
// UnifiedNoticesTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
struct UnifiedNoticesTests {
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
private func makePost(
content: String,
nickname: String = "alice",
createdAt: UInt64? = nil,
urgent: Bool = false
) -> BoardPostPacket {
BoardPostPacket(
postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
geohash: "9q8yy",
content: content,
authorSigningKey: Data(repeating: 1, count: 32),
authorNickname: nickname,
createdAt: createdAt ?? baseMs,
expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
flags: urgent ? BoardPostPacket.urgentFlag : 0,
signature: Data(repeating: 2, count: 64)
)
}
private func makeNote(
content: String,
nickname: String? = "alice",
createdAt: Date? = nil,
geohash: String = "9q8yy"
) -> LocationNotesManager.Note {
LocationNotesManager.Note(
id: UUID().uuidString,
pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""),
content: content,
createdAt: createdAt ?? baseDate,
nickname: nickname,
geohash: geohash
)
}
@Test
func merge_dropsBridgedCopyOfBoardPost() {
let post = makePost(content: "free couch on 5th")
let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30))
let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
#expect(merged.count == 1)
#expect(merged[0].isBoardPost)
}
@Test
func merge_keepsNoteWithSameContentOutsideWindow() {
let post = makePost(content: "water station here")
let oldNote = makeNote(
content: "water station here",
createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60)
)
let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote])
#expect(merged.count == 2)
}
@Test
func merge_keepsSameTextNoteFromNeighborCell() {
// The notes subscription covers the center cell plus 8 neighbors; a
// matching note posted to a *neighbor* is not the bridged copy.
let post = makePost(content: "free couch on 5th")
let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz")
let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote])
#expect(merged.count == 2)
}
@Test
func merge_keepsNoteFromDifferentAuthor() {
let post = makePost(content: "meetup at 6", nickname: "alice")
let note = makeNote(content: "meetup at 6", nickname: "bob")
let merged = UnifiedNotices.merge(posts: [post], notes: [note])
#expect(merged.count == 2)
}
@Test
func merge_sortsUrgentFirstThenNewest() {
let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true)
let newerPost = makePost(content: "later post", createdAt: baseMs)
let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30))
let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note])
#expect(merged.map(\.content) == ["road closed", "a note", "later post"])
#expect(merged[0].isUrgent)
}
@Test
func merge_anonNicknamesMatchForDedupe() {
// Bridged posts from an empty nickname arrive as anon notes with no
// "n" tag; they must still dedupe against the anon board copy.
let post = makePost(content: "hello", nickname: "")
let bridged = makeNote(content: "hello", nickname: nil)
let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
#expect(merged.count == 1)
#expect(merged[0].isBoardPost)
#expect(merged[0].author == "anon")
}
@Test
func noticeItem_normalizesNoteDisplayName() {
let note = LocationNotesManager.Note(
id: "e1",
pubkey: "deadbeef",
content: "hi",
createdAt: baseDate,
nickname: "dave",
geohash: "9q8yy"
)
let item = NoticeItem(note: note)
#expect(item.author == "dave")
#expect(!item.isBoardPost)
#expect(!item.isUrgent)
}
}
@@ -34,8 +34,6 @@ struct GossipSyncBoardTests {
config.messageSyncIntervalSeconds = 0 config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0
// Silence the prekey round (added by the prekeys feature; groupMessage
// rides the message schedule, already off) so board is isolated.
config.prekeyBundleSyncIntervalSeconds = 0 config.prekeyBundleSyncIntervalSeconds = 0
return config return config
} }
@@ -38,12 +38,12 @@ struct SyncTypeFlagsBoardTests {
/// decode path accepts the bytes and simply maps unknown bits to no /// decode path accepts the bytes and simply maps unknown bits to no
/// message type, so a board-only request reads as "nothing I can serve". /// message type, so a board-only request reads as "nothing I can serve".
@Test func unknownBitsDecodeToNoTypes() throws { @Test func unknownBitsDecodeToNoTypes() throws {
// Bits 11-15 are unassigned after the feature integration (bit 8 is // Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle,
// board, 9 prekey, 10 group); a future (or unknown) two-byte bitfield // bit 10 = groupMessage); a future (or unknown) two-byte bitfield must
// must decode without error and yield no known types. // decode without error and yield no known types.
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8]))) let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8])))
#expect(decoded.toMessageTypes().isEmpty) #expect(decoded.toMessageTypes().isEmpty)
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] { for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] {
#expect(!decoded.contains(type)) #expect(!decoded.contains(type))
} }
} }
+19 -9
View File
@@ -13,33 +13,43 @@ struct SyncTypeFlagsTests {
} }
@Test func decodeDropsPhantomBits() { @Test func decodeDropsPhantomBits() {
// Bits 11+ map to no message type (bits 8/9/10 are board/prekey/group // Bits 11+ map to no message type (bit 8 = boardPost, bit 9 =
// after the feature integration). They must not survive decode as // prekeyBundle, bit 10 = groupMessage). They must not survive decode
// phantom membership. // as phantom membership.
let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type
let decoded = SyncTypeFlags.decode(phantom) let decoded = SyncTypeFlags.decode(phantom)
#expect(decoded?.rawValue == 0) #expect(decoded?.rawValue == 0)
#expect(decoded?.toMessageTypes().isEmpty == true) #expect(decoded?.toMessageTypes().isEmpty == true)
} }
@Test func boardBitSurvivesDecode() {
// Bit 8 maps to boardPost and spills the field into a second byte;
// it must survive decode while the phantom high bits (11+) are
// stripped. Bits 9 (prekeyBundle) and 10 (groupMessage) are cleared
// to isolate the board bit.
let mixed = Data([0x00, 0xF9]) // bit 8 (board) known, bits 11..15 phantom
let decoded = SyncTypeFlags.decode(mixed)
#expect(decoded?.contains(.board) == true)
#expect(decoded?.rawValue == 0b1_0000_0000)
}
@Test func phantomBitsAreStrippedButKnownBitsSurvive() { @Test func phantomBitsAreStrippedButKnownBitsSurvive() {
// Low byte = announce(0) + message(1); high byte = phantom (bits 11+, // Low byte = announce(0) + message(1); high byte bits 11+ are phantom.
// which map to no known type).
let mixed = Data([0b0000_0011, 0xF8]) let mixed = Data([0b0000_0011, 0xF8])
let decoded = SyncTypeFlags.decode(mixed) let decoded = SyncTypeFlags.decode(mixed)
#expect(decoded?.contains(.announce) == true) #expect(decoded?.contains(.announce) == true)
#expect(decoded?.contains(.message) == true) #expect(decoded?.contains(.message) == true)
// Only the two known bits remain; phantom high byte is gone. // Only the two known bits remain; phantom high bits are gone.
#expect(decoded?.rawValue == 0b0000_0011) #expect(decoded?.rawValue == 0b0000_0011)
} }
@Test func rawValueInitNormalizesPhantomBits() { @Test func rawValueInitNormalizesPhantomBits() {
let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF) let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF)
// Every known type bit is set; nothing above them survives. The // Every known type bit is set; nothing above them survives. boardPost
// highest known bit is 10 (groupMessage), so the field serializes to // occupies bit 8, so the known set spills into a second byte.
// two bytes.
#expect(flags.contains(.announce)) #expect(flags.contains(.announce))
#expect(flags.contains(.fileTransfer)) #expect(flags.contains(.fileTransfer))
#expect(flags.contains(.board))
let data = flags.toData() let data = flags.toData()
#expect(data?.count == 2) #expect(data?.count == 2)
} }
+39 -9
View File
@@ -1,3 +1,4 @@
import Combine
import Testing import Testing
import Foundation import Foundation
import SwiftUI import SwiftUI
@@ -39,6 +40,7 @@ private struct SmokeFeatureModels {
let verificationModel: VerificationModel let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel let conversationUIModel: ConversationUIModel
let peerListModel: PeerListModel let peerListModel: PeerListModel
let boardAlertsModel: BoardAlertsModel
} }
@MainActor @MainActor
@@ -80,6 +82,14 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
locationChannelsModel: locationChannelsModel locationChannelsModel: locationChannelsModel
) )
let boardAlertsModel = BoardAlertsModel(
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { _ in false },
emitSystemLine: { _, _ in }
)
)
return SmokeFeatureModels( return SmokeFeatureModels(
publicChatModel: publicChatModel, publicChatModel: publicChatModel,
appChromeModel: appChromeModel, appChromeModel: appChromeModel,
@@ -88,7 +98,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
privateConversationModel: privateConversationModel, privateConversationModel: privateConversationModel,
verificationModel: verificationModel, verificationModel: verificationModel,
conversationUIModel: conversationUIModel, conversationUIModel: conversationUIModel,
peerListModel: peerListModel peerListModel: peerListModel,
boardAlertsModel: boardAlertsModel
) )
} }
@@ -106,6 +117,7 @@ private func installSmokeEnvironment<V: View>(
.environmentObject(featureModels.verificationModel) .environmentObject(featureModels.verificationModel)
.environmentObject(featureModels.conversationUIModel) .environmentObject(featureModels.conversationUIModel)
.environmentObject(featureModels.peerListModel) .environmentObject(featureModels.peerListModel)
.environmentObject(featureModels.boardAlertsModel)
} }
@MainActor @MainActor
@@ -395,9 +407,17 @@ struct ViewSmokeTests {
} }
@Test @Test
func locationNotesView_rendersNoRelayAndLoadedStates() throws { func noticesView_rendersNoRelayAndLoadedStates() throws {
let (viewModel, _, _) = makeSmokeViewModel() let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel) let featureModels = makeSmokeFeatureModels(for: viewModel)
featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq")))
defer { featureModels.locationChannelsModel.select(.mesh) }
let board = BoardManager(
transport: transport,
store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }),
publishToNostr: { _, _, _, _ in nil },
deleteFromNostr: { _, _ in }
)
let noRelayManager = LocationNotesManager( let noRelayManager = LocationNotesManager(
geohash: "u4pruydq", geohash: "u4pruydq",
@@ -440,18 +460,28 @@ struct ViewSmokeTests {
eose?() eose?()
_ = mount( _ = mount(
LocationNotesView( NoticesView(
geohash: "u4pruydq",
senderNickname: viewModel.nickname, senderNickname: viewModel.nickname,
manager: noRelayManager board: board,
initialTab: .geo,
notesManager: noRelayManager
) )
.environmentObject(featureModels.locationChannelsModel) .environmentObject(featureModels.locationChannelsModel)
) )
_ = mount( _ = mount(
LocationNotesView( NoticesView(
geohash: "u4pruydq",
senderNickname: viewModel.nickname, senderNickname: viewModel.nickname,
manager: loadedManager board: board,
initialTab: .geo,
notesManager: loadedManager
)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
NoticesView(
senderNickname: viewModel.nickname,
board: board,
initialTab: .mesh
) )
.environmentObject(featureModels.locationChannelsModel) .environmentObject(featureModels.locationChannelsModel)
) )
@@ -1,264 +0,0 @@
//
// WifiBulkCryptoTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk channel crypto")
struct WifiBulkCryptoTests {
private static func keyHex(_ key: SymmetricKey) -> String {
key.withUnsafeBytes { Data($0).map { String(format: "%02x", $0) }.joined() }
}
private func makeKey() throws -> SymmetricKey {
try #require(WifiBulkCrypto.deriveKey(
senderToken: Data(repeating: 0x11, count: 32),
receiverToken: Data(repeating: 0x22, count: 32),
transferID: Data(repeating: 0x33, count: 16)
))
}
// MARK: HKDF derivation
@Test("HKDF derivation matches fixed vectors")
func hkdfVectors() throws {
// Vectors computed independently with CryptoKit HKDF<SHA256>,
// ikm = senderToken receiverToken, salt = transferID,
// info = "bitchat-bulk-v1", 32 bytes out.
let key1 = try makeKey()
#expect(Self.keyHex(key1) == "9ee6f4bf7753a8a9564d6760b7064e31657f1a6bcca2b3ff266bb975cc4f66eb")
let key2 = try #require(WifiBulkCrypto.deriveKey(
senderToken: Data((0..<32).map { UInt8($0) }),
receiverToken: Data((0..<32).map { UInt8(255 - $0) }),
transferID: Data((0..<16).map { UInt8($0 * 3) })
))
#expect(Self.keyHex(key2) == "432ebb559f2f546d632a91d53b5c25af36f15d1ba53917910a0041329dc0efd4")
}
@Test("HKDF derivation is order- and role-sensitive")
func hkdfRoleSensitivity() throws {
let a = Data(repeating: 0x11, count: 32)
let b = Data(repeating: 0x22, count: 32)
let tid = Data(repeating: 0x33, count: 16)
let forward = try #require(WifiBulkCrypto.deriveKey(senderToken: a, receiverToken: b, transferID: tid))
let reversed = try #require(WifiBulkCrypto.deriveKey(senderToken: b, receiverToken: a, transferID: tid))
#expect(Self.keyHex(forward) != Self.keyHex(reversed))
}
@Test("HKDF derivation rejects wrong-length inputs")
func hkdfRejectsBadLengths() {
let token = Data(repeating: 1, count: 32)
let tid = Data(repeating: 2, count: 16)
#expect(WifiBulkCrypto.deriveKey(senderToken: Data(count: 31), receiverToken: token, transferID: tid) == nil)
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: Data(count: 33), transferID: tid) == nil)
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: token, transferID: Data(count: 15)) == nil)
}
// MARK: Frame sealing
@Test("Frame seal/open round-trips")
func frameRoundTrip() throws {
let key = try makeKey()
let plaintext = Data((0..<1000).map { UInt8($0 % 251) })
let body = try WifiBulkCrypto.sealFrameBody(plaintext, direction: .senderToReceiver, counter: 7, key: key)
let opened = try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 7, key: key)
#expect(opened == plaintext)
}
@Test("Tampered frames are rejected")
func tamperRejection() throws {
let key = try makeKey()
var body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: key)
body[body.count - 1] ^= 0x01 // flip a tag bit
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
}
}
@Test("Frames cannot be replayed at another counter or reflected across directions")
func nonceBinding() throws {
let key = try makeKey()
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 3, key: key)
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 4, key: key)
}
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
try WifiBulkCrypto.openFrameBody(body, direction: .receiverToSender, counter: 3, key: key)
}
}
@Test("Frames sealed under a different key are rejected")
func wrongKeyRejection() throws {
let key = try makeKey()
let otherKey = try #require(WifiBulkCrypto.deriveKey(
senderToken: Data(repeating: 0x44, count: 32),
receiverToken: Data(repeating: 0x22, count: 32),
transferID: Data(repeating: 0x33, count: 16)
))
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: otherKey)
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
}
}
@Test("Auth and receipt control frames validate and reject forgeries")
func controlFrames() throws {
let key = try makeKey()
let transferID = Data(repeating: 0x33, count: 16)
let hash = Data(repeating: 0x55, count: 32)
let auth = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
#expect(WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: transferID, key: key))
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: Data(repeating: 0x34, count: 16), key: key))
var forgedAuth = auth
forgedAuth[forgedAuth.count - 1] ^= 0x01
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(forgedAuth, transferID: transferID, key: key))
let receipt = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: hash, key: key)
#expect(WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: hash, key: key))
#expect(!WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: Data(repeating: 0x56, count: 32), key: key))
// An auth frame is not a receipt (distinct counter).
#expect(!WifiBulkCrypto.validateReceiptFrameBody(auth, payloadHash: hash, key: key))
}
// MARK: Frame buffer
@Test("Frame buffer reassembles frames from arbitrary byte boundaries")
func frameBufferReassembly() throws {
let bodyA = Data(repeating: 0xAA, count: 100)
let bodyB = Data(repeating: 0xBB, count: 5)
var stream = WifiBulkCrypto.frameData(body: bodyA)
stream.append(WifiBulkCrypto.frameData(body: bodyB))
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 1024)
// Drip-feed 3 bytes at a time.
var extracted: [Data] = []
var index = stream.startIndex
while index < stream.endIndex {
let next = stream.index(index, offsetBy: 3, limitedBy: stream.endIndex) ?? stream.endIndex
buffer.append(Data(stream[index..<next]))
while let body = try buffer.nextFrameBody() {
extracted.append(body)
}
index = next
}
#expect(extracted == [bodyA, bodyB])
#expect(try buffer.nextFrameBody() == nil)
}
@Test("Frame buffer rejects oversized frame lengths without buffering them")
func frameBufferOversizeRejection() {
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 64)
buffer.append(WifiBulkCrypto.frameData(body: Data(repeating: 1, count: 65)).prefix(8))
#expect(throws: WifiBulkCryptoError.frameTooLarge) {
_ = try buffer.nextFrameBody()
}
}
// MARK: Payload assembler
private func sealedChunks(_ payload: Data, chunkSize: Int, key: SymmetricKey) throws -> [Data] {
try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in
try WifiBulkCrypto.sealFrameBody(
Data(payload[offset..<min(offset + chunkSize, payload.count)]),
direction: .senderToReceiver,
counter: UInt64(index),
key: key
)
}
}
@Test("Assembler reassembles and verifies a chunked payload")
func assemblerHappyPath() throws {
let key = try makeKey()
let payload = Data((0..<200_000).map { UInt8($0 % 253) })
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
var result: Data?
for chunk in try sealedChunks(payload, chunkSize: 64 * 1024, key: key) {
result = try assembler.consume(frameBody: chunk)
}
#expect(result == payload)
}
@Test("Assembler rejects a payload whose final hash mismatches the offer")
func assemblerHashMismatch() throws {
let key = try makeKey()
let payload = Data(repeating: 0x77, count: 100_000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(repeating: 0, count: 32), // wrong hash
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
_ = try assembler.consume(frameBody: chunks[0])
#expect(throws: WifiBulkCryptoError.hashMismatch) {
_ = try assembler.consume(frameBody: chunks[1])
}
}
@Test("Assembler rejects overflow beyond the offered size")
func assemblerOverflow() throws {
let key = try makeKey()
let payload = Data(repeating: 0x77, count: 1000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: 500, // offer promised less than the sender streams
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunk = try WifiBulkCrypto.sealFrameBody(payload, direction: .senderToReceiver, counter: 0, key: key)
#expect(throws: WifiBulkCryptoError.payloadOverflow) {
_ = try assembler.consume(frameBody: chunk)
}
}
@Test("Assembler enforces the receiver-side size cap at construction")
func assemblerSizeCap() throws {
let key = try makeKey()
#expect(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
expectedHash: Data(repeating: 0, count: 32),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
) == nil)
#expect(WifiBulkPayloadAssembler(
key: key,
expectedSize: 0,
expectedHash: Data(repeating: 0, count: 32),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
) == nil)
}
@Test("Assembler rejects out-of-order chunks")
func assemblerOutOfOrder() throws {
let key = try makeKey()
let payload = Data(repeating: 0x42, count: 100_000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
_ = try assembler.consume(frameBody: chunks[1]) // skip chunk 0
}
}
}
@@ -1,246 +0,0 @@
//
// WifiBulkLoopbackTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import BitFoundation
import Foundation
import Network
import Testing
@testable import bitchat
/// End-to-end integration over real Network.framework sockets on localhost:
/// two `WifiBulkTransferService` instances negotiate through an in-process
/// "Noise" pipe, then move the payload over a genuine TCP connection using
/// the production listener/browser-free test hooks (peer-to-peer and Bonjour
/// are disabled unit-test hosts have no AWDL or mDNS access).
@Suite("WifiBulk loopback integration", .serialized)
struct WifiBulkLoopbackTests {
private static let senderPeer = PeerID(str: "00112233aabbccdd")
private static let receiverPeer = PeerID(str: "ddccbbaa33221100")
private func makeConfig() -> WifiBulkTransferServiceConfig {
var config = WifiBulkTransferServiceConfig()
config.usePeerToPeer = false
config.publishBonjourService = false
config.offerTimeout = 5.0
config.transferWindow = 10.0
return config
}
@Test("Payload crosses a real TCP loopback channel and lands verified")
func loopbackTransferSucceeds() async throws {
let payload = Data((0..<300_000).map { UInt8($0 % 249) })
let delivered = WifiBulkTestBox<Data>()
let progress = WifiBulkTestBox<String>()
let fallbacks = WifiBulkTestBox<Bool>()
let ports = WifiBulkTestBox<(Data, UInt16)>()
let offers = WifiBulkTestBox<Data>()
var senderService: WifiBulkTransferService?
var receiverService: WifiBulkTransferService?
// Once the receiver has accepted AND the listener port is known,
// connect the receiver straight to 127.0.0.1 (Bonjour stand-in).
let accepted = WifiBulkTestBox<Data>()
let connectIfReady: () -> Void = {
guard let (transferID, port) = ports.values.first,
accepted.values.contains(transferID),
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
receiverService?._test_connectIncoming(
transferID: transferID,
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
)
}
let senderEnv = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { typed, _ in
// Sender receiver control plane (offer).
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
offers.append(Data(typed.dropFirst()))
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
return true
},
isPeerConnected: { _ in true },
deliverReceivedFile: { _, _, _ in },
progressStart: { id, total in progress.append("start:\(id):\(total)") },
progressChunkSent: { id in progress.append("chunk:\(id)") },
progressReset: { id in progress.append("reset:\(id)") },
progressCancel: { id in progress.append("cancel:\(id)") }
)
let receiverEnv = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { typed, _ in
// Receiver sender control plane (response).
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
let body = Data(typed.dropFirst())
if let response = WifiBulkResponse.decode(body), response.accepted {
accepted.append(response.transferID)
}
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
connectIfReady()
return true
},
isPeerConnected: { _ in true },
deliverReceivedFile: { data, peer, limit in
#expect(peer == Self.senderPeer)
#expect(limit == FileTransferLimits.maxWifiBulkPayloadBytes)
delivered.append(data)
},
progressStart: { _, _ in },
progressChunkSent: { _ in },
progressReset: { _ in },
progressCancel: { _ in }
)
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
sender._test_onListenerReady = { transferID, port in
ports.append((transferID, port))
connectIfReady()
}
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
senderService = sender
receiverService = receiver
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-loopback") {
fallbacks.append(true)
}
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
#expect(delivered.values.first == payload)
#expect(fallbacks.count == 0)
// Deterministic teardown: both sides drop all transfer state.
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
#expect(await wifiBulkWait { receiver._test_activeIncomingCount == 0 })
// Progress mirrored the BLE contract: start with the chunk total,
// then exactly `total` chunk ticks (the last one gated on the receipt).
let totalChunks = (payload.count + TransportConfig.wifiBulkChunkBytes - 1) / TransportConfig.wifiBulkChunkBytes
#expect(await wifiBulkWait { progress.values.filter { $0 == "chunk:t-loopback" }.count == totalChunks })
#expect(progress.values.first == "start:t-loopback:\(totalChunks)")
#expect(!progress.values.contains("reset:t-loopback"))
}
@Test("A gatecrasher without the channel key is disconnected and the real peer still succeeds")
func gatecrasherIsRejected() async throws {
let payload = Data((0..<150_000).map { UInt8($0 % 241) })
let delivered = WifiBulkTestBox<Data>()
let fallbacks = WifiBulkTestBox<Bool>()
let ports = WifiBulkTestBox<(Data, UInt16)>()
let accepted = WifiBulkTestBox<Data>()
var senderService: WifiBulkTransferService?
var receiverService: WifiBulkTransferService?
let gatecrashed = WifiBulkTestBox<Bool>()
let connectIfReady: () -> Void = {
guard let (transferID, port) = ports.values.first,
accepted.values.contains(transferID),
gatecrashed.count > 0,
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
receiverService?._test_connectIncoming(
transferID: transferID,
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
)
}
let senderEnv = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { typed, _ in
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
return true
},
isPeerConnected: { _ in true },
deliverReceivedFile: { _, _, _ in },
progressStart: { _, _ in },
progressChunkSent: { _ in },
progressReset: { _ in },
progressCancel: { _ in }
)
let receiverEnv = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { typed, _ in
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
let body = Data(typed.dropFirst())
if let response = WifiBulkResponse.decode(body), response.accepted {
accepted.append(response.transferID)
}
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
connectIfReady()
return true
},
isPeerConnected: { _ in true },
deliverReceivedFile: { data, _, _ in delivered.append(data) },
progressStart: { _, _ in },
progressChunkSent: { _ in },
progressReset: { _ in },
progressCancel: { _ in }
)
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
let gateQueue = DispatchQueue(label: "test.gatecrasher")
sender._test_onListenerReady = { transferID, port in
ports.append((transferID, port))
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
// A stranger who saw the Bonjour advertisement connects first and
// sends garbage that cannot carry a valid MAC.
let crasher = NWConnection(
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort),
using: .tcp
)
crasher.stateUpdateHandler = { state in
if case .ready = state {
let junkBody = Data(repeating: 0xAA, count: 60)
crasher.send(
content: WifiBulkCrypto.frameData(body: junkBody),
completion: .contentProcessed { _ in
gatecrashed.append(true)
connectIfReady()
}
)
}
}
crasher.start(queue: gateQueue)
}
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
senderService = sender
receiverService = receiver
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-gatecrash") {
fallbacks.append(true)
}
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
#expect(delivered.values.first == payload)
#expect(fallbacks.count == 0)
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
}
@Test("Receiver vanishing mid-negotiation leaves the sender to time out into BLE")
func vanishedReceiverFallsBack() async {
var config = makeConfig()
config.offerTimeout = 0.3
let fallbacks = WifiBulkTestBox<Bool>()
let environment = WifiBulkTransferServiceEnvironment(
sendNoisePayload: { _, _ in true }, // offer sent, receiver never answers
isPeerConnected: { _ in true },
deliverReceivedFile: { _, _, _ in },
progressStart: { _, _ in },
progressChunkSent: { _ in },
progressReset: { _ in },
progressCancel: { _ in }
)
let sender = WifiBulkTransferService(environment: environment, config: config)
sender.sendFile(payload: Data(repeating: 1, count: 100_000), to: Self.receiverPeer, transferId: "t-vanish") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
#expect(sender._test_activeOutgoingCount == 0)
}
}
@@ -1,107 +0,0 @@
//
// WifiBulkMessagesTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk offer/response TLV")
struct WifiBulkMessagesTests {
private func makeOffer(
fileSize: UInt64 = 300_000,
serviceName: String = "a1b2c3d4e5f60718a1b2c3d4e5f60718"
) -> WifiBulkOffer {
WifiBulkOffer(
transferID: Data(repeating: 0xAB, count: WifiBulkWire.transferIDLength),
fileSize: fileSize,
payloadHash: Data(repeating: 0xCD, count: WifiBulkWire.hashLength),
token: Data(repeating: 0xEF, count: WifiBulkWire.tokenLength),
serviceName: serviceName
)
}
@Test("Offer round-trips through TLV encoding")
func offerRoundTrip() throws {
let offer = makeOffer()
let encoded = try #require(offer.encode())
let decoded = try #require(WifiBulkOffer.decode(encoded))
#expect(decoded == offer)
}
@Test("Offer encode rejects malformed field lengths")
func offerEncodeRejectsBadFields() {
#expect(WifiBulkOffer(
transferID: Data(repeating: 1, count: 15), // short transferID
fileSize: 1,
payloadHash: Data(repeating: 2, count: 32),
token: Data(repeating: 3, count: 32),
serviceName: "x"
).encode() == nil)
#expect(makeOffer(serviceName: "").encode() == nil)
#expect(makeOffer(serviceName: String(repeating: "a", count: 64)).encode() == nil)
}
@Test("Offer decode rejects missing or wrong-length fields")
func offerDecodeRejectsMalformed() throws {
let encoded = try #require(makeOffer().encode())
// Truncation anywhere breaks a TLV boundary or drops a required field.
#expect(WifiBulkOffer.decode(encoded.dropLast(1)) == nil)
#expect(WifiBulkOffer.decode(encoded.prefix(3)) == nil)
#expect(WifiBulkOffer.decode(Data()) == nil)
// A wrong-length transferID TLV is ignored, leaving the field missing.
var mangled = Data([0x01, 0x00, 0x02, 0xAA, 0xBB]) // transferID of 2 bytes
mangled.append(encoded.dropFirst(3 + WifiBulkWire.transferIDLength))
#expect(WifiBulkOffer.decode(mangled) == nil)
}
@Test("Offer decode skips unknown TLVs for forward compatibility")
func offerDecodeSkipsUnknownTLVs() throws {
var encoded = try #require(makeOffer().encode())
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03]) // unknown type 0x7F
let decoded = try #require(WifiBulkOffer.decode(encoded))
#expect(decoded == makeOffer())
}
@Test("Accept response round-trips with token")
func acceptResponseRoundTrip() throws {
let response = WifiBulkResponse.accept(
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength),
token: Data(repeating: 0x22, count: WifiBulkWire.tokenLength)
)
let encoded = try #require(response.encode())
let decoded = try #require(WifiBulkResponse.decode(encoded))
#expect(decoded == response)
#expect(decoded.accepted)
#expect(decoded.token?.count == WifiBulkWire.tokenLength)
}
@Test("Decline response round-trips without token")
func declineResponseRoundTrip() throws {
let response = WifiBulkResponse.decline(
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength)
)
let encoded = try #require(response.encode())
let decoded = try #require(WifiBulkResponse.decode(encoded))
#expect(decoded == response)
#expect(!decoded.accepted)
#expect(decoded.token == nil)
}
@Test("Accept response without a token is rejected")
func acceptWithoutTokenRejected() throws {
// Hand-build: transferID + accepted=1, no token TLV.
var data = Data()
WifiBulkWire.appendTLV(0x01, value: Data(repeating: 0x11, count: 16), into: &data)
WifiBulkWire.appendTLV(0x02, value: Data([1]), into: &data)
#expect(WifiBulkResponse.decode(data) == nil)
}
}
@@ -1,150 +0,0 @@
//
// WifiBulkPolicyTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk policy")
struct WifiBulkPolicyTests {
private func candidate(
payloadBytes: Int = 300_000,
capabilities: PeerCapabilities = [.wifiBulk],
direct: Bool = true,
session: Bool = true
) -> WifiBulkPolicy.SendCandidate {
WifiBulkPolicy.SendCandidate(
payloadBytes: payloadBytes,
peerCapabilities: capabilities,
isDirectlyConnected: direct,
hasEstablishedNoiseSession: session
)
}
@Test("Eligible large transfer to a direct wifiBulk peer is offered")
func eligibleTransferOffered() {
#expect(WifiBulkPolicy.shouldOffer(candidate(), enabled: true))
}
@Test("Fallback matrix: every failed gate keeps the transfer on BLE")
func fallbackMatrix() {
// Feature disabled.
#expect(!WifiBulkPolicy.shouldOffer(candidate(), enabled: false))
// Small file: negotiation overhead not worth it.
#expect(!WifiBulkPolicy.shouldOffer(candidate(payloadBytes: 64 * 1024), enabled: true))
// Peer doesn't advertise the capability.
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: []), enabled: true))
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: [.prekeys, .gateway]), enabled: true))
// Multi-hop recipient (reachable but not directly connected).
#expect(!WifiBulkPolicy.shouldOffer(candidate(direct: false), enabled: true))
// No established Noise session to carry the offer.
#expect(!WifiBulkPolicy.shouldOffer(candidate(session: false), enabled: true))
// Payload beyond even the Wi-Fi ceiling.
#expect(!WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: FileTransferLimits.maxWifiBulkPayloadBytes + 1),
enabled: true
))
}
@Test("Offer threshold is strictly greater than the minimum")
func offerThresholdBoundary() {
#expect(!WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes),
enabled: true
))
#expect(WifiBulkPolicy.shouldOffer(
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes + 1),
enabled: true
))
}
private func offer(fileSize: UInt64) -> WifiBulkOffer {
WifiBulkOffer(
transferID: Data(repeating: 1, count: WifiBulkWire.transferIDLength),
fileSize: fileSize,
payloadHash: Data(repeating: 2, count: WifiBulkWire.hashLength),
token: Data(repeating: 3, count: WifiBulkWire.tokenLength),
serviceName: "0011223344556677"
)
}
@Test("Receiver accepts an in-cap offer from a direct peer")
func receiverAccepts() {
#expect(WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1_000_000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
}
@Test("Receiver enforces its own size cap, not the sender's word")
func receiverSizeCap() {
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
#expect(WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes)),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 0),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: true
))
}
@Test("Receiver declines when disabled, indirect, or saturated")
func receiverDeclines() {
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: 0,
enabled: false
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: false,
activeIncomingTransfers: 0,
enabled: true
))
#expect(!WifiBulkPolicy.shouldAccept(
offer: offer(fileSize: 1000),
senderIsDirectlyConnected: true,
activeIncomingTransfers: TransportConfig.wifiBulkMaxConcurrentIncoming,
enabled: true
))
}
@Test("This build advertises the wifiBulk capability when enabled")
func localCapabilityAdvertised() {
#expect(PeerCapabilities.localSupported.contains(.wifiBulk) == TransportConfig.wifiBulkEnabled)
}
@Test("File packets above the BLE cap encode/decode only with the Wi-Fi limit")
func filePacketWifiLimit() throws {
let content = Data(repeating: 0x5A, count: 2 * 1024 * 1024) // 2 MiB
let packet = BitchatFilePacket(fileName: "big.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content)
// BLE cap unchanged.
#expect(packet.encode() == nil)
let encoded = try #require(packet.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes))
#expect(BitchatFilePacket.decode(encoded) == nil) // BLE-cap decode still rejects
let decoded = try #require(BitchatFilePacket.decode(encoded, limit: FileTransferLimits.maxWifiBulkPayloadBytes))
#expect(decoded.content == content)
#expect(decoded.fileName == "big.jpg")
}
}
@@ -1,246 +0,0 @@
//
// WifiBulkTransferServiceTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Network
import Testing
@testable import bitchat
/// Thread-safe capture box for closures invoked on service queues.
final class WifiBulkTestBox<Value>: @unchecked Sendable {
private let lock = NSLock()
private var storage: [Value] = []
func append(_ value: Value) {
lock.lock()
storage.append(value)
lock.unlock()
}
var values: [Value] {
lock.lock()
defer { lock.unlock() }
return storage
}
var count: Int { values.count }
}
func wifiBulkWait(
timeout: TimeInterval = 5.0,
_ condition: @escaping () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() { return true }
try? await Task.sleep(nanoseconds: 20_000_000)
}
return condition()
}
/// Negotiation/fallback decisions exercised through the real service with the
/// network kept on loopback and Bonjour publication disabled.
@Suite("WifiBulk transfer service negotiation", .serialized)
struct WifiBulkTransferServiceTests {
private static let peer = PeerID(str: "aabbccddeeff0011")
private func makeConfig() -> WifiBulkTransferServiceConfig {
var config = WifiBulkTransferServiceConfig()
config.usePeerToPeer = false
config.publishBonjourService = false
config.offerTimeout = 0.25
config.transferWindow = 2.0
return config
}
private func makeEnvironment(
sendNoisePayload: @escaping (Data, PeerID) -> Bool,
deliver: @escaping (Data, PeerID, Int) -> Void = { _, _, _ in },
progressEvents: WifiBulkTestBox<String>? = nil
) -> WifiBulkTransferServiceEnvironment {
WifiBulkTransferServiceEnvironment(
sendNoisePayload: sendNoisePayload,
isPeerConnected: { _ in true },
deliverReceivedFile: deliver,
progressStart: { id, total in progressEvents?.append("start:\(id):\(total)") },
progressChunkSent: { id in progressEvents?.append("chunk:\(id)") },
progressReset: { id in progressEvents?.append("reset:\(id)") },
progressCancel: { id in progressEvents?.append("cancel:\(id)") }
)
}
private var payload: Data { Data(repeating: 0x42, count: 100_000) }
@Test("No established Noise session falls straight back to BLE")
func noSessionFallsBack() async {
let fallbacks = WifiBulkTestBox<Bool>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in false }),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-nosession") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
#expect(service._test_activeOutgoingCount == 0)
}
@Test("Unanswered offer times out and falls back exactly once")
func offerTimeoutFallsBackOnce() async {
let fallbacks = WifiBulkTestBox<Bool>()
let progress = WifiBulkTestBox<String>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-timeout") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
// Wait past the transfer window: the expiry must not double-fire.
try? await Task.sleep(nanoseconds: 2_300_000_000)
#expect(fallbacks.count == 1)
#expect(service._test_activeOutgoingCount == 0)
// Progress state was silently reset ahead of the BLE re-start.
#expect(progress.values.contains("reset:t-timeout"))
#expect(!progress.values.contains("cancel:t-timeout"))
}
@Test("Declined offer falls back exactly once, even on duplicate declines")
func declineFallsBackOnce() async {
let fallbacks = WifiBulkTestBox<Bool>()
let offers = WifiBulkTestBox<Data>()
var service: WifiBulkTransferService?
let environment = makeEnvironment(sendNoisePayload: { typed, peer in
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
let offer = WifiBulkOffer.decode(typed.dropFirst()) else { return true }
offers.append(offer.transferID)
guard let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
// Deliver the decline twice; the fallback must still fire once.
service?.handleResponsePayload(decline, from: peer)
service?.handleResponsePayload(decline, from: peer)
return true
})
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
service = sut
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-decline") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { fallbacks.count == 1 })
try? await Task.sleep(nanoseconds: 300_000_000)
#expect(fallbacks.count == 1)
#expect(sut._test_activeOutgoingCount == 0)
}
@Test("Responses from the wrong peer are ignored")
func wrongPeerResponseIgnored() async {
let fallbacks = WifiBulkTestBox<Bool>()
var service: WifiBulkTransferService?
let environment = makeEnvironment(sendNoisePayload: { typed, _ in
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
let offer = WifiBulkOffer.decode(typed.dropFirst()),
let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
// Decline arrives from an unrelated peer: must be ignored, so the
// transfer ends via offer timeout instead.
service?.handleResponsePayload(decline, from: PeerID(str: "1122334455667788"))
return true
})
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
service = sut
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-wrongpeer") {
fallbacks.append(true)
}
// Not fallen back before the offer timeout window
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(fallbacks.count == 0)
// but the timeout still cleans up.
#expect(await wifiBulkWait { fallbacks.count == 1 })
}
@Test("User cancel tears down without BLE fallback")
func userCancelDoesNotFallBack() async {
let fallbacks = WifiBulkTestBox<Bool>()
let progress = WifiBulkTestBox<String>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-cancel") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
service.cancelTransfer(transferId: "t-cancel")
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
try? await Task.sleep(nanoseconds: 400_000_000) // past the offer timeout
#expect(fallbacks.count == 0)
#expect(progress.values.contains("cancel:t-cancel"))
}
@Test("Receiver declines an offer that exceeds its size cap")
func receiverDeclinesOversizedOffer() async {
let responses = WifiBulkTestBox<Data>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { typed, _ in
if typed.first == NoisePayloadType.bulkTransferResponse.rawValue {
responses.append(Data(typed.dropFirst()))
}
return true
}),
config: makeConfig()
)
let offer = WifiBulkOffer(
transferID: Data(repeating: 7, count: WifiBulkWire.transferIDLength),
fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
payloadHash: Data(repeating: 8, count: WifiBulkWire.hashLength),
token: Data(repeating: 9, count: WifiBulkWire.tokenLength),
serviceName: "0011223344556677"
)
if let encoded = offer.encode() {
service.handleOfferPayload(encoded, from: Self.peer)
}
#expect(await wifiBulkWait { responses.count == 1 })
let response = WifiBulkResponse.decode(responses.values[0])
#expect(response?.accepted == false)
#expect(response?.transferID == offer.transferID)
#expect(service._test_activeIncomingCount == 0)
}
@Test("Malformed offers are dropped without a response")
func malformedOfferDropped() async {
let responses = WifiBulkTestBox<Data>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { typed, _ in
responses.append(typed)
return true
}),
config: makeConfig()
)
service.handleOfferPayload(Data([0x01, 0x02, 0x03]), from: Self.peer)
try? await Task.sleep(nanoseconds: 200_000_000)
#expect(responses.count == 0)
#expect(service._test_activeIncomingCount == 0)
}
@Test("stop() tears down active transfers without falling back")
func stopTearsDownWithoutFallback() async {
let fallbacks = WifiBulkTestBox<Bool>()
let service = WifiBulkTransferService(
environment: makeEnvironment(sendNoisePayload: { _, _ in true }),
config: makeConfig()
)
service.sendFile(payload: payload, to: Self.peer, transferId: "t-stop") {
fallbacks.append(true)
}
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
service.stop()
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
try? await Task.sleep(nanoseconds: 400_000_000)
#expect(fallbacks.count == 0)
}
}
@@ -75,6 +75,11 @@ public final class TorManager: ObservableObject {
} }
private var didStart = false private var didStart = false
// shutdownCompletely() resets `didStart` asynchronously (after Arti has
// actually stopped). A startIfNeeded() arriving in that window must not be
// dropped it is recorded here and honored when the shutdown finishes.
private var shutdownsInFlight = 0
private var startPendingAfterShutdown = false
private var bootstrapMonitorStarted = false private var bootstrapMonitorStarted = false
private var pathMonitor: NWPathMonitor? private var pathMonitor: NWPathMonitor?
private var isAppForeground: Bool = true private var isAppForeground: Bool = true
@@ -90,6 +95,11 @@ public final class TorManager: ObservableObject {
public func startIfNeeded() { public func startIfNeeded() {
guard allowAutoStart else { return } guard allowAutoStart else { return }
guard isAppForeground else { return } guard isAppForeground else { return }
if shutdownsInFlight > 0 {
SecureLogger.debug("TorManager: startIfNeeded() deferred - shutdown in flight", category: .session)
startPendingAfterShutdown = true
return
}
guard !didStart else { return } guard !didStart else { return }
didStart = true didStart = true
isDormant = false isDormant = false
@@ -329,6 +339,8 @@ public final class TorManager: ObservableObject {
public func shutdownCompletely() { public func shutdownCompletely() {
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session) SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
startPendingAfterShutdown = false
shutdownsInFlight += 1
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
_ = arti_stop() _ = arti_stop()
@@ -352,6 +364,12 @@ public final class TorManager: ObservableObject {
self.bootstrapMonitorStarted = false self.bootstrapMonitorStarted = false
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded() // Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
// Clearing it here races with startup and defeats the grace period // Clearing it here races with startup and defeats the grace period
self.shutdownsInFlight -= 1
if self.shutdownsInFlight == 0 && self.startPendingAfterShutdown {
self.startPendingAfterShutdown = false
SecureLogger.debug("TorManager: honoring start deferred during shutdown", category: .session)
self.startIfNeeded()
}
} }
} }
} }
@@ -2,10 +2,6 @@
public enum FileTransferLimits { public enum FileTransferLimits {
/// Absolute ceiling enforced for any file payload (voice, image, other). /// Absolute ceiling enforced for any file payload (voice, image, other).
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Ceiling for transfers negotiated onto the peer-to-peer Wi-Fi bulk
/// channel. The receiver enforces it against the size in the accepted
/// offer; the Bluetooth path keeps `maxPayloadBytes`.
public static let maxWifiBulkPayloadBytes: Int = 8 * 1024 * 1024 // 8 MiB
/// Voice notes stay small for low-latency relays. /// Voice notes stay small for low-latency relays.
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
/// Compressed images after downscaling should comfortably fit under this budget. /// Compressed images after downscaling should comfortably fit under this budget.
@@ -21,7 +17,7 @@ public enum FileTransferLimits {
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
}() }()
public static func isValidPayload(_ size: Int, limit: Int = maxPayloadBytes) -> Bool { public static func isValidPayload(_ size: Int) -> Bool {
size <= limit size <= maxPayloadBytes
} }
} }
@@ -24,19 +24,18 @@ public enum MessageType: UInt8 {
// Fragmentation (simplified) // Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads case fileTransfer = 0x22 // Binary file/audio/image payloads
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped) case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body) case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
// Mesh diagnostics // Mesh diagnostics
case ping = 0x26 // Directed echo request (nonce + origin TTL) case ping = 0x26 // Directed echo request (nonce + origin TTL)
case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL) case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL)
// Gateway mode: signed Nostr event ferried between a mesh-only peer and // Gateway mode: signed Nostr event ferried between a mesh-only peer and
// an internet gateway peer. // an internet gateway peer.
case nostrCarrier = 0x28 case nostrCarrier = 0x28
public var description: String { public var description: String {
switch self { switch self {
case .announce: return "announce" case .announce: return "announce"
@@ -1,64 +0,0 @@
//
// LogExportBuffer.swift
// BitLogger
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if DEBUG
import Foundation
/// In-memory ring buffer of the most recent sanitized log lines, so a tester
/// can export logs untethered when live streaming isn't available.
///
/// DEBUG-only: never compiled into release builds, matching the rest of the
/// logging stack. Bounded by both a line count and a byte budget (oldest
/// evicted first). Appends run on a private serial queue so a hot logging path
/// never blocks on buffer maintenance and the main thread is never touched.
///
/// It stores exactly the SecureLogger-sanitized text (fingerprints truncated,
/// base64 redacted, peer IDs shortened), so the export carries no secrets.
public final class LogExportBuffer {
public static let shared = LogExportBuffer()
private let queue = DispatchQueue(label: "chat.bitchat.securelogger.export", qos: .utility)
private var lines: [String] = []
private var byteCount = 0
private let maxLines: Int
private let maxBytes: Int
init(maxLines: Int = 2000, maxBytes: Int = 512 * 1024) {
self.maxLines = maxLines
self.maxBytes = maxBytes
}
/// Append one already-formatted, already-sanitized log line. Non-blocking
/// (async on the private queue).
func append(_ line: String) {
queue.async {
self.lines.append(line)
self.byteCount += line.utf8.count + 1 // + newline
while self.lines.count > self.maxLines || self.byteCount > self.maxBytes {
guard !self.lines.isEmpty else { break }
let removed = self.lines.removeFirst()
self.byteCount -= (removed.utf8.count + 1)
}
}
}
/// A newline-joined snapshot of the buffered lines, oldest first. Safe to
/// call from the main thread (brief synchronous read).
public func snapshot() -> String {
queue.sync { lines.joined(separator: "\n") }
}
public func clear() {
queue.async {
self.lines.removeAll(keepingCapacity: true)
self.byteCount = 0
}
}
}
#endif
@@ -1,104 +0,0 @@
//
// LogNetworkSink.swift
// BitLogger
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if DEBUG
import Foundation
#if canImport(Network)
import Network
#endif
/// Best-effort, fire-and-forget UDP forwarder for sanitized log lines, so a
/// sideloaded device on iOS 17+ (where `idevicesyslog` Wi-Fi streaming no
/// longer works) can stream its logs to a LAN collector with no cable.
///
/// Design constraints:
/// - DEBUG-only: never compiled into release. A privacy-first release build
/// carries zero network-log code.
/// - Opt-in and off by default: nothing is ever sent until a collector host
/// is configured. An empty host means no egress.
/// - Never blocks the caller and never throws into the app: every send runs
/// on a private queue and failures are silently dropped. A dead or absent
/// collector cannot affect app behavior (UDP is connectionless datagrams
/// to nowhere are simply lost).
/// - Forwards the exact same sanitized text the ring buffer captures, so no
/// secrets leave the device. Each line is prefixed with the device's label
/// (`[nickname] `) so one Mac-side `nc -lu <port>` can demux all devices.
public final class LogNetworkSink {
public static let shared = LogNetworkSink()
/// UserDefaults keys shared with the in-app config UI (App Info sheet).
public static let hostDefaultsKey = "debug.logSink.host"
public static let portDefaultsKey = "debug.logSink.port"
public static let defaultPort = 9999
/// Where the device label is read from (the app's chosen nickname).
public static let nicknameDefaultsKey = "bitchat.nickname"
private let queue = DispatchQueue(label: "chat.bitchat.securelogger.netsink", qos: .utility)
private let defaults: UserDefaults
private var label = ""
private var enabled = false
#if canImport(Network)
private var connection: NWConnection?
#endif
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
/// Re-read collector host/port and the device label from UserDefaults and
/// (re)build the UDP connection. Call on launch and whenever the config UI
/// changes. An empty host tears the sink down (no egress).
public func reloadConfiguration() {
let host = (defaults.string(forKey: Self.hostDefaultsKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let storedPort = defaults.integer(forKey: Self.portDefaultsKey)
let port = storedPort > 0 ? storedPort : Self.defaultPort
let label = (defaults.string(forKey: Self.nicknameDefaultsKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
configure(host: host, port: port, label: label)
}
/// Point the sink at `host:port` with a device `label`. Empty host or an
/// invalid port disables egress.
public func configure(host: String, port: Int, label: String) {
queue.async {
self.label = label
#if canImport(Network)
self.connection?.cancel()
self.connection = nil
guard !host.isEmpty,
(1...65535).contains(port),
let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
self.enabled = false
return
}
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .udp)
connection.start(queue: self.queue)
self.connection = connection
self.enabled = true
#else
self.enabled = false
#endif
}
}
/// Forward one already-formatted, already-sanitized line. Non-blocking;
/// drops silently when disabled or on any send failure.
func send(_ line: String) {
queue.async {
guard self.enabled else { return }
#if canImport(Network)
guard let connection = self.connection else { return }
let prefixed = self.label.isEmpty ? line : "[\(self.label)] \(line)"
guard let data = (prefixed + "\n").data(using: .utf8) else { return }
connection.send(content: data, completion: .idempotent)
#endif
}
}
}
#endif
@@ -20,11 +20,4 @@ public extension OSLog {
static let security = OSLog(subsystem: subsystem, category: "security") static let security = OSLog(subsystem: subsystem, category: "security")
static let handshake = OSLog(subsystem: subsystem, category: "handshake") static let handshake = OSLog(subsystem: subsystem, category: "handshake")
static let sync = OSLog(subsystem: subsystem, category: "sync") static let sync = OSLog(subsystem: subsystem, category: "sync")
// Added for the observability pass: coarse filtering buckets for the mesh
// routing layer, the internet gateway bridge, and the Wi-Fi bulk transport.
// The bracket tags in the messages ([ROUTE]/[GW]/[WIFI]/) remain the
// primary filter; these just let `log stream --category` narrow further.
static let mesh = OSLog(subsystem: subsystem, category: "mesh")
static let gateway = OSLog(subsystem: subsystem, category: "gateway")
static let transport = OSLog(subsystem: subsystem, category: "transport")
} }
@@ -86,17 +86,6 @@ public final class SecureLogger {
case .fault: return 4 case .fault: return 4
} }
} }
/// Short uppercase label for the in-memory export buffer line prefix.
var label: String {
switch self {
case .debug: return "DEBUG"
case .info: return "INFO"
case .warning: return "WARN"
case .error: return "ERROR"
case .fault: return "FAULT"
}
}
var osLogType: OSLogType { var osLogType: OSLogType {
switch self { switch self {
@@ -111,34 +100,18 @@ public final class SecureLogger {
// MARK: - Global Threshold // MARK: - Global Threshold
/// Minimum level that will be logged. Override via env BITCHAT_LOG_LEVEL /// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL.
/// (env always wins). The default is `.debug` in DEBUG builds and `.info`
/// otherwise: a sideloaded, tap-launched build can't receive the env var,
/// so DEBUG must surface the `.debug` decision logs out of the box. Release
/// emits nothing regardless (all log paths are `#if DEBUG`).
/// Internal-settable so tests can verify level filtering; app code should not mutate it. /// Internal-settable so tests can verify level filtering; app code should not mutate it.
internal static var minimumLevel: LogLevel = defaultMinimumLevel internal static var minimumLevel: LogLevel = {
/// The level used when nothing mutates `minimumLevel`. Honors the
/// BITCHAT_LOG_LEVEL env override; otherwise `.debug` in DEBUG builds (so a
/// tap-launched sideload surfaces decision logs) and `.info` elsewhere.
/// Exposed for tests.
internal static var defaultMinimumLevel: LogLevel {
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased() let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
switch env { switch env {
case "debug": return .debug case "debug": return .debug
case "info": return .info
case "warning": return .warning case "warning": return .warning
case "error": return .error case "error": return .error
case "fault": return .fault case "fault": return .fault
default: default: return .info
#if DEBUG
return .debug
#else
return .info
#endif
} }
} }()
private static func shouldLog(_ level: LogLevel) -> Bool { private static func shouldLog(_ level: LogLevel) -> Bool {
return level.order >= minimumLevel.order return level.order >= minimumLevel.order
@@ -195,7 +168,6 @@ public extension SecureLogger {
#if DEBUG #if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
record(level: .error, message: "\(location) Error in \(sanitized): \(errorDesc)")
#else #else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
#endif #endif
@@ -286,10 +258,9 @@ private extension SecureLogger {
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = "\(location) \(message())".sanitized() let sanitized = "\(location) \(message())".sanitized()
os_log("%{public}@", log: category, type: level.osLogType, sanitized) os_log("%{public}@", log: category, type: level.osLogType, sanitized)
record(level: level, message: sanitized)
#endif #endif
} }
/// Log a security event /// Log a security event
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
file: String, line: Int, function: String) { file: String, line: Int, function: String) {
@@ -298,18 +269,6 @@ private extension SecureLogger {
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let message = "\(location) \(event.message)" let message = "\(location) \(event.message)"
os_log("%{public}@", log: .security, type: level.osLogType, message) os_log("%{public}@", log: .security, type: level.osLogType, message)
record(level: level, message: message)
#endif
}
/// Fan a sanitized line out to the in-memory export buffer and the
/// (opt-in, off by default) UDP network sink. DEBUG-only; both consumers
/// are non-blocking. Same formatted text everywhere.
static func record(level: LogLevel, message: String) {
#if DEBUG
let line = "[\(level.label)] \(message)"
LogExportBuffer.shared.append(line)
LogNetworkSink.shared.send(line)
#endif #endif
} }
@@ -51,25 +51,4 @@ final class LogLevelFilteringTests: XCTestCase {
XCTAssertEqual(counter.count, 1, "Enabled levels must still log") XCTAssertEqual(counter.count, 1, "Enabled levels must still log")
} }
/// A sideloaded, tap-launched build can't receive BITCHAT_LOG_LEVEL, so the
/// DEBUG default must be `.debug` (not `.info`) or the `.debug` decision
/// logs stay invisible on device. Release still defaults to `.info` and
/// emits nothing regardless (all log paths are `#if DEBUG`).
func testDebugBuildDefaultsToDebugLevelUnlessOverridden() throws {
// Only meaningful when the env override isn't set (CI sometimes sets it).
try XCTSkipUnless(
ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"] == nil,
"BITCHAT_LOG_LEVEL is set; default-level assertion doesn't apply"
)
#if DEBUG
XCTAssertEqual(
SecureLogger.defaultMinimumLevel,
.debug,
"DEBUG builds must default to .debug so tap-launched sideloads surface decision logs"
)
#else
XCTAssertEqual(SecureLogger.defaultMinimumLevel, .info)
#endif
}
} }