* Live push-to-talk voice for DMs: stream while you talk, voice note as fallback
Holding the mic in a DM now streams AAC frames live over the Noise session
(walkie-talkie style, ~0.5s mouth-to-ear at one hop) while recording the same
audio as a normal voice note. On release the note ships through the existing
fileTransfer pipeline; receivers that heard the live stream absorb it silently
into the same bubble (matched by the burst ID embedded in the file name), so
reliability comes for free and nobody sees duplicates.
Protocol:
- NoisePayloadType.voiceFrame = 0x08 carrying VoiceBurstPacket
(burstID + seq + START/data/END/CANCELED, length-prefixed AAC frames)
- 210-byte burst-content budget keeps each Noise packet inside the 256-byte
padding bucket: one BLE frame, never the fragment scheduler
- fire-and-forget: frames are dropped (never queued) without an established
session; live is only offered when the peer is mesh-reachable
Receive:
- ChatLiveVoiceCoordinator assembles bursts (jitter-ordered, 0.5s gap skip,
3s idle end, flood/size caps), persists progressively as ADTS .aac so even
a partial burst is a replayable bubble
- live autoplay only when the conversation is on screen, app active, and the
new app-info "live voice messages" toggle is on (also gates live sending)
- one-playback-at-a-time via a shared ExclusivePlayback slot
Capture:
- PTTCaptureEngine taps AVAudioEngine, dual-encodes: live AAC frames + the
finalized .m4a (same 16kHz/mono/16kbps settings as VoiceRecorder)
- VoiceRecordingViewModel now drives a pluggable VoiceCaptureSession; the
composer HUD shows a pulsing LIVE treatment when streaming
Includes the push-to-talk design doc, 6 new localization keys across all 29
locales, and unit tests for framing, packetizer budget, ADTS output, codec
round-trip, and the assembly/absorb lifecycle. Public-mesh PTT (MessageType
0x29) lands separately on top of this.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* PTT follow-ups from review + field test: peer-ID normalization, toggle gates inbound, drop-path diagnostics
Codex review fixes (#1403):
- makeVoiceCaptureSession normalizes the selected peer with toShort() before
the reachability/session checks and binds the send target to that same
routing ID — a conversation selected under the stable 64-hex Noise key no
longer silently falls back to a classic note while the short-ID session is
established
- the live-voice toggle now gates inbound bursts too: off means
classic-notes-only in both directions (no live bubble, partial file, or
early notification; the finalized note still arrives), with a test
Field-test diagnostics (first device run: DM frames decrypted but no bubble
appeared, with no log evidence of which guard dropped them):
- coordinator logs undecodable frames (size + hex prefix) and blocked drops
- makeAssembly logs directory/file-handle failures instead of returning nil
silently
- PTTLiveVoiceSession logs capture start and finish (packet/frame/duration
counts); PTTCaptureEngine logs engine start success/failure with the input
format; BLEService.sendVoiceFrame logs no-session drops
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix iPhone live-capture failure: dead input unit (AURemoteIO -10851, 0 Hz)
Field testing showed the phone's live capture failing at mic enable with
AURemoteIO -10851 and an input format of 0 Hz / 2 ch — an input unit bound
to an earlier (playback-only or settling) audio session. The Mac, which has
no session lifecycle, captured fine, which is why public bursts from the Mac
worked while phone-side sends degraded from working (first hold) to sporadic
to dead across holds.
Three layers of defense:
- PTTCaptureEngine recreates its AVAudioEngine on every start(), after the
session is configured, so the input unit binds to the session that is
active now; a dead input (0 Hz or 0 channels) is now a distinct, logged
error instead of a silent setup failure
- PTTLiveVoiceSession retries the capture start once after a 150 ms
route-settle pause
- VoiceRecordingViewModel falls back to the classic VoiceRecorder within the
same hold if the live engine still cannot start — a route glitch now costs
the live stream, never the voice note
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Unified notices: merge board pins and location notes into one sheet
One pin icon in the header now opens a single Notices sheet with a
geo/mesh scope toggle, replacing the separate board, location-notes,
and mesh-only note buttons:
- geo tab: current geohash's notices — mesh-synced board posts merged
and deduped with Nostr kind-1 location notes, with per-item mesh/net
source badges. Scope follows the selected location channel, or the
device's building geohash when chatting on mesh.
- mesh tab: mesh-local board only (fully offline).
- One composer: geo posts go to the board and bridge to Nostr (existing
bridge), so mesh and internet see the same notice.
- Merged delete: tombstoning an own board post now also retracts the
bridged Nostr copy via NIP-09 (new createDeleteEvent, bridged event
ids tracked in BoardManager); own Nostr-only notes are deletable too.
- LocationNotesManager accepts any channel-precision geohash (1-12
chars), not just building-level.
BoardView and LocationNotesView are superseded by NoticesView.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Notices round 2: honest composer, friendlier copy, new-pin chat alerts
- Urgent + expiry controls now appear on the mesh tab only: the bridged
Nostr copy of a geo post carries neither, so relay-side readers would
never see them. Geo posts default to non-urgent with 7-day expiry, and
the bridged note now gets a NIP-40 expiration tag so honoring relays
drop it in step with the board copy.
- Geo tab explainer reuses the original location-notes description
(keeps its 29 existing translations); mesh tab gets a new plain-
language description.
- New-pin chat alerts, fully local (no wire traffic): BoardStore fires
postArrivals for posts newly accepted from the wire; BoardAlertsModel
filters own posts, dedups by postID, and for urgent pins created
within the last 30 minutes emits one system line into the matching
timeline (geo pin -> that geohash's chat, mesh pin -> mesh chat),
collapsing simultaneous arrivals into a count line.
- Routine pins light up the header: the pin icon tints orange whenever
the current scope has notices at all, and fills (pin.fill) while
unseen new pins are waiting; opening the sheet clears them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* i18n: translate the unified-notices strings into all 28 non-English locales
Adds the 13 new notices keys (sheet title, geo/mesh tabs, mesh
description, source badges, urgent alert lines, button tooltip and
accessibility strings) to the string catalog with translations for
every locale the app ships. The geo tab already reuses the fully
translated location_notes.description; this covers the rest. Insertion
preserves the catalog's case-insensitive key order, so the diff is
purely additive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address Codex review: panic-wipe reset, scoped badge clear, geohash-aware dedupe
- BoardStore.wipe() now emits didWipe; BoardAlertsModel subscribes and
resets, so a panic wipe drops pending urgent lines (which could
otherwise re-append pre-wipe content into chat after the collapse
flush), unseen badge scopes, and handled-post history.
- Opening the notices sheet clears unseen badges only for the scopes it
actually shows (mesh + current geo scope); pins for other geohash
channels keep their badge until visited.
- LocationNotesManager.Note now retains the matched g tag, and the
bridged-copy dedupe requires the note's geohash to equal the board
post's — a same-text note from a neighboring cell is no longer
swallowed as a duplicate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add capability bits to announce TLV
Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".
PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Private groups: creator-managed encrypted group chat over the mesh
Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs:
Protocol
- MessageType.groupMessage = 0x25: broadcast packets with a cleartext
16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as
AEAD AAD), inner Ed25519 sender signature over
"bitchat-group-msg-v1"|groupID|messageID|timestamp|content
- NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07:
creator-signed group state (key, epoch, roster) 1:1 over Noise; signature
over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise
session peer must BE the creator
- SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens
1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients
ignore unknown bits and answer with types they know
- PeerCapabilities.localSupported now advertises .groups
Storage
- GroupStore: symmetric keys in the keychain, roster/name/epoch as
protected JSON in Application Support; wiped in panicClearAllData()
Behavior
- Non-members relay 0x25 like any broadcast but cannot read it; group
messages join gossip-sync backfill with the public-message window
- Receivers drop wrong-epoch envelopes, bad sender signatures, and
senders missing from the creator-signed roster
- Fire-and-flood delivery (no per-member acks in v1)
UI
- Groups open as chat windows through the private-chat sheet (virtual
"group_" peer IDs); groups section in the people sheet; /group
create/invite/remove/leave/list commands; invitees get a system message
+ notification and the group appears in their people sheet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes
Addresses the Codex review and adversarial-review findings on #1383:
- TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to
65535 and truncating, so an oversize group message fails to seal and
surfaces send_failed rather than shipping ciphertext recipients drop.
- Roster nicknames truncate on a Character boundary (never mid-scalar), so a
multi-byte nickname can no longer make the whole signed roster undecodable.
- Invites now bump the epoch (rotate the key) like removals, giving every
roster change a strictly-increasing epoch so out-of-order invite states no
longer last-writer-wins a just-added member back out.
- Removing a member now sends them a creator-signed roster-without-them under
a throwaway all-zero key (never the rotated key), so their client
deactivates the group and surfaces "removed" instead of going silently dark.
- /block is enforced in the group receive path: a blocked member's messages
are dropped from display and notifications, consistent with every other
inbound path.
- Media affordances are disabled in group chats (both computed sites) so the
composer can't strand a media placeholder that never sends; media-in-groups
is a documented v2 item.
- Creator signature now covers the group name and the sender signature covers
the epoch (wire-format-affecting; needs Android parity before ship).
- Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered
receipts can never leak into group conversations under a future refactor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add capability bits to announce TLV
Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".
PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Prekey bundles: forward-secret async first contact for courier mail
Courier envelopes were sealed with one-way Noise X to the recipient's
long-lived static key, so a later compromise of that key exposed every
envelope captured in transit. This adds one-time prekey bundles:
- PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys
bound to the owner's Noise static key by an Ed25519 signature over
"bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its
own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness)
and verified against the announce-bound signing key before caching.
- Sealed envelope v2: Noise X where the responder static is the one-time
prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides
encrypted inside and is authenticated exactly like v1 (blocked-sender
check included). CourierEnvelope gains an optional prekeyID TLV that
v1 decoders skip as unknown.
- Local prekeys live in the Keychain; consumed privates survive a 48h
grace window for spray-and-wait redeliveries, then are deleted (the
forward-secrecy clock starts at deletion). The batch tops back up and
re-gossips when unconsumed count drops below 3, and everything is
wiped in panic mode.
- Routing: courier sealing picks a cached verified bundle when one
exists (one prekey per message, reused across deposit retries), with
the advertised .prekeys capability as a veto for on-mesh peers, and
falls back to static sealing otherwise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Prekeys: authenticate bundle packets, fix consume-republish, deflake CI
Fixes the prekey-bundle PR review + CI failure:
- CI root cause: the receive queue (mesh.message) is concurrent, so a
gossiped prekey bundle can be processed before the announce that binds
its owner's signing key. The old handler dropped such bundles outright,
so under CI parallel load the bundle was permanently lost and the
cache/gossip tests flaked (verifiedBundleEntersGossipStore,
prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before
their binding are now retained per-owner (bounded) and re-attempted when
the verified announce lands, atomically to avoid a check-then-act race.
- Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM):
require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the
packet's Ed25519 signature (covers senderID + timestamp) against the
owner's bound signing key, in addition to the inner bundle signature.
Stops replay under a fresh timestamp / fake senderID.
- Key the gossip prekey-bundle store/dedup by the bundle's authenticated
identity (noiseStaticPublicKey), not the unauthenticated packet
senderID, so one valid bundle sprayed under many fabricated sender IDs
can't multiply entries and exhaust the 200-owner cap.
- Bump published-bundle generatedAt strictly on consume (Codex P1):
consuming a prekey shrinks the published bundle, so it now republishes
with a strictly newer generatedAt and re-gossips, so peers replace the
cached copy and stop assigning the consumed ID before its 48h grace.
- Guard the panic/clear detached Application Support tree-deletes behind
TestEnvironment.isRunningTests: the SPM test process shares that tree,
so the wipe could land mid-test and flake file-dependent tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update sync tests for prekeyBundle as bit 9 / default sync round
Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables
a prekey sync round by default. That broke tests authored by other PRs
that assumed bit 9 was phantom or that only their own sync round fires:
- SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+
(0xFE -> 0xFC / 0xFD), since bit 9 is now assigned.
- GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync
round in configs that run maintenance (as they already do for message/
fragment/fileTransfer), so they isolate the behavior under test.
Full app suite (1301 tests) green locally via SPM.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add mesh diagnostics: /ping, /trace, and topology map
- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
+ origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
normalization, layout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing
Three fixes for PR #1377 review:
1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the
panicClearAllData / clearCurrentPublicTimeline detached utility-priority
tasks delete the real ~/Library/Application Support/files tree, which the
test process shares. The wipe fires at a nondeterministic time and raced
the sendImage test's JPEG in files/images/outgoing (write then re-read),
so prepareImagePacket threw and the test timed out. Both wipes are now
skipped under tests (existing TestEnvironment.isRunningTests pattern);
this also stops test runs from deleting the developer's real media.
2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on
packet.senderID let one connected peer rotate forged sender IDs to bypass
the 5-per-10s budget. The limiter now keys on the ingress link (the
directly connected peer that delivered the packet); the pong still goes
to the claimed sender. Regression test proves rotating senders over one
link exhaust one budget (fails 10 vs 5 pongs on the old code).
3. Codex P2: /ping output arrived up to 10s later and was routed from
selectedPrivateChatPeer at callback time, misrouting the result after a
chat switch. The origin conversation is now captured when the command is
issued (CommandOutputDestination) and deferred output is routed there:
a DM result lands in the origin chat's history even if deselected, and a
mesh-timeline result pins to #mesh instead of the active channel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Conform DiagnosticsMockContext to sendPublicMessage
CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after
this branch forked, so the diagnostics test mock no longer conformed once
main was merged in. Add the no-op stub.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add capability bits to announce TLV
Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".
PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Transitive verification: vouch for verified peers over Noise
When a Noise session establishes with a peer I verified and that peer
advertises the .vouch capability, send signed attestations (up to 16,
most recently verified first, at most once per peer per 24h) for the
OTHER fingerprints I verified. Receivers accept vouches only from
senders they verified themselves, verify the Ed25519 signature against
the sender's announce-bound signing key, and surface the result as a
new derived trust tier: vouched (unfilled seal) between casual and
trusted.
Protocol:
- NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations:
voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp
(uint64 ms BE), Ed25519 signature over
"bitchat-vouch-v1" | fingerprint | signingKey | timestamp.
The voucher is implicit in the authenticated session.
- PeerCapabilities.localSupported now advertises .vouch.
Storage (SecureIdentityStateManager / IdentityCache):
- vouches keyed by vouchee, capped at 8 vouchers each; validity is
recomputed on read (voucher still verified-by-me, < 30 days old), so
unverifying a voucher retires their vouches without cascade deletes.
- New IdentityCache fields are Optional so pre-existing encrypted
caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but
raw values are strings, so persisted values are unaffected (and
vouched itself is never persisted).
- Panic wipe clears vouch state with the rest of the identity cache.
UI: unfilled checkmark.seal badge in the mesh peer list (filled seal
stays exclusive to verified) and a "vouched for by N people you
verified" section with voucher names in FingerprintView; VoiceOver
labels and xcstrings entries included.
Tests: attestation encode/decode + signature (forged/tampered/expired),
accept-policy gates, batch cap, trust-level derivation incl. voucher
invalidation, persistence compat, and coordinator exchange/accept
policies. Full macOS suite: 1088 tests passing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix CI deadlock in vouch tests and live-refresh the fingerprint sheet on vouch acceptance
Two fixes for PR #1380 review findings:
1. CI "Run Swift Tests (app)" hang (exit 137): the new
SecureIdentityStateManagerVouchTests suite was nonisolated, so Swift
Testing ran its tests in parallel on the Swift Concurrency cooperative
pool. Each test enqueues a queue.async(.barrier) write (setVerified)
and immediately blocks in queue.sync / queue.sync(.barrier)
(recordVouch / effectiveTrustLevel). On CI's few-core runners every
cooperative-pool thread ended up parked behind a pending barrier that
never got a dispatch worker, deadlocking the whole test process until
the watchdog SIGKILLed it. The suite is now @MainActor, matching the
production isolation of the vouch API (ChatVouchCoordinator is
@MainActor) and keeping blocking syncs off the cooperative pool.
2. Codex P2: an open fingerprint sheet did not refresh its vouched badge
when a vouch batch was accepted - VerificationModel.bind() never
observed the trust-change signal. It now subscribes to the
"peerStatusUpdated" notification that
ChatVouchCoordinator.notifyPeerTrustChanged() posts (same source
PeerListModel uses) and forwards it to objectWillChange. Added a
regression test that pins VerificationModel's own subscription
(verified to fail without the fix).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Skip media-wipe detached tasks under tests (shared-filesystem race)
panicClearAllData and clearCurrentPublicTimeline delete the real
~/Library/Application Support/files tree in detached utility-priority
tasks. The SPM test process shares that tree and ChatViewModelTests
invoke both methods, so under parallel scheduling the wipe lands at a
nondeterministic time — deleting media a concurrently running test just
wrote (and the developer's real app data with it). Guard both with the
existing TestEnvironment.isRunningTests pattern, mirroring the same fix
on feat/mesh-diagnostics (#1377).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Port vouch capability-race fix to feat/vouching (ports b8adcbe9)
Ports the on-device-confirmed fix from the integration test branch
(commit b8adcbe9) onto feat/vouching so PR #1380 is actually correct.
On-device testing confirmed the transitive vouch propagated once the
send was triggered on verify / announce arrival rather than auth alone.
Vouch attestations only ever sent from peerAuthenticated, gated on the
peer's .vouch capability. That capability arrives via the peer's announce,
processed independently of the Noise handshake, so at auth time the set was
usually empty -> gate failed -> vouch silently skipped and never retried.
- Refactor the send path into a reusable attemptVouch(to:fingerprint:now:).
- Trigger on peer-list updates (peersUpdated): fired after every verified
announce, so the batch goes out once the .vouch bit actually arrives.
- Trigger on local verification (vouchToConnectedVerifiedPeers): verifying a
peer runs a vouch pass over connected verified peers, covering the
verify-while-connected case and propagating the new identity onward.
- Relax the capability gate: treat an empty/unknown set as eligible (the
Noise 0x12 payload is ignored by non-supporting peers); only skip when a
non-empty set explicitly lacks .vouch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Cashu ecash chips: detect, render, and redeem tokens + /pay command
Content-level Cashu support, no wire-protocol changes:
- CashuTokenDecoder: summarizes V3 (cashuA base64url-JSON) tokens —
amount summed across proofs, unit, mint host, memo — and V4 (cashuB)
via a minimal bounded CBOR reader. All input is treated as
adversarial: size caps, depth/item budgets, overflow guards, display
sanitization; malformed payloads fail closed to a generic chip.
- PaymentChipView: cashu chips now show "500 sat · mint.example.com"
(+ memo) instead of a generic label; tap opens a cashu: wallet URL
and falls back to https://redeem.cashu.me when no wallet handles it;
context menu adds copy token / redeem in wallet / redeem on web.
- extractCashuLinks now returns bare deduplicated bearer strings so the
chip can decode them (cashu: URIs still detected via the embedded
token).
- /pay <token>: validates the token decodes, sends it as the message
body; DMs send directly, public channels require an explicit
"/pay <token> public" confirm since tokens are bearer instruments.
Suggested everywhere except public geohash channels.
- Tests: decoder (V3/V4 decode, summation, URI forms, truncation/
garbage/huge fuzzing, CBOR depth bounds) and /pay command flows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cashu: strict decode on the /pay SEND path
The permissive decoder turned any non-empty cashuB… base64 that failed
CBOR parsing into a generic TokenInfo, so the /pay guard accepted base64
junk and truncated V4 tokens and relayed them with a success message.
Add a `strict` flag to CashuTokenDecoder.decode: in strict mode there is
no permissive V4 fallback and the token must resolve to a known version
with a positive amount, else it returns nil. Rendering keeps the
permissive path (an unknown chip is fine for display). /pay now decodes
with strict:true and surfaces "invalid cashu token" instead of sending.
Tests: /pay with truncated cashuB / base64 junk is rejected; valid V3
and valid definite-length V4 still send; decoder strict-mode unit tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add geohash bulletin board: persistent signed notices over mesh sync
New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.
Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.
UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Board: bound orphan tombstones and reject future-dated posts at ingest
Two hardening fixes from Codex review of the geohash bulletin board:
- Orphan tombstones (P1): retention was derived solely from the
sender-chosen deletedAt, so self-signed tombstones for unseen post IDs
with far-future deletedAt persisted and re-entered sync unboundedly.
Retention is now also clamped to receive time (now + 7d + 1h skew --
no post can outlive that), and orphans are capped at 100 globally and
5 per author key with oldest-received evicted first. Matched
tombstones and disk restores keep their existing behavior.
- Future-dated posts (P2): ingest only checked expiresAt > now, letting
posts dated years ahead sort above honest posts and squat the 200
global slots without ever pruning. The single ingest chokepoint
(radio, sync, and disk restore all funnel through it) now rejects
createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the
decoder's span rule is unchanged.
Adds tests for the skew boundary, far-future expiry, receive-time
tombstone clamping, orphan caps/eviction, and matched-tombstone
exemption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders
Outgoing kind-20000 geohash messages mine a NIP-13 nonce tag (8 leading
zero bits, ~256 hashes, typically <1 ms) off the main actor before
signing. Mining is hard-capped at 2 s and cancellable (newer send or
channel switch): on cap/cancel the committed target steps down so the
message still ships promptly with an honest commitment - sending is
never blocked and nothing is dropped. The hot loop serializes the
canonical event once and rewrites only the fixed-width nonce bytes.
Inbound kind-20000 events are scored per NIP-13 commitment semantics
(committed target counts; the ID must actually meet it, extra work
earns nothing) and never hard-rejected: validated PoW >= 8 bits skips
the per-sender rate-limit bucket while the per-content flood bucket
still applies, so old non-mining clients keep working under today's
strict limits while bulk spam gets expensive.
Presence heartbeats (kind 20001), kind-1 notes, and DMs are unchanged;
no UI beyond a pow= field in an existing sampled debug log.
Reimplemented from scratch rather than cherry-picking the stale
feature/pow-geohash-mining-ui branch (unbounded loop, hard receive
filtering, mining UI, XCTest, force unwraps).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Geohash: serialize PoW sends so order matches send order
Two location-channel sends back-to-back only cancelled the previous
mining task and started a new one. Cancellation merely *expedites* NIP-13
mining (the target is polled and steps down; it never aborts the send),
so the cancelled task still appended + relayed once mining returned. Both
tasks ran concurrently and the second (shorter to mine) could finish
first, reordering messages in the timeline and on relays.
Chain the mining tasks: each geohash send captures the previous send's
task, cancels it (to expedite, so delays never stack), and awaits its
completion before it echoes and relays. Order is now always send order.
The >2s mining cap is preserved: cancellation expedites the awaited task,
so a send is never blocked beyond NostrPoW.miningTimeCap.
Test: two rapid sends where the first mines longer (larger content) still
land in send order for both the local echo and the relayed events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Store-and-forward previously delivered to an out-of-range peer only if a
mutual favorite happened to be connected at send time and later met the
recipient directly, and everything except courier envelopes died with the
app process. This closes those gaps end to end:
- Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key
held only in the Keychain (no plaintext at rest); queued private
messages now survive an app kill and flush on next launch.
- Deposit retry: queued messages are re-deposited whenever a new eligible
courier connects, tracked per message so the same courier is never
double-burned, until 3 distinct couriers carry it or it expires.
- Tiered open couriering: signature-verified strangers can now carry mail
(2 envelopes/depositor into a 20-slot pool) alongside mutual favorites
(5 each); overflow evicts verified-tier mail before favorites'.
- Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV,
wire-compatible with old clients); couriers split half their remaining
budget with each newly encountered courier so mail diffuses through a
moving crowd.
- Remote handover: a verified relayed announce now floods a copy toward
the multi-hop recipient (directed-relay treatment, 10-min per-envelope
cooldown) while the carried original stays put for a direct encounter.
- Public history: gossip-sync window for whole public messages widened
from 15 min to 6 h, matched on the receive-acceptance side, and the
message store persists to disk so devices bridge partitions and
restarts ("town crier").
- Privacy-safe local delivery counters (bare tallies, log-only) so the
store-and-forward stack is measurable on-device.
- Panic wipe now also clears the sealed outbox, gossip archive, and
counters.
- Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X,
actual flood control, courier system, gossip sync, Nostr path); the old
document described a bloom filter, three fragment types, and a
MessageRetryService that don't exist.
1037 macOS tests pass (17 new); iOS builds.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers
When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.
- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
domain-separated by a courier prologue; sender identity authenticated
via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
instances, plus codec/crypto/store/router suites (986 tests green)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix courier handoff verification and directed sends
* Authenticate courier deposits by ingress peer
* Gate courier handover on direct announces and isolate store test
Envelopes are removed from the courier store optimistically, so releasing
them on a relayed (multi-hop) announce risks losing carried mail to a
speculative flood that never reaches the recipient. Handover now also
requires the announce to have arrived directly (full TTL), i.e. an actual
encounter with a live link; regression test builds a relayed copy of a
genuinely signed announce (TTL is excluded from announce signatures).
Also make CourierStore's on-disk location injectable so the persistence
test round-trips through a temp directory instead of wiping the real
Application Support store, and reattach BLEAnnounceHandler's doc comment
to the class it describes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use Xcode-bundled Swift in CI instead of a standalone toolchain
The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop couriered mail from blocked senders at envelope open
The UI-layer block check (isPeerBlocked in the transport event
coordinator) resolves a fingerprint from the live session or peer list,
but a couriered message arrives precisely when its sender is absent —
no session, no registry entry — so the check failed open and a blocked
identity's mail was delivered anyway. Gate in openCourierEnvelope,
where the sealed sender's full static key is in hand.
End-to-end test ferries a full deposit→carry→handover round and
verifies the envelope from a blocked sender never reaches the delegate
(confirmed failing without the gate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix favorites end-to-end: peer-list dedup, Nostr sync, /fav key corruption
- UnifiedPeerService: dedup offline favorites against mesh peers by noise
key. Phase 2 compared a 64-hex noise-key PeerID against 16-hex mesh IDs
(never equal), leaving only a nickname+isConnected heuristic — a mutual
favorite that was reachable-but-not-connected or renamed rendered twice,
and a same-nick stranger could suppress a favorite entirely.
- Nostr inbound: intercept [FAVORITED]/[UNFAVORITED] markers in the live
PM handler so they update theyFavoritedUs instead of rendering as chat
text; mutual favorites can now form over Nostr. Delete the dead
favorite-aware PM variant and ChatNostrCoordinator.handleFavoriteNotification
(unwired, parsed a stale FAVORITE:TRUE|… format no sender emits).
- NostrTransport.isPeerReachable: match short form regardless of incoming
ID width — toggling an offline favorite (addressed by 64-hex noise key)
was silently dropped with no reachable transport.
- BLEService.sendPrivateMessage: normalize recipient to the short ID like
sendFilePrivate, so a 64-hex target hits the existing Noise session
instead of initiating a handshake with a 32-byte wire recipient ID.
- /fav, /unfav: stop writing Data(hexString: peerID.id) — the 8-byte
routing ID for mesh peers — into the favorites store as a "noise key",
and stop double-sending the favorite notification; delegate to
toggleFavorite with a proper state check.
- FavoritesPersistenceService.updatePeerFavoritedUs: keep the stored
nickname when the caller passes the "Unknown" placeholder.
- Bump marketing version to 1.5.4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Route DMs to mutual favorites via Nostr when a mesh-keyed peer goes offline
Field-tested on device: with a DM window opened while the peer was on
mesh (conversation keyed by the short 16-hex ID), walking out of range
and sending failed instantly with "peer not reachable" even though the
header showed the peer as Nostr-reachable (mutual favorite, npub known).
sendPrivateMessage derived the favorites key as Data(hexString:
peerID.id) — for a short mesh ID that is the 8-byte routing ID, never
the noise key — so the mutual-favorite/Nostr-key checks always came up
empty and the send failed before reaching MessageRouter. Conversations
keyed by the full 64-hex noise-key ID (opened from the offline favorite
row) were unaffected, which is why later tests appeared to work.
Resolve the noise key properly (peerID.noiseKey, then the unified peer
row, then the favorites store by derived short ID) and add a regression
test for the mesh-keyed-peer-goes-offline case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Label Nostr DMs from favorites with their stored nickname
Field-tested: a DM delivered over the Nostr fallback rendered as
"anon#678e" instead of the sender's name. The inbound handler named the
sender via displayNameForNostrPubkey, which only knows geohash-scoped
names — even though the pipeline had already resolved the sender's
noise key (the conversation is keyed by it).
When the conversation key carries a noise key, prefer the favorite's
stored nickname; geohash DMs (nostr_ keys) keep the anon geo name. This
also stops an inbound Nostr [FAVORITED] from overwriting the stored
nickname with the anon fallback, since the same name feeds
updatePeerFavoritedUs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix courier path for offline favorites addressed by noise-key IDs
Two Codex review findings, both the same ID-width confusion this PR
targets, in the courier flow:
- CourierDirectory.favoritesBacked resolved recipients only via
getFavoriteStatus(forPeerID:), which requires a short 16-hex ID —
offline favorites are addressed by the full 64-hex noise-key ID, so
attemptCourierDeposit silently bailed for exactly the peers couriers
exist to serve. The 64-hex ID now yields its own key directly.
- openCourierEnvelope emitted the derived short mesh ID even when the
sender has no live mesh identity, landing couriered mail in an
unresolvable short-ID thread labeled "Unknown". Absent senders now
emit the full noise-key ID so the message joins the stable favorite
conversation; present senders keep the live short-ID thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Make peer lists accessible and actionable; block by stable identity
Who you can reach — the app's most important fact — was encoded in
unlabeled 10pt icons with macOS-only tooltips, and the mesh list had no
actions (block/favorite/verify were slash-command-only).
- Both peer lists become real accessibility citizens: each row is one
element announcing name, reachability, and favorite/unread/blocked
state, with a button trait and custom actions for the gesture-only
interactions. Neither file previously had a single accessibility
modifier. Reachability icons gain tooltips reusing existing strings;
teleported vs in-area pins are explained.
- Mesh rows gain the context menu the geohash list already had: direct
message, favorite, show fingerprint, block/unblock. The fingerprint
double-tap, previously shadowed by the single tap, is reordered so it
fires.
- The DM header's offline state (previously EmptyView — absence of a
glyph as the only signal) becomes a dimmed "offline" tag, and a
geohash DM — always Nostr-routed — no longer mislabels itself
"offline".
- App Info gains a SYMBOLS legend defining every glyph the lists and
headers use; nothing defined them before.
- Mesh block/unblock now resolve by the peer's stable Noise identity
instead of a `/block <displayName>` string, so the exact tapped row is
affected and offline peers can be unblocked (with covering tests).
New strings are added source-language (en) only.
* Surface block/unblock feedback in the conversation where it was triggered
setMeshPeerBlocked silently returned when the peer's identity could not
be resolved (e.g. long-press-blocking an old public message from a
sender who left and was never a favorite), where the /block command
printed "cannot block X: not found or unable to verify identity" — post
that same message from the guard branch.
Both the failure and confirmation messages now route through
addCommandOutput instead of addSystemMessage, so blocking from inside a
private chat prints into that chat rather than invisibly into the
public timeline (same routing #1363 applied to command output).
The confirmation also reuses the /block wording ("blocked X. you will
no longer receive messages from them") for parity with the command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove dead accessibility label and unreachable /unblock fallback
The favorite button's .accessibilityLabel in MeshPeerList is
unreachable: the row-level .accessibilityElement(children: .ignore)
swallows child elements, and the row's custom accessibility action
already covers favoriting.
ConversationUIModel.unblock is only called from the mesh peer list with
a non-optional mesh peerID, so the "/unblock <name>" fallback branch
could never run — take PeerID directly and drop the branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
CommandProcessor results (/help text, errors like "unknown command",
/msg confirmations) were always appended to the public timeline via
addSystemMessage, so a command typed inside a DM appeared to do
nothing until the user switched back to the public channel.
handleCommand now routes .success/.error output to the open private
chat when one is selected, falling back to the public timeline
otherwise. The DM selection is read after processing so commands that
switch chats (/msg) print into the conversation they just opened.
Follow-up to #1354, which added /help and surfaced this routing gap.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The app test job intermittently hung at process exit. The suite is
load-sensitive and historically prone to cooperative-pool/teardown
deadlocks; the security changes added background work to the test process
that pushed it over the edge. Make the unit-test BLEService/identity
manager quiescent and remove blocking sync:
- forceSave() no longer does queue.sync(.barrier). It is reachable from
deinit and from async tests on the swift-concurrency cooperative pool,
where a blocking barrier-sync can starve/deadlock the pool. It now
cancels the debounce timer and persists directly. (Removed the
now-unneeded queue-specific-key re-entrancy machinery.)
- SecureIdentityStateManager persists synchronously under tests instead of
scheduling a DispatchSourceTimer that lingers past process exit.
- Gate gossip-sync start (in addition to the maintenance timer) behind
real Bluetooth init, so the test BLEService runs no periodic
sign/broadcast/sync churn.
- Skip the panic Nostr reconnect under tests (connecting the shared relay
singleton starts network/reconnect work that never completes).
Production behavior is unchanged: real Bluetooth builds run all timers and
the debounced save as before; the debounce save now actually fires
(previously a Timer on a GCD queue that never ran).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A broad audit surfaced ten critical/high issues across the crypto,
transport, identity, and panic-wipe layers. This fixes all ten.
Critical:
- Nostr DMs were unauthenticated. The NIP-17 seal was signed with a
throwaway ephemeral key and the receiver never verified it, so anyone
who knows a recipient's npub could forge messages (and delivery/read
receipts) into an existing trusted conversation. The seal is now
signed with the sender's real identity key, and the receiver verifies
the seal signature and that seal.pubkey == rumor.pubkey.
NOTE: this is a breaking wire-protocol change (see PR).
- Public BLE messages trusted registry membership instead of the packet
signature. Since senderID is attacker-controlled, any verified peer
could be impersonated in public chat. A valid signature from the
claimed sender is now required before any registry identity is used.
- Unverified announces still persisted the announced identity, letting a
replayed noisePublicKey overwrite a victim's stored signing key and
nickname. persistIdentity is now gated on verification.
High:
- Noise decrypt trapped on a 16-19 byte ciphertext (negative prefix
length after nonce extraction) — a remote crash. Now validated.
- Identity-cache debounce save used Timer.scheduledTimer on a GCD queue
with no run loop, so it never fired; block/verify/favorite changes
only persisted on explicit forceSave. Replaced with a
DispatchSourceTimer on the queue; forceSave is now serialized.
- Identity-cache key load couldn't tell "missing" from a transient
keychain failure and would regenerate (deleting) the key, orphaning
the cache. Now uses getIdentityKeyWithResult and falls back to a
session-only ephemeral key without clobbering the persisted key/cache.
- BLE receive-dedup key lacked a payload digest, so post-handshake
flushes (queued msgs + delivery/read acks in the same ms) were dropped
as duplicates. Digest added, matching the ingress registry.
- Maintenance timer was created only in init and never recreated after a
panic stop/start, silently degrading the mesh until app restart. Now
recreated in startServices.
- Panic wipe left persisted location state (selected channel, teleport
set, bookmarks) and cached per-geohash Nostr private keys behind. Both
are now cleared.
- Panic spawned an orphan NostrRelayManager instead of reusing .shared,
splitting relay state from every other component. Now reuses .shared.
Tests updated to assert the fixed behavior (announce no longer persists
unverified identities; public messages require a signature; receive
dedup ID includes the payload digest).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Centralize UI theme colors into semantic ThemePalette tokens
Introduce AppTheme/ThemePalette (Utils/Theme.swift) with an environment
key and @ThemedPalette property wrapper, persisted via @AppStorage.
Views now resolve background/primary/secondary/accentBlue/alertRed/
divider tokens from the environment instead of computing colors inline,
removing the backgroundColor/textColor/secondaryTextColor prop-drilling
through the header, composer, and people-sheet hierarchy.
Matrix theme output is pixel-identical; this is groundwork for a
user-selectable theme switcher.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add liquid glass theme with in-app appearance switcher
Add a .liquidGlass AppTheme alongside the matrix terminal theme,
selectable from a one-line APPEARANCE row in the app info sheet
(persisted via @AppStorage, applies live).
Liquid glass renders system fonts and colors over a subtle gradient
backdrop, with the header and composer floating as Liquid Glass panels
(real .glassEffect() on iOS/macOS 26, compiler-gated with an
ultraThinMaterial fallback for older SDKs). The message list scrolls
underneath the chrome via safe-area insets, and all sheets share the
same backdrop and surface language. The matrix theme is unchanged.
Details:
- Theme is threaded through ChatMessageFormatter so message
AttributedStrings switch font design per theme; the per-message
format cache gains a variant key so themes never serve each other's
cached strings
- New palette tokens: accent (interactive tint) and locationAccent
(geohash green), replacing scattered hardcoded greens/blues in the
voice note, waveform, verification, and people-sheet views
- Header controls get full-height tap targets and the people count
becomes a real Button (previously a tap gesture on the cluster)
- CommandSuggestionsView renders nothing when empty instead of a
zero-height view that pushed the composer input off-center
- New appearance strings localized for all 29 catalog languages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ConversationStore.auditInvariants() verifies the per-conversation and
store-level message-ID indexes, caps, timestamp ordering, unread-set
membership, and selection validity - wired to the existing read-receipt
cleanup cadence, loud (.error) on violation, sampled heartbeat when
healthy (~2.8ms per audit at 5k messages, benchmarked and floored).
Router drops log both outcomes (marked failed / skipped by no-downgrade
guard); relay cap evictions, age sweeps, and jittered reconnect delays
log their counts; mirrored republishes get a sampled proof line. 11 new
invariant tests corrupt store state through DEBUG-only hooks since the
single-writer lockdown makes those states unreachable via intents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Package.swift's .process("Noise") resource claim silently excluded all
of bitchatTests/Noise/ from compilation since Oct 2025 - including a
complete official-vector runner (cacophony + snow XX transcripts,
transport messages, handshake hash, byte-identical to upstream).
Narrowing the resource to the JSON file and loading via Bundle.module
brings 51 Noise tests back to life, with a guard asserting each
vector's protocol name matches the app's.
CI gains a performance floor gate: perf-floors.json carries deliberately
generous floors (~25% of measured throughput) that catch algorithmic
regressions without flaking on runner variance; PERF lines reach the
gate via an O_APPEND side-channel file since swift test --parallel
swallows passing tests' stdout.
Tests are now hermetic: FavoritesPersistenceService uses an in-memory
keychain under test (fixes the securityd hang that blocked pipeline
benchmarks locally) and read-receipt persistence uses a wiped scratch
UserDefaults suite instead of .standard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Transport callers no longer reach the raw NoiseEncryptionService:
getNoiseService() is deleted in favor of narrow purpose-named Transport
methods (session public key, identity fingerprint, static/signing keys,
sign/verify, callback installation). VerificationService now reaches
crypto through the transport, so it can no longer pin a stale service
across a panic reset. myPeerID/myNickname become private(set); the
existing setNickname mutator is the sole nickname path.
ConversationStore is now the sole owner of private-chat selection:
PrivateChatManager.selectedPeer is a published read-only mirror, and
startChat/endChat mutate through the store intent. The bridge method
and its five call sites are deleted, removing a latent bug where a
stale manager selection pushed back into the store could resurrect a
just-removed conversation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feature models observe per-conversation objects directly: PublicChatModel
forwards the active Conversation's objectWillChange, PrivateInboxModel
republishes only for the selected peer's conversation - background
appends no longer invalidate foreground views. LegacyConversationStore,
the coalescing bridge, and IdentityResolver are deleted (resolver
canonicalization proved display-invisible: nothing enumerates direct
conversations, lookups are by exact peer ID, and raw keying is strictly
more robust - documented as a design deviation). Selection state moves
into the store. ChatViewModel.messages/privateChats survive as derived
read views for coordinators that genuinely need them; hot paths use a
new store-direct privateMessages(for:) witness.
Final numbers vs pre-migration baselines:
pipeline.privateIngest 9.7k -> 24.0k msg/s (2.5x)
pipeline.publicIngest 6.8k -> 13.7k msg/s (2.0x)
delivery updates 38k -> 117-133k/s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mesh and geohash timelines are now store conversations. All public
mutation sites flow through store intents; PublicMessagePipeline keeps
its 80ms UI batching but commits batches via store appends with each
buffered entry carrying its destination conversation (a mid-batch
channel switch now flushes instead of dropping the buffer).
ChatViewModel.messages becomes a cached get-only view of the active
conversation, invalidated through the change subject. The mesh
late-insert threshold is consciously removed: it only ever ordered the
non-rendered messages copy, so strict timestamp insertion makes the
working set agree with rendered order. PublicTimelineStore and the
per-message full-array legacy sync are deleted; the coalescing bridge
mirrors public conversations for the remaining legacy readers.
pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady;
store.append 237k/s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All private-message mutations now flow through store intents:
coordinators, PrivateChatManager (its @Published dicts deleted - now
read-only views over the store), outbound sends, delivery status, and
chat migration. The O(1) store dedup replaces the full-scan duplicate
check; insertion order is maintained by the store so sanitizeChat's
re-sort is a documented no-op. Both bootstrapper Combine bridges and
the Task.yield store synchronization are deleted.
ChatViewModel.privateChats/unreadPrivateMessages become get-only derived
views (measured: naive rebuild equals a change-invalidated cache within
noise, so the simpler form stays). Feature models still read the legacy
store, fed by a coalescing LegacyConversationStoreBridge (one mirror
per burst, marked for step-5 deletion).
pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-conversation ObservableObjects with O(1) dedup via an incrementally
maintained message-ID index, binary-search timestamp insertion, folded
cap policies, a no-downgrade delivery rule, and a typed change subject.
All mutation flows through store intents (conversation mutators are
fileprivate). The previous store is renamed LegacyConversationStore
pending deletion in step 5. 16 behavioral tests including per-
conversation publish isolation; store.append benchmarks at ~144k
messages/sec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChatPeerListCoordinator, ChatComposerCoordinator, ChatOutgoingCoordinator,
and GeoChannelCoordinator complete the migration; every coordinator now
depends on a narrow @MainActor context protocol. GeoChannelCoordinator's
three injected closures collapse into a weak context. New intent op
recordPublicActivity(forChannelKey:) keeps lastPublicActivityAt
single-writer. 15 new mock-context tests; flaky-poll deadline in the
gift-wrap dedup test raised for parallel load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChatTransportEventCoordinator, ChatPeerIdentityCoordinator,
ChatMediaTransferCoordinator, ChatVerificationCoordinator, and
ChatLifecycleCoordinator drop their unowned ChatViewModel back-refs for
narrow @MainActor contexts (20-36 members each), reusing shared
witnesses across protocols. The two remaining raw writers of
sentReadReceipts now route through owner intent ops
(unmarkReadReceiptsSent, syncReadReceiptsForSentMessages), closing the
gaps noted in the previous commit. NoiseEncryptionService stays fully
out of the verification coordinator via installNoiseSessionCallbacks.
26 new mock-context tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nostrKeyMapping, sentGeoDeliveryAcks/sentReadReceipts dedupe,
isBatchingPublic, geo subscription lifecycle, and private-chat selection
hand-off now mutate through single intent operations on ChatViewModel,
with backing storage locked down via private(set) so the single-writer
property is compiler-enforced. Context protocols downgrade to read-only
access where reads remain. 8 new contract tests.
Known remaining writers outside the protocols: sentReadReceipts is
passed inout to PrivateChatManager.syncReadReceiptsForSentMessages and
un-marked by ChatTransportEventCoordinator on disconnect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BLEService's per-packet-type orchestration moves into owned, tested
components (BLEAnnounceHandler, BLEPublicMessageHandler,
BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler),
each taking an environment struct of closures so every queue hop stays
in BLEService and the handlers are synchronously testable. Behavior is
preserved verbatim, including Noise session recovery on decrypt failure
and single-block UI event ordering. handleLeave/handleRequestSync stay
in place as already-thin delegations. BLEService drops to 3393 lines.
Four coordinators (delivery, private conversation, Nostr, public
conversation) drop their unowned/weak ChatViewModel back-references for
narrow @MainActor context protocols, with ChatViewModel conformances as
single shared witnesses for overlapping members. Their true coupling is
now an explicit, reviewable surface, and each gains a mock-context test
suite covering flows previously testable only through the full view
model. Delivery/read acks now also clear the router's retained-send
outbox via the delivery context.
New LargeTopologyTests exercise production-shaped meshes with the
in-memory harness: an 8-peer relay chain with per-hop TTL decay, a
14-peer cyclic mesh with exactly-once delivery, partition/heal, and
topology churn.
App-layer runtime/model files updated alongside.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Extract message list into dedicated MessageListView
* Fix broken smoke test
Mounts two distinct fixtures to cover separate render branches: a truncatable message (>2000 chars, no payment tokens) and a payment message (lightning + cashu, private, partial delivery). Expectations guard that each fixture actually reaches its intended branch.
---------
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
* fix: Rate limit iOS peer notifications to prevent flood
- Remove aggressive formIntersection that cleared recentlySeenPeers
when peers temporarily dropped, causing them to be treated as "new"
- Add 5-minute cooldown between notifications (aligns with Android)
- Use fixed notification identifier so iOS updates existing notification
instead of creating new ones
- Only mark peers as seen when notification is sent, so peers arriving
during cooldown are included in the next notification
Fixes notification spam every 10-30 seconds when peers fluctuate.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore: Bump version to 1.5.1
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
BitchatMessage is a class, so modifying its properties through an array
reference doesn't mutate the array itself. Changed var to let binding.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add findMessageIndex helper to handle peer ID format mismatch
(short 16-hex vs long 64-hex noise key)
- Prevent DELIVERED acks from downgrading .read status back to .delivered
(fixes race condition where late-arriving delivery acks overwrote read status)
- Use incoming peerID for READ receipts instead of creating new ID from noise key
- Explicitly re-assign messages array to trigger @Published setter
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add `nonisolated` to the static method since it only performs
thread-safe FileManager operations and is called from Task.detached.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add code to clear iOS app switcher snapshot cache during panic mode.
iOS automatically captures screenshots for the app switcher, which could
reveal sensitive message content visible at the time.
Changes:
- Add clearAppSwitcherSnapshots() helper function (iOS only)
- Call it from panicClearAllData() during file cleanup phase
- Clears all files in Library/Caches/Snapshots/ directory
Security: Sensitive information visible in the app when it entered
background will no longer be recoverable from snapshot cache after
panic reset.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Block notifications from bypassing the user blocking feature on iOS.
Vulnerabilities fixed:
1. Nostr public messages: checkForMentions() was called regardless of
blocking status, allowing blocked users to trigger mention notifications
2. Noise-encrypted DMs: didReceiveNoisePayload() didn't check blocking
before calling handlePrivateMessage(), allowing DM notifications
Changes:
- ChatViewModel+Nostr.swift: Add blocking check before checkForMentions()
and sendHapticFeedback() in subscribeNostrEvent
- ChatViewModel.swift: Add isPeerBlocked() check in didReceiveNoisePayload
before processing private messages
- Add NotificationBlockingTests.swift with 5 tests for blocking behavior
Security: Blocked users can no longer trigger notifications through any
message path (Nostr public, Nostr DM, or BLE Noise DM).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Decouple MessageRouter from specific Mesh/Nostr implementations
- Update NostrTransport to implement isPeerReachable using a local cache of favorited peers
- Update ChatViewModel to inject transports as a list
- Mark NostrTransport as @unchecked Sendable to handle internal thread safety
Pass the UserDefaults-backed sentReadReceipts from ChatViewModel to
consolidateMessages() to correctly identify already-read messages
after app restart. This prevents duplicate read receipts and incorrect
unread badges when reopening a chat shortly after reading it.
Addresses PR review feedback.
- Remove duplicate Regexes enum, use MessageFormattingEngine.Patterns
- Delete unused formatMessage() function (views use formatMessageAsText)
- Move message consolidation logic to PrivateChatManager
- Add consolidateMessages() and syncReadReceiptsForSentMessages()
- Wire PrivateChatManager to UnifiedPeerService for peer lookup
Reduces ChatViewModel from 5998 to 5713 lines (-285 lines)
All 303 tests pass
Removed duplicate handlePrivateMessage(payload:senderPubkey:...) method
(~63 lines) that was nearly identical to handlePrivateMessage(_:senderPubkey:...).
Changes:
- Added missing isNostrBlocked check to the remaining method
- Updated call site to use _ parameter style
- Removed redundant implementation