Commit Graph
934 Commits
Author SHA1 Message Date
201dbac49a docs: reconcile protocol docstrings with implementation (#1374)
- BitchatProtocol.swift: stop advertising "timing obfuscation prevents
  traffic analysis" — what exists is randomized relay jitter
  (RelayController, 10-220 ms) and PKCS#7-style padding to
  256/512/1024/2048-byte blocks (MessagePadding); there is no cover
  traffic or per-message timing obfuscation. Also update the stale
  Message Types list (Delivery/Read are Noise payloads, no Version
  negotiation type; add CourierEnvelope/RequestSync/FileTransfer).
- MessageType.swift: header said "6 essential" types; the enum has 9
  cases.

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

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

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

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:07:52 +02:00
c2a5668569 Sync cleanups: normalize SyncTypeFlags, single announce-ID path, TODO (#1373)
Follow-ups deferred from the REQUEST_SYNC review (#1371):

- SyncTypeFlags.init(rawValue:) now masks to the union of bits that map to a
  known message type (derived from the bit↔type table, so it tracks new
  types automatically). Phantom bits from a truncated/garbled flags field —
  or a type a newer peer added — no longer live in the set as membership no
  contains() matches yet toData() re-serializes.

- GossipSyncManager stored each latest announce as (hex-id string, packet)
  and diffed announces against the stored string while every other type
  recomputed the ID via PacketIdUtil. Collapsed the store to just the packet
  and recompute the ID everywhere, removing the latent dual-path divergence.

- Documented the REQUEST_SYNC TLV table and marked fragmentIdFilter (0x06)
  with a TODO(v2): it's parsed/re-serialized but never populated or honored
  (reserved for incremental fragment sync) — finish or drop, not silent dead
  surface.

Adds SyncTypeFlags phantom-bit/round-trip tests and a GossipSyncManager test
that an announce already in the requester's filter is suppressed (guards the
recompute path). Full suite: 1034 tests pass.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:04:14 +02:00
7341696280 Expand store-and-forward: open couriers, spray-and-wait, persistent outbox, 6h public history (#1372)
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>
2026-07-06 19:33:16 +02:00
295f855b6f Harden REQUEST_SYNC and stop gossip-sync re-send loops (#1371)
* Harden REQUEST_SYNC and stop gossip-sync re-send loops

Two fixes from an end-to-end review of the sync path:

Efficiency: the GCS filter (400B, p=7) covers ~355 packet IDs, but stores
hold up to 1000 messages + 600 fragments + 200 files. Once a mesh
accumulates more than the filter can cover, responders re-sent the entire
older tail to every requester every round — ~120KB per pair per 30s during
file transfers, dropped by dedup after the airtime was already burned.
Requesters now stamp the dormant sinceTimestamp TLV with the oldest
timestamp their filter covers, and responders skip older packets (announces
exempt: they carry the signing keys needed to verify everything else).
Periodic sync also sends one request per type schedule instead of a union
filter, so fragment floods can't crowd messages out of the filter budget.

Security: a ~40-byte unsigned REQUEST_SYNC with an empty filter could elicit
a full store replay (~900KB) — an unauthenticated >10,000x amplification
vector, repeatable in a tight loop and relayable with crafted TTL to fan the
drain out of every reachable node. Requests now require ttl == 0, a valid
signature from the claimed sender's announced signing key, and a matching
link binding; REQUEST_SYNC is never relayed regardless of TTL; and responses
are rate-limited per peer (8 per 30s sliding window, ~3x the legitimate
cadence).

Cross-platform: verified against bitchat-android — it signs REQUEST_SYNC and
sends SYNC_TTL_HOPS = 0, so both gates hold; it neither sends nor honors
sinceTimestamp yet, so mixed pairs keep today's behavior with no regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address Codex review: enforce no-relay on route path, exact since-cursor

Two P2 findings from Codex on the REQUEST_SYNC hardening:

- Route-forwarding bypass: handleRequestSync's early return for a rejected
  (nonzero-TTL / unsigned) request still fell through to
  forwardAlongRouteIfNeeded, which relays any routed packet with ttl > 1
  regardless of type. The no-relay invariant was only enforced on the flood
  path. BLERouteForwardingPolicy now suppresses REQUEST_SYNC outright, so a
  crafted request with a route and TTL headroom can't be forwarded to the
  next hop either.

- Inexact since-cursor: GCSFilter.buildFilter trimmed by hash order when the
  encoding overflowed the byte budget, so the cursor (computed from the
  untrimmed prefix) could claim coverage of timestamps whose packets were
  dropped from the filter — re-sending exactly those every round. buildFilter
  now trims from the input tail (oldest, since candidates are newest-first)
  and reports includedCount; the cursor is derived from that, so the covered
  set is always a contiguous newest-prefix and the cursor is exact.

Adds GCSFilter includedCount coverage (full vs trimmed), a route-forwarding
test for REQUEST_SYNC, and makes the truncated-cursor test robust to trim
variance. Full suite: 1029 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:17:26 +02:00
3ea4188699 Single-source MARKETING_VERSION in the xcconfig (#1369)
The version lived in five places: Release.xcconfig (which Debug
includes) plus four literal per-target overrides in the pbxproj that
shadow it. A bump that misses any subset splits app and extension
versions, and App Store validation rejects the archive
("CFBundleShortVersionString of an app extension must match its
containing parent app"). Remove the pbxproj entries so every target in
every configuration inherits the one xcconfig value; verified all six
target/config combinations resolve to 1.5.4 via -showBuildSettings.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:15:13 +02:00
a66c591f8e Courier: deposit in parallel when the only route is a send queue (#1368)
* 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>
v1.5.4
2026-07-06 17:45:57 +02:00
75da63c9d7 Fix favorites end-to-end: peer-list duplicates, Nostr sync, /fav key corruption (v1.5.4) (#1367)
* 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>
2026-07-06 17:17:21 +02:00
d285c6ad53 ux-fixes: lock alignment, caption band, wrapping, tap targets, VoiceOver, theme sweep (#1366)
* 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>
2026-07-06 14:22:38 +02:00
8296630cf3 Deflake app test suite: hermetic caches, robust async waits, perf-gate retry (#1365)
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>
2026-07-06 10:25:17 +02:00
c74e212ea3 Make peer lists accessible and actionable; block mesh peers by stable identity (#1360)
* 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>
2026-07-06 10:12:53 +02:00
7a0c821807 Improve message-list interactions: empty-state guidance, jump-to-latest, per-message actions (#1359)
* Improve message-list interactions: empty-state guidance, jump-to-latest, per-message actions

Three usability gaps in the message list, all presentation-layer:

- Empty timeline was a blank screen. It now narrates itself in dim,
  terminal-styled lines: what the channel is, that it's waiting for
  peers, and where the channel switcher and help live. Disappears with
  the first message.

- Scrolled up in a busy channel, nothing signalled that new messages
  arrived and there was no way back. A small "jump to latest" pill now
  appears while scrolled up, counting messages that arrived below, and
  taps back to the newest via the existing scroll helper. The unseen
  count re-baselines on channel switch so a cross-channel count delta is
  never shown as "new".

- A single tap anywhere on a message overwrote the composer draft with
  "@sender " and force-focused the field — casual taps while reading
  destroyed drafts. That whole-row tap is removed; mention/DM/hug/slap/
  block now live in the per-message context menu (reusing the handlers
  the existing action sheet already calls), and mention appends to the
  draft rather than replacing it. A failed own private message gets a
  resend item. The triple-tap-to-clear gesture gains a confirmation.

New strings are added source-language (en) only.

* Remove the failed original when resending a private message

Resend re-submitted the content but left the red failed bubble in
place, so every tap stacked another copy under it. Route resend
through ConversationUIModel, which drops the failed original from the
conversation store (removePrivateMessage) before sending the new copy.

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

* Count only rendered human messages in the jump-to-latest pill

The unseen count was a raw delta of the messages array, so system
lines (join/leave narration) and whitespace-only messages that never
render as rows inflated the "N new" pill. Baseline the counters
against the number of messages that render as human message rows,
using the same predicates the row builder applies.

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

* Hide mention/DM context-menu actions inside 1:1 conversations

In a private conversation, mentioning the only other participant is
noise and the DM action just reopens the already-open conversation
(toggling the sidebar). Gate both behind privatePeer == nil so the
public-timeline context menu is unchanged; hug/slap/block/copy/resend
remain in DMs.

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>
2026-07-06 10:09:04 +02:00
0d251ad20c Require confirmation before deleting a received image; label media controls (#1358)
* Require confirmation before deleting a received image; label media controls

Double-tapping a received image permanently deleted the message and its
file — no confirmation, no undo — while double-tap is the most ingrained
photo gesture on mobile, and it raced the reveal tap via
`.exclusively(before:)`. A mesh may never re-deliver that image, so an
accidental double-tap can destroy the only copy.

- Remove the double-tap-to-delete gesture. Delete moves into a
  long-press context menu behind a confirmation dialog ("this cannot be
  undone — the sender may not be in range to send it again"), alongside
  explicit open and hide-image actions (the swipe-to-re-blur was
  undiscoverable). Taps now only reveal and open.
- The blur overlay says "tap to reveal" instead of a bare eye-slash.
- Add the first accessibility support to these media views: labeled
  image states (hidden/revealed/sending) with custom actions, labeled
  voice play/pause with the duration as the value, and labeled cancel
  buttons.

Delete remains available and its underlying behavior is unchanged — it's
just gated. New strings are added source-language (en) only.

* Expose the in-flight cancel button to VoiceOver

The image tile uses accessibilityElement(children: .ignore), which
collapses the whole subtree — including the visible cancel button shown
while a send is in flight — into one element. VoiceOver users could not
cancel an in-progress image send. Add a cancel accessibility action for
the sending state.

* Mark the accessibility delete action destructive too

The context-menu delete already uses role: .destructive; the matching
accessibility action did not. Make them consistent.

* Deduplicate image actions and align accessibility labels with convention

Extract the open/hide/delete button set shared by the context menu and
accessibilityActions into a single @ViewBuilder so the two can't drift.
Move the interaction hints out of the accessibility labels into
accessibilityHint (labels stay nouns; "tap to reveal" was wrong for
VoiceOver activation anyway), and rename the blurred-state action to
"reveal image" since it reveals rather than opens.

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

* Offer cancel-send in the context menu while an image is sending

The context menu body was empty during sends, which some OS versions
still present as an empty preview. The accessibility path already
exposed a cancel-send action in that state; share the same button with
the context menu so pointer/touch users get a cancel path too.

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

* Label broken images honestly and drop actions that need the file

When the image file fails to load, the placeholder kept the "hidden
image"/"image" accessibility label with a reveal/open hint, and the
context menu still offered open/reveal on a URL that will not load.
Track the failed load, announce "image unavailable" with no interaction
hint, show a broken-photo glyph instead of an endless spinner, disable
the reveal/open gestures, and drop open/hide/reveal from the context
menu and accessibility actions -- keeping delete so received broken
attachments can still be cleaned up.

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>
2026-07-06 10:07:18 +02:00
c8ceac1968 Give private DMs an unmistakable visual signature (#1357)
* Give private DMs an unmistakable visual signature

An open DM renders identically to the public room — same view, same
green-on-black surface, with a small header name and two orange icons
as the only cues. For this audience the cost of misreading "am I in the
encrypted DM or the public channel?" is severe: sensitive text typed
into the wrong composer.

Four presentation-layer cues; no formatter or cache changes:

- The composer placeholder states the destination instead of a generic
  prompt: "message @jack — private" in a DM, "message #mesh — public,
  nearby" on mesh, "message #9q8yy — public" in a geohash channel.
- A persistent lock caption sits above the DM composer. It reads
  "private · end-to-end encrypted" only once the Noise session is
  actually secured or verified, and "private conversation" before that
  — the caption must not overstate encryption mid-handshake.
- The DM sheet header carries a faint orange wash (6%), extending the
  existing orange self-accent to the chrome.
- Each private message row is prefixed with a small orange lock glyph
  (view-layer, hidden from VoiceOver — the caption carries the
  semantic; the cached AttributedString formatter is untouched).

New strings are added source-language (en) only.

* Fix geohash-DM caption and placeholder

Two carve/review follow-ups:

- The privacy caption showed "private conversation" for geohash DMs,
  implying they are not encrypted — but geohash DMs are NIP-17
  gift-wrapped (always end-to-end encrypted), they just carry no Noise
  session status. Show the encrypted caption for geohash DMs and for
  secured Noise sessions; the pre-secured wording now applies only while
  a mesh handshake is still in progress.
- The private-chat placeholder prepended "@" to the partner name, which
  for a geohash DM (whose display name is already "#geohash/@name")
  produced a doubled "@". The "@" is now added only for mesh nicknames.

* Make the DM header orange wash visible in the matrix theme

The 6% orange background was chained after .themedSurface(), so in the
default matrix theme (whose themedSurface paints an opaque background)
the wash sat behind the surface and never rendered — it was only
visible in liquid glass. Apply the orange tint before .themedSurface()
so it layers in front of the themed background.

* Align DM lock glyph across text and media rows; keep header wash visible under glass

Media rows in a private conversation now get the same leading lock
glyph as text rows, so left edges line up instead of misaligning by
the glyph's width. The DM header's orange wash gets a higher opacity
under the liquid-glass theme, where themedSurface() adds no opaque
backing and 6% orange disappears into the backdrop gradient. Also
drops the dead sender != "system" guard in TextMessageView — system
messages are routed to systemMessageRow before this view is built.

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

* Remove orphaned content.input.message_placeholder from the string catalog

The destination-stating placeholders replaced its last code reference;
nothing on the branch resolves this key anymore.

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>
2026-07-06 10:06:18 +02:00
9ccff9cce4 Make private-message delivery status legible and accessible (#1356)
* Make private-message delivery status legible and accessible

The delivery indicator is the most stress-relevant signal in an
off-grid messenger, but it is hard to read:

- The status glyphs are 10pt icons whose only explanation is a
  `.help()` tooltip, which does not exist on iOS.
- Delivered vs read is the same double-checkmark distinguished only by
  colour.
- No case carries an accessibility label, so VoiceOver announces
  nothing.
- Two failure reasons ("Not delivered", "Encryption failed") bypass the
  localized reason catalog and are hardcoded English.

Changes (presentation only; the DeliveryStatus enum and the
contract-tested `displayText` are untouched):

- Add `DeliveryStatus.bitchatDescription`, a localized app-layer
  description, used as the macOS tooltip, a VoiceOver label on every
  status glyph, and — on iOS, where tooltips don't exist — a
  tap-to-reveal caption under the message.
- Failure reasons stay visible as a red caption without a tap.
- Read vs delivered is now legible without colour: read uses
  filled-circle checkmarks.
- Route the two hardcoded failure reasons through the localized catalog.

New strings are added source-language (en) only.

* Show the failure reason on failed media messages too

TextMessageView gained a visible red failure caption (the status
glyph's .help() tooltip does not exist on iOS), but MediaMessageView
still rendered the bare glyph — so a failed voice-note or image send
showed only a 10pt red triangle with no reason on iOS. Add the same
failure caption to media messages.

* Collapse revealed delivery detail when the status changes

A caption revealed while a message was "sending" stayed open and
silently morphed through later statuses (sent, delivered, read).
Reset showDeliveryDetail when the snapshotted DeliveryStatus changes.

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

* Add tap-to-reveal delivery detail to media rows

Media rows showed the same delivery glyphs as text rows but offered no
way to explain them on iOS, where .help() tooltips don't exist. Mirror
the text-row pattern: the glyph is now a button that reveals the
localized status caption below the header, failure reasons stay
visible without a tap, and the revealed caption collapses when the
status advances.

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

* Localize the remaining voice-note failure reasons

ChatMediaTransferCoordinator still passed hardcoded English reasons
into .failed(reason:), which now surface verbatim in the always-visible
failure caption. Route them through String(localized:) under the
existing content.delivery.reason.* convention.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:58:25 +02:00
66536063ca Route command output to the conversation where the command was typed (#1363)
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>
2026-07-05 20:26:31 +02:00
Hot Pixel GroupandGitHub e191e9c6f2 Fix slash-command suggestions that insert commands the processor rejects (#1354)
The autocomplete panel is the only in-app surface for discovering slash
commands, but several suggestions do not match what CommandProcessor
accepts, so tapping them inserts a command that returns "unknown
command":

- CommandInfo suggests /dm, /favorite, /unfavorite, but the processor
  only handles /m, /msg, /fav, /unfav. Aliases are aligned to the
  accepted spellings (msg, fav, unfav).
- Favorites are suggested only in geohash contexts (isGeoPublic ||
  isGeoDM) — exactly where the processor rejects them ("favorites are
  only for mesh peers"). The gating is inverted so they appear in mesh,
  where they work.

Also, small related fixes to the discovery surface:
- /help is now handled (the ChatViewModel command docstring already
  claimed it existed); it prints a local system line listing the valid
  commands, and the unknown-command error points at it.
- The suggestion panel keeps the matched command's usage row (e.g.
  "/msg <nickname>") visible while arguments are typed, instead of
  vanishing at the first space; in that mode the row is informational
  and no longer overwrites the draft on tap.

New string is added source-language (en) only. The CommandInfo contract
test is updated to the corrected metadata.
2026-07-05 14:15:57 +02:00
b31a63ce37 Burn down SwiftLint advisory violations from 109 to 4 (#1362)
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>
2026-07-05 14:09:13 +02:00
0f26a27980 Add SwiftLint as an advisory CI-only lint job (no Xcode plugin dependency) (#1361)
* Add SwiftLint as an advisory CI-only lint job (no Xcode plugin dependency)

* Harden the advisory lint job and exclude build dirs from local runs

The lint job runs a third-party container image, so drop its token to
read-only, stop actions/checkout from persisting credentials into the
workspace the container can read, and pin the image by digest as well
as tag (tags are mutable). Also add an excluded: list to .swiftlint.yml
so local swiftlint runs don't drown in .build/DerivedData artifacts —
CI checkouts are fresh, so this only affects working trees.

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:36:59 +02:00
68eeba97ff Use Xcode-bundled Swift in CI instead of a standalone toolchain (#1353)
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>
2026-07-01 23:51:58 +02:00
GitHub Action f688e529f6 Automated update of relay data - Sun Jun 21 07:37:42 UTC 2026 2026-06-21 07:37:42 +00:00
jackandGitHub 96e32ba990 Merge pull request #1345 from permissionlesstech/fix/security-audit-critical-high-batch
Fix security audit findings: 3 critical, 7 high
2026-06-17 09:44:26 +02:00
jack 0a2f4d9c9d Tighten panic wipe and NIP-17 regressions 2026-06-17 09:27:13 +02:00
jack 914135adb0 Fix panic wipe relay and geohash state 2026-06-16 13:56:15 +02:00
jackandGitHub cd7ffa0df9 Merge branch 'main' into fix/security-audit-critical-high-batch 2026-06-16 13:52:10 +02:00
GitHub Action bbe1ed0652 Automated update of relay data - Sun Jun 14 07:34:58 UTC 2026 2026-06-14 07:34:58 +00:00
jackandClaude Fable 5 f07b032b99 Fix CI exit hang: sign reassembled public packets in FragmentationTests
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>
2026-06-12 23:44:12 +02:00
jackandClaude Fable 5 2cbcb290f7 Remove lingering save timer from SecureIdentityStateManager (CI exit hang)
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>
2026-06-12 22:55:51 +02:00
jackandClaude Fable 5 9cf7c80518 Reduce test-process churn to fix flaky CI exit hang
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>
2026-06-12 22:40:39 +02:00
jackandClaude Fable 5 f76fd8a538 Fix CI hang: gate maintenance timer to Bluetooth-enabled; sign dedup test packet
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>
2026-06-12 20:09:06 +02:00
jackandClaude Fable 5 09c2c12838 Fix deadlock in identity-cache forceSave (CI hang)
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>
2026-06-12 17:46:24 +02:00
jackandClaude Fable 5 8378ff949a Address Codex review: verify public messages against registry signing key
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>
2026-06-12 14:18:44 +02:00
jackandClaude Fable 5 ca63893197 Fix security audit findings: 3 critical, 7 high
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>
2026-06-12 14:07:28 +02:00
fdf28aa5bb Fix launch crash: recursive dispatch_once between NostrRelayManager and NetworkActivationService (#1343)
* 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>
v1.5.3
2026-06-12 12:48:53 +02:00
266827ceff Extend BLE mesh range: relax RSSI gates, lift sparse TTL clamps, faster walk-back reconnects (#1338)
* 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>
v1.5.2
2026-06-12 10:38:39 +02:00
97bc3f53bc Raise positive waitUntil timeouts to 5s for loaded CI runners (#1340)
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>
2026-06-12 10:08:42 +02:00
9dc0ba6991 Fix CI app-suite deadlock: cooperative-pool-blocking thread-safety tests (#1339)
* 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>
2026-06-12 09:50:48 +02:00
af954b05ea Liquid Glass theme with in-app appearance switcher (#1337)
* 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>
2026-06-12 01:13:04 +02:00
jackandGitHub c8381737bb Final re-grade fixes: parked EOSE fallback, panic atomicity, perf-gate calibration, serial benchmarks (#1336)
Final re-grade fixes: parked EOSE fallback, panic-reset atomicity, overflow visibility
2026-06-11 22:25:26 +02:00
jackandClaude Fable 5 fa136d8973 Run perf benchmarks serially in their own CI step
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>
2026-06-11 22:18:55 +02:00
jackandClaude Fable 5 7fbe6a4a7e Fail hung CI jobs fast with a 15-minute timeout
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>
2026-06-11 22:00:34 +02:00
jackandClaude Fable 5 4791114406 Recalibrate perf floors to slowest-observed-CI basis
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>
2026-06-11 21:45:59 +02:00
jackandClaude Fable 5 1a6a08f92a Make Noise-service replacement atomic with the identity swap
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>
2026-06-11 21:36:12 +02:00
jackandClaude Fable 5 0b007eee1a Close the final re-grade findings: parked EOSE fallback, panic atomicity, overflow visibility
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>
2026-06-11 21:29:08 +02:00
jackandGitHub 1480b51c76 Close architecture re-grade gaps: visible failures, relay bounds, crypto encapsulation, perf gate, field diagnostics, resurrected Noise vectors (#1335)
Close architecture re-grade gaps: visible send failures, relay bounds, crypto encapsulation, perf gate, resurrected Noise vector tests
2026-06-11 20:10:28 +01:00
jackandClaude Fable 5 0e38ccfb3b Snapshot delivery status in message rows so read receipts render immediately
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>
2026-06-11 19:54:39 +02:00
jackandClaude Fable 5 e74f36927f Sample BLE notification backpressure logs
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>
2026-06-11 19:40:51 +02:00
jackandClaude Fable 5 cf3b85f65c Compile all logging out of release builds
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>
2026-06-11 19:31:28 +02:00
jackandClaude Fable 5 8899cb7f9e Add field correctness diagnostics: store invariant audit, drop and bounds proofs
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>
2026-06-11 19:28:47 +02:00
jackandClaude Fable 5 22be3d6392 Resurrect dead Noise vector tests; add CI perf floors; make tests hermetic
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>
2026-06-11 15:53:34 +02:00