Compare commits

..
Author SHA1 Message Date
jackandClaude Fable 5 6f02319f6f i18n: machine-translate remaining feature strings into 23 languages (needs_review)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 00:37:17 +02:00
jackandClaude Fable 5 970802c923 i18n: uppercase Spanish section headers (SÍMBOLOS, RED) to match English
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 00:01:23 +02:00
jack 935dcdbd51 Merge remote-tracking branch 'origin/fix/i18n-defaultvalue-net' into feat/integration-all-observability 2026-07-06 23:59:31 +02:00
jack bf5c9a3850 Merge remote-tracking branch 'origin/fix/es-translations' into feat/integration-all-observability 2026-07-06 23:59:22 +02:00
jackandClaude Fable 5 9cbfd131da i18n: add English defaultValue fallback to key-only localized strings so no language shows raw keys
Non-en languages are each missing ~138 of 336 catalog keys, so a key
absent from that language's table renders as the raw dot-key on device
(no English fallback). This adds English defaultValues so every miss
resolves to English instead of a raw key.

- Class (a): 217 String(localized: "dot.key") calls missing defaultValue
  now carry defaultValue: "<en value>" pulled verbatim from the catalog
  (format specifiers preserved).
- Class (b): 41 SwiftUI LocalizedStringKey literals (Text/Button/alert/
  confirmationDialog/TextField) wrapped as
  Text(String(localized: "key", defaultValue: "en")) so they resolve to
  English too. Existing comments folded into the resolved String.

No catalog or en values changed; Swift source only. Both schemes build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 23:58:29 +02:00
jackandClaude Fable 5 20255c9916 Fill missing Spanish (es) translations for feature + UI strings
Add es localizations for the 138 string keys that had an en entry but no
es entry, so Spanish-language devices no longer render raw dot-keys.
Covers bulletin/legend, network topology, groups, gateway, media/image,
voice notes, delivery, payment, vouching, and mesh system messages.
Format specifiers, plural substitutions, punctuation, and the app's
lowercase aesthetic are preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 23:51:28 +02:00
jackandClaude Fable 5 b62f98cc62 App Info: uppercase SYMBOLS/NETWORK headers, move network under HOW TO USE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 23:45:29 +02:00
jackandClaude Fable 5 02aa1d67db Observability: DEBUG debug-level default, greppable decision tags, log export + UDP LAN sink
Debugging the integration build on real iOS 26.5 devices over the air.

- BitLogger DEBUG default minimumLevel now .debug (was .info) so a
  tap-launched sideload surfaces .debug decision logs without the
  BITCHAT_LOG_LEVEL env var; env still wins; release unchanged (.info,
  emits nothing). Extracted defaultMinimumLevel + a test asserting it.
- Greppable bracket decision tags at .info (visible even at .info) via
  SecureLogger, ID-prefixes only, sanitized:
  [ROUTE] source-route vs flood origination (+reason) + fragment
  targeted-resync; [GW] uplink accept/reject, downlink rebroadcast/drop,
  loop skips, drain-timer; [WIFI] offer/accept/decline, AWDL connect,
  transfer start/complete+bytes, fallback+reason; [PREKEY] seal choice,
  consume, unknown-prekey open fail, bundle ingest, re-gossip;
  [SYNC] one concise summary per cycle + per-request served counts (no
  per-packet spam); [PING]/[TRACE] RTT + computed path.
- New OSLog categories: mesh, gateway, transport.
- Refactored BLESourceRouteOriginationPolicy.route -> decide returning a
  Decision enum (flood(reason)/route(hops)) so the flood reason is
  greppable and unit-tested.
- DEBUG-only in-memory ring buffer (LogExportBuffer, ~2000 lines/512KB,
  thread-safe, off main thread) + App Info "Export Logs" share sheet
  (iOS UIActivityViewController / macOS NSSavePanel), VoiceOver labels.
- DEBUG-only UDP LAN log sink (LogNetworkSink): opt-in, off by default,
  fire-and-forget, never blocks/throws; each line prefixed with the
  device nickname for demux; configured via App Info host:port fields.
  Same sanitized formatted line feeds buffer, sink, and export.

All new code is #if DEBUG; release emits nothing and ships no export/sink UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 23:04:33 +02:00
jackandClaude Fable 5 e0e90af9fd Integration test fixes: reconcile SyncTypeFlags phantom-bit assertions and sync-round counts
- SyncTypeFlags phantom-bit tests used bits 8/9/10 as unmapped examples;
  board/prekey/group now map those, so use bits 11+ (still unmapped).
- rawValueInitNormalizesPhantomBits: highest known bit is now 10, so the
  flags serialize to 2 bytes, not 1.
- Board/fragment/announce sync tests silence the prekey round (added by the
  prekeys feature; default 60s schedule fires at the maintenance barrier).
