* Deposit with couriers in parallel when the only route is a send queue
The courier path was nearly unreachable: NostrTransport claims any
favorite with a known npub as "reachable" regardless of connectivity,
and the mesh favorite exchange shares npubs, so for essentially every
courier-eligible recipient the router picked Nostr's reachable branch.
With no internet the message just sat in the relay send queue — in the
flagship scenario (internet shutdown, mutual friend standing right
there) the courier walked away carrying nothing.
Add Transport.canDeliverPromptly(to:), defaulting to reachability for
radio-backed transports; NostrTransport answers honestly by mirroring
the relay manager's connection state (fail-closed behind Tor). When the
chosen transport can't hand the message off promptly, the router now
also deposits a sealed copy with connected couriers. Double delivery is
harmless: receivers dedup by message ID, and delivered/read acks never
downgrade the carried status. When relays are up, sends are trusted and
no courier quota is spent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Track DM-relay connectivity, not any-relay, for prompt delivery
Codex review: NostrRelayManager.isConnected is true when any relay is
up, including geohash/custom relays — but private messages target the
default (gift-wrap-capable) relay set and queue when none of those are
connected. A lone geohash relay would have suppressed the parallel
courier deposit while the DM sat in the queue. Publish a DM-scoped
connectivity flag and drive canDeliverPromptly from it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* 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>
* Fix lock glyph alignment, privacy-caption band, and empty-state wrapping
- Message-row locks: align to first text baseline instead of a hardcoded
top padding that left the lock ~4pt below the line's visual center
- Header/caption locks: 1pt optical lift (lock.fill ink is bottom-heavy;
geometric centering reads low); seal badge stays untouched
- DM privacy caption: sit on the themed surface like the rest of the
bottom chrome instead of painting its own orange band
- Empty-state lines: non-breaking spaces so the closing * can't orphan
and 'bitchat/ for help' can't break right after the slash
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Unify sheet close buttons, widen tiny tap targets, handle long nicknames
- New SheetCloseButton component: one glyph size/weight (13 semibold),
32pt visual box, 44pt hit target; adopted by all 7 sheets (sizes had
drifted across 12/13/14pt, two had no frame at all)
- Favorite star buttons get real tap targets (peer list + DM header)
- DM header nickname: single line with middle truncation instead of
wrapping into the fixed-height header; peer-list names truncate tail
- Geohash people rows: leading glyph 12 -> 10 to match mesh rows
- Sidebar lock glyphs get the same optical lift as the DM header
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Make channel switching, voice notes, and header actions work under VoiceOver
- Channel rows in the location sheet are now single activatable buttons
(label + selected trait + switch hint) with the bookmark toggle
mirrored as a named accessibility action; bookmark buttons labeled
- Voice-note mic: press-and-hold drag gestures can't be activated by
VoiceOver, so the default accessibility action now toggles
start/stop-and-send; announces 'recording' state; localized labels
- Attachment button: camera (long-press) path exposed as a named
action; labels localized instead of hardcoded English
- People-count button announces connected vs no-one-reachable (was
color-only); verification QR button gains a spoken name (.help is
only a hint on iOS); bitchat/ logo exposes its tap-for-app-info as a
button (panic triple-tap stays undiscoverable on purpose)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Theme-correctness sweep: palette colors everywhere, AX-size header growth
- Fingerprint/verification sheet cards: palette-tinted boxes instead of
fixed gray bands that ignored matrix green and occluded glass
- Voice-note card: palette background (translucent) instead of opaque
white/black; waveform + payment chips + image placeholder follow suit
- .secondary/.primary/Color.blue swapped for palette.secondary/primary/
accentBlue across location sheets, people sheets, message captions,
and the header count (system gray read wrong under matrix green)
- Autocomplete/command rows: dropped the uniform gray wash that dulled
the themed overlay panel
- 'tap to reveal' caption follows the theme font instead of hardcoding
monospaced
- Headers use minHeight so two-line accessibility text sizes grow the
bar instead of clipping inside it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix main header expanding to fill the screen
The header bar's fixed height was load-bearing: its children fill the
bar with .frame(maxHeight: .infinity) tap targets, so switching to an
open-ended minHeight let the header expand to swallow all available
vertical space, centering the title mid-screen and crushing the
timeline into the composer. Restore the fixed height — headerHeight is
a @ScaledMetric, so it already grows with Dynamic Type. Reproduced and
verified both layouts with an offscreen render harness.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* DM header: floating glass panel instead of muddy orange wash under glass
Orange at 14% over the backdrop gradient reads as a gray-beige band,
not a privacy signature. Under liquid glass the DM header now uses the
same floating chrome panel as the main header; the private signature is
already carried by the orange lock, caption, and composer accents.
Matrix keeps its orange wash over the opaque themed surface, unchanged.
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>
Four spurious CI failures on July 5, all loaded-runner flakiness:
- ViewSmokeTests.voiceAndMediaViews_renderAndWarmCaches asserted an exact
bin count on WaveformCache.shared for the same URL the mounted
VoiceNoteView was concurrently warming at its default 120-bin width;
whichever barrier write landed last owned the entry. Probe the cache with
a dedicated audio file no view touches, and purge both URLs. Also replace
the fixed 250ms sleep for loadDuration's background hop with a waitUntil
poll.
- sendImage_privateChatProcessesAndTransfersImage (and its sendVoiceNote /
sendImage siblings) wait on work that hops through Task.detached; the
global executor is shared with every parallel test worker, so a loaded
runner can exceed the 5s wait. Raise those positive waits to
TestConstants.longTimeout (10s) — waitUntil returns as soon as the
condition holds, so passing runs are unaffected.
- subscribeNostrEvent_addsToTimeline_ifMatchesGeohash raced concurrently
running suites (e.g. CommandProcessorTests) on the process-wide
LocationChannelManager singleton: a mid-test channel flip reroutes or
drops the event permanently, so no fixed wait recovers. The wait loop now
re-asserts the channel and redelivers the event on each poll — idempotent
because channel switches clear the processed-event set and the store
dedups by message ID — so interference heals while genuine failures still
time out.
- The performance floor gate failed on a saturated runner
(gcs.buildAndDecode at 85% of floor). check-perf-floors.sh now re-runs
the benchmark suite up to twice when a metric lands below floor,
appending to the same PERF log and keeping each benchmark's best value
across attempts: noise clears on a retry, a real algorithmic regression
fails every attempt. Floors are unchanged and never lowered by the
mechanism; missing-benchmark failures exit immediately without retrying.
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>
Mechanical style fixes across the enabled rule set, mostly via
swiftlint --fix (trailing_comma, comma, colon, trailing_newline,
comment_spacing, unused_closure_parameter, unneeded_break_in_switch,
opening_brace) plus hand fixes:
- non_optional_string_data_conversion (45): .data(using: .utf8)! and
?? Data() fallbacks replaced with the non-optional Data(_.utf8),
including two production sites (NIP-44 HKDF info constant and the
announce canonicalization context/nickname bytes — byte-identical
output, only the impossible-nil handling is gone).
- switch_case_alignment: LocationChannel had a misindented closing
brace; also repaired an --fix artifact in BLEService's .none case.
- redundant_string_enum_value: TrustLevel raw values equal to the case
names (encoded form unchanged).
- unused_optional_binding: let _ = binds replaced with != nil / is Bool.
- static_over_final_class: PreviewView.layerClass.
- Resolved the BinaryProtocolTests TODO by documenting that 8-byte
recipient ID truncation is the fixed wire-field size, not a bug.
The 4 remaining violations are all todo markers for a shared
test-helpers module (tracked in #1088) and one Reuse note.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bisecting (base was 4/4 clean, branch 3/3 hung, reliably reproducible)
pinned the parallel-suite exit hang to the public-message signature
requirement (security fix#2), via FragmentationTests:
reassemblyFromFragmentsDeliversPublicMessage and
duplicateFragmentDoesNotBreakReassembly send fragments of an UNSIGNED
public message and `await capture.waitForPublicMessages(...)`. With #2 the
reassembled unsigned message is now (correctly) dropped, so
didReceivePublicMessage never fires. The helper then trips a latent bug:
on timeout it cancels the waiter task but never resumes its
CheckedContinuation, so the throwing task group's teardown awaits a child
that never completes and the whole test process hangs at exit (SIGKILL'd
by CI). Base never hit it because the message always arrived in time.
Fix matches the security model — real public broadcasts are signed: sign
the reassembled packet with a NoiseEncryptionService and preseed the
sender's signing key (same pattern as duplicatePacket_isDeduped), so #2
verifies and delivers it. Full parallel suite now exits cleanly 5/5 locally
(branch was 3/3 hung before).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app test job hung at process exit (all tests pass, then SIGKILL at the
CI timeout). Root cause: fix#5 replaced the dead Timer.scheduledTimer with
a real DispatchSourceTimer, created per manager instance, resumed and never
cancelled. Those live timer sources kept the dispatch machinery alive so the
swift-testing process never exited. The earlier `isRunningTests` guard was
fragile (it does not reliably detect the swift-testing-only runner on CI).
Drop the debounce timer entirely. Mutations now persist via the same
serialized `queue` barrier their callers already run on (saveIdentityCache ->
performSave directly); forceSave is a direct, non-blocking call (no
queue.sync, which is unsafe on the cooperative pool). No timer is left
scheduled, so nothing keeps the process alive. The original bug is still
fixed — saves now actually happen, unlike the never-firing Timer.
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>
Root cause of the CI app-test hang was a pre-existing bleQueue<->collectionsQueue
lock inversion driven by the periodic maintenance timer (performMaintenance ->
drainAllPendingWrites takes collectionsQueue while another path holds it and
sync-waits on bleQueue via readLinkState). The timer is created unconditionally
in init, so it also ran in the unit-test process (initializeBluetoothManagers:
false), where it only churns BLE writes/notifications/announces that don't exist.
Recent timing changes made the latent deadlock surface reliably.
- Only start the maintenance timer when real CoreBluetooth managers were
initialized (maintenanceTimerEnabled). Production behavior is unchanged; the
unit-test process no longer runs the timer and cannot hit the inversion.
Also fix BLEServiceCoreTests.duplicatePacket_isDeduped, which sent an unsigned
public packet that the new signature requirement (security fix#2) correctly
drops. The test now signs the packet and preseeds the sender's signing key
(production sendMessage signs public broadcasts), exercising the dedup path
(security fix#7) end to end. _test_handlePacket gains an optional
signingPublicKey to seed the registry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The forceSave() rewrite used queue.sync(flags: .barrier), but forceSave
is also called from deinit. The debounce timer's barrier hop captured
self strongly, so when that block dropped the last reference the manager
deallocated *on* the identity queue — deinit -> forceSave -> queue.sync
then deadlocked synchronizing onto the queue it was already running on.
This hung the test process at exit (CI SIGKILL / exit 137).
- forceSave() now detects (via a queue-specific key) when it is already
executing on the queue and runs the save directly instead of sync-ing
onto itself.
- The timer's barrier hop now captures self weakly, so it can no longer
trigger a deallocation on the queue in the first place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The public-message signature check fell back to signedSenderDisplayName,
which only searches the asynchronously-persisted identity cache. Because
the peer registry is updated synchronously on a verified announce, a
message arriving immediately after that announce could have a valid
signature and a verified registry entry yet still be dropped (cache not
caught up).
Verify the packet signature against the signing key already present in
the synchronously-updated peer registry first; fall back to the
persisted-identity lookup only for peers not yet in the registry. The
security property is unchanged: a spoofed senderID claiming a registry
peer still fails registry verification and the persisted fallback, and
is dropped.
Adds tests for the race (delivered via registry key before cache
persists) and the spoof case (invalid signature falls back and drops).
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>
* Fix launch crash: recursive dispatch_once between NostrRelayManager and NetworkActivationService
NostrRelayManager.init() runs applyDefaultRelayPolicy(force: true), which
calls dependencies.activationAllowed() when the user has location
permission or a mutual favorite. That closure resolves
NetworkActivationService.shared, whose init captured
NostrRelayManager.shared — re-entering the still-running dispatch_once on
the same thread. libdispatch traps on recursive dispatch_once
(EXC_BREAKPOINT in _dispatch_once_wait), killing the app ~50ms after
launch, before the first frame.
Fresh installs were unaffected (no permission, no favorites, so the
policy path never touched NetworkActivationService during init), which is
why this passed local testing but crashed established TestFlight users on
every launch. Two independent TestFlight crash reports on 1.5.2 (1)
show the identical stack.
Break the cycle by resolving the relay controller lazily: store a
provider closure in init and dereference NostrRelayManager.shared on
first use (start()/reevaluate()), after both singletons have finished
initializing. The injectable test initializer keeps its signature.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Bump version to 1.5.3
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>
* Extend mesh range: relax RSSI gates, lift sparse TTL clamps, drain connection queue
Range improvements to the BLE mesh, all policy-level (no wire/protocol
changes):
- Drain the connection candidate queue from the maintenance tick.
Weak-RSSI discoveries are enqueued rather than connected, but the
queue was only drained on disconnect/failure/timeout events — an
isolated node surrounded only by weak (distant) peers queued them
all and never connected to anyone.
- Relax isolated RSSI floors from -90/-92 to -95/-100 and relax after
30s instead of 60s. When isolated, a fringe connection beats no
connection; CoreBluetooth rarely reports below -100 so prolonged
isolation now effectively accepts any decodable peer.
- Drop the global high-timeout RSSI escalation (-80 after 3 timeouts
in 60s). One flaky distant peer could blind the node to every other
edge-of-range peer; per-peripheral cooldown, the discovery ignore
window, and score bias already contain flaky links individually.
- Relay at full incoming TTL in thin chains (degree <= 2). Sparse
line topologies are exactly where every hop counts and where flood
cost is minimal; previously messages lost a hop to the clamp.
- Raise the fragment relay TTL cap from 5 to 7 in sparse graphs so
media reaches as far as text; dense graphs keep the 5-hop clamp to
contain full-fanout fragment floods.
- Extend peer reachability retention from 21s to 60s (verified) /
45s (unverified) so duty-cycled nodes (worst-case dense announce
interval 38s) don't forget peers between announces.
- Extend the directed store-and-forward spool window from 15s to 60s
so brief link gaps heal via the periodic flush.
957 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reconnect quickly after walk-away disconnects
Field test (walk away + return between two devices) showed reconnect
landing exactly 15.0s after the supervision-timeout disconnect: the
scheduler records a dropped established connection via
recordDisconnectError into the same map as connect timeouts, and
handleDiscovery hard-ignores rediscoveries for 15s.
Those are different situations. A connect attempt that timed out means
the peer likely isn't reachable, so backing off is right. A dropped
established connection usually means the peer walked out of range and
will return — track it separately and only ignore rediscoveries for 3s
(enough for CoreBluetooth to settle), so walking back into range
reconnects ~12s sooner.
Disconnect errors also no longer feed the weak-link cooldown or the
candidate-score timeout bias; those penalties now apply only to peers
that never answered a connect attempt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Honor the disconnect settle window on the queue drain path
Codex review caught that the 3s settle window was only enforced in
handleDiscovery. A candidate can already be sitting in the queue when
its peripheral drops (weak-RSSI adverts are enqueued even while
connected, since the RSSI check precedes the existing-state check),
and didDisconnectPeripheral immediately drains the queue — so the
stale entry could reconnect right through the window, recreating the
reconnect/cancel thrash it exists to prevent.
nextCandidate now defers such candidates with retryAfter for the
window's remainder, mirroring the weak-link cooldown pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Relay on one link per bound peer instead of both dual-role links
Three-device field test (star topology) showed every relayed fragment
arriving twice at the leaf: dual-role pairs hold two live links (we as
central writing to their peripheral, they as central subscribed to
ours) and broadcast/relay fanout sent the same packet down both — 2x
airtime on exactly the pairs that talk most, with the receiver just
discarding the duplicate.
The fanout selector now collapses link selection to one link per bound
peer, preferring the peripheral (write) side since it has per-link
flow control via canSendWriteWithoutResponse, while notifications
share the peripheral manager's update queue across all centrals.
Links with no bound peer yet (pre-announce) pass through untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Only notify "bitchatters nearby" on the empty-to-populated transition
Devices sitting idle and connected kept re-firing the notification.
Two bugs in handleNetworkAvailability:
- Peers first sighted during the 5-minute cooldown were never added to
recentlySeenPeers (the formUnion only ran when a notification
fired), so they stayed "new" forever and re-triggered on the next
routine peer-list event once the cooldown lapsed.
- There was no went-from-zero gate at all: any unseen peer notified,
even while already meshed with others who are visible in the app.
Every sighted peer is now recorded regardless of cooldown, and the
notification only fires when the mesh transitions from confirmed-empty
to populated with genuinely new peers. meshWasEmpty resets only via
the existing confirmed-empty paths (30s empty confirmation, 10-minute
quiet reset), so brief link flaps stay silent. The cooldown becomes
injectable so tests can prove the transition gate independently.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Bump version to 1.5.2; Xcode 26.5 project settings update
Marketing version 1.5.1 -> 1.5.2 (pbxproj + Release.xcconfig).
Project settings refresh from Xcode 26.5: upgrade-check stamp, drop
redundant DEVELOPMENT_TEAM self-references, scheme version stamps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Disable string catalog symbol generation
The Xcode 26.5 settings refresh enabled STRING_CATALOG_GENERATE_SYMBOLS
(the new default), which fails on the literal "%@" key in
Localizable.xcstrings — a pure format placeholder can't become a Swift
identifier. Nothing in the codebase references generated catalog
symbols, so turn the feature off rather than renaming keys around it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Guard _PreviewHelpers references for archive builds
Archiving for TestFlight failed: _PreviewHelpers is a development
asset, so its sources (PreviewKeychainManager, BitchatMessage.preview)
are excluded from Release/archive builds, and two call sites
referenced them unconditionally:
- TextMessageView's #Preview block — now wrapped in #if DEBUG
- FavoritesPersistenceService.makeDefaultKeychain's test branch — the
in-memory-keychain-under-test path is now #if DEBUG; tests always
run Debug so behavior is unchanged, and Release always gets the real
KeychainManager
Verified with an iOS Release arm64 build (the archive configuration).
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>
NoiseCoverageTests' session-callback test failed on the first CI run
that actually executed it (every run since it landed had hung and been
killed before completion): onSessionEstablished fires via
DispatchQueue.global().async, and the test waited only 0.5s — fine on
a dev machine, too tight on a loaded CI runner saturated by parallel
test workers.
Raise every positive-wait timeout from 0.5s to 5s (matching
TestConstants.defaultTimeout) across the suites that poll for async
callbacks. waitUntil returns as soon as the condition holds, so
passing runs are unaffected; only genuine failures wait longer. The
two negative waits in BLEServiceCoreTests ("expect nothing arrives")
deliberately keep their short windows.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Exclude perf baselines from parallel CI via --skip; sample hung tests
Every app-suite CI run since the perf baselines landed (#1335) has
timed out at the 15-minute job limit — main has been red for five
consecutive runs. The job logs show the PerformanceBaselineTests
fixtures dispatched into the parallel phase despite the
BITCHAT_SKIP_PERF_BASELINES env guard from #1336, followed by ~11
minutes of silence until the timeout kills swiftpm-testing. The suite
passes locally in seconds with identical flags, so the hang is
specific to the CI toolchain/runners — consistent with the known
XCTest-measure-under-parallel-workers hang the serial step was
created to avoid.
Two changes:
- Exclude the baselines from the parallel phase with --skip at the
SPM level, which removes them from the worker processes entirely
instead of relying on the env guard reaching setUpWithError. They
still run (and gate) in the dedicated serial step.
- Wrap the parallel run in a 10-minute watchdog that samples any
still-running test processes before killing them, so if anything
else ever hangs, the run fails fast with thread stacks in the log
instead of a silent 15-minute timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Arm the test-hang watchdog only for the test execution phase
Codex review: swift test builds before running, so the watchdog timer
included dependency resolution and compilation — a cold-cache coverage
build on a slow runner could be killed before tests ever started.
Build the tests in their own step (bounded by the 15-minute job
timeout like any build) and run the watchdog around swift test
--skip-build, tightened to 5 minutes now that it times only test
execution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Hammer transport thread-safety tests from the dispatch pool, not the cooperative pool
The watchdog added in this PR captured the actual CI hang twice, with
identical stacks both times: NostrTransportTests' 100-task groups park
every Swift Concurrency cooperative thread in a blocking queue.sync
(the pool has one thread per core — 3 on CI runners, 10+ on dev
machines, which is why this never reproduced locally). Blocking the
entire cooperative pool violates the forward-progress contract, and
the runners' dispatch wedges under the resulting asyncAndWait flood —
taking concurrently running tests down with it (the panic-reset test
deadlocked in a serviceQueue barrier that never got scheduled).
Run the same 100 concurrent hammer iterations via
DispatchQueue.concurrentPerform from a single global-queue hop instead:
identical thread-safety coverage, executed on dispatch worker threads
where blocking is legal, zero cooperative threads parked.
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>
* 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>
The intermittent CI hang was caught by the new job timeout: the run
froze on the last remaining parallel test slot, an XCTest measure
benchmark (testNostrInboundEventHandling_freshEvents), after 4 hangs
in 5 runs - while the same suite completes in seconds locally and the
test itself is bounded. Independent of the micro-cause, benchmarks
do not belong inside the parallel suite: measuring while test processes
contend for cores is where our 2x CI variance came from. The parallel
run now skips benchmarks (BITCHAT_SKIP_PERF_BASELINES=1) and a
dedicated serial step runs them on an otherwise idle runner with a
6-minute step timeout, feeding the floor gate as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two app-test jobs hung intermittently (40+ minutes against a normal
~4-5), holding macOS runners against GitHub's 360-minute default and
starving the queue - subsequent runs sat pending, which read as "CI now
takes 10+ minutes". Jobs now time out at 15 minutes (3x the normal
duration) so a hang fails loudly instead of silently consuming the
runner pool. The intermittent hang itself is under investigation
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gate tripped on a uniformly slow runner: every benchmark ran at
~2/3 of the previous CI run and nostrInbound.duplicate fell to 87% of
its floor. Root cause: floors were derived from local numbers, but CI
slowdown is benchmark-dependent - sub-millisecond passes amplify runner
overhead (the duplicate path runs at ~20% of local speed on CI while
most benchmarks run at 40-60%). Floors are now ~50% of the slowest
observed CI run, recorded alongside the local references. Every floor
remains 10-200x above known regression values (the pre-optimization
duplicate path measured 2.2k/sec against the 250k floor), so order-of-
magnitude regressions still fail loudly. Verified against the slow
run's numbers: all 11 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
During a panic reset, the new NoiseEncryptionService was assigned
before the identity barrier ran, so a previously queued send block
could observe the new crypto service alongside the old peer ID -
signing with the new identity while carrying the old sender. The
service teardown, replacement, callback configuration, and derived
identity swap now run inside one messageQueue barrier
(refreshPeerIdentity executes inline via its re-entrancy check), so
queued sends see either the complete old identity or the complete new
one, never a mix.
Found by Codex review on #1336.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EOSE callbacks parked while Tor is bootstrapping now get a fallback
unblock at the standard 10s EOSE timeout (via the injected scheduler,
generation-guarded, single-fire) instead of waiting up to ~225s for
Tor-readiness retry exhaustion. The identity swap in
refreshPeerIdentity runs inside a messageQueue barrier with re-entrancy
guard, so a panic reset can no longer race in-flight packet builds
(deadlock analysis documented; both call paths verified off-queue).
Relay send-queue overflow drops now log a sampled warning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The publish chain was healthy: the store mutates the shared
BitchatMessage and republishes, the .statusChanged fan-out reaches both
mirrored conversations, and PrivateInboxModel fires objectWillChange for
the selected DM under either key. The break was at the row view:
TextMessageView/MediaMessageView stored the reference-typed message and
read deliveryStatus in body, so SwiftUI's structural diff compared the
field by identity - same instance, mutated in place, row body skipped.
The blue tick waited for an unrelated invalidation (proven empirically
with a hosting-view probe).
Rows now snapshot deliveryStatus as a value at init; every republish
rebuilds row values with a fresh enum, the diff sees the change, and
the row re-renders immediately. Also fixes in-place send-progress
updates in media rows. Regression tests cover both mirrored selection
keyings at the feature-model level and the snapshot mechanic itself.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The enqueue/drain/still-full logs fire per fragment during media
transfers (~150 lines for one 35KB image in field captures). They now
sample first + every 25th with a running event count, the sent/pending
lines merge into one, and the redundant peripheral-ready line is gone -
same treatment the relay event logs received. The drop-after-exhaustion
error stays unsampled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Production builds previously still emitted info/warning/error entries
via os_log (content private-redacted, but entries, categories, and
timing metadata were visible, and message strings were constructed).
For a privacy-first app the right posture is silence: every SecureLogger
wrapper and both cores are now gated behind #if DEBUG, so release
builds construct no log strings and emit nothing. Debug builds are
unchanged (public formatting, level threshold via BITCHAT_LOG_LEVEL).
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>
MessageRouter outbox drops (attempt cap, TTL expiry in flush and
cleanup, per-peer overflow eviction) now invoke onMessageDropped, wired
to mark the message .failed in the ConversationStore - guarded so a
late failure never downgrades an already delivered/read status.
NostrRelayManager pending subscriptions gain a per-relay cap (64,
oldest-by-sequence eviction; durable intent still replays from
subscriptionRequestState) and a 10-minute age sweep on the existing
connect path. Reconnect backoff gets injectable +/-20% jitter so
recovering relays don't thundering-herd.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a private message is mirrored into stable-key and ephemeral-peer
conversations as one shared BitchatMessage instance, the first
conversation's status apply mutated the shared object and the second
skipped as already-equal - state stayed correct but the mirrored
conversation never republished, so a view observing it rendered stale
delivery/read status. The ID-only fan-out now republishes and emits
.statusChanged for every skipped conversation whose message holds the
applied status; genuinely-rejected distinct copies (downgrades) stay
untouched, and duplicate acks still publish nothing.
Found by Codex review on #1334.
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>
ConversationStore maintains an exact messageID -> Set<ConversationID>
map at every mutation point (append/upsert/remove/migrate/trim/clear),
so delivery updates are ID-only lookups that fan out to mirrored
ephemeral/stable copies. ChatDeliveryCoordinator shrinks 327 -> 119
lines: the positional location index, its growth-detection/rebuild
machinery, and the duplicate no-downgrade check are deleted - the rule
now lives in exactly one place. The middle-insertion regression tests
are rewritten against the store since stale positional locations are
structurally impossible now.
delivery updates: 38k -> 262k/s (~6.9x); ingest pipelines unchanged.
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>
docs/CONVERSATION-STORE-DESIGN.md records the approved design: a
single-writer ConversationStore of per-conversation ObservableObjects
(per-conversation publishing, incremental ID index, folded caps, typed
change subject) replacing today's four-store/three-bridge topology,
with a five-step migration plan and explicit deletions/non-goals.
New pipeline benchmarks measure the CURRENT architecture end-to-end so
every migration step is judged against real before-numbers:
pipeline.privateIngest ~9.7k msg/s, pipeline.publicIngest ~6.8k msg/s
(200-message passes, stable within 1.5%).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Make location notes robust: durable relay subscriptions, failure decay, auto-recovery
Location notes (and geohash chat / DMs) intermittently stopped showing
events because the Nostr relay layer lost subscriptions and blacklisted
relays:
- Replay active subscriptions on every relay (re)connect. Relays drop
REQs with the socket; previously a drop silently killed the
subscription on that relay for the rest of the session. Durable
subscription intent now also survives disconnect()/resetAllConnections
(background -> foreground).
- Keep failed REQ sends queued instead of dropping them.
- Raise the EOSE fallback from a fixed 2s Timer to a 10s injected
schedule (Tor needs more than 2s), and settle EOSE trackers when a
relay disconnects before answering so initial load doesn't stall.
- Decay "permanently failed" relay markings after a 10-minute cooldown;
previously ~9 minutes of outage (or one DNS hiccup) excluded a relay
until app restart, with nothing resetting it on macOS.
- Make geo relay selection deterministic (distance, then host) so
publishers and subscribers with the same directory agree on relays.
- Post .geoRelayDirectoryDidRefresh after a directory fetch and let
LocationNotesManager auto-resubscribe out of the "no relays" state
instead of requiring a manual retry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Guard subscription activation on connection identity
A REQ send completion from a dead socket could land after
handleDisconnection cleared the relay's subscriptions and re-mark the
subscription active, making the next connection skip the durable replay
and leave that relay silent. Only mark a subscription active if the
completing socket is still the relay's live connection.
Regression test defers send completions in the mock so the stale
completion deterministically interleaves between disconnect and
reconnect.
Addresses Codex review on #1333.
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>
The coordinator scheduled its delayed owner-level read pass via
DispatchQueue.main.asyncAfter, which a busy CI runner's main queue can
delay past any reasonable polling deadline. Scheduling is now an
injected context member (scheduleOnMainAfter); the ChatViewModel witness
keeps the exact asyncAfter behavior while the test mock runs the work
synchronously, eliminating the wall-clock poll entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
swift test runs with --enable-code-coverage and each matrix job prints
an llvm-cov per-file + total summary (informational only - no
thresholds, so coverage can never be the reason a build goes red).
Local baseline at introduction: 69.7% lines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NotificationService.shared and FavoritesPersistenceService.shared
accesses across nine coordinators/components consolidate into
ChatViewModel witnesses behind intent-named context members
(notifyPrivateMessage, notifyMention, favoriteRelationship(forNoiseKey:),
allFavoriteRelationships, postLocalNotification, ...). Singleton reach
from coordinators is now zero; nine new tests cover previously
untestable notification/favorites flows.
Also root-causes the long-flaky gift-wrap dedup test: parallel tests
share LocationChannelManager.shared, so channel-switch tests trigger
clearProcessedNostrEvents() on every live ChatViewModel, wiping the
dedup record mid-test. The invariant (forged-signature copies never
poison dedup) now lives as a deterministic NostrInboundPipeline
mock-context test; two Schnorr concurrency probes added along the way
stay as regression guards for the P256K shared-context assumptions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 1,109-line coordinator becomes a 187-line facade wiring three
components, each with its own narrow context protocol:
GeohashSubscriptionManager (384 lines - subscription IDs + relay
lifecycle, the only NostrRelayManager toucher), NostrInboundPipeline
(490 lines - the hot event path, dedup-before-verify ordering preserved
verbatim), and GeoPresenceTracker (192 lines - teleport detection,
sampling LRU, notification cooldowns, now directly tested).
Perf baselines confirm the hot path is unchanged: fresh events
2,131 -> 2,138/sec, duplicates ~1.41M/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>
New PerformanceBaselineTests measure the hot paths (Nostr inbound, BLE
packet pipeline, GCS filters, delivery index, message formatting) with
deterministic fixtures and logged PERF metrics - baselines, not
assertions, so they cannot flake CI.
The suite immediately exposed that every inbound handler ran Schnorr
verification before the dedup lookup, so duplicate events - which
dominate real multi-relay traffic - each paid ~0.5ms of main-actor
crypto for nothing. All five handlers now do cheap rejects (kind, dedup
lookup) first and only record an event as processed AFTER its signature
verifies, so a forged-signature copy can never poison the dedup set and
suppress the genuine event. Gift-wrap verification also moves entirely
off the main actor with an atomic main-actor check-and-record.
Measured: duplicate-event handling 2.2k -> 1.39M events/sec (~640x);
fresh events unchanged (crypto-bound).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SecureLogger's public wrappers evaluated their message autoclosure before
the level check inside log(), so every filtered debug message across the
codebase still paid for string interpolation - hundreds of call sites on
the per-packet/per-event hot paths. The wrappers now guard the level
first, and debug() compiles out of release builds entirely. Regression
tests verify filtered messages are never constructed.
The two heaviest per-event debug logs (NostrRelayManager inbound events,
ChatNostrCoordinator geo events) are now sampled every 100th with a
running count, so debug-enabled dev builds stop emitting hundreds of
lines per minute in busy geohashes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The incremental index refresh assumed growth meant appended messages,
but PublicMessagePipeline inserts out-of-order arrivals by timestamp:
the count grows while the tail ID stays put, so the inserted message
never entered the index and later delivery updates for it silently
no-op'd (and, with retain-until-ack routing, left it queued for
resend). Detect non-append growth by checking the previously indexed
tail kept its position, and rebuild when it hasn't. Same check on the
per-peer private chat arrays, which re-sort by timestamp.
Found by Codex review on #1331.
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>
NostrRelayManager no longer strands work when Tor is slow to bootstrap:
failed readiness waits retry (bounded by nostrTorReadyMaxWaitAttempts)
instead of dropping queued relay connections, parked EOSE callbacks fire
after exhaustion so callers never hang, and sends made before Tor is
ready are queued locally (capped) instead of being dropped on a failed
wait - still strictly fail-closed.
MessageRouter now prefers a connected transport over a merely
window-reachable one, and sends made on a weak reachability signal are
retained in the outbox until a delivery/read ack confirms receipt
(receivers dedup by message ID), with resends bounded by attempt count.
GCS sync filters from the wire are bounds-checked (p in 1...32, m > 1)
at both the packet decode and filter decode layers; oversized Golomb
parameters previously decoded to garbage via silent shift overflow.
BLELinkStateStore is now explicitly pinned to bleQueue: debug builds
trap any access from another queue, enforcing the ownership discipline
the surrounding code already relied on by convention.
CI gains an iOS simulator build job (arm64 only; the vendored Arti
xcframework has no x86_64 simulator slice) so iOS-conditional code
paths are compile-checked - SPM tests only cover the macOS slice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vendored arti.xcframework rebuilt from source (Rust 1.96.0, normalized
archive metadata for reproducible hashes). New ARTI-BINARY-PROVENANCE.md
records toolchain, rebuild steps, and a SHA256 manifest for every file
in the xcframework. A new CI workflow turns that policy into a gate:
PRs must keep the binary matching the manifest, and binary changes must
ship with source/lockfile/build-script evidence.
Also raises TorManager.awaitReady's default timeout from 25s to 75s to
match the bootstrap monitor deadline - a shorter wait reported "not
ready" while Arti was still legitimately bootstrapping, silently
stranding queued relay work.
Privacy policy, Tor integration doc, and privacy assessment updated to
match the current implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Refactor BLE transport event handling
* Make image output paths unique
* Keep queued Nostr read receipts alive
* Refine BLE ingress fanout
* Rediscover BLE service after invalidation
* Extract BLE notification retry buffer
* Extract BLE inbound write buffer
* Extract BLE fragment assembly buffer
* Tidy secure log handling from device run
* Extract BLE outbound fragment scheduler
* Harden app CI media tests
* Redact BLE message content from logs
* Extract BLE Noise session queues
* Fix BLE read receipt UI updates
* Allow self-authored RSR ingress replies
* Harden read receipt queue test timing
* Extract BLE outbound policy and incoming file storage
* Avoid duplicate BLE link snapshots during send
* Canonicalize Nostr relay URLs
* Extract BLE link state store
* Extract BLE connection scheduler
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Refactor BLE transport event handling
* Make image output paths unique
* Keep queued Nostr read receipts alive
* Refine BLE ingress fanout
* Rediscover BLE service after invalidation
* Extract BLE notification retry buffer
* Extract BLE inbound write buffer
* Extract BLE fragment assembly buffer
* Tidy secure log handling from device run
* Extract BLE outbound fragment scheduler
* Harden app CI media tests
* Redact BLE message content from logs
* Extract BLE Noise session queues
* Fix BLE read receipt UI updates
* Allow self-authored RSR ingress replies
* Harden read receipt queue test timing
* Extract BLE outbound policy and incoming file storage
* Avoid duplicate BLE link snapshots during send
* Canonicalize Nostr relay URLs
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Refactor BLE transport event handling
* Make image output paths unique
* Keep queued Nostr read receipts alive
* Refine BLE ingress fanout
* Rediscover BLE service after invalidation
* Extract BLE notification retry buffer
* Extract BLE inbound write buffer
* Extract BLE fragment assembly buffer
* Tidy secure log handling from device run
* Allow self-authored RSR ingress replies
* Harden read receipt queue test timing
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Run GeoRelay fetch pipeline off main actor
* Capture GeoRelay session before detached fetch
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Expand protocol coverage with edge-case tests
* Stabilize read receipt transport test
* Stabilize BLE duplicate packet test
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Expand coverage for transport, chat, and media flows
* Stabilize transport and media coverage tests
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Expand coverage for relay, identity, and location flows
* Fix macOS SwiftPM CI failures
---------
Co-authored-by: jack <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>
* Improve BLE mesh reliability for large transfers
Major reliability improvements for fragment-based transfers (photos, files):
**Notification Queue Fixes**
- Fix silent packet loss when notification queue is full - now queues for retry
- Fix retry queue bug where remaining items were lost when one retry failed
- Add periodic drain mechanism as backup (every 5 seconds)
**Write Queue Fixes**
- Fix drainPendingWrites to use atomic take-send-requeue pattern
- Add logging when peripheral is ready for more writes
- Add periodic drain for pending writes as backup
**Fragment Pacing**
- Increase fragment spacing from 4-5ms to 25-30ms to prevent buffer overflow
- Conservative pacing prevents packet loss on congested BLE connections
**Thread Safety**
- Fix race conditions in stopServices() and emergencyDisconnectAll()
- Synchronize access to peripherals/centrals dictionaries during cleanup
- Clear pending message queues in emergencyDisconnectAll()
**Error Recovery**
- Add handlers for all BLE state transitions (poweredOff, unauthorized, etc.)
- Clear Noise session and re-initiate handshake on decryption failure
- Queue ACKs/receipts for delivery after handshake instead of dropping
- Re-queue failed pending messages for retry
**Other Improvements**
- Add route freshness validation in MeshTopologyTracker (60s threshold)
- Add TTL (24h) and size limits (100 per peer) for MessageRouter outbox
- Fix connection timeout to check peripheral.state before canceling
- Add NoiseEncryptionService.clearSession(for:) method
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix race condition and increase test timeouts
- Fix race in sendNoisePayload: use sync barrier instead of async
to ensure payload is queued before initiating handshake
(addresses Codex review feedback)
- Increase BLEServiceTests sleep from 0.5s to 1.0s for CI reliability
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* Add comprehensive test coverage for ChatViewModel and BLEService
This commit adds 14 new tests to improve the safety net for future
refactoring of ChatViewModel and BLEService:
New test files:
- ChatViewModelTorTests: Tor lifecycle notification handlers (8 tests)
- ChatViewModelDeliveryStatusTests: Delivery status state machine (6 tests)
- ChatViewModelRefactoringTests: Command routing and message handling (4 tests)
- BLEServiceCoreTests: Packet deduplication and stale broadcast filtering (2 tests)
- PublicMessagePipelineTests: Message ordering and deduplication (4 tests)
- MessageRouterTests: Transport selection and outbox behavior (4 tests)
- PrivateChatManagerTests: Chat selection and read receipts (2 tests)
- UnifiedPeerServiceTests: Fingerprint resolution and blocking (2 tests)
- RelayControllerTests: TTL, handshake, and fragment relay logic (4 tests)
Modified files:
- MockTransport: Added peer snapshot publishing on connect/disconnect
- TestHelpers: Added waitUntil polling helper for async tests
- MockIdentityManager: Extended for new test scenarios
Total: 454 tests across 64 suites (was 440)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix flaky test by using waitUntil instead of fixed sleep
Replace fixed 200ms sleep with waitUntil helper for more reliable
async assertion in routing tests. This prevents timing issues on CI.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Arti's dormant/wake FFI functions are no-op stubs that don't actually
implement dormant mode. The app was wasting 2.5-12 seconds trying to
wake from a dormant state that doesn't exist before falling back to
a full restart.
This change:
- Removes wakeFromDormant() call and function
- Simplifies goDormantOnBackground() to just mark state as not ready
- Goes straight to restartArti() on foreground, eliminating the delay
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>
Update root Package.swift to reference localPackages/Arti instead of
the removed localPackages/Tor directory.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Replace C Tor (0.4.8.21) with Rust Arti (1.9.0/arti-client 0.38)
- 70% smaller binary: 21MB xcframework vs 67MB (6.9MB vs 14MB per slice)
- Memory-safe Rust implementation with modern async (tokio)
- Same SOCKS5 proxy interface at 127.0.0.1:39050 for drop-in compatibility
- FFI wrapper (arti-bitchat crate) with C-compatible exports
- Swift TorManager maintains identical public API
- Aggressive size optimization: opt-level=z, lto=fat, panic=abort, strip
- Supports iOS device, iOS simulator (Apple Silicon), and macOS
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Include the build-minimal.sh script alongside BITCHAT_TOR.md so future
rebuilds can be done directly from the bitchat repo.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>