mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
3c610a83cd98c8cf4ccb529722df3ef9bf115b63
188
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3c610a83cd |
Cashu ecash chips: detect, render, and redeem tokens + /pay command (#1376)
* Cashu ecash chips: detect, render, and redeem tokens + /pay command Content-level Cashu support, no wire-protocol changes: - CashuTokenDecoder: summarizes V3 (cashuA base64url-JSON) tokens — amount summed across proofs, unit, mint host, memo — and V4 (cashuB) via a minimal bounded CBOR reader. All input is treated as adversarial: size caps, depth/item budgets, overflow guards, display sanitization; malformed payloads fail closed to a generic chip. - PaymentChipView: cashu chips now show "500 sat · mint.example.com" (+ memo) instead of a generic label; tap opens a cashu: wallet URL and falls back to https://redeem.cashu.me when no wallet handles it; context menu adds copy token / redeem in wallet / redeem on web. - extractCashuLinks now returns bare deduplicated bearer strings so the chip can decode them (cashu: URIs still detected via the embedded token). - /pay <token>: validates the token decodes, sends it as the message body; DMs send directly, public channels require an explicit "/pay <token> public" confirm since tokens are bearer instruments. Suggested everywhere except public geohash channels. - Tests: decoder (V3/V4 decode, summation, URI forms, truncation/ garbage/huge fuzzing, CBOR depth bounds) and /pay command flows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Cashu: strict decode on the /pay SEND path The permissive decoder turned any non-empty cashuB… base64 that failed CBOR parsing into a generic TokenInfo, so the /pay guard accepted base64 junk and truncated V4 tokens and relayed them with a success message. Add a `strict` flag to CashuTokenDecoder.decode: in strict mode there is no permissive V4 fallback and the token must resolve to a known version with a positive amount, else it returns nil. Rendering keeps the permissive path (an unknown chip is fine for display). /pay now decodes with strict:true and surfaces "invalid cashu token" instead of sending. Tests: /pay with truncated cashuB / base64 junk is rejected; valid V3 and valid definite-length V4 still send; decoder strict-mode unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
60be88a4f5 |
Geohash bulletin board: persistent signed notices over mesh sync (#1379)
* Add geohash bulletin board: persistent signed notices over mesh sync
New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.
Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.
UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Board: bound orphan tombstones and reject future-dated posts at ingest
Two hardening fixes from Codex review of the geohash bulletin board:
- Orphan tombstones (P1): retention was derived solely from the
sender-chosen deletedAt, so self-signed tombstones for unseen post IDs
with far-future deletedAt persisted and re-entered sync unboundedly.
Retention is now also clamped to receive time (now + 7d + 1h skew --
no post can outlive that), and orphans are capped at 100 globally and
5 per author key with oldest-received evicted first. Matched
tombstones and disk restores keep their existing behavior.
- Future-dated posts (P2): ingest only checked expiresAt > now, letting
posts dated years ahead sort above honest posts and squat the 200
global slots without ever pruning. The single ingest chokepoint
(radio, sync, and disk restore all funnel through it) now rejects
createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the
decoder's span rule is unchanged.
Adds tests for the skew boundary, far-future expiry, receive-time
tombstone clamping, orphan caps/eviction, and matched-tombstone
exemption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
ede6368296 |
NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders (#1382)
* NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders Outgoing kind-20000 geohash messages mine a NIP-13 nonce tag (8 leading zero bits, ~256 hashes, typically <1 ms) off the main actor before signing. Mining is hard-capped at 2 s and cancellable (newer send or channel switch): on cap/cancel the committed target steps down so the message still ships promptly with an honest commitment - sending is never blocked and nothing is dropped. The hot loop serializes the canonical event once and rewrites only the fixed-width nonce bytes. Inbound kind-20000 events are scored per NIP-13 commitment semantics (committed target counts; the ID must actually meet it, extra work earns nothing) and never hard-rejected: validated PoW >= 8 bits skips the per-sender rate-limit bucket while the per-content flood bucket still applies, so old non-mining clients keep working under today's strict limits while bulk spam gets expensive. Presence heartbeats (kind 20001), kind-1 notes, and DMs are unchanged; no UI beyond a pow= field in an existing sampled debug log. Reimplemented from scratch rather than cherry-picking the stale feature/pow-geohash-mining-ui branch (unbounded loop, hard receive filtering, mining UI, XCTest, force unwraps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Geohash: serialize PoW sends so order matches send order Two location-channel sends back-to-back only cancelled the previous mining task and started a new one. Cancellation merely *expedites* NIP-13 mining (the target is polled and steps down; it never aborts the send), so the cancelled task still appended + relayed once mining returned. Both tasks ran concurrently and the second (shorter to mine) could finish first, reordering messages in the timeline and on relays. Chain the mining tasks: each geohash send captures the previous send's task, cancels it (to expedite, so delays never stack), and awaits its completion before it echoes and relays. Order is now always send order. The >2s mining cap is preserved: cancellation expedites the awaited task, so a send is never blocked beyond NostrPoW.miningTimeCap. Test: two rapid sends where the first mines longer (larger content) still land in send order for both the local echo and the relayed events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
276cde44e7 |
Gate Tor/relay startup on network reachability (#1389)
On a mesh-only/offline device the app used to bootstrap Tor and spin
Nostr relay reconnects forever ("connecting to Tor…"), wasting battery
even when there was provably no network path at all.
Add an NWPathMonitor-backed reachability signal (NetworkReachabilityMonitor)
and fold it into NetworkActivationService's activation gate:
- Tor bootstrap and relay connect/reconnect are now gated on the network
path being usable. When the path is fully unsatisfied (no interface at
all) we set autoStart off, shut Tor down, and disconnect relays instead
of looping. When a usable path returns we resume.
- Conservative policy: only NWPath.Status.unsatisfied counts as offline.
A flaky-but-present link stays "reachable" (Tor tolerates intermittent
connectivity); we never tear down on the first hiccup.
- Transitions are debounced (ReachabilityDebounce, ~2.5s) so path flapping
cannot thrash Tor/relay startup. The debounce is a pure value type,
unit-tested without the Network framework or real timers.
- Starts optimistic (reachable) so nothing is suppressed before the first
path evaluation arrives.
- BLE mesh never consults this gate and works fully offline.
- NWPathMonitor's background callback hops to the main actor before
touching any state.
Surfaces NetworkActivationService.isNetworkReachable for UI to distinguish
"offline" from "connecting to Tor".
Tests: pure debounce (satisfied → allowed, unsatisfied → suppressed after
interval, flap debounced, recover-after-outage) plus service wiring
(unreachable suppresses Tor+relays, recovery resumes, loss disconnects).
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
0a2f4d9c9d | Tighten panic wipe and NIP-17 regressions | ||
|
|
914135adb0 | Fix panic wipe relay and geohash state | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
8a867a17a1 |
Remove noise service exposure; single-owner selection state
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> |
||
|
|
ed86ed1065 |
Surface router drops as failed status; bound relay pending subscriptions; jitter backoff
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> |
||
|
|
8289a6d05e |
Republish mirrored conversations on shared-instance status changes
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> |
||
|
|
38331e62f1 |
Cut views over to ConversationStore; delete legacy store, bridge, resolver
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> |
||
|
|
7fb1f4a219 |
Route delivery status through the store; delete the location index
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> |
||
|
|
99d1d1dccd |
Cut public message path over to ConversationStore; delete PublicTimelineStore
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> |
||
|
|
879d8cba12 |
Cut private message path over to ConversationStore
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> |
||
|
|
ac3a2f2d34 |
Add single-writer ConversationStore core (additive)
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> |
||
|
|
45650854e7 |
Add conversation-store design doc and end-to-end ingest baselines
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> |
||
|
|
75dd83d9cc |
Make location notes robust: durable relay subscriptions, failure decay, auto-recovery (#1333)
* 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> |
||
|
|
6c0dbbbd0d |
Make lifecycle delayed read-pass deterministic in tests
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> |
||
|
|
cc76086615 |
Inject notification/favorites singletons through coordinator contexts
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> |
||
|
|
638f3f5005 |
Split ChatNostrCoordinator into owned components along domain boundaries
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> |
||
|
|
6091ee83ad |
Finish coordinator migration: zero ChatViewModel back-references remain
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> |
||
|
|
82736c4991 |
Migrate five more coordinators to narrow context protocols
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> |
||
|
|
707b22878d |
Convert shared coordinator state to owner-side intent operations
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> |
||
|
|
80bed1f395 |
Add performance baselines; check dedup before verifying Nostr signatures
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> |
||
|
|
19b28cf49d |
Rebuild message location index on non-append growth
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> |
||
|
|
3a995e20b6 |
Extract BLE packet handlers and migrate coordinators to narrow contexts
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> |
||
|
|
6bda919dd4 |
Fix transport reliability gaps: Tor stalls, weak-signal sends, GCS input validation
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> |