- DiagnosticsMockContext: conform to the CommandContextProvider members added
  by private-groups (group*) and cashu-chips (sendPublicMessage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:38:45 +02:00
jack 94e7f91b7b Merge remote-tracking branch 'origin/docs/whitepaper-reconcile' into feat/integration-all 2026-07-06 22:31:24 +02:00
jack 00c7f0460a Merge remote-tracking branch 'origin/feat/cashu-chips' into feat/integration-all
# Conflicts:
#	bitchat/Localizable.xcstrings
#	bitchat/Models/CommandInfo.swift
#	bitchat/Services/CommandProcessor.swift
2026-07-06 22:31:20 +02:00
jack 175da268dd Merge remote-tracking branch 'origin/feat/geohash-pow' into feat/integration-all 2026-07-06 22:29:19 +02:00
jack f6f7fa704a Merge remote-tracking branch 'origin/feat/geo-board' into feat/integration-all
# Conflicts:
#	bitchat/Services/BLE/BLEOutboundPacketPolicy.swift
#	bitchat/Services/BLE/BLEService.swift
#	bitchat/Services/Transport.swift
#	bitchat/Sync/GossipSyncManager.swift
#	bitchat/Sync/SyncTypeFlags.swift
#	bitchat/ViewModels/ChatViewModel.swift
#	localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
2026-07-06 22:29:16 +02:00
jack 4f5d25808f Merge remote-tracking branch 'origin/feat/source-route-send' into feat/integration-all
# Conflicts:
#	bitchat/Models/RequestSyncPacket.swift
#	bitchat/Services/BLE/BLEService.swift
#	bitchatTests/GossipSyncManagerTests.swift
2026-07-06 22:25:19 +02:00
jack a05c583fb5 Merge remote-tracking branch 'origin/feat/mesh-diagnostics' into feat/integration-all
# Conflicts:
#	bitchat/Models/CommandInfo.swift
#	bitchat/Services/BLE/BLEOutboundPacketPolicy.swift
#	bitchat/Services/BLE/BLEReceivePipeline.swift
#	bitchat/Services/BLE/BLEService.swift
#	bitchat/Services/CommandProcessor.swift
#	bitchat/Services/Transport.swift
#	bitchat/Sync/SyncTypeFlags.swift
#	bitchat/ViewModels/ChatViewModel.swift
#	bitchatTests/BLEServiceCoreTests.swift
#	localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
2026-07-06 22:22:40 +02:00
jack 25efa62587 Merge remote-tracking branch 'origin/feat/wifi-bulk-transport' into feat/integration-all
# Conflicts:
#	bitchat/Protocols/BitchatProtocol.swift
#	bitchat/Protocols/PeerCapabilities+Local.swift
#	bitchat/ViewModels/ChatTransportEventCoordinator.swift
#	bitchat/ViewModels/NostrInboundPipeline.swift
2026-07-06 22:18:59 +02:00
jack 0d93ce0874 Merge remote-tracking branch 'origin/feat/gateway-mode' into feat/integration-all
# Conflicts:
#	bitchat/Localizable.xcstrings
#	bitchat/Services/BLE/BLEOutboundPacketPolicy.swift
#	bitchat/Services/BLE/BLEService.swift
#	bitchat/Sync/SyncTypeFlags.swift
#	localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
2026-07-06 22:17:35 +02:00
jack 20cdd6653a Merge remote-tracking branch 'origin/feat/private-groups' into feat/integration-all
# Conflicts:
#	bitchat/Protocols/BitchatProtocol.swift
#	bitchat/Protocols/PeerCapabilities+Local.swift
#	bitchat/Services/BLE/BLEOutboundPacketPolicy.swift
#	bitchat/Services/BLE/BLEService.swift
#	bitchat/Services/Transport.swift
#	bitchat/Sync/GossipSyncManager.swift
#	bitchat/Sync/SyncTypeFlags.swift
#	bitchat/ViewModels/ChatTransportEventCoordinator.swift
#	bitchat/ViewModels/ChatViewModel.swift
#	bitchat/ViewModels/NostrInboundPipeline.swift
#	bitchatTests/ChatTransportEventCoordinatorContextTests.swift
#	bitchatTests/GossipSyncManagerTests.swift
#	localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
2026-07-06 22:13:31 +02:00
jack 862484c0d4 Merge remote-tracking branch 'origin/feat/vouching' into feat/integration-all
# Conflicts:
#	bitchat/Protocols/PeerCapabilities+Local.swift
#	bitchat/ViewModels/ChatViewModel.swift
2026-07-06 22:08:06 +02:00
jack e1e481a1a7 Merge remote-tracking branch 'origin/feat/prekeys' into feat/integration-all
# Conflicts:
#	bitchat/Sync/GossipSyncManager.swift
2026-07-06 22:07:37 +02:00
jack aed3e6a273 Merge remote-tracking branch 'origin/feat/capability-bits' into feat/integration-all 2026-07-06 22:06:33 +02:00
jackandClaude Fable 5 b481a70458 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>
2026-07-06 21:27:41 +02:00
jackandClaude Fable 5 2b7fd2002b Wi-Fi bulk: normalize peer ID before eligibility checks
Stable/verified private chats address a peer by its full 64-hex Noise
key, but the Noise session, advertised capabilities, and connection state
are keyed by the short (SHA256-derived 16-hex) routing ID. sendFilePrivate
resolved the Wi-Fi bulk SendCandidate with the raw peerID, so a 64-hex key
made hasEstablishedSession return false; shouldOffer never selected Wi-Fi
and a >BLE-cap payload cancelled as "Wi-Fi unavailable" even when the
direct peer advertised .wifiBulk.

Normalize with peerID.toShort() once before the session/capability checks
(extracted into wifiBulkSendCandidate) and use the normalized ID for the
negotiation packet and the BLE fallback too.

Test: eligibility + offer path resolves when addressed by a 64-hex Noise
key — the session is established under the short ID, the raw-key lookup
misses it, and the normalized send path still offers Wi-Fi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:21:37 +02:00
jackandClaude Fable 5 ccccb2dd8c 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>
2026-07-06 21:17:15 +02:00
jackandClaude Fable 5 385e9e2aab 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>
2026-07-06 21:16:11 +02:00
jackandClaude Fable 5 48035872e1 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>
2026-07-06 21:13:29 +02:00
jackandClaude Fable 5 d4594b9504 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>
2026-07-06 21:13:10 +02:00
jackandClaude Fable 5 9ae440be6a 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>
2026-07-06 21:12:52 +02:00
jackandClaude Fable 5 fd2dffdda7 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>
2026-07-06 21:12:18 +02:00
jackandClaude Fable 5 953d24f7f2 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>
2026-07-06 21:07:59 +02:00
jackandClaude Fable 5 6dd8dd055a 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>
2026-07-06 21:01:02 +02:00
jackandClaude Fable 5 85112d809b 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>
2026-07-06 20:58:42 +02:00
jackandClaude Fable 5 e4b3cff5fa Wi-Fi bulk transport: AWDL data plane for large media with BLE fallback
BLE stays the control plane: a sender with a queued file > 64 KiB to a
directly connected peer advertising the wifiBulk capability sends a
bulkTransferOffer (0x04) inside the established Noise session — transferID,
file size, payload SHA-256, a fresh 32-byte token, and a random per-transfer
Bonjour instance name. The receiver answers bulkTransferResponse (0x05) with
its own token half, then both sides meet on a per-transfer
_bitchat-bulk._tcp channel over peer-to-peer Wi-Fi (AWDL).

The TCP stream is secured independently of TLS: both tokens traveled inside
Noise, so only the two peers can derive the ChaChaPoly channel key
(HKDF-SHA256, domain "bitchat-bulk-v1", transferID as salt). Frames are
length-prefixed sealed boxes with structured direction+counter nonces; the
first frame must prove knowledge of the key or the client is disconnected,
and the final hash is verified against the offer before delivery.

Decline, timeout, or any mid-transfer error falls back to BLE fragmentation
exactly once, driving the same TransferProgressManager stream so the UI is
unchanged. Wi-Fi-negotiated transfers may carry up to 8 MiB (new
FileTransferLimits.maxWifiBulkPayloadBytes, enforced by the receiver from
the accepted offer); the BLE path keeps its existing caps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:35:40 +02:00
jackandClaude Fable 5 fb80403cd4 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>
2026-07-06 20:31:09 +02:00
jackandClaude Fable 5 7ad19ab4c3 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>
2026-07-06 20:22:38 +02:00
jackandClaude Fable 5 b1ff5e6b82 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>
2026-07-06 20:18:00 +02:00
jackandClaude Fable 5 ee148a018b 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>
2026-07-06 20:14:05 +02:00
jackandClaude Fable 5 26b247c329 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>
2026-07-06 20:13:48 +02:00
jackandClaude Fable 5 81168adad1 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>
2026-07-06 20:11:17 +02:00
jackandClaude Fable 5 18dcc747d7 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>
2026-07-06 20:02:12 +02:00
jackandClaude Fable 5 6056d20a13 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>
2026-07-06 20:00:55 +02:00
jackandClaude Fable 5 112c0ec1ed 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>
2026-07-06 19:54:39 +02:00
jackandClaude Fable 5 688b954fb8 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>
2026-07-06 19:44:55 +02:00
jackandClaude Fable 5 f5dde45c18 docs: reconcile protocol docstrings with implementation
- 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: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:40:36 +02:00
268 changed files with 11333 additions and 29404 deletions
-30
View File
@@ -1,30 +0,0 @@
name: Dead Code
on:
push:
branches:
- main
pull_request:
jobs:
periphery:
name: Periphery scan
runs-on: macos-latest
timeout-minutes: 30
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
# block merges. Drop continue-on-error once the baseline proves stable.
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install Periphery
# homebrew-core formula; the peripheryapp tap lags years behind.
run: brew install periphery
- name: Scan for dead code
# Config comes from .periphery.yml; known findings (mostly iOS-only
# code invisible to a macOS scan) are suppressed by the committed
# baseline. --strict fails the step when NEW dead code appears.
run: periphery scan --strict --disable-update-check
-1
View File
@@ -1 +0,0 @@
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
-14
View File
@@ -1,14 +0,0 @@
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
#
# CI runs the macOS scheme only (an iOS scan needs a device destination and
# doubles the build time). macOS-only scans falsely flag iOS-only code —
# state restoration, screenshot handlers, background BLE sampling — so those
# findings live in .periphery.baseline.json rather than being "fixed".
# When auditing by hand, scan BOTH schemes and intersect:
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
project: bitchat.xcodeproj
schemes:
- bitchat (macOS)
retain_swift_ui_previews: true
relative_results: true
baseline: .periphery.baseline.json
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.7.0
MARKETING_VERSION = 1.5.4
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+22
View File
@@ -36,12 +36,34 @@ enum AppEvent: Sendable, Equatable {
actor AppEventStream {
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
func stream() -> AsyncStream<AppEvent> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.onTermination = { [id] _ in
Task {
await self.removeContinuation(id)
}
}
}
}
func emit(_ event: AppEvent) {
for continuation in continuations.values {
continuation.yield(event)
}
}
func finish() {
for continuation in continuations.values {
continuation.finish()
}
continuations.removeAll()
}
private func removeContinuation(_ id: UUID) {
continuations.removeValue(forKey: id)
}
}
/// Identity key for a direct conversation. Equality and hashing use the
-9
View File
@@ -10,10 +10,6 @@ final class AppChromeModel: ObservableObject {
@Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false
@Published var isNoticesSheetPresented = false
/// When the sheet is opened for "notes left here" (empty mesh timeline),
/// it should land on the geo tab instead of the channel-derived default.
@Published var noticesSheetPrefersGeoTab = false
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@@ -66,11 +62,6 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true
}
func presentNotices(geoTab: Bool = false) {
noticesSheetPrefersGeoTab = geoTab
isNoticesSheetPresented = true
}
/// Builds the mesh topology map model from the transport's gossiped
/// graph plus the live nickname table. Unknown nodes (heard about via a
/// neighbor claim but never announced to us) fall back to a short ID.
+8 -32
View File
@@ -18,6 +18,8 @@ final class AppRuntime: ObservableObject {
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// and `ChatViewModel` observe and mutate it through its intent API.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel
@@ -26,7 +28,6 @@ final class AppRuntime: ObservableObject {
let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel
let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel
private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>()
@@ -40,7 +41,7 @@ final class AppRuntime: ObservableObject {
#endif
init(
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
keychain: KeychainManagerProtocol = KeychainManager(),
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
@@ -49,6 +50,8 @@ final class AppRuntime: ObservableObject {
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
@@ -88,24 +91,6 @@ final class AppRuntime: ObservableObject {
chatViewModel: self.chatViewModel,
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()
bindRuntimeObservers()
@@ -217,16 +202,7 @@ final class AppRuntime: ObservableObject {
chatViewModel.applicationWillTerminate()
}
func handleNotificationResponse(
identifier: String,
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
userInfo: [AnyHashable: Any]
) {
if actionIdentifier == NotificationService.waveActionID {
chatViewModel.sendMeshWave()
return
}
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID)
@@ -373,12 +349,12 @@ private extension AppRuntime {
TorManager.shared.isAutoStartAllowed() {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting")
)
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
String(localized: "system.tor.dev_bypass", defaultValue: "development build: Tor bypass enabled.", comment: "System message when Tor bypass is enabled in development")
)
}
}
+10
View File
@@ -796,6 +796,16 @@ extension ConversationStore {
return messageIDs
}
/// Removes every direct conversation (panic clear).
func removeAllDirectConversations() {
let directIDs = conversationIDs.filter { id in
if case .direct = id { return true }
return false
}
for id in directIDs {
removeConversation(id)
}
}
}
// MARK: - Diagnostics support
-18
View File
@@ -12,9 +12,6 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
/// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String?
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
@@ -153,17 +150,6 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendVoiceNote(at: url)
}
/// Capture backend for the mic gesture: live PTT when the current DM
/// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession {
chatViewModel.makeVoiceCaptureSession()
}
/// Whether this message is a live voice burst still streaming in.
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
}
func cancelMediaSend(messageID: String) {
chatViewModel.cancelMediaSend(messageID: messageID)
}
@@ -189,10 +175,6 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
chatViewModel.$activePublicVoiceTalker
.receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker)
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
+6
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Combine
import Foundation
@@ -16,6 +17,7 @@ final class LocationChannelsModel: ObservableObject {
private let manager: LocationChannelManager
private let network: NetworkActivationService
private let gateway: GatewayService
private var cancellables = Set<AnyCancellable>()
init(
manager: LocationChannelManager? = nil,
@@ -100,6 +102,10 @@ final class LocationChannelsModel: ObservableObject {
network.setUserTorEnabled(enabled)
}
func setGatewayEnabled(_ enabled: Bool) {
gateway.setEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel,
permissionState == .authorized,
-89
View File
@@ -1,89 +0,0 @@
//
// NearbyNotesCounter.swift
// bitchat
//
// Counts unexpired location notes left at the user's current building-level
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
// subscribes while a view holds it active, and only when location notes are
// enabled and location permission is already granted (it never prompts).
// This is free and unencumbered software released into the public domain.
//
import Combine
import Foundation
@MainActor
final class NearbyNotesCounter: ObservableObject {
static let shared = NearbyNotesCounter()
@Published private(set) var noteCount = 0
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
init(locationManager: LocationChannelManager = .shared) {
self.locationManager = locationManager
}
/// Begins (or keeps) the notes subscription for the current building
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
/// hold it.
func activate() {
activeHolders += 1
guard activeHolders == 1 else { return }
channelsCancellable = locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// The app-info kill switch must take effect immediately, not on the
// next location change or remount.
settingCancellable = NotificationCenter.default
.publisher(for: LocationNotesSettings.didChangeNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
retarget()
}
func deactivate() {
activeHolders = max(0, activeHolders - 1)
guard activeHolders == 0 else { return }
channelsCancellable = nil
settingCancellable = nil
managerCancellable = nil
manager?.cancel()
manager = nil
noteCount = 0
}
private func retarget() {
guard activeHolders > 0,
LocationNotesSettings.enabled,
locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash
else {
managerCancellable = nil
manager?.cancel()
manager = nil
noteCount = 0
return
}
if let manager {
manager.setGeohash(geohash)
return
}
let fresh = LocationNotesManager(geohash: geohash)
manager = fresh
managerCancellable = fresh.$notes
.receive(on: DispatchQueue.main)
.sink { [weak self] notes in
let now = Date()
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
}
}
}
+8
View File
@@ -25,6 +25,10 @@ final class PeerIdentityStore: ObservableObject {
stablePeerIDsByShortID[peerID] = stablePeerID
}
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
stablePeerIDsByShortID = mappings
}
func fingerprint(for peerID: PeerID) -> String? {
peerFingerprintsByPeerID[peerID]
}
@@ -90,6 +94,10 @@ final class PeerIdentityStore: ObservableObject {
invalidateEncryptionCache(for: peerID)
}
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
encryptionStatuses = statuses
}
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
verifiedFingerprints = fingerprints
}
+2 -2
View File
@@ -250,7 +250,7 @@ final class PrivateConversationModel: ObservableObject {
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
displayName = "#\(group.name) (\(group.members.count))"
} else {
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
displayName = String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
}
return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID,
@@ -328,7 +328,7 @@ final class PrivateConversationModel: ObservableObject {
}
}
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
}
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
+7 -1
View File
@@ -3,6 +3,7 @@ import Combine
import Foundation
struct FingerprintPresentationState: Equatable {
let statusPeerID: PeerID
let peerNickname: String
let encryptionStatus: EncryptionStatus
let theirFingerprint: String?
@@ -55,6 +56,10 @@ final class VerificationModel: ObservableObject {
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
chatViewModel.beginQRVerification(with: qr)
}
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid
@@ -105,6 +110,7 @@ final class VerificationModel: ObservableObject {
}
return FingerprintPresentationState(
statusPeerID: statusPeerID,
peerNickname: peerNickname,
encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint,
@@ -180,6 +186,6 @@ final class VerificationModel: ObservableObject {
}
}
return String(localized: "common.unknown", comment: "Label for an unknown peer")
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer")
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 B

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

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

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

+10 -11
View File
@@ -8,6 +8,9 @@
import SwiftUI
import UserNotifications
#if DEBUG
import BitLogger
#endif
@main
struct BitchatApp: App {
@@ -40,10 +43,14 @@ struct BitchatApp: App {
.environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel)
.onAppear {
appDelegate.runtime = runtime
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
runtime.handleOpenURL(url)
@@ -104,20 +111,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
let actionIdentifier = response.actionIdentifier
let userInfo = response.notification.request.content.userInfo
// Complete only after the response is handled: for a background
// action (👋 wave) the system may suspend the app the moment the
// completion handler runs, which would drop the queued send.
Task { @MainActor in
self.runtime?.handleNotificationResponse(
identifier: identifier,
actionIdentifier: actionIdentifier,
userInfo: userInfo
)
completionHandler()
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
}
completionHandler()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
-175
View File
@@ -1,175 +0,0 @@
//
// PTTAudioCodec.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
/// carries a bit reservoir across frames); one instance per burst.
/// Not thread-safe confine to one queue.
final class PTTFrameEncoder {
private let converter: AVAudioConverter
private var pendingInput: [AVAudioPCMBuffer] = []
init?() {
guard let pcm = PTTAudioFormat.pcmFormat,
let aac = PTTAudioFormat.aacFormat,
let converter = AVAudioConverter(from: pcm, to: aac)
else { return nil }
converter.bitRate = PTTAudioFormat.bitRate
self.converter = converter
}
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
pendingInput.append(buffer)
return drainConverter()
}
private func drainConverter() -> [Data] {
var frames: [Data] = []
while true {
let output = AVAudioCompressedBuffer(
format: converter.outputFormat,
packetCapacity: 8,
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
)
var error: NSError?
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
guard let self, let next = self.pendingInput.first else {
outStatus.pointee = .noDataNow
return nil
}
self.pendingInput.removeFirst()
outStatus.pointee = .haveData
return next
}
if status == .error {
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return frames
}
frames.append(contentsOf: Self.extractPackets(from: output))
// .haveData means the output buffer filled and more may be ready;
// anything else means the converter wants more input.
if status != .haveData { return frames }
}
}
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
var frames: [Data] = []
frames.reserveCapacity(Int(buffer.packetCount))
for index in 0..<Int(buffer.packetCount) {
let description = descriptions[index]
guard description.mDataByteSize > 0 else { continue }
let start = buffer.data.advanced(by: Int(description.mStartOffset))
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
}
return frames
}
}
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
/// inbound burst. Not thread-safe confine to one queue/actor.
final class PTTFrameDecoder {
private let converter: AVAudioConverter
private let pcmFormat: AVAudioFormat
private let aacFormat: AVAudioFormat
init?() {
guard let pcm = PTTAudioFormat.pcmFormat,
let aac = PTTAudioFormat.aacFormat,
let converter = AVAudioConverter(from: aac, to: pcm)
else { return nil }
self.converter = converter
self.pcmFormat = pcm
self.aacFormat = aac
}
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
/// while the decoder is still priming (the first frame of a stream).
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
frame.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
input.data.copyMemory(from: base, byteCount: frame.count)
}
input.byteLength = UInt32(frame.count)
input.packetCount = 1
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
mStartOffset: 0,
mVariableFramesInPacket: 0,
mDataByteSize: UInt32(frame.count)
)
guard let output = AVAudioPCMBuffer(
pcmFormat: pcmFormat,
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
) else { return nil }
var consumed = false
var error: NSError?
let status = converter.convert(to: output, error: &error) { _, outStatus in
if consumed {
outStatus.pointee = .noDataNow
return nil
}
consumed = true
outStatus.pointee = .haveData
return input
}
guard status != .error else {
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return nil
}
return output.frameLength > 0 ? output : nil
}
}
/// Sample-rate/channel converter from the microphone's native format to the
/// 16 kHz mono processing format. Stateful; not thread-safe.
final class PTTInputResampler {
private let converter: AVAudioConverter
private let outputFormat: AVAudioFormat
private let ratio: Double
init?(inputFormat: AVAudioFormat) {
guard let pcm = PTTAudioFormat.pcmFormat,
let converter = AVAudioConverter(from: inputFormat, to: pcm)
else { return nil }
self.converter = converter
self.outputFormat = pcm
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
}
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
var consumed = false
var error: NSError?
let status = converter.convert(to: output, error: &error) { _, outStatus in
if consumed {
outStatus.pointee = .noDataNow
return nil
}
consumed = true
outStatus.pointee = .haveData
return buffer
}
guard status != .error else {
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return nil
}
return output.frameLength > 0 ? output : nil
}
}
@@ -1,84 +0,0 @@
//
// PTTAudioFormat.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import Foundation
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
/// ~16 kbps deliberately identical to `VoiceRecorder`'s voice-note settings
/// so a burst's finalized `.m4a` and its live frames sound the same.
enum PTTAudioFormat {
static let sampleRate: Double = 16_000
static let channelCount: AVAudioChannelCount = 1
static let bitRate = 16_000
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
static let samplesPerFrame: AVAudioFrameCount = 1024
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
/// Uncompressed processing format (deinterleaved float PCM).
static var pcmFormat: AVAudioFormat? {
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
}
/// Compressed wire format.
static var aacFormat: AVAudioFormat? {
var description = AudioStreamBasicDescription(
mSampleRate: sampleRate,
mFormatID: kAudioFormatMPEG4AAC,
mFormatFlags: 0,
mBytesPerPacket: 0,
mFramesPerPacket: samplesPerFrame,
mBytesPerFrame: 0,
mChannelsPerFrame: channelCount,
mBitsPerChannel: 0,
mReserved: 0
)
return AVAudioFormat(streamDescription: &description)
}
/// Voice-note container settings for the finalized `.m4a`, mirroring
/// `VoiceRecorder.startRecording()`.
static var voiceNoteFileSettings: [String: Any] {
[
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: Int(channelCount),
AVEncoderBitRateKey: bitRate
]
}
}
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
/// stream is playable at any prefix a partially received burst is still a
/// replayable voice note.
enum ADTSFramer {
private static let headerSize = 7
/// MPEG-4 sampling frequency index for 16 kHz.
private static let samplingFrequencyIndex: UInt8 = 8
private static let channelConfiguration: UInt8 = 1
/// Wraps one raw AAC-LC frame in an ADTS header.
static func frame(_ aacFrame: Data) -> Data {
let frameLength = aacFrame.count + headerSize
var data = Data(capacity: frameLength)
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
data.append(0xFF)
data.append(0xF1)
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
// private bit 0, channel config high bit.
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
data.append(UInt8((frameLength >> 3) & 0xFF))
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
data.append(0xFC)
data.append(aacFrame)
return data
}
}
-144
View File
@@ -1,144 +0,0 @@
//
// PTTBurstPlayer.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// Plays one inbound live voice burst with a small jitter buffer.
///
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
/// an underrun (missing/late packets) simply pauses output until the next
/// buffer arrives, which self-heals timing without explicit silence
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
@MainActor
final class PTTBurstPlayer {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
private let decoder: PTTFrameDecoder
private var queuedBuffers: [AVAudioPCMBuffer] = []
private var queuedDuration: TimeInterval = 0
private var scheduledCount = 0
private var engineStarted = false
private var finished = false
private var stopped = false
private var deadlineTask: Task<Void, Never>?
private(set) var isPlaying = false
init?() {
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
self.decoder = decoder
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
deadlineTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
self?.startIfReady(force: true)
}
}
/// Decodes and queues frames (in burst order). Starts playback when the
/// jitter buffer fills.
func enqueue(_ frames: [Data]) {
guard !stopped else { return }
for frame in frames {
guard let pcm = decoder.decode(frame) else { continue }
if engineStarted {
schedule(pcm)
} else {
queuedBuffers.append(pcm)
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
}
}
startIfReady(force: false)
}
/// The burst ended: stop once everything scheduled has played out.
func finishAfterDrain() {
finished = true
stopIfDrained()
}
/// Immediate stop (cancel, another playback taking over, teardown).
func stop() {
guard !stopped else { return }
stopped = true
deadlineTask?.cancel()
queuedBuffers = []
if engineStarted {
node.stop()
engine.stop()
}
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
private func startIfReady(force: Bool) {
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
#if os(iOS)
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
}
#endif
engine.prepare()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
stopped = true
return
}
engineStarted = true
isPlaying = true
VoiceNotePlaybackCoordinator.shared.activate(self)
node.play()
let buffered = queuedBuffers
queuedBuffers = []
queuedDuration = 0
for buffer in buffered {
schedule(buffer)
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
scheduledCount += 1
node.scheduleBuffer(buffer) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.scheduledCount -= 1
self.stopIfDrained()
}
}
}
private func stopIfDrained() {
guard finished, scheduledCount <= 0 else { return }
stop()
}
}
extension PTTBurstPlayer: ExclusivePlayback {
/// A live stream can't meaningfully pause; yielding the floor stops it.
/// The burst keeps assembling to file, so nothing is lost.
nonisolated func pauseForExclusivity() {
Task { @MainActor [weak self] in
self?.stop()
}
}
}
@@ -1,183 +0,0 @@
//
// PTTCaptureEngine.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// Captures microphone audio for a live push-to-talk burst, producing both:
/// - live AAC frames via `onFrames` (called on the capture queue), and
/// - a finalized `.m4a` voice note on `stop()` the same artifact
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
/// handles delivery to receivers that missed the live stream.
final class PTTCaptureEngine {
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
/// engine keeps running (the UI owns the gesture) but stops encoding.
private static let maxCaptureDuration: TimeInterval = 120
/// Recreated on every `start()`: an engine whose input unit was
/// instantiated against an earlier (playback-only or inactive) audio
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
private var engine = AVAudioEngine()
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
// Capture-queue-confined state.
private var resampler: PTTInputResampler?
private var encoder: PTTFrameEncoder?
private var file: AVAudioFile?
private var fileURL: URL?
private var encodedFrameCount = 0
private var running = false
private var captureStart = Date()
/// Whether `engine.start()` succeeded for the current capture (main-actor
/// callers only; see `stopEngineIfStarted`).
private var engineStarted = false
/// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)?
enum CaptureError: Error {
case inputUnavailable
case audioSetupFailed
}
func start(outputURL: URL) throws {
#if os(iOS)
try Self.configureAudioSession()
#endif
// Fresh engine per capture so its input unit binds to the session
// that is active *now* (see `engine` doc comment).
engine = AVAudioEngine()
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
throw CaptureError.inputUnavailable
}
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
let encoder = PTTFrameEncoder(),
let pcmFormat = PTTAudioFormat.pcmFormat
else { throw CaptureError.audioSetupFailed }
let file = try AVAudioFile(
forWriting: outputURL,
settings: PTTAudioFormat.voiceNoteFileSettings,
commonFormat: pcmFormat.commonFormat,
interleaved: pcmFormat.isInterleaved
)
queue.sync {
self.resampler = resampler
self.encoder = encoder
self.file = file
self.fileURL = outputURL
self.encodedFrameCount = 0
self.captureStart = Date()
self.running = true
}
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.queue.async { self?.process(buffer) }
}
engine.prepare()
do {
try engine.start()
} catch {
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
engine.inputNode.removeTap(onBus: 0)
queue.sync { self.teardown(deleteFile: true) }
throw error
}
engineStarted = true
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
}
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
func stop() -> (url: URL?, encodedFrames: Int) {
stopEngineIfStarted()
let result: (URL?, Int) = queue.sync {
let url = fileURL
let frames = encodedFrameCount
teardown(deleteFile: false)
return (url, frames)
}
#if os(iOS)
Self.deactivateAudioSession()
#endif
return result
}
func cancel() {
stopEngineIfStarted()
queue.sync { teardown(deleteFile: true) }
#if os(iOS)
Self.deactivateAudioSession()
#endif
}
/// Touching `inputNode` on an engine that never started instantiates its
/// input unit against whatever session is active and spams AURemoteIO
/// errors a canceled-before-start hold must not touch the engine.
private func stopEngineIfStarted() {
guard engineStarted else { return }
engineStarted = false
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
// MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer) {
guard running,
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
let resampled = resampler?.resample(buffer)
else { return }
do {
try file?.write(from: resampled)
} catch {
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
}
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
encodedFrameCount += frames.count
onFrames?(frames)
}
private func teardown(deleteFile: Bool) {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
if deleteFile, let url = fileURL {
try? FileManager.default.removeItem(at: url)
}
fileURL = nil
}
// MARK: - Audio session (iOS)
#if os(iOS)
private static func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
#if targetEnvironment(simulator)
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
#else
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP])
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
}
private static func deactivateAudioSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
#endif
}
-39
View File
@@ -1,39 +0,0 @@
//
// PTTSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// User preference for live push-to-talk voice. One switch controls both
/// directions: streaming your holds live, and auto-playing inbound bursts.
/// Off means voice messages behave exactly like classic voice notes.
enum PTTSettings {
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
static var liveVoiceEnabled: Bool {
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
}
/// Autoplay is foreground-only: audio must never start from the
/// background.
@MainActor
static var isAppActive: Bool {
#if os(iOS)
return UIApplication.shared.applicationState == .active
#elseif os(macOS)
return NSApplication.shared.isActive
#else
return true
#endif
}
}
@@ -1,183 +0,0 @@
//
// VoiceCaptureSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Foundation
/// Capture backend behind the composer's hold-to-record gesture.
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
/// additionally streams frames live while the button is held.
@MainActor
protocol VoiceCaptureSession: AnyObject {
/// Whether audio is leaving the device in real time while recording
/// drives the composer's LIVE treatment.
var isLive: Bool { get }
func requestPermission() async -> Bool
func start() async throws
/// Stops capture and returns the finalized voice-note file, or nil when
/// nothing valid was captured.
func finish() async -> URL?
func cancel() async
}
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@MainActor
final class VoiceNoteCaptureSession: VoiceCaptureSession {
var isLive: Bool { false }
func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission()
}
func start() async throws {
try await VoiceRecorder.shared.startRecording()
}
func finish() async -> URL? {
await VoiceRecorder.shared.stopRecording()
}
func cancel() async {
await VoiceRecorder.shared.cancelRecording()
}
}
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
/// recording, then finalizes the same audio as a standard voice note whose
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
/// heard the live stream absorb the note silently instead of seeing a
/// duplicate.
@MainActor
final class PTTLiveVoiceSession: VoiceCaptureSession {
let burstID: Data
private let sendPacket: (Data) -> Void
private let capture = PTTCaptureEngine()
/// Capture-queue-confined stream state: packetizes frames and lazily
/// emits START so packet order is guaranteed by queue serialization.
private final class StreamState {
var packetizer: VoiceBurstPacketizer
var sentStart = false
init(burstID: Data) {
packetizer = VoiceBurstPacketizer(burstID: burstID)
}
}
private let stream: StreamState
private var startDate: Date?
private var completed = false
var isLive: Bool { true }
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
/// target peer; must be safe to call from any queue (BLEService hops to
/// its own message queue internally).
init(sendPacket: @escaping (Data) -> Void) {
self.burstID = VoiceBurstPacket.makeBurstID()
self.sendPacket = sendPacket
self.stream = StreamState(burstID: burstID)
}
func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission()
}
func start() async throws {
let outputURL = try Self.makeOutputURL(burstID: burstID)
let sendPacket = sendPacket
let stream = stream
capture.onFrames = { frames in
if !stream.sentStart {
stream.sentStart = true
if let start = VoiceBurstPacket(
burstID: stream.packetizer.burstID,
seq: 0,
kind: .start(codec: .aacLC16kMono)
) {
sendPacket(start.encode())
}
}
for frame in frames {
for packet in stream.packetizer.add(frame) {
sendPacket(packet)
}
}
// Flush per callback batch: at ~130-byte frames the budget fits
// one frame per packet anyway, and holding residue would add
// ~100 ms of avoidable latency.
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
}
do {
try capture.start(outputURL: outputURL)
} catch {
// The HAL can briefly report a dead input right after the audio
// session (re)activates while the route settles; one retry after
// a short pause covers it (observed on iPhone field tests).
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
try? await Task.sleep(nanoseconds: 150_000_000)
try capture.start(outputURL: outputURL)
}
startDate = Date()
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
}
func finish() async -> URL? {
guard !completed else { return nil }
completed = true
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
let (url, encodedFrames) = capture.stop()
// stop() drained the capture queue, so touching `stream` is safe now.
guard elapsed >= VoiceRecorder.minRecordingDuration, let url else {
sendControlPacket(.canceled)
if let url {
try? FileManager.default.removeItem(at: url)
}
return nil
}
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
let durationMs = UInt32((Double(encodedFrames) * PTTAudioFormat.frameDuration * 1000).rounded())
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
return url
}
func cancel() async {
guard !completed else { return }
completed = true
capture.cancel()
sendControlPacket(.canceled)
}
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
sendPacket(packet.encode())
}
private static func makeOutputURL(burstID: Data) throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
}
}
@@ -181,35 +181,23 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
}
}
/// Something that can hold the app's single audio-playback slot and yield it
/// when another playback starts (voice notes pause; live bursts stop).
protocol ExclusivePlayback: AnyObject {
func pauseForExclusivity()
}
extension VoiceNotePlaybackController: ExclusivePlayback {
func pauseForExclusivity() {
pause()
}
}
/// Ensures only one voice playback (note or live burst) runs at a time.
/// Ensures only one voice note plays at a time.
final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator()
private weak var activeController: (any ExclusivePlayback)?
private weak var activeController: VoiceNotePlaybackController?
private init() {}
func activate(_ controller: any ExclusivePlayback) {
func activate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
return
}
activeController?.pauseForExclusivity()
activeController?.pause()
activeController = controller
}
func deactivate(_ controller: any ExclusivePlayback) {
func deactivate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
activeController = nil
}
@@ -5,6 +5,7 @@ import AVFoundation
actor VoiceRecorder {
enum RecorderError: Error {
case microphoneAccessDenied
case recorderInitializationFailed
case recordingInProgress
}
+6
View File
@@ -56,6 +56,12 @@ final class WaveformCache {
}
}
func purgeAll() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAll()
}
}
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations
+7
View File
@@ -88,6 +88,8 @@ import BitFoundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity {
let peerID: PeerID // 8 random bytes
let sessionStart: Date
var handshakeState: HandshakeState
}
@@ -96,6 +98,7 @@ enum HandshakeState {
case initiated
case inProgress
case completed(fingerprint: String)
case failed(reason: String)
}
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
@@ -107,6 +110,7 @@ struct CryptographicIdentity: Codable {
// Optional Ed25519 signing public key (used to authenticate public messages)
var signingPublicKey: Data? = nil
let firstSeen: Date
let lastHandshake: Date?
}
/// Represents the social layer of identity - user-assigned names and trust relationships.
@@ -189,6 +193,9 @@ struct IdentityCache: Codable {
// Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Schema version for future migrations
var version: Int = 1
}
//
@@ -108,6 +108,8 @@ protocol SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management
func getFavorites() -> Set<String>
func setFavorite(_ fingerprint: String, isFavorite: Bool)
func isFavorite(fingerprint: String) -> Bool
// MARK: Blocked Users Management
@@ -121,7 +123,8 @@ protocol SecureIdentityStateManagerProtocol {
// MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
// MARK: Cleanup
func clearAllIdentityData()
func removeEphemeralSession(peerID: PeerID)
@@ -136,6 +139,7 @@ protocol SecureIdentityStateManagerProtocol {
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func validVouchers(for fingerprint: String) -> [VouchRecord]
func isVouched(fingerprint: String) -> Bool
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
@@ -318,13 +322,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
firstSeen: existing.firstSeen
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = existing
} else {
// Update signing key
// Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
self.cryptographicIdentities[fingerprint] = existing
let updated = CryptographicIdentity(
fingerprint: existing.fingerprint,
publicKey: existing.publicKey,
signingPublicKey: existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
}
// Persist updated state (already assigned in branches above)
} else {
@@ -333,7 +345,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
firstSeen: now
firstSeen: now,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = entry
}
@@ -498,7 +511,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID,
sessionStart: Date(),
handshakeState: handshakeState
)
}
}
+6
View File
@@ -33,12 +33,18 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBonjourServices</key>
<array>
<string>_bitchat-bulk._tcp</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key>
<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>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
+4732 -15684
View File
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,13 @@ extension BitchatMessage {
enum Media {
case voice(URL)
case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
}
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
+3 -1
View File
@@ -7,6 +7,7 @@ struct BitchatPeer: Equatable {
let peerID: PeerID // Hex-encoded peer ID
let noisePublicKey: Data
let nickname: String
let lastSeen: Date
let isConnected: Bool
let isReachable: Bool
@@ -76,13 +77,14 @@ struct BitchatPeer: Equatable {
peerID: PeerID,
noisePublicKey: Data,
nickname: String,
lastSeen _: Date = Date(),
lastSeen: Date = Date(),
isConnected: Bool = false,
isReachable: Bool = false
) {
self.peerID = peerID
self.noisePublicKey = noisePublicKey
self.nickname = nickname
self.lastSeen = lastSeen
self.isConnected = isConnected
self.isReachable = isReachable
+21 -25
View File
@@ -28,7 +28,6 @@ enum CommandInfo: String, Identifiable {
case unfavorite = "unfav"
case ping
case trace
case drop
var id: String { rawValue }
@@ -37,13 +36,11 @@ enum CommandInfo: String, Identifiable {
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
return "<" + String(localized: "content.input.nickname_placeholder", defaultValue: "nickname") + ">"
case .group:
return "<" + String(localized: "content.input.group_placeholder") + ">"
return "<" + String(localized: "content.input.group_placeholder", defaultValue: "create|invite|leave|list") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
case .drop:
return "<" + String(localized: "content.input.note_placeholder") + ">"
return "<" + String(localized: "content.input.token_placeholder", defaultValue: "token") + ">"
case .clear, .help, .who:
return nil
}
@@ -51,26 +48,25 @@ enum CommandInfo: String, Identifiable {
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .group: String(localized: "content.commands.group")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .pay: String(localized: "content.commands.pay")
case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace")
case .drop: String(localized: "content.commands.drop")
case .block: String(localized: "content.commands.block", defaultValue: "block or list blocked peers")
case .clear: String(localized: "content.commands.clear", defaultValue: "clear chat messages")
case .group: String(localized: "content.commands.group", defaultValue: "create or manage private groups")
case .help: String(localized: "content.commands.help", defaultValue: "show available commands")
case .hug: String(localized: "content.commands.hug", defaultValue: "send someone a warm hug")
case .message: String(localized: "content.commands.message", defaultValue: "send private message")
case .pay: String(localized: "content.commands.pay", defaultValue: "send a cashu ecash token in this chat")
case .slap: String(localized: "content.commands.slap", defaultValue: "slap someone with a trout")
case .unblock: String(localized: "content.commands.unblock", defaultValue: "unblock a peer")
case .who: String(localized: "content.commands.who", defaultValue: "see who's online")
case .favorite: String(localized: "content.commands.favorite", defaultValue: "add to favorites")
case .unfavorite: String(localized: "content.commands.unfavorite", defaultValue: "remove from favorites")
case .ping: String(localized: "content.commands.ping", defaultValue: "measure round-trip time to a mesh peer")
case .trace: String(localized: "content.commands.trace", defaultValue: "estimate the mesh path to a peer")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// Cashu tokens are bearer instruments: in a public geohash any nearby
// stranger can redeem one, so don't *suggest* /pay there (the
// processor still allows it behind an explicit "public" confirm).
@@ -78,11 +74,11 @@ enum CommandInfo: String, Identifiable {
if !isGeoPublic {
commands.append(.pay)
}
// The processor rejects favorites, groups, and mesh diagnostics in
// geohash contexts, so only suggest them where they work: mesh.
// The processor rejects favorites, groups and mesh diagnostics in
// geohash contexts, so only suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return commands
}
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
}
}
+1 -1
View File
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
return messageBuffer
}
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete
@@ -21,6 +21,12 @@ enum NoiseSecurityConstants {
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
+1
View File
@@ -14,4 +14,5 @@ enum NoiseSecurityError: Error {
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
+8 -2
View File
@@ -13,6 +13,8 @@ import BitFoundation
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
@@ -21,6 +23,8 @@ final class NoiseSessionManager {
var onSessionFailed: ((PeerID, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = { peerID, role in
SecureNoiseSession(
peerID: peerID,
@@ -33,10 +37,12 @@ final class NoiseSessionManager {
#if DEBUG
init(
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
keychain _: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = sessionFactory
}
#endif
+10 -2
View File
@@ -6,12 +6,14 @@ struct NostrIdentity: Codable {
let privateKey: Data
let publicKey: Data
let npub: String // Bech32-encoded public key
let createdAt: Date
/// Memberwise initializer
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
self.privateKey = privateKey
self.publicKey = publicKey
self.npub = npub
self.createdAt = createdAt
}
/// Generate a new Nostr identity
@@ -37,6 +39,12 @@ struct NostrIdentity: Codable {
self.privateKey = privateKeyData
self.publicKey = xOnlyPubkey
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
self.createdAt = Date()
}
/// Get signing key for event signatures
func signingKey() throws -> P256K.Signing.PrivateKey {
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
}
/// Get Schnorr signing key for Nostr event signatures
+32 -12
View File
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain
}
@@ -37,6 +37,14 @@ final class NostrIdentityBridge {
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// Get Nostr public key associated with a Noise public key
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
@@ -49,10 +57,29 @@ final class NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity
func clearAllAssociations() {
// Must go through the injected keychain, not raw SecItem calls:
// under test that keychain is in-memory, and a direct delete here
// would wipe the developer's real Nostr identity on every test run.
keychain.deleteAll(service: keychainService)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil
// Also drop the in-memory derived per-geohash identities. These hold the
@@ -86,13 +113,6 @@ final class NostrIdentityBridge {
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
/// geohash-chat identity for the same cell string.
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
try deriveIdentity(forGeohash: "bridge|" + cell)
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
+36 -113
View File
@@ -19,11 +19,6 @@ struct NostrProtocol {
case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000
case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
/// Sealed courier envelope parked on relays under its rotating
/// recipient tag (`#x`). Regular (stored) kind so it survives until
/// its NIP-40 expiration the whole point is store-and-forward.
case courierDrop = 1401
}
/// Create a NIP-17 private message
@@ -260,105 +255,17 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey)
}
// MARK: - Mesh bridge (rendezvous) events
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
/// channel subscriptions (which filter on `#g`); `m` carries the original
/// mesh message ID so receivers dedup the bridged copy against the radio
/// copy by timeline ID.
static func createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
meshMessageID: String? = nil
) throws -> NostrEvent {
var tags = [["r", cell]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
tags.append(["m", meshMessageID])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
/// cell: empty content, `r` tag only the bridge analogue of geohash
/// presence, counted into "people across the bridge".
static func createBridgePresenceEvent(
cell: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["r", cell]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
/// parked on relays. `x` is the hex recipient tag the recipient (or a
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
/// the envelope expiry so honoring relays garbage-collect the drop. The
/// signing identity should be a throwaway the envelope authenticates
/// its sender internally via Noise-X, and linking drops to a stable
/// publisher key would leak courier traffic patterns.
static func createCourierDropEvent(
envelope: Data,
recipientTagHex: String,
expiresAt: Date,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let tags = [
["x", recipientTagHex],
["expiration", String(Int(expiresAt.timeIntervalSince1970))],
]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .courierDrop,
tags: tags,
content: envelope.base64EncodedString()
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
/// drop the note in step with a bridged board post's expiry.
static func createGeohashTextNote(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
expiresAt: Date? = nil,
urgent: Bool = false
nickname: String? = nil
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let expiresAt {
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
}
if urgent {
tags.append(["t", "urgent"])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
@@ -369,25 +276,7 @@ struct NostrProtocol {
let schnorrKey = try senderIdentity.schnorrSigningKey()
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
private static func createSeal(
@@ -634,6 +523,37 @@ struct NostrProtocol {
return sharedSecretData
}
// Direct version that doesn't try to add prefixes
private static func deriveSharedSecretDirect(
privateKey: P256K.Schnorr.PrivateKey,
publicKey: Data
) throws -> Data {
// Direct shared secret calculation
// Convert Schnorr private key to KeyAgreement private key
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: privateKey.dataRepresentation
)
// Use the public key as-is (should already have prefix)
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
dataRepresentation: publicKey,
format: .compressed
)
// Perform ECDH
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
with: keyAgreementPublicKey,
format: .compressed
)
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
}
private static func randomizedTimestamp() -> Date {
// Add random offset to current time for privacy
// This prevents timing correlation attacks while the actual message timestamp
@@ -763,8 +683,11 @@ struct NostrEvent: Codable {
enum NostrError: Error {
case invalidPublicKey
case invalidPrivateKey
case invalidEvent
case invalidCiphertext
case signingFailed
case encryptionFailed
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
+12 -82
View File
@@ -117,6 +117,7 @@ final class NostrRelayManager: ObservableObject {
let url: String
var isConnected: Bool = false
var lastError: Error?
var lastConnectedAt: Date?
var messagesSent: Int = 0
var messagesReceived: Int = 0
var reconnectAttempts: Int = 0
@@ -183,26 +184,11 @@ final class NostrRelayManager: ObservableObject {
}
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
// Track EOSE per subscription to signal when initial stored events are
// done. Completion is scoped to relays the REQ actually reached: targets
// still mid-connect must not hold the callback hostage until the fallback
// timer (a dead relay of five used to pin "loading" for the full 10s).
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
/// Targets the REQ has not been delivered to yet (still connecting).
var awaitingSend: Set<String>
/// Relays that received the REQ and have not sent EOSE yet.
var awaitingEOSE: Set<String>
/// True once any relay received the REQ (or answered with EOSE)
/// completion with zero sends would mean "done" without ever asking.
var didSend = false
var pendingRelays: Set<String>
var callback: () -> Void
let epoch: Int
/// Done when every relay that got the REQ has resolved, provided at
/// least one did or when every target dropped out entirely.
var isComplete: Bool {
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
}
}
private var eoseTrackers: [String: EOSETracker] = [:]
private var eoseTrackerEpoch = 0
@@ -361,6 +347,7 @@ final class NostrRelayManager: ObservableObject {
relays[index].nextReconnectTime = nil
if resetState {
relays[index].lastError = nil
relays[index].lastConnectedAt = nil
relays[index].lastDisconnectedAt = nil
relays[index].messagesSent = 0
relays[index].messagesReceived = 0
@@ -808,7 +795,7 @@ final class NostrRelayManager: ObservableObject {
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackerEpoch += 1
let epoch = eoseTrackerEpoch
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch)
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
// Fallback timeout to avoid hanging if a relay never sends EOSE.
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in
@@ -948,19 +935,8 @@ final class NostrRelayManager: ObservableObject {
toSend[id] = state.messageString
}
for (id, messageString) in toSend {
if self.subscriptions[relayUrl]?.contains(id) == true {
// Already subscribed on this relay (e.g. a tracker promoted
// after an earlier flush): its EOSE is coming, count it.
markEOSESubscribed(id: id, relayUrl: relayUrl)
continue
}
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
startPendingEOSETrackingIfNeeded(id: id)
// Mark at send *initiation*, not in the async completion: a fast
// relay's EOSE could otherwise complete the tracker while this
// relay REQ already on the wire still sat in awaitingSend.
// If the send fails the socket is going down with it, and the
// disconnect settle (or the fallback timer) releases the wait.
markEOSESubscribed(id: id, relayUrl: relayUrl)
connection.send(.string(messageString)) { [weak self, weak connection] error in
Task { @MainActor [weak self] in
guard let self else { return }
@@ -1042,12 +1018,8 @@ final class NostrRelayManager: ObservableObject {
}
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
// An EOSE proves the relay received the REQ even if the local
// send completion hasn't run yet.
tracker.awaitingSend.remove(relayUrl)
tracker.awaitingEOSE.remove(relayUrl)
tracker.didSend = true
if tracker.isComplete {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
eoseTrackers.removeValue(forKey: subId)
tracker.callback()
} else {
@@ -1103,6 +1075,7 @@ final class NostrRelayManager: ObservableObject {
relays[index].isConnected = isConnected
relays[index].lastError = error
if isConnected {
relays[index].lastConnectedAt = dependencies.now()
relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil
} else {
@@ -1127,11 +1100,9 @@ final class NostrRelayManager: ObservableObject {
/// callbacks; treat it as done and let the remaining relays (or the
/// fallback timeout) drive completion.
private func settleEOSETrackers(droppingRelay relayUrl: String) {
for (id, var tracker) in eoseTrackers
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
tracker.awaitingSend.remove(relayUrl)
tracker.awaitingEOSE.remove(relayUrl)
if tracker.isComplete {
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
eoseTrackers.removeValue(forKey: id)
tracker.callback()
} else {
@@ -1140,24 +1111,6 @@ final class NostrRelayManager: ObservableObject {
}
}
/// Whether any of `relayUrls` currently holds a live connection. Lets
/// subscribers distinguish "loaded, empty" from "never reached a relay"
/// when an EOSE fallback fires.
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
let targets = Set(relayUrls)
return relays.contains { targets.contains($0.url) && $0.isConnected }
}
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
/// this relay instead of the never-connected remainder.
private func markEOSESubscribed(id: String, relayUrl: String) {
guard var tracker = eoseTrackers[id],
tracker.awaitingSend.remove(relayUrl) != nil else { return }
tracker.awaitingEOSE.insert(relayUrl)
tracker.didSend = true
eoseTrackers[id] = tracker
}
private func handleDisconnection(relayUrl: String, error: Error) {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
@@ -1510,29 +1463,6 @@ struct NostrFilter: Encodable {
filter.limit = limit
return filter
}
// For the mesh bridge: rendezvous messages (kind 20000) and presence
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [20000, 20001]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["r": cells]
filter.limit = limit
return filter
}
// For courier drops: sealed envelopes (kind 1401) parked under rotating
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
// UTC days x recipients) in one filter.
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["x": recipientTagsHex]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters
+11 -7
View File
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
/// 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).
func encode() -> Data? {
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard resolvedSize <= UInt64(limit) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
}
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
static func decode(_ data: Data) -> BitchatFilePacket? {
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
/// 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
let end = data.endIndex
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
for byte in value {
size = (size << 8) | UInt64(byte)
}
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
if size > UInt64(limit) {
return nil
}
fileSize = size
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
mimeType = String(data: Data(value), encoding: .utf8)
case .content:
let proposedSize = content.count + value.count
if proposedSize > FileTransferLimits.maxPayloadBytes {
if proposedSize > limit {
return nil
}
content.append(contentsOf: value)
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
}
guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
return BitchatFilePacket(
fileName: fileName,
fileSize: fileSize ?? UInt64(content.count),
+6 -11
View File
@@ -74,11 +74,12 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered
// Private groups (0x04/0x05 reserved by other features)
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
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 groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Live voice (push-to-talk)
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
@@ -90,9 +91,10 @@ enum NoisePayloadType: UInt8 {
case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt"
case .delivered: return "delivered"
case .bulkTransferOffer: return "bulkTransferOffer"
case .bulkTransferResponse: return "bulkTransferResponse"
case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate"
case .voiceFrame: return "voiceFrame"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
@@ -130,9 +132,6 @@ protocol BitchatDelegate: AnyObject {
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
func didReceiveGroupMessage(payload: Data, timestamp: Date)
// Public live-voice burst packet (signature-verified by the transport)
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@@ -156,10 +155,6 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation
}
+4 -4
View File
@@ -10,11 +10,11 @@ enum Geohash {
return map
}()
/// Validates a geohash string at any channel precision (1-12 characters).
/// Validates a geohash string for building-level precision (8 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 }
/// - Returns: true if valid 8-character base32 geohash, false otherwise
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
guard geohash.count == 8 else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
+6 -6
View File
@@ -24,17 +24,17 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String {
switch self {
case .building:
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
return String(localized: "location_levels.building", defaultValue: "building", comment: "Name for building-level location channel")
case .block:
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
return String(localized: "location_levels.block", defaultValue: "block", comment: "Name for block-level location channel")
case .neighborhood:
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
return String(localized: "location_levels.neighborhood", defaultValue: "neighborhood", comment: "Name for neighborhood-level location channel")
case .city:
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
return String(localized: "location_levels.city", defaultValue: "city", comment: "Name for city-level location channel")
case .province:
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
return String(localized: "location_levels.province", defaultValue: "province", comment: "Name for province-level location channel")
case .region:
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
return String(localized: "location_levels.region", defaultValue: "region", comment: "Name for region-level location channel")
}
}
}
@@ -32,14 +32,6 @@ struct NostrCarrierPacket: Equatable {
enum Direction: UInt8 {
case toGateway = 0x01
case fromGateway = 0x02
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
/// publish its signed rendezvous event. Directed, like `toGateway`.
case toBridge = 0x03
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
/// event from a remote island. Broadcast, like `fromGateway`.
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
/// carrier quietly bridge traffic degrades to invisible, not junk.
case fromBridge = 0x04
}
let direction: Direction
+2 -24
View File
@@ -9,25 +9,19 @@ struct AnnouncementPacket {
let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
/// tagged rendezvous events without their own location fix.
let bridgeGeohash: String?
init(
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data,
directNeighbors: [Data]?,
capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
capabilities: PeerCapabilities? = nil
) {
self.nickname = nickname
self.noisePublicKey = noisePublicKey
self.signingPublicKey = signingPublicKey
self.directNeighbors = directNeighbors
self.capabilities = capabilities
self.bridgeGeohash = bridgeGeohash
}
private enum TLVType: UInt8 {
@@ -36,7 +30,6 @@ struct AnnouncementPacket {
case signingPublicKey = 0x03
case directNeighbors = 0x04
case capabilities = 0x05
case bridgeGeohash = 0x06
}
func encode() -> Data? {
@@ -81,15 +74,6 @@ struct AnnouncementPacket {
data.append(capabilityBytes)
}
// TLV for bridge rendezvous cell (optional; old clients skip it)
if let bridgeGeohash = bridgeGeohash,
let cellData = bridgeGeohash.data(using: .utf8),
!cellData.isEmpty, cellData.count <= 12 {
data.append(TLVType.bridgeGeohash.rawValue)
data.append(UInt8(cellData.count))
data.append(cellData)
}
return data
}
@@ -100,7 +84,6 @@ struct AnnouncementPacket {
var signingPublicKey: Data?
var directNeighbors: [Data]?
var capabilities: PeerCapabilities?
var bridgeGeohash: String?
while offset + 2 <= data.count {
let typeRaw = data[offset]
@@ -133,10 +116,6 @@ struct AnnouncementPacket {
}
case .capabilities:
capabilities = PeerCapabilities(encoded: Data(value))
case .bridgeGeohash:
if length <= 12 {
bridgeGeohash = String(data: value, encoding: .utf8)
}
}
} else {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -150,8 +129,7 @@ struct AnnouncementPacket {
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors,
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
capabilities: capabilities
)
}
}
@@ -3,5 +3,9 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
static let localSupported: PeerCapabilities = {
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
return caps
}()
}
-220
View File
@@ -1,220 +0,0 @@
//
// VoiceBurstPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Security
/// Audio codec of a live voice burst. START packets carry it so receivers can
/// reject bursts they can't decode instead of feeding garbage to the decoder.
enum VoiceBurstCodec: UInt8 {
/// AAC-LC, 16 kHz, mono, ~16 kbps matches the voice-note recorder, so
/// the finalized `.m4a` and the live frames come from the same encoder
/// settings.
case aacLC16kMono = 0x01
}
/// One packet of a live push-to-talk voice burst (the inner payload of
/// `NoisePayloadType.voiceFrame`, and for public mesh bursts the payload
/// of `MessageType.voiceFrame`).
///
/// Wire format:
/// ```
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload]
/// ```
/// - flags 0x01 (START): payload = [codec: UInt8]
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
struct VoiceBurstPacket: Equatable {
enum Kind: Equatable {
case start(codec: VoiceBurstCodec)
case frames([Data])
case end(totalDataPackets: UInt16, durationMs: UInt32)
case canceled
}
static let burstIDSize = 8
private static let headerSize = burstIDSize + 2 + 1
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
static let maxFramesPerPacket = 8
private enum Flags {
static let start: UInt8 = 0x01
static let end: UInt8 = 0x02
static let canceled: UInt8 = 0x04
}
let burstID: Data
let seq: UInt16
let kind: Kind
init?(burstID: Data, seq: UInt16, kind: Kind) {
guard burstID.count == Self.burstIDSize else { return nil }
if case .frames(let frames) = kind {
guard !frames.isEmpty,
frames.count <= Self.maxFramesPerPacket,
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
else { return nil }
}
self.burstID = burstID
self.seq = seq
self.kind = kind
}
func encode() -> Data {
var data = Data(capacity: Self.headerSize + payloadSize)
data.append(burstID)
data.append(UInt8((seq >> 8) & 0xFF))
data.append(UInt8(seq & 0xFF))
switch kind {
case .start(let codec):
data.append(Flags.start)
data.append(codec.rawValue)
case .frames(let frames):
data.append(0)
for frame in frames {
let length = UInt16(frame.count)
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(frame)
}
case .end(let totalDataPackets, let durationMs):
data.append(Flags.end)
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
data.append(UInt8(totalDataPackets & 0xFF))
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
}
case .canceled:
data.append(Flags.canceled)
}
return data
}
static func decode(_ data: Data) -> VoiceBurstPacket? {
// Work on a re-based copy so subscripting is offset-safe.
let data = Data(data)
guard data.count >= headerSize else { return nil }
let burstID = data.prefix(burstIDSize)
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
let flags = data[burstIDSize + 2]
let payload = data.dropFirst(headerSize)
let kind: Kind
switch flags {
case Flags.start:
guard let codecByte = payload.first,
let codec = VoiceBurstCodec(rawValue: codecByte)
else { return nil }
kind = .start(codec: codec)
case Flags.end:
guard payload.count >= 6 else { return nil }
let bytes = Array(payload)
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
kind = .end(totalDataPackets: total, durationMs: duration)
case Flags.canceled:
kind = .canceled
case 0:
var frames: [Data] = []
var offset = payload.startIndex
while offset < payload.endIndex {
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
offset = payload.index(offset, offsetBy: 2)
guard length > 0,
payload.distance(from: offset, to: payload.endIndex) >= length,
frames.count < maxFramesPerPacket
else { return nil }
let end = payload.index(offset, offsetBy: length)
frames.append(Data(payload[offset..<end]))
offset = end
}
guard !frames.isEmpty else { return nil }
kind = .frames(frames)
default:
return nil
}
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
}
static func makeBurstID() -> Data {
var bytes = Data(count: burstIDSize)
let result = bytes.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
}
guard result == errSecSuccess else {
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
}
return bytes
}
private var payloadSize: Int {
switch kind {
case .start: return 1
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
case .end: return 6
case .canceled: return 0
}
}
}
/// Greedy packetizer for outgoing bursts: batches encoded frames into
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
/// packet in a single BLE frame after Noise encryption and padding.
/// Not thread-safe confine to one queue.
struct VoiceBurstPacketizer {
let burstID: Data
private let budget: Int
private var pendingFrames: [Data] = []
private var pendingSize = 0
/// seq 0 is reserved for START; data packets start at 1.
private(set) var nextSeq: UInt16 = 1
private(set) var dataPacketCount: UInt16 = 0
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
self.burstID = burstID
self.budget = budget
}
/// Adds one encoded frame, returning any packets that became full.
/// Frames larger than the budget are dropped (the encoder's ~130-byte
/// frames never hit this; it guards against misconfiguration looping).
mutating func add(_ frame: Data) -> [Data] {
let frameCost = 2 + frame.count
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
var packets: [Data] = []
if !pendingFrames.isEmpty,
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
packets.append(contentsOf: flush())
}
pendingFrames.append(frame)
pendingSize += frameCost
return packets
}
/// Emits any buffered frames as a final data packet.
mutating func flush() -> [Data] {
guard !pendingFrames.isEmpty,
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
else {
pendingFrames = []
pendingSize = 0
return []
}
pendingFrames = []
pendingSize = 0
nextSeq &+= 1
dataPacketCount &+= 1
return [packet.encode()]
}
}
+28 -1
View File
@@ -11,7 +11,14 @@ import Foundation
/// Manages autocomplete functionality for chat
final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [
"/msg", "/who", "/clear",
"/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock"
]
/// Get autocomplete suggestions for current text
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
let textToPosition = String(text.prefix(cursorPosition))
@@ -66,6 +73,26 @@ final class AutocompleteService {
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
}
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
guard let regex = commandRegex else { return nil }
let nsText = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
guard let match = matches.last else { return nil }
let fullRange = match.range(at: 0)
let captureRange = match.range(at: 1)
let prefix = nsText.substring(with: captureRange).lowercased()
let suggestions = commands
.filter { $0.hasPrefix("/\(prefix)") }
.sorted()
.prefix(5)
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
}
private func needsArgument(command: String) -> Bool {
switch command {
case "/who", "/clear":
@@ -14,8 +14,6 @@ struct BLEFileTransferHandlerEnvironment {
let localNickname: () -> String
/// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Verifies a packet's signature against a candidate signing key (registry path).
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast file packet for gossip sync.
@@ -46,54 +44,49 @@ final class BLEFileTransferHandler {
self.environment = environment
}
/// Returns `false` when the packet fails sender authentication and must
/// not be relayed onward. Every other outcome returns `true`: files
/// directed to another peer are forwarded untouched, and local-only drops
/// (malformed payload, quota, save failure) don't affect multi-hop
/// delivery to nodes that may handle them fine.
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
/// pass the ceiling that was enforced against the accepted offer.
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
return true
}
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = resolveSenderNickname(
packet: packet,
from: peerID,
isBroadcast: !deliveryPlan.isPrivateMessage,
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
env: env
) else {
allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID) else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return false
return
}
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
return
}
if deliveryPlan.shouldTrackForSync {
env.trackPacketSeen(packet)
}
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return true
return
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return true
return
case .failure(.unsupportedMime(let mimeType, let bytes)):
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
return true
return
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
return true
return
}
// BCH-01-002: Enforce storage quota before saving
@@ -106,7 +99,7 @@ final class BLEFileTransferHandler {
mime.defaultExtension,
mime.category.rawValue
) else {
return true
return
}
if deliveryPlan.isPrivateMessage {
@@ -122,66 +115,11 @@ final class BLEFileTransferHandler {
originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil,
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap).
deliveryStatus: deliveryPlan.isPrivateMessage
? .delivered(to: env.localNickname(), at: ts)
: nil
senderPeerID: peerID
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message)
return true
}
/// Resolves the authenticated display name for a file transfer's sender.
///
/// Directed (private) transfers are addressed to us specifically and keep
/// the lenient connected-peer path. Broadcast transfers carry an
/// attacker-controllable `senderID` exactly like public messages and public
/// voice frames registry membership alone is NOT proof of identity, so a
/// valid packet signature from the claimed sender is required before we
/// trust it. Without this, a peer that observed a public voice burst could
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
/// overwrite the signature-verified live bubble with attacker audio.
private func resolveSenderNickname(
packet: BitchatPacket,
from peerID: PeerID,
isBroadcast: Bool,
peers: [PeerID: BLEPeerInfo],
env: BLEFileTransferHandlerEnvironment
) -> String? {
guard isBroadcast else {
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID)
}
// Our own broadcasts replayed back via gossip sync (ttl==0) are
// trivially authentic and cannot be verified against the peer registry
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
// does. Verify against the signing key already in the
// (synchronously-updated) registry first, then fall back to the
// persisted-identity signature lookup for peers not yet cached there.
let isSelf = peerID == env.localPeerID()
let registrySigningKey = peers[peerID]?.signingPublicKey
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: false
) ?? signedDisplayName
}
}
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
}
enum BLEIncomingFileValidator {
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
guard let filePacket = BitchatFilePacket.decode(payload) else {
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
/// pass the ceiling enforced against the accepted offer.
static func validate(
payload: Data,
limit: Int = FileTransferLimits.maxPayloadBytes
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
return .failure(.malformedPayload)
}
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
}
@@ -61,6 +61,7 @@ struct BLEFragmentAssemblyBuffer {
}
private struct Metadata {
let type: UInt8
let total: Int
let timestamp: Date
let isBroadcast: Bool
@@ -149,6 +150,7 @@ struct BLEFragmentAssemblyBuffer {
fragmentsByKey[header.key] = [:]
metadataByKey[header.key] = Metadata(
type: header.originalType,
total: header.total,
timestamp: now,
isBroadcast: header.isBroadcastFragment,
+3 -11
View File
@@ -33,21 +33,13 @@ final class BLEFragmentHandler {
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
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.
// Don't process our own fragments
if peerID == env.localPeerID() {
if header.isBroadcastFragment {
env.trackPacketSeen(packet)
}
return
}
guard let header = BLEFragmentHeader(packet: packet) else { return }
if header.isBroadcastFragment {
env.trackPacketSeen(packet)
}
@@ -78,21 +78,10 @@ struct BLEIngressLinkRegistry {
return .failure(.selfLoopback(packetType: packet.type))
}
if let boundPeerID, boundPeerID != claimedSenderID {
if requiresDirectSenderBinding(packet) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
// A direct announce claiming a new sender on a bound link is either
// a spoof or a legitimate peer-ID rotation on a connection that
// outlived the old ID. Attribute it to the claimed sender and let
// it through: announces are self-authenticating, and only a
// signature-verified announce may rebind the link (BLEService).
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
return .success(BLEIngressPacketContext(
receivedFromPeerID: claimedSenderID,
validationPeerID: claimedSenderID
))
}
if let boundPeerID,
boundPeerID != claimedSenderID,
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
let receivedFromPeerID = boundPeerID ?? claimedSenderID
@@ -109,15 +98,12 @@ struct BLEIngressLinkRegistry {
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
}
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
// must be the link peer it elicits a full store replay, and the
// response is addressed to whoever the sender claims to be.
packet.type == MessageType.requestSync.rawValue
}
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
if packet.type == MessageType.requestSync.rawValue { return true }
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
}
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
+2 -12
View File
@@ -203,19 +203,9 @@ final class BLELinkStateStore {
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
assertOwned()
var previousPeerID: PeerID?
let updated = updatePeripheral(peripheralUUID) {
previousPeerID = $0.peerID
$0.peerID = peerID
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID
}
guard updated != nil else { return }
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
// so the old peer no longer claims this link.
if let previousPeerID, previousPeerID != peerID,
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
}
peerToPeripheralUUID[peerID] = peripheralUUID
}
func removePeripheral(_ peripheralID: String) -> PeerID? {
@@ -25,4 +25,9 @@ final class BLELogRateLimiter {
}
}
func removeAll() {
queue.sync {
lastLogTimeByKey.removeAll()
}
}
}
@@ -12,15 +12,12 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
// voiceFrame is deliberately unpadded: padding to the 512 block would
// push every ~490-byte signed voice packet over the MTU into the
// fragment path.
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
return false
}
}
static func priority(for packet: BitchatPacket, data _: Data) -> BLEOutboundWritePriority {
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
switch messageType {
case .fragment:
+10 -15
View File
@@ -10,8 +10,6 @@ struct BLEPeerInfo: Equatable {
var isVerifiedNickname: Bool
var lastSeen: Date
var capabilities: PeerCapabilities = []
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
var bridgeGeohash: String?
}
struct BLEPeerAnnounceUpdate: Equatable {
@@ -119,15 +117,6 @@ struct BLEPeerRegistry {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
}
/// A rendezvous cell advertised by any bridge-capable peer, if one is
/// known lets location-less devices join the island's rendezvous.
func advertisedBridgeGeohash() -> String? {
peers.values
.filter { $0.capabilities.contains(.bridge) }
.compactMap(\.bridgeGeohash)
.first
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
@@ -152,6 +141,14 @@ struct BLEPeerRegistry {
}
}
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
} || selfNickname == info.nickname
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
}
mutating func markDisconnected(_ peerID: PeerID) {
guard var info = peers[peerID] else { return }
info.isConnected = false
@@ -171,8 +168,7 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date,
capabilities: PeerCapabilities = [],
bridgeGeohash: String? = nil
capabilities: PeerCapabilities = []
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate(
@@ -189,8 +185,7 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now,
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
capabilities: capabilities
)
return update
@@ -51,16 +51,16 @@ struct BLEReceivePipeline {
// Courier envelopes are directed opaque ciphertext like DMs; a
// remote handover toward a relayed announce rides this same
// deterministic relay treatment instead of the broadcast clamp.
// Ping/pong diagnostics ride it too: probes need the same
// deterministic multi-hop relay as DMs (always relay, jitter,
// 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.
// Ping/pong diagnostics also ride it: probes need the same
// deterministic multi-hop relay as DMs (always relay, jitter,
// no TTL cap) so RTT and hop counts reflect the real path.
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|| packet.type == MessageType.courierEnvelope.rawValue
|| packet.type == MessageType.nostrCarrier.rawValue
|| packet.type == MessageType.ping.rawValue
|| packet.type == MessageType.pong.rawValue
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|| packet.type == MessageType.pong.rawValue) && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
@@ -70,7 +70,6 @@ struct BLEReceivePipeline {
// announce-class TTL headroom so alerts travel the extra hop.
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
&& BoardWire.urgentFlag(in: packet.payload),
isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue,
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
@@ -1,53 +0,0 @@
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) }
}
}
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,27 @@ import Foundation
/// 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.
enum BLESourceRouteOriginationPolicy {
/// Returns the intermediate-hop route to attach, or nil to keep the
/// current flood/direct-write behavior unchanged.
/// Why a packet kept flood/direct-write behavior instead of routing.
/// The `rawValue` doubles as the greppable `[ROUTE]` log reason.
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:
/// - we authored the packet (relays must not rewrite and re-sign someone
@@ -19,22 +38,22 @@ enum BLESourceRouteOriginationPolicy {
/// - routing to the recipient is not suppressed by a recent unconfirmed
/// routed send, and
/// - the topology yields a complete path.
static func route(
static func decide(
for packet: BitchatPacket,
to recipient: PeerID,
localPeerIDData: Data,
isRecipientConnected: (PeerID) -> Bool,
shouldAttemptRoute: (PeerID) -> Bool,
computeRoute: (PeerID) -> [Data]?
) -> [Data]? {
guard packet.senderID == localPeerIDData else { return nil }
) -> Decision {
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
guard let recipientData = packet.recipientID,
recipientData.count == 8,
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
guard packet.ttl > 1 else { return nil }
guard !isRecipientConnected(recipient) else { return nil }
guard shouldAttemptRoute(recipient) else { return nil }
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
return route
!recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) }
guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) }
guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) }
guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) }
guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) }
return .route(route)
}
}
@@ -1,160 +0,0 @@
//
// 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) + ""
}
}
+11 -42
View File
@@ -19,27 +19,18 @@ final class BoardManager: ObservableObject {
@Published private(set) var posts: [BoardPostPacket] = []
private let transport: Transport
/// 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, _ urgent: Bool) -> String?
/// Requests NIP-09 deletion of a previously bridged note.
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
/// 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 let store: BoardStore
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
private var cancellable: AnyCancellable?
init(
transport: Transport,
store: BoardStore = .shared,
publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil,
deleteFromNostr: ((String, String) -> Void)? = nil
publishToNostr: ((String, String, String) -> Void)? = nil
) {
self.transport = transport
self.store = store
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
cancellable = store.$postsSnapshot
.receive(on: DispatchQueue.main)
.sink { [weak self] snapshot in
@@ -124,10 +115,10 @@ final class BoardManager: ObservableObject {
)
transport.sendBoardPayload(BoardWire.post(post).encode())
// Nostr bridge: geohash posts also go out as kind-1 location notes so
// online users see them. Remember the event id for merged deletes.
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) {
bridgedEventIDs[postID] = eventID
// One-way Nostr bridge (v1): geohash posts also go out as kind-1
// location notes so online users see them. No inbound merge yet.
if !geohash.isEmpty {
publishToNostr(trimmed, geohash, cleanNickname)
}
return true
}
@@ -149,20 +140,14 @@ final class BoardManager: ObservableObject {
signature: signature
)
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
}
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64, urgent: Bool) -> String? {
private static func livePublishToNostr(content: String, geohash: String, nickname: String) {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
return nil
return
}
do {
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
@@ -170,27 +155,11 @@ final class BoardManager: ObservableObject {
content: content,
geohash: geohash,
senderIdentity: identity,
nickname: nickname,
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000),
urgent: urgent
nickname: nickname
)
NostrRelayManager.shared.sendEvent(event, to: relays)
return event.id
} catch {
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)
}
}
}
+8 -18
View File
@@ -78,16 +78,6 @@ final class BoardStore {
/// Live posts, published on the main thread for the board UI.
@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 tombstones: [StoredTombstone] = []
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
@@ -114,11 +104,6 @@ final class BoardStore {
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
if result == .accepted {
persistLocked()
if case .post(let post) = wire {
DispatchQueue.main.async { [weak self] in
self?.postArrivals.send(post)
}
}
}
return result
}
@@ -146,6 +131,14 @@ final class BoardStore {
// MARK: - Maintenance
func pruneExpired() {
let nowMs = currentMs()
queue.sync {
pruneExpiredLocked(nowMs: nowMs)
persistLocked()
}
}
/// Panic wipe: drop all board data from memory and disk.
func wipe() {
queue.sync {
@@ -156,9 +149,6 @@ final class BoardStore {
}
publishSnapshotLocked()
}
DispatchQueue.main.async { [weak self] in
self?.didWipe.send()
}
}
// MARK: - Internals (call only on `queue`)
@@ -1,95 +0,0 @@
//
// 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
/// When the notice fades (board expiry or a note's NIP-40 tag, as dead
/// drops carry). Nil means it only ages out of the relay window.
let expiresAt: Date?
let source: Source
var isBoardPost: Bool {
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
expiresAt = post.expiresAt > 0
? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000)
: nil
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 = note.isUrgent
expiresAt = note.expiresAt
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
}
}
}
-29
View File
@@ -146,8 +146,6 @@ final class CommandProcessor {
return handleTrace(args)
case "/pay":
return handlePay(args)
case "/drop":
return handleDrop(args)
case "/help":
return .success(message: Self.helpText)
default:
@@ -173,36 +171,9 @@ final class CommandProcessor {
/ping @name measure round-trip time (mesh only)
/trace @name estimated mesh path (mesh only)
/pay <token> send a cashu ecash token in this chat
/drop <message> pin a note to this place for 24h (needs location)
/help this list
"""
/// /drop <text> a dead drop: pins a note to the current building-level
/// geohash with a 24h NIP-40 expiry. Anyone who passes through here and
/// looks at notices (or hits the empty-timeline "notes left here" hint)
/// reads it.
private func handleDrop(_ args: String) -> CommandResult {
guard LocationNotesSettings.enabled else {
return .error(message: "location notes are off — enable them in the info screen")
}
guard let content = args.trimmedOrNilIfEmpty else {
return .error(message: "usage: /drop <message>")
}
let location = LocationChannelManager.shared
guard location.permissionState == .authorized else {
return .error(message: "leaving a note needs location — enable it in the info screen")
}
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
location.refreshChannels()
return .error(message: "still finding this place — try again in a moment")
}
guard let nickname = contextProvider?.nickname,
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
return .error(message: "no geo relays reachable — note not left")
}
return .success(message: "📍 note left here — it fades in 24h")
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
+8 -29
View File
@@ -42,8 +42,6 @@ final class CourierStore {
var sprayedTo: Set<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
/// Last publish of this envelope as a bridge courier drop on relays.
var lastBridgePublishAt: Date?
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
let prekeyID: UInt32?
@@ -61,7 +59,6 @@ final class CourierStore {
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil,
lastBridgePublishAt: Date? = nil,
prekeyID: UInt32? = nil
) {
self.recipientTag = recipientTag
@@ -73,7 +70,6 @@ final class CourierStore {
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
self.lastBridgePublishAt = lastBridgePublishAt
self.prekeyID = prekeyID
}
@@ -90,7 +86,6 @@ final class CourierStore {
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt)
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
}
}
@@ -247,30 +242,6 @@ final class CourierStore {
}
}
/// Envelopes to park on relays as bridge courier drops. Non-destructive
/// like remote handover the relay copy is speculative, so the carried
/// copy stays until direct handover or expiry. The per-envelope cooldown
/// keeps relay churn from republishing the same mail; publishing relays
/// dedup identical events by ID anyway.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
return queue.sync {
pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = []
for index in envelopes.indices {
if let last = envelopes[index].lastBridgePublishAt,
date.timeIntervalSince(last) < cooldown {
continue
}
envelopes[index].lastBridgePublishAt = date
// The relay copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
// MARK: - Spray-and-wait (on encountering another courier)
/// Envelopes to re-deposit with a courier we just encountered, each with
@@ -301,6 +272,14 @@ final class CourierStore {
// MARK: - Maintenance
func pruneExpired() {
let date = now()
queue.sync {
pruneExpiredLocked(at: date)
persistLocked()
}
}
/// Panic wipe: drop all carried mail from memory and disk.
func wipe() {
queue.sync {
@@ -58,6 +58,12 @@ final class StoreAndForwardMetrics {
SecureLogger.debug("📊 S&F \(event.rawValue)\(total)", category: .session)
}
func snapshot() -> [String: Int] {
lock.lock()
defer { lock.unlock() }
return counts
}
/// Included in the panic wipe alongside the stores it describes.
func reset() {
lock.lock()
@@ -34,7 +34,23 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService()
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
/// Default keychain for the `shared` singleton. Under test this is an
/// in-memory keychain so touching `shared` never blocks on securityd
/// (`SecItemCopyMatching` can hang in test environments) and never reads
/// or writes the developer's real keychain. Production behavior is
/// unchanged. Tests that need their own instance keep injecting a mock
/// via `init(keychain:)`.
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
// PreviewKeychainManager lives in _PreviewHelpers, a development
// asset excluded from archive builds release code must not
// reference it. Tests always run Debug, so the guard is lossless.
#if DEBUG
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
#endif
return KeychainManager()
}
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
self.keychain = keychain
loadFavorites()
@@ -1,35 +0,0 @@
//
// BoundedIDSet.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
/// evicted when full. Shared by the gateway and bridge loop-prevention
/// caches.
struct BoundedIDSet {
private var members: Set<String> = []
private var order: [String] = []
let capacity: Int
init(capacity: Int) {
self.capacity = capacity
}
func contains(_ id: String) -> Bool {
members.contains(id)
}
/// Returns false when the ID was already present.
@discardableResult
mutating func insert(_ id: String) -> Bool {
guard members.insert(id).inserted else { return false }
order.append(id)
if order.count > capacity {
members.remove(order.removeFirst())
}
return true
}
}
@@ -1,284 +0,0 @@
//
// BridgeCourierService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Foundation
import Security
/// Courier delivery over the internet bridge: sealed courier envelopes are
/// parked on relays as kind-1401 "drops" tagged with their rotating
/// recipient tag, so delivery stops requiring a physical courier to bump
/// into the recipient.
///
/// Three duties, all gated on the bridge toggle:
/// - Sender: when the message router seals mail for an unreachable peer, a
/// copy is published as a drop (queued until relays connect). The drop is
/// signed with a fresh throwaway key per publish the envelope
/// authenticates its sender internally via Noise-X, and a stable publisher
/// key would leak courier traffic patterns to relays.
/// - Recipient: subscribes for its own candidate tags (adjacent UTC days)
/// and opens matching drops directly.
/// - Gateway (bridge + gateway toggles): additionally watches the tags of
/// verified local mesh peers and hands matching drops to them as directed
/// courier packets, so mesh-only recipients are served too.
///
/// Privacy: a drop reveals to relays only that "someone" is messaging "some
/// 16-byte day-rotating tag". Only parties who already know the recipient's
/// Noise static key can compute the tag; the payload is an opaque Noise-X
/// seal. Duplicate deliveries (drop + physical courier + direct link) are
/// absorbed downstream by message-ID dedup.
@MainActor
final class BridgeCourierService: ObservableObject {
enum Limits {
/// Drops waiting for relay connectivity (bounded, drop-oldest).
static let maxPendingDrops = 20
/// Republish cooldown for gateway-held envelopes.
static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60
/// Local peers a gateway watches drops for (x3 candidate tags each).
static let maxWatchedPeers = 16
/// Tag-set refresh cadence (also covers UTC day rollover).
static let refreshIntervalSeconds: TimeInterval = 30 * 60
/// Minimum spacing for announce-driven refreshes.
static let announceRefreshDebounceSeconds: TimeInterval = 60
/// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack).
static let maxDropEnvelopeBytes = 20 * 1024
static let maxTrackedIDs = 512
}
static let shared = BridgeCourierService()
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
var bridgeEnabled: (@MainActor () -> Bool)?
var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event to the default (DM) relays.
var publishEvent: (@MainActor (NostrEvent) -> Void)?
/// (Re)opens the drop subscription for the given hex tags.
var openSubscription: (@MainActor ([String]) -> Void)?
var closeSubscription: (@MainActor () -> Void)?
/// Our own Noise static public key.
var myNoiseKey: (@MainActor () -> Data?)?
/// Verified reachable local peers with known Noise keys.
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
/// Seals content into a carry-only envelope for a recipient key.
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
/// Opens a drop addressed to us (tag verified inside).
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
/// Hands a drop to a matching local peer as a directed courier packet.
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)?
/// Held envelopes eligible for (re)publish, honoring the cooldown.
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
/// Timer injection for tests; nil arms a real `Task`.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
// MARK: State
private(set) var myTagsHex: Set<String> = []
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
/// Message IDs already published as drops (sender-side dedup).
private var publishedDropKeys: BoundedIDSet
/// Drop event IDs already handled (multi-relay dedup).
private var seenDropEventIDs: BoundedIDSet
private var subscriptionOpen = false
private var lastSubscribedTags: Set<String> = []
private var refreshTimerArmed = false
private var lastAnnounceRefresh = Date.distantPast
private let now: () -> Date
init(now: @escaping () -> Date = Date.init) {
self.now = now
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs)
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs)
}
// MARK: - Sender role
/// Parallel-deposit a sealed copy of an outbound private message as a
/// relay drop. Called by the message router alongside physical courier
/// deposits; idempotent per message ID. Returns true when a fresh drop
/// was sealed (published now or queued for the next relay connection)
/// the router marks the message "carried" so the sender sees progress.
@discardableResult
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
guard bridgeEnabled?() ?? false else { return false }
guard !publishedDropKeys.contains(messageID) else { return false }
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
publishedDropKeys.insert(messageID)
publishDrop(envelope)
return true
}
/// Publishes held envelopes (mail we carry for others) as drops,
/// honoring the per-envelope cooldown.
func publishHeldEnvelopes() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
publishDrop(envelope)
}
}
private func publishDrop(_ envelope: CourierEnvelope) {
guard let encoded = envelope.encode(),
encoded.count <= Limits.maxDropEnvelopeBytes,
!envelope.isExpired else { return }
guard relaysConnected?() ?? false else {
pendingDrops.append((envelope, nil))
if pendingDrops.count > Limits.maxPendingDrops {
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops)
}
return
}
guard let identity = Self.makeThrowawayIdentity(),
let event = try? NostrProtocol.createCourierDropEvent(
envelope: encoded,
recipientTagHex: envelope.recipientTag.hexEncodedString(),
expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000),
senderIdentity: identity
) else {
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
return
}
publishEvent?(event)
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
}
/// Drops queued while relays were unreachable publish on reconnect.
func flushPendingDrops() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
let queued = pendingDrops
pendingDrops.removeAll()
for item in queued {
publishDrop(item.envelope)
}
}
// MARK: - Subscription (recipient + gateway watch)
/// Recomputes the watched tag set and (re)opens the subscription.
/// Call on toggle changes, relay connectivity changes, and periodically
/// (tags rotate daily); idempotent.
func refresh() {
armRefreshTimerIfNeeded()
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
}
return
}
let date = now()
if let myKey = myNoiseKey?() {
myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() })
} else {
myTagsHex = []
}
// While bridging with internet, every device watches drops for its
// verified local peers the single-switch analogue of gateway duty.
let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers)
watchedPeerTags = peers.map { peer in
(peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() }))
}
let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex))
guard !allTags.isEmpty else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
lastSubscribedTags = []
}
return
}
// Resubscribe only when the watched set actually changed refresh
// fires on every verified announce (field logs showed the drop
// subscription rebuilt every ~60s for an unchanged tag set).
if !subscriptionOpen || allTags != lastSubscribedTags {
openSubscription?(allTags.sorted())
subscriptionOpen = true
lastSubscribedTags = allTags
}
flushPendingDrops()
publishHeldEnvelopes()
}
/// Announce-driven refresh, debounced a newly verified peer should be
/// watched promptly, but announce storms must not thrash subscriptions.
func refreshAfterVerifiedAnnounce() {
guard bridgeEnabled?() ?? false else { return }
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
lastAnnounceRefresh = now()
refresh()
}
private func armRefreshTimerIfNeeded() {
guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return }
refreshTimerArmed = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.refreshTimerArmed = false
self.refresh()
}
if let scheduleTimer {
scheduleTimer(Limits.refreshIntervalSeconds, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000))
fire()
}
}
}
// MARK: - Inbound drops
/// Entry point for every drop event the subscription delivers (the relay
/// manager has already verified the event signature).
func handleDropEvent(_ event: NostrEvent) {
guard bridgeEnabled?() ?? false else { return }
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
guard seenDropEventIDs.insert(event.id) else { return }
guard let data = Data(base64Encoded: event.content),
data.count <= Limits.maxDropEnvelopeBytes,
let envelope = CourierEnvelope.decode(data),
!envelope.isExpired else {
return
}
let tagHex = envelope.recipientTag.hexEncodedString()
// The envelope's own tag must match the event's filterable tag
// otherwise a mislabeled drop could ride a subscription it doesn't
// belong to.
guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return }
if myTagsHex.contains(tagHex) {
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
openEnvelope?(envelope)
return
}
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))", category: .session)
deliverToPeer?(envelope, match.peerID)
}
}
// MARK: - Helpers
/// A fresh random Nostr identity for signing one drop.
static func makeThrowawayIdentity() -> NostrIdentity? {
for _ in 0..<10 {
var bytes = Data(count: 32)
let status = bytes.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
guard status == errSecSuccess else { return nil }
if let identity = try? NostrIdentity(privateKeyData: bytes) {
return identity
}
}
return nil
}
}
@@ -1,689 +0,0 @@
//
// BridgeService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Combine
import Foundation
/// Policy engine for the mesh bridge: an opt-in stitcher of disjoint BLE
/// mesh islands that share a place. While the toggle is on, this device's
/// public mesh messages are additionally signed (with a derived, unlinkable
/// per-cell Nostr identity) as rendezvous events for the local geohash cell
/// and published to the cell's deterministic geo relays directly when we
/// have internet, or deposited with a bridge gateway peer over a directed
/// `toBridge` carrier when we are mesh-only. Inbound rendezvous events from
/// other islands render into the mesh timeline marked as bridged.
///
/// A device with BOTH this toggle and the gateway toggle on serves the
/// island: it accepts `toBridge` deposits, publishes them, and rebroadcasts
/// remote rendezvous events onto the mesh as `fromBridge` carriers so
/// mesh-only peers see across the bridge too.
///
/// Consent model:
/// - Nothing crosses a bridge unless its author signed it for the bridge:
/// gateways carry only finished, Schnorr-signed rendezvous events, so a
/// neighbor's gateway cannot exfiltrate radio-only traffic. The per-message
/// "nearby only" flag simply skips composing a rendezvous copy.
/// - Receiving over radio (`fromBridge` carriers) is always on it is
/// passive and leaks nothing. Subscribing over the internet (which reveals
/// the coarse cell to relays) and publishing both require the toggle.
///
/// Loop-prevention rules (adapted from `GatewayService`, unit-tested):
/// 1. An event learned from a `fromBridge` mesh broadcast is never published
/// and never rebroadcast (`meshBroadcastEventIDs`) a second bridge
/// gateway on the same island cannot echo mesh-carried traffic.
/// 2. An event this device published (own messages or uplinked deposits,
/// `publishedEventIDs`) is never downlink-rebroadcast: it originated on
/// this island, so our own relay subscription redelivering it must not
/// double BLE airtime.
/// 3. A subscription event is rebroadcast at most once
/// (`rebroadcastEventIDs`, marked after send), and never when the island
/// already holds the radio copy (`isMessageSeenLocally` on the event's
/// mesh message ID) remote islands' traffic is the only thing worth
/// airtime.
/// Receivers additionally dedup by timeline message ID: a bridged copy
/// carries the original mesh message ID in its `m` tag, so the store's
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order.
///
/// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays, radios, or CoreLocation.
@MainActor
final class BridgeService: ObservableObject {
enum Limits {
/// Uplink deposits held while relays are unreachable.
static let maxQueuedUplinks = 20
static let maxQueuedUplinksPerDepositor = 5
/// Uplink deposits accepted per depositor per minute.
static let uplinkEventsPerMinutePerDepositor = 10
/// Downlink mesh rebroadcasts per minute BLE airtime is precious,
/// and bridge traffic shares the radio with everything else.
static let downlinkEventsPerMinute = 20
static let maxPendingDownlinks = 30
/// Accepted clock skew for a rendezvous event.
static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512
/// Presence heartbeat cadence while the bridge is active.
static let presenceIntervalSeconds: TimeInterval = 4 * 60
/// A rendezvous participant counts toward "via bridge" for this long
/// after their last event.
static let participantFreshnessSeconds: TimeInterval = 10 * 60
/// Content cap, matching the public-message pipeline's own limit.
static let maxContentBytes = 16_000
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
static let cellPrecision = 6
}
struct QueuedUplink {
let depositor: PeerID
let cell: String
let event: NostrEvent
}
/// A validated rendezvous message ready for the timeline.
struct InboundBridgeMessage {
let messageID: String
let senderNickname: String
let senderPubkey: String
let content: String
let timestamp: Date
}
/// A person currently visible across the bridge (fresh, not attributed
/// to the local island), for the people sheet.
struct BridgedParticipant: Identifiable, Equatable {
let pubkey: String
let nickname: String?
let lastSeen: Date
var id: String { pubkey }
/// Geohash-chat convention: nickname#last-4-of-pubkey, so two remote
/// "anon"s stay distinguishable.
var displayName: String {
(nickname?.trimmedOrNilIfEmpty ?? "anon") + "#" + String(pubkey.suffix(4))
}
}
static let shared = BridgeService()
/// The user toggle. While true this device publishes its own public mesh
/// messages to the rendezvous and subscribes to it when online.
@Published private(set) var isEnabled: Bool
/// Distinct remote rendezvous participants seen within the freshness
/// window. Approximate by design: local participants are subtracted by
/// matching their events' mesh message IDs against the local timeline,
/// which cannot attribute silent (presence-only) local peers.
@Published private(set) var bridgedPeerCount: Int = 0
/// The people behind the count, newest activity first.
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
/// The rendezvous cell currently in use, when the bridge is active.
@Published private(set) var activeCell: String?
/// Per-session compose flag: while true, outgoing messages stay on the
/// radio no rendezvous copy is composed, so no gateway can carry them.
@Published var nearbyOnly: Bool = false
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
/// Publishes a signed event to the geo relays for a cell.
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
/// Opens the rendezvous subscription for (cell + neighbors); events are
/// fed back via `handleRendezvousEvent`.
var openSubscription: (@MainActor ([String]) -> Void)?
/// Closes the rendezvous subscription.
var closeSubscription: (@MainActor () -> Void)?
/// Whether any Nostr relay connection is currently working.
var relaysConnected: (@MainActor () -> Bool)?
/// The local neighborhood cell from CoreLocation, if permitted.
var locationCell: (@MainActor () -> String?)?
/// Asks the location layer for a fresh one-shot fix. The bridge must
/// pump location itself: channel data otherwise only flows while some
/// other feature (channels sheet, location notes) happens to be active
/// a field failure mode where the bridge silently never got a cell.
var requestLocationFix: (@MainActor () -> Void)?
/// A rendezvous cell advertised by a reachable mesh bridge peer's
/// announce lets a mesh-only, location-less device still compose
/// correctly tagged events.
var meshAdvertisedCell: (@MainActor () -> String?)?
/// Sends an encoded `toBridge` carrier directed to a bridge peer.
var sendToBridgePeer: (@MainActor (Data, PeerID) -> Bool)?
/// Reachable mesh peers advertising the `.bridge` capability.
var availableBridgePeers: (@MainActor () -> [PeerID])?
/// Broadcasts an encoded `fromBridge` carrier on the mesh.
var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Delivers a validated inbound bridge message to the mesh timeline.
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
/// True when the mesh timeline already holds this message ID (the radio
/// copy) used to skip pointless downlink airtime.
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
/// Derives the unlinkable per-cell rendezvous identity.
var deriveIdentity: (@MainActor (String) throws -> NostrIdentity)?
/// Local nickname for the `n` tag.
var myNickname: (@MainActor () -> String)?
/// Fired on toggle changes (advertise/withdraw `.bridge` + re-announce).
var onEnabledChanged: (@MainActor (Bool) -> Void)?
/// Fired when the active rendezvous cell changes (including to nil) so
/// the announce advertisement stays current.
var onActiveCellChanged: (@MainActor (String?) -> Void)?
/// Schedules a closure after a delay; nil arms a real `Task`. Injected so
/// timers are deterministic in tests.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
// MARK: State
/// Loop rule 1: event IDs seen in `fromBridge` mesh broadcasts.
private var meshBroadcastEventIDs: BoundedIDSet
/// Loop rule 2: event IDs this device published (own or deposited).
private var publishedEventIDs: BoundedIDSet
/// Loop rule 3: event IDs this device already rebroadcast.
private var rebroadcastEventIDs: BoundedIDSet
/// Timeline message IDs already injected (either arrival path).
private var injectedMessageIDs: BoundedIDSet
/// Cells the rendezvous subscription covers (own + neighbor ring).
private(set) var subscribedCells: Set<String> = []
private(set) var queuedUplinks: [QueuedUplink] = []
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
private var downlinkSendTimes: [Date] = []
private var pendingDownlinks: [(event: NostrEvent, cell: String)] = []
private var downlinkDrainScheduled = false
private var presenceTimerArmed = false
private var lastPresenceAt = Date.distantPast
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname).
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
private let defaults: UserDefaults
private let now: () -> Date
private static let enabledKey = "bridge.userEnabled"
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults
self.now = now
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
}
// MARK: - Toggle & lifecycle
func setEnabled(_ enabled: Bool) {
guard enabled != isEnabled else { return }
isEnabled = enabled
defaults.set(enabled, forKey: Self.enabledKey)
if !enabled {
queuedUplinks.removeAll()
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
participants.removeAll()
bridgedPeerCount = 0
bridgedParticipants = []
}
SecureLogger.info("🌉 Bridge mode \(enabled ? "enabled" : "disabled")", category: .session)
refreshRendezvous()
onEnabledChanged?(enabled)
}
/// Recomputes the active cell and (re)opens or closes the subscription.
/// Call on toggle changes, location updates, and relay connectivity
/// changes; idempotent.
func refreshRendezvous() {
let cell = isEnabled ? currentCell() : nil
// No cell yet: ask for a fix the availableChannels change re-enters
// here once it lands.
if isEnabled, cell == nil {
requestLocationFix?()
}
guard cell != activeCell else {
// The maintenance timer must run even cell-less: it is what
// retries the location fix (launch races the permission
// callback, so the first request can silently no-op).
if isEnabled { armPresenceTimerIfNeeded() }
return
}
if activeCell != nil {
closeSubscription?()
subscribedCells = []
}
activeCell = cell
onActiveCellChanged?(cell)
guard let cell else {
if isEnabled { armPresenceTimerIfNeeded() }
return
}
// Own cell + neighbors: islands straddling a cell edge still meet.
// Publishes go to the own cell only; symmetric because both sides
// subscribe to each other's cell via the neighbor ring.
let cells = [cell] + Geohash.neighbors(of: cell)
subscribedCells = Set(cells)
openSubscription?(cells)
SecureLogger.info("🌉 Bridge: rendezvous open for cell \(cell)", category: .session)
publishPresence()
armPresenceTimerIfNeeded()
}
/// The rendezvous cell: our own location when we have it, else the cell
/// a reachable bridge gateway advertises in its announce.
private func currentCell() -> String? {
if let own = locationCell?(), !own.isEmpty {
return String(own.prefix(Limits.cellPrecision))
}
if let advertised = meshAdvertisedCell?(), GatewayService.isValidGeohash(advertised) {
return String(advertised.prefix(Limits.cellPrecision))
}
return nil
}
// One switch does the right thing: while bridging, a device with
// internet automatically serves its island (accepts deposits, carries
// remote messages onto the radio). The marginal cost over bridging
// yourself is small the relay connections and subscription already
// exist for you and a separate "serve others" lever proved to be a
// silent trap for mesh-only neighbors.
// MARK: - Outgoing (sender role)
/// Composes and ships the bridged copy of an outgoing public mesh
/// message. Call after the radio send; no-op when the bridge is off,
/// no cell is known, or the message was flagged nearby-only upstream.
func bridgeOutgoing(content: String, messageID: String) {
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
guard content.utf8.count <= Limits.maxContentBytes else { return }
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgeMeshEvent(
content: content,
cell: cell,
senderIdentity: identity,
nickname: myNickname?(),
meshMessageID: messageID
) else {
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
return
}
publishedEventIDs.insert(event.id)
injectedMessageIDs.insert(messageID) // our own timeline already has it
if relaysConnected?() ?? false {
publishToRelays?(event, cell)
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
let payload = carrier.encode(),
let gateway = availableBridgePeers?().first {
if sendToBridgePeer?(payload, gateway) ?? false {
SecureLogger.debug("🌉 Bridge: uplinked own event via gateway \(gateway.id.prefix(8))", category: .session)
}
}
}
/// Publishes a presence heartbeat so silent participants still register
/// across the bridge. Throttled: several triggers (enable, cell change,
/// relay reconnect) can coincide, and same-second heartbeats are
/// byte-identical events anyway.
func publishPresence() {
guard isEnabled, let cell = activeCell, relaysConnected?() ?? false else { return }
guard now().timeIntervalSince(lastPresenceAt) >= 30 else { return }
lastPresenceAt = now()
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: identity) else { return }
publishedEventIDs.insert(event.id)
publishToRelays?(event, cell)
}
/// Maintenance heartbeat while bridging: presence, participant pruning,
/// and a location retry. Runs with or without a cell the cell-less
/// case is exactly when the location retry matters.
private func armPresenceTimerIfNeeded() {
guard isEnabled, !presenceTimerArmed else { return }
presenceTimerArmed = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.presenceTimerArmed = false
self.publishPresence()
self.pruneParticipants()
// Location refresh: migrates cells on a moving device and
// recovers a launch that raced the permission callback.
if self.activeCell == nil {
self.refreshRendezvous()
} else {
self.requestLocationFix?()
}
self.armPresenceTimerIfNeeded()
}
if let scheduleTimer {
scheduleTimer(Limits.presenceIntervalSeconds, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Limits.presenceIntervalSeconds * 1_000_000_000))
fire()
}
}
}
// MARK: - Subscription ingress (internet role)
/// Entry point for every event the rendezvous subscription delivers.
/// Handles presence accounting, timeline injection, and when acting as
/// the island's gateway downlink rebroadcast.
func handleRendezvousEvent(_ event: NostrEvent) {
guard isEnabled else { return }
// The subscription spans our cell + neighbors; trust only the
// event's own signed `r` tag, and only within that ring.
guard let cell = event.tags.first(where: { $0.count >= 2 && $0[0] == "r" })?[1],
subscribedCells.contains(cell) else {
return
}
guard let kind = classify(event, cell: cell) else { return }
// Events we published come back from our own subscription; they are
// presence-neutral (we never count ourselves) and never re-injected
// or rebroadcast. Two layers: the published-ID cache (this session)
// and pubkey self-recognition the rendezvous identity is derived
// deterministically, so even after a relaunch wipes the cache our
// own relay-backfilled events are recognized (field bug: own
// pre-restart messages re-rendered as bridged).
guard !publishedEventIDs.contains(event.id) else { return }
if isOwnRendezvousEvent(event, cell: cell) {
publishedEventIDs.insert(event.id) // never downlink it either
return
}
guard event.isValidSignature() else { return }
switch kind {
case .presence:
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
case .message(let message):
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
if isLocalRadioCopy {
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
}
recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname)
inject(message)
// Serving duty: carry remote islands' messages onto the radio for
// mesh-only peers. Local-origin events are skipped the island
// already heard them (loop rule 3). The drain is jitter-delayed:
// with every online bridger serving, the holdoff lets gateways
// hear each other's broadcasts and skip duplicates.
if !isLocalRadioCopy,
!meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id),
!pendingDownlinks.contains(where: { $0.event.id == event.id }) {
pendingDownlinks.append((event, cell))
if pendingDownlinks.count > Limits.maxPendingDownlinks {
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
}
scheduleDownlinkDrainIfNeeded(jitter: true)
}
}
}
// MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets with bridge
/// directions. `directedToUs` is true for `toBridge` deposits addressed
/// to this device; false for `fromBridge` broadcasts.
func handleMeshCarrier(_ carrier: NostrCarrierPacket, from peerID: PeerID, directedToUs: Bool) {
switch carrier.direction {
case .toBridge:
guard directedToUs else { return }
handleUplinkDeposit(carrier, from: peerID)
case .fromBridge:
guard !directedToUs else { return }
handleDownlinkBroadcast(carrier)
case .toGateway, .fromGateway:
return // GatewayService territory; routed there by the caller.
}
}
// MARK: - Uplink (gateway role: mesh peer -> internet)
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
guard isEnabled else { return }
// Cheap structural gates before any crypto, mirroring GatewayService.
guard let event = structurallyValidEvent(from: carrier) else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
return
}
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
return
}
guard allowUplinkDeposit(from: depositor) else {
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))", category: .session)
return
}
guard event.isValidSignature() else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
return
}
if relaysConnected?() ?? false {
publish(event, cell: carrier.geohash)
} else {
enqueueUplink(QueuedUplink(depositor: depositor, cell: carrier.geohash, event: event))
}
// No local injection: the depositor's radio broadcast already carried
// the message to this island, including us.
}
/// Publish everything queued while relays were unreachable.
func flushQueuedUplinks() {
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
let queued = queuedUplinks
queuedUplinks.removeAll()
for item in queued where !publishedEventIDs.contains(item.event.id) {
publish(item.event, cell: item.cell)
}
}
private func publish(_ event: NostrEvent, cell: String) {
publishedEventIDs.insert(event.id)
publishToRelays?(event, cell)
SecureLogger.info("🌉 Bridge: published carried event \(event.id.prefix(8))… for cell \(cell)", category: .session)
}
@discardableResult
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { return false }
if queuedUplinks.count >= Limits.maxQueuedUplinks {
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
}
queuedUplinks.append(item)
return true
}
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
let cutoff = now().addingTimeInterval(-60)
var times = uplinkDepositTimes[depositor, default: []]
times.removeAll { $0 < cutoff }
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
uplinkDepositTimes[depositor] = times
return false
}
times.append(now())
uplinkDepositTimes[depositor] = times
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
uplinkDepositTimes = uplinkDepositTimes.filter { $0.value.contains { $0 >= cutoff } }
}
return true
}
// MARK: - Downlink (gateway role: internet -> mesh)
private func drainPendingDownlinks() {
let cutoff = now().addingTimeInterval(-60)
downlinkSendTimes.removeAll { $0 < cutoff }
while !pendingDownlinks.isEmpty,
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
let (event, cell) = pendingDownlinks.removeFirst()
guard isFresh(event) else { continue }
// Suppression recheck at send time: another gateway may have
// broadcast this event during our jitter holdoff.
guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id) else { continue }
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
let payload = carrier.encode() else { continue }
broadcastToMesh?(payload)
SecureLogger.debug("🌉 Bridge: downlinked remote event \(event.id.prefix(8))… onto the mesh", category: .session)
// Mark-after-send (loop rule 3): a queue-overflow drop stays
// retryable on relay redelivery.
rebroadcastEventIDs.insert(event.id)
downlinkSendTimes.append(now())
}
scheduleDownlinkDrainIfNeeded()
}
private func scheduleDownlinkDrainIfNeeded(jitter: Bool = false) {
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
let delay: TimeInterval
if jitter {
// Multi-gateway suppression window: enough spread for another
// gateway's broadcast to land and mark the event mesh-carried.
delay = Double.random(in: 0.2...1.5)
} else {
let oldest = downlinkSendTimes.min() ?? now()
delay = max(0.05, 60 - now().timeIntervalSince(oldest))
}
downlinkDrainScheduled = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.downlinkDrainScheduled = false
self.drainPendingDownlinks()
}
if let scheduleTimer {
scheduleTimer(delay, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
fire()
}
}
}
// MARK: - Downlink (receiver role: carried event arrives over radio)
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
// Reception is deliberately NOT gated on the toggle: it is passive
// radio, and two phones side by side should not disagree about what
// the channel said. Publishing/subscribing remain opt-in.
guard let event = structurallyValidEvent(from: carrier),
!publishedEventIDs.contains(event.id),
!isOwnRendezvousEvent(event, cell: carrier.geohash),
event.isValidSignature() else {
return
}
// Mark after verification (a forged copy must not poison the cache),
// and use the marking as multi-path dedup.
guard meshBroadcastEventIDs.insert(event.id) else { return }
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
return
}
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
inject(message)
}
// MARK: - Injection & participants
private func inject(_ message: InboundBridgeMessage) {
guard injectedMessageIDs.insert(message.messageID) else { return }
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
injectInbound?(message)
}
private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
let previous = participants[pubkey]
// Local attribution is sticky: one radio-confirmed message marks the
// pubkey as an islander for as long as they stay fresh. Presence
// events carry no nickname, so a known name is never forgotten.
participants[pubkey] = (
lastSeen: now(),
isLocal: (previous?.isLocal ?? false) || isLocal,
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
)
recomputeBridgedCount()
}
private func pruneParticipants() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
participants = participants.filter { $0.value.lastSeen >= cutoff }
recomputeBridgedCount()
}
private func recomputeBridgedCount() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
let visible = participants
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
.sorted { $0.lastSeen > $1.lastSeen }
if visible.count != bridgedPeerCount {
bridgedPeerCount = visible.count
}
if visible != bridgedParticipants {
bridgedParticipants = visible
}
}
// MARK: - Validation
private enum RendezvousKind {
case message(InboundBridgeMessage)
case presence
}
/// Classifies a structurally acceptable rendezvous event; nil rejects.
private func classify(_ event: NostrEvent, cell: String) -> RendezvousKind? {
guard isFresh(event),
event.tags.contains(where: { $0.count >= 2 && $0[0] == "r" && $0[1] == cell }),
GatewayService.isValidGeohash(cell) else {
return nil
}
switch event.kind {
case NostrProtocol.EventKind.geohashPresence.rawValue:
return .presence
case NostrProtocol.EventKind.ephemeralEvent.rawValue:
let content = event.content
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1]
let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1]
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id
return .message(InboundBridgeMessage(
messageID: messageID,
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))",
senderPubkey: event.pubkey,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
))
default:
return nil
}
}
/// Parse + size + cell + kind + `r` tag + freshness, with NO signature
/// verification callers dedup/rate-limit first, Schnorr-verify last.
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
GatewayService.isValidGeohash(carrier.geohash),
let event = carrier.event(),
classify(event, cell: carrier.geohash) != nil else {
return nil
}
return event
}
private func isFresh(_ event: NostrEvent) -> Bool {
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
}
/// True when the event was signed by this device's own derived
/// rendezvous identity for the cell. Survives relaunches (unlike the
/// published-ID cache) because the derivation is deterministic; the
/// underlying identity cache makes this cheap.
private func isOwnRendezvousEvent(_ event: NostrEvent, cell: String) -> Bool {
guard let identity = try? deriveIdentity?(cell) else { return false }
return identity.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
}
+55 -27
View File
@@ -38,11 +38,7 @@ import Foundation
/// original broadcast packet is the TTL relay's job, not ours.
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
/// 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).
/// repeat deposits and relay echoes are absorbed.
/// 3. Uplink is only attempted for locally composed events at the send site
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
/// carrier never re-enter the uplink path. This is a call-site convention;
@@ -82,6 +78,7 @@ final class GatewayService: ObservableObject {
let depositor: PeerID
let geohash: String
let event: NostrEvent
let queuedAt: Date
}
static let shared = GatewayService()
@@ -155,7 +152,7 @@ final class GatewayService: ObservableObject {
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
}
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway)
onEnabledChanged?(enabled)
}
@@ -166,7 +163,7 @@ final class GatewayService: ObservableObject {
/// for broadcasts (downlink rebroadcasts from a gateway).
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
guard let carrier = NostrCarrierPacket.decode(payload) else {
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))", category: .session)
SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))", category: .gateway)
return
}
switch carrier.direction {
@@ -178,10 +175,6 @@ final class GatewayService: ObservableObject {
// Downlink rides broadcast only; a directed fromGateway is malformed.
guard !directedToUs else { return }
handleDownlinkBroadcast(carrier)
case .toBridge, .fromBridge:
// Mesh-bridge carriers are BridgeService territory; the ingress
// router dispatches them there.
return
}
}
@@ -193,7 +186,7 @@ final class GatewayService: ObservableObject {
// age) no crypto so junk and stale replays are dropped before we
// ever pay for a MainActor Schnorr verify.
guard let event = structurallyValidEvent(from: carrier) else {
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8)) (failed validation)", category: .security)
SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))", category: .gateway)
return
}
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
@@ -204,18 +197,19 @@ final class GatewayService: ObservableObject {
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))", category: .gateway)
return
}
// Consume the per-depositor rate token BEFORE the expensive verify so
// a flood of distinct forged/junk deposits is bounded by cheap work,
// not by main-actor Schnorr verifications.
guard allowUplinkDeposit(from: depositor) else {
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))", category: .session)
SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))", category: .gateway)
return
}
// Only now pay for cryptographic verification; receivers verify again.
guard event.isValidSignature() else {
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8)) (bad signature)", category: .security)
SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))", category: .gateway)
return
}
@@ -224,7 +218,10 @@ final class GatewayService: ObservableObject {
publish(event, geohash: carrier.geohash)
accepted = true
} else {
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event))
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
@@ -250,7 +247,7 @@ final class GatewayService: ObservableObject {
private func publish(_ event: NostrEvent, geohash: String) {
publishedEventIDs.insert(event.id)
publishToRelays?(event, geohash)
SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway)
}
/// Returns true when the item was actually stored for later publish.
@@ -258,7 +255,7 @@ final class GatewayService: ObservableObject {
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))", category: .session)
SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))", category: .gateway)
return false
}
if queuedUplinks.count >= Limits.maxQueuedUplinks {
@@ -301,26 +298,26 @@ final class GatewayService: ObservableObject {
// event's own `#g` tag to match the carrier geohash.
guard isFresh(event),
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
}
// Loop rule 1: never rebroadcast mesh-carried events back onto the
// mesh. Loop rule 2 (self-echo): never rebroadcast an event this
// gateway itself uplinked (`publishedEventIDs`) it originated on this
// 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
// mesh. Loop rule 2: rebroadcast each 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
// still waiting to be sent.
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id),
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))", category: .gateway)
return
}
// Verify before spending BLE airtime; receivers verify again.
guard event.isValidSignature() else { return }
guard event.isValidSignature() else {
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))", category: .gateway)
return
}
pendingDownlinks.append((event, geohash))
if pendingDownlinks.count > Limits.maxPendingDownlinks {
@@ -328,6 +325,7 @@ final class GatewayService: ObservableObject {
// dropped event is not yet in `rebroadcastEventIDs`, so a later
// relay redelivery can still carry it.
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway)
}
drainPendingDownlinks()
}
@@ -344,6 +342,7 @@ final class GatewayService: ObservableObject {
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
let payload = carrier.encode() else { continue }
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
// rebroadcast (loop rule 2).
rebroadcastEventIDs.insert(event.id)
@@ -363,9 +362,11 @@ final class GatewayService: ObservableObject {
let oldest = downlinkSendTimes.min() ?? now()
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
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
guard let self else { return }
self.downlinkDrainScheduled = false
SecureLogger.info("[GW] drain-timer fired", category: .gateway)
self.drainPendingDownlinks()
}
if let scheduleDrainTimer {
@@ -420,7 +421,7 @@ final class GatewayService: ObservableObject {
return false
}
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))", category: .session)
SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))", category: .gateway)
return true
}
@@ -466,3 +467,30 @@ final class GatewayService: ObservableObject {
&& geohash.allSatisfy { allowed.contains($0) }
}
}
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
/// evicted when full.
private struct BoundedIDSet {
private var members: Set<String> = []
private var order: [String] = []
let capacity: Int
init(capacity: Int) {
self.capacity = capacity
}
func contains(_ id: String) -> Bool {
members.contains(id)
}
/// Returns false when the ID was already present.
@discardableResult
mutating func insert(_ id: String) -> Bool {
guard members.insert(id).inserted else { return false }
order.append(id)
if order.count > capacity {
members.remove(order.removeFirst())
}
return true
}
}
@@ -1,122 +0,0 @@
//
// GeohashChatActivityTracker.swift
// bitchat
//
// Tracks actual chat-message activity per sampled geohash so the empty mesh
// timeline can point at a nearby channel where a conversation is happening
// not merely where participants are present.
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// A recent chat message observed in a sampled geohash channel.
struct GeohashChatPreview: Equatable, Sendable {
let senderName: String
let content: String
let timestamp: Date
init(senderName: String, content: String, timestamp: Date) {
self.senderName = senderName
self.content = content
self.timestamp = timestamp
}
}
/// The liveliest nearby conversation, resolved against the user's regional
/// channels.
struct NearbyConversation: Equatable, Sendable {
let channel: GeohashChannel
/// Chat messages seen within the activity window.
let messageCount: Int
let lastMessage: GeohashChatPreview
}
/// Records kind-20000 chat events seen by the background geohash sampling
/// subscriptions (blocked and self senders are filtered by the caller) and
/// answers "where nearby is a conversation actually happening?".
@MainActor
final class GeohashChatActivityTracker: ObservableObject {
static let shared = GeohashChatActivityTracker()
/// How far back a message still counts as "a conversation is happening".
private let window: TimeInterval
/// Per-geohash recent message timestamps (pruned to the window).
private var messageTimes: [String: [Date]] = [:]
/// Per-geohash newest message preview.
private var lastMessages: [String: GeohashChatPreview] = [:]
private let now: () -> Date
init(
window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds,
now: @escaping () -> Date = { Date() }
) {
self.window = window
self.now = now
}
func recordChatMessage(
geohash: String,
senderName: String,
content: String,
timestamp: Date
) {
let gh = geohash.lowercased()
let clamped = min(timestamp, now())
guard now().timeIntervalSince(clamped) < window else { return }
var times = messageTimes[gh] ?? []
times.append(clamped)
messageTimes[gh] = prune(times)
if let existing = lastMessages[gh], existing.timestamp > clamped {
// Keep the newer preview.
} else {
lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped)
}
objectWillChange.send()
}
/// Messages seen in the window for one geohash.
func messageCount(for geohash: String) -> Int {
prune(messageTimes[geohash.lowercased()] ?? []).count
}
func lastMessage(for geohash: String) -> GeohashChatPreview? {
let gh = geohash.lowercased()
guard messageCount(for: gh) > 0 else { return nil }
return lastMessages[gh]
}
/// The busiest channel with at least one chat message in the window.
/// Ties go to the more local (higher-precision) channel, so a lone
/// message on your block beats a lone message across the region.
func mostActiveConversation(among channels: [GeohashChannel]) -> NearbyConversation? {
var best: NearbyConversation?
for channel in channels {
let count = messageCount(for: channel.geohash)
guard count > 0, let last = lastMessage(for: channel.geohash) else { continue }
let candidate = NearbyConversation(channel: channel, messageCount: count, lastMessage: last)
if let current = best {
let better = count > current.messageCount
|| (count == current.messageCount
&& channel.level.precision > current.channel.level.precision)
if better { best = candidate }
} else {
best = candidate
}
}
return best
}
func clear() {
messageTimes.removeAll()
lastMessages.removeAll()
objectWillChange.send()
}
private func prune(_ times: [Date]) -> [Date] {
let cutoff = now().addingTimeInterval(-window)
return times.filter { $0 >= cutoff }
}
}
@@ -9,12 +9,12 @@
import Foundation
/// Represents a participant in a geohash channel
struct GeoPerson: Identifiable, Equatable, Sendable {
let id: String // pubkey hex (lowercased)
let displayName: String
let lastSeen: Date
public struct GeoPerson: Identifiable, Equatable, Sendable {
public let id: String // pubkey hex (lowercased)
public let displayName: String
public let lastSeen: Date
init(id: String, displayName: String, lastSeen: Date) {
public init(id: String, displayName: String, lastSeen: Date) {
self.id = id
self.displayName = displayName
self.lastSeen = lastSeen
@@ -23,7 +23,7 @@ struct GeoPerson: Identifiable, Equatable, Sendable {
/// Protocol for resolving display names and checking block status
@MainActor
protocol GeohashParticipantContext: AnyObject {
public protocol GeohashParticipantContext: AnyObject {
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
func displayNameForPubkey(_ pubkeyHex: String) -> String
/// Returns true if the pubkey is blocked
@@ -32,16 +32,16 @@ protocol GeohashParticipantContext: AnyObject {
/// Tracks participants across multiple geohash channels
@MainActor
final class GeohashParticipantTracker: ObservableObject {
public final class GeohashParticipantTracker: ObservableObject {
/// Activity cutoff duration (defaults to 5 minutes)
let activityCutoff: TimeInterval
public let activityCutoff: TimeInterval
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
private var participants: [String: [String: Date]] = [:]
/// Currently visible people for the active geohash
@Published private(set) var visiblePeople: [GeoPerson] = []
@Published public private(set) var visiblePeople: [GeoPerson] = []
/// The currently active geohash (if any)
private var activeGeohash: String?
@@ -52,17 +52,17 @@ final class GeohashParticipantTracker: ObservableObject {
/// Timer for periodic refresh
private var refreshTimer: Timer?
init(activityCutoff: TimeInterval = -300) { // default 5 minutes
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
self.activityCutoff = activityCutoff
}
/// Configure with a context provider
func configure(context: GeohashParticipantContext) {
public func configure(context: GeohashParticipantContext) {
self.context = context
}
/// Set the currently active geohash
func setActiveGeohash(_ geohash: String?) {
public func setActiveGeohash(_ geohash: String?) {
activeGeohash = geohash
if geohash == nil {
visiblePeople = []
@@ -72,13 +72,13 @@ final class GeohashParticipantTracker: ObservableObject {
}
/// Record activity from a participant in the current active geohash
func recordParticipant(pubkeyHex: String) {
public func recordParticipant(pubkeyHex: String) {
guard let gh = activeGeohash else { return }
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
}
/// Record activity from a participant in a specific geohash
func recordParticipant(pubkeyHex: String, geohash: String) {
public func recordParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = participants[geohash] ?? [:]
map[key] = Date()
@@ -94,7 +94,7 @@ final class GeohashParticipantTracker: ObservableObject {
}
/// Remove a participant from all geohashes (used when blocking)
func removeParticipant(pubkeyHex: String) {
public func removeParticipant(pubkeyHex: String) {
let key = pubkeyHex.lowercased()
for (gh, var map) in participants {
map.removeValue(forKey: key)
@@ -104,14 +104,14 @@ final class GeohashParticipantTracker: ObservableObject {
}
/// Get participant count for a specific geohash
func participantCount(for geohash: String) -> Int {
public func participantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = participants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
/// Get the visible people list for the active geohash (read-only query)
func getVisiblePeople() -> [GeoPerson] {
public func getVisiblePeople() -> [GeoPerson] {
guard let gh = activeGeohash, let context = context else { return [] }
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = (participants[gh] ?? [:])
@@ -126,12 +126,12 @@ final class GeohashParticipantTracker: ObservableObject {
}
/// Refresh the visible people list
func refresh() {
public func refresh() {
visiblePeople = getVisiblePeople()
}
/// Start the periodic refresh timer
func startRefreshTimer(interval: TimeInterval = 30.0) {
public func startRefreshTimer(interval: TimeInterval = 30.0) {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in
@@ -141,19 +141,19 @@ final class GeohashParticipantTracker: ObservableObject {
}
/// Stop the periodic refresh timer
func stopRefreshTimer() {
public func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
/// Clear all participant data
func clear() {
public func clear() {
participants.removeAll()
visiblePeople = []
}
/// Clear participant data for a specific geohash
func clear(geohash: String) {
public func clear(geohash: String) {
participants.removeValue(forKey: geohash)
if activeGeohash == geohash {
visiblePeople = []
@@ -406,6 +406,7 @@ enum GroupCryptoError: Error, Equatable {
case malformedPayload
case signingFailed
case sealFailed
case wrongEpoch
case decryptionFailed
case badSenderSignature
}
+29 -99
View File
@@ -12,79 +12,10 @@ import Foundation
import Security
final class KeychainManager: KeychainManagerProtocol {
/// Default keychain for components that construct their own rather than
/// having one injected. Under test this is an in-memory keychain: the
/// xctest runner's code signature changes every build, so any read of a
/// real login-keychain item triggers a macOS password prompt that
/// "Always Allow" can never satisfy and tests must never read or
/// mutate the developer's real keychain (`SecItemCopyMatching` can also
/// hang in test environments). Production behavior is unchanged.
static func makeDefault() -> KeychainManagerProtocol {
// PreviewKeychainManager lives in _PreviewHelpers, a development
// asset excluded from archive builds release code must not
// reference it. Tests always run Debug, so the guard is lossless.
#if DEBUG
if TestEnvironment.isRunningTests { return sharedTestKeychain }
#endif
return KeychainManager()
}
#if DEBUG
/// One store per process, mirroring the real keychain: separate
/// default-constructed components (e.g. two NostrIdentityBridge
/// instances in BoardManager's publish and delete paths) must see each
/// other's writes, or they would derive different Nostr identities
/// under test.
private static let sharedTestKeychain = PreviewKeychainManager()
#endif
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
// 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
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
@@ -131,7 +62,7 @@ final class KeychainManager: KeychainManagerProtocol {
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrService as String: service,
kSecAttrAccessible as String: Self.itemAccessibility,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
kSecAttrLabel as String: "bitchat-\(key)"
]
#if os(macOS)
@@ -281,6 +212,11 @@ final class KeychainManager: KeychainManagerProtocol {
// MARK: - Generic Operations
private func save(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
return saveData(data, forKey: key)
}
private func saveData(_ data: Data, forKey key: String) -> Bool {
// Delete any existing item first to ensure clean state
_ = delete(forKey: key)
@@ -291,7 +227,7 @@ final class KeychainManager: KeychainManagerProtocol {
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrService as String: service,
kSecAttrAccessible as String: Self.itemAccessibility,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
kSecAttrLabel as String: "bitchat-\(key)"
]
#if os(macOS)
@@ -326,6 +262,11 @@ final class KeychainManager: KeychainManagerProtocol {
return false
}
private func retrieve(forKey key: String) -> String? {
guard let data = retrieveData(forKey: key) else { return nil }
return String(data: data, encoding: .utf8)
}
private func retrieveData(forKey key: String) -> Data? {
// Base query
let base: [String: Any] = [
@@ -381,7 +322,22 @@ final class KeychainManager: KeychainManagerProtocol {
}
// MARK: - Cleanup
func deleteAllPasswords() -> Bool {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword
]
// Add service if not empty
if !service.isEmpty {
query[kSecAttrService as String] = service
}
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
// Delete ALL keychain data for panic mode
func deleteAllKeychainData() -> Bool {
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
@@ -566,30 +522,4 @@ final class KeychainManager: KeychainManagerProtocol {
SecItemDelete(query as CFDictionary)
}
/// Delete every item stored under a custom service
func deleteAll(service customService: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let items = result as? [[String: Any]] else {
return
}
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
}
}
+18 -213
View File
@@ -17,10 +17,6 @@ struct LocationNotesDependencies {
var now: () -> Date
// Fires when the geo relay directory refreshes; used to retry after "no relays".
var relayDirectoryUpdates: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher()
/// Whether any of the target relays has a live connection distinguishes
/// "loaded, empty" from "still connecting (Tor warming up)" when EOSE
/// fires without data. Defaults to true so tests keep legacy behavior.
var anyRelayConnected: @MainActor (_ relayUrls: [String]) -> Bool = { _ in true }
private static let idBridge = NostrIdentityBridge()
@@ -50,10 +46,7 @@ struct LocationNotesDependencies {
relayDirectoryUpdates: NotificationCenter.default
.publisher(for: .geoRelayDirectoryDidRefresh)
.map { _ in () }
.eraseToAnyPublisher(),
anyRelayConnected: { relayUrls in
NostrRelayManager.shared.isAnyRelayConnected(among: relayUrls)
}
.eraseToAnyPublisher()
)
}
@@ -64,10 +57,6 @@ final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
/// The initial fetch timed out with zero target relays connected
/// (usually Tor still bootstrapping): not "empty", just not there
/// yet. Retries automatically once a relay comes up.
case connecting
case ready
case noRelays
}
@@ -78,33 +67,6 @@ final class LocationNotesManager: ObservableObject {
let content: String
let createdAt: Date
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
/// NIP-40 expiration, when the note carries one (dead drops do).
let expiresAt: Date?
/// Carries a `["t","urgent"]` tag (parity with urgent board posts).
let isUrgent: Bool
init(
id: String,
pubkey: String,
content: String,
createdAt: Date,
nickname: String?,
geohash: String,
expiresAt: Date? = nil,
isUrgent: Bool = false
) {
self.id = id
self.pubkey = pubkey
self.content = content
self.createdAt = createdAt
self.nickname = nickname
self.geohash = geohash
self.expiresAt = expiresAt
self.isUrgent = isUrgent
}
var displayName: String {
let suffix = String(pubkey.suffix(4))
@@ -120,22 +82,18 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading
@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 noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable?
private var expiryPruneTimer: Timer?
private var connectivityRetryTimer: Timer?
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
private enum Strings {
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
static let noRelays = String(localized: "location_notes.error.no_relays", defaultValue: "no geo relays available near this location. try again soon.", comment: "Shown when no geo relays are available near the selected location")
static func failedToSend(_ detail: String) -> String {
String(
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
format: String(localized: "location_notes.error.failed_to_send", defaultValue: "failed to send note. %@", comment: "Shown when a location note fails to send"),
locale: .current,
detail
)
@@ -146,10 +104,10 @@ final class LocationNotesManager: ObservableObject {
let norm = geohash.lowercased()
self.geohash = norm
self.dependencies = dependencies
if !Geohash.isValidGeohash(norm) {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
// Validate geohash (building-level precision: 8 chars)
if !Geohash.isValidBuildingGeohash(norm) {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
}
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
subscribe()
// The relay directory may load after init (remote fetch over Tor);
// retry automatically instead of staying stuck on "no relays".
@@ -160,41 +118,14 @@ final class LocationNotesManager: ObservableObject {
self.subscribe()
}
}
// NIP-40 notes can expire while displayed (a 24h dead drop crossing
// its boundary); ingest-time filtering alone would keep it visible
// until the subscription is recreated.
expiryPruneTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.pruneExpiredNotes()
}
}
}
deinit {
expiryPruneTimer?.invalidate()
connectivityRetryTimer?.invalidate()
}
/// Drops notes whose NIP-40 expiry has passed. Their ids stay in
/// `noteIDs` so a relay replay cannot resurrect them.
func pruneExpiredNotes() {
let now = dependencies.now()
let expired = notes.contains { note in
if let expiresAt = note.expiresAt { return expiresAt <= now }
return false
}
guard expired else { return }
notes.removeAll { note in
if let expiresAt = note.expiresAt { return expiresAt <= now }
return false
}
}
func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
guard Geohash.isValidGeohash(norm) else {
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
// Validate geohash (building-level precision: 8 chars)
guard Geohash.isValidBuildingGeohash(norm) else {
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return
}
if let sub = subscriptionID {
@@ -206,7 +137,6 @@ final class LocationNotesManager: ObservableObject {
initialLoadComplete = false
errorMessage = nil
geohash = norm
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
notes.removeAll()
noteIDs.removeAll()
subscribe()
@@ -233,8 +163,6 @@ final class LocationNotesManager: ObservableObject {
private func subscribe() {
state = .loading
errorMessage = nil
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = nil
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
@@ -265,19 +193,14 @@ final class LocationNotesManager: ObservableObject {
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag - accept any of our 9 geohashes
guard let matchedGeohash = event.tags.first(where: { tag in
guard event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
})?[1].lowercased() else { return }
}) else { return }
guard !self.noteIDs.contains(event.id) else { return }
// NIP-40: relays are not required to enforce expiration drop
// expired notes client-side so 24h dead drops actually vanish.
let expiresAt = Self.expirationDate(of: event)
if let expiresAt, expiresAt <= self.dependencies.now() { return }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let urgent = event.tags.contains { $0.count >= 2 && $0[0].lowercased() == "t" && $0[1].lowercased() == "urgent" }
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash, expiresAt: expiresAt, isUrgent: urgent)
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.enforceMemoryCap()
@@ -285,51 +208,14 @@ final class LocationNotesManager: ObservableObject {
}, { [weak self] in
guard let self = self else { return }
self.initialLoadComplete = true
guard self.state != .noRelays else { return }
// EOSE with no data and zero connected target relays means the
// 10s fallback fired while Tor was still warming up showing
// "no notes" would be a lie. Wait visibly and retry.
if self.notes.isEmpty, !self.dependencies.anyRelayConnected(relays) {
self.state = .connecting
self.scheduleConnectivityRetry(relays: relays)
} else {
if self.state != .noRelays {
self.state = .ready
}
})
}
/// While `.connecting`, poll for a live target relay and re-subscribe as
/// soon as one appears (fresh REQ, fresh EOSE tracking). The poll dies
/// with the state: any subscribe/cancel invalidates it.
private func scheduleConnectivityRetry(relays: [String]) {
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = Timer.scheduledTimer(
withTimeInterval: TransportConfig.uiGeoNotesConnectivityRetrySeconds,
repeats: true
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.retryIfRelaysAvailable(relays: relays)
}
}
}
func retryIfRelaysAvailable(relays: [String]) {
guard state == .connecting else {
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = nil
return
}
guard dependencies.anyRelayConnected(relays) else { return }
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = nil
SecureLogger.debug("LocationNotesManager: relay came up, retrying notes fetch for \(geohash)", category: .session)
refresh()
}
/// Send a location note for the current geohash using the per-geohash
/// identity, optionally expiring via NIP-40 (dead drops pass 24h; the
/// composer's option passes nil) and optionally tagged urgent.
func send(content: String, nickname: String, expiresAt: Date? = nil, urgent: Bool = false) {
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
@@ -344,9 +230,7 @@ final class LocationNotesManager: ObservableObject {
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname,
expiresAt: expiresAt,
urgent: urgent
nickname: nickname
)
dependencies.sendEvent(event, relays)
// Optimistic local-echo
@@ -355,10 +239,7 @@ final class LocationNotesManager: ObservableObject {
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname,
geohash: geohash,
expiresAt: expiresAt,
isUrgent: urgent
nickname: nickname
)
self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0)
@@ -371,44 +252,6 @@ 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
}
}
/// The NIP-40 `expiration` tag as a date, if the event carries one.
static func expirationDate(of event: NostrEvent) -> Date? {
guard let tag = event.tags.first(where: { $0.count >= 2 && $0[0].lowercased() == "expiration" }),
let seconds = TimeInterval(tag[1])
else { return nil }
return Date(timeIntervalSince1970: seconds)
}
/// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() {
if notes.count > maxNotesInMemory {
@@ -418,50 +261,12 @@ final class LocationNotesManager: ObservableObject {
}
}
/// One-shot dead-drop publish without holding a subscription: pins a
/// note to `geohash` that expires via NIP-40. Returns false when no geo
/// relays are known or signing fails.
@MainActor
static func postDrop(
content: String,
nickname: String,
geohash: String,
expiry: TimeInterval = TransportConfig.locationDropExpirySeconds,
dependencies: LocationNotesDependencies = .live
) -> Bool {
guard let trimmed = content.trimmedOrNilIfEmpty else { return false }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
SecureLogger.warning("LocationNotesManager: drop blocked, no geo relays for geohash=\(geohash)", category: .session)
return false
}
do {
let identity = try dependencies.deriveIdentity(geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: identity,
nickname: nickname,
expiresAt: dependencies.now().addingTimeInterval(expiry)
)
dependencies.sendEvent(event, relays)
return true
} catch {
SecureLogger.error("LocationNotesManager: failed to post drop: \(error)", category: .session)
return false
}
}
/// Explicitly cancel the subscription. The prune timer stays alive (it
/// holds only a weak self) so a reused instance the notices sheet
/// cancels on tab switch and refreshes on return keeps pruning.
/// Explicitly cancel subscription and release resources.
func cancel() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
connectivityRetryTimer?.invalidate()
connectivityRetryTimer = nil
state = .idle
errorMessage = nil
}
@@ -1,29 +0,0 @@
//
// LocationNotesSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// User preference for location notes (dead drops): leaving notes pinned to
/// nearby places with /drop and surfacing notes others left here. On by
/// default, but everything it powers additionally requires location
/// permission the toggle in app info is the kill switch.
enum LocationNotesSettings {
private static let enabledKey = "locationNotes.enabled"
/// Fired on every toggle write so live consumers (the nearby-notes
/// counter) can drop or restart their relay subscription immediately.
static let didChangeNotification = Notification.Name("bitchat.locationNotesSettingsDidChange")
static var enabled: Bool {
get { UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true }
set {
UserDefaults.standard.set(newValue, forKey: enabledKey)
NotificationCenter.default.post(name: didChangeNotification, object: nil)
}
}
}

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