Compare commits

..
Author SHA1 Message Date
jackandClaude Fable 5 02828731b9 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>
2026-07-06 14:34:46 +02:00
jackandClaude Fable 5 f5b2150f11 Merge branch 'main' into feat/courier
Resolve DeliveryStatusView conflict: adopt main's bitchatDescription
extension (tooltip + VoiceOver via body modifiers) and fold the
.carried case into it, dropping the branch's Strings enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:27:17 +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
jackandGitHub b28fb26504 Merge branch 'main' into feat/courier 2026-07-01 23:52:52 +02:00
jackandGitHub ba0861a446 Merge branch 'main' into feat/courier 2026-07-01 23:02:13 +02:00
jackandClaude Fable 5 424c47be82 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>
2026-07-01 22:54:03 +02:00
jackandClaude Fable 5 20c1c0f687 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>
2026-07-01 22:48:10 +02:00
jack ebdcdf83dd Authenticate courier deposits by ingress peer 2026-06-19 17:39:00 +02:00
jack 2c04f65c83 Fix courier handoff verification and directed sends 2026-06-17 10:04:16 +02:00
jackandGitHub 293380e671 Merge branch 'main' into feat/courier 2026-06-17 10:03:07 +02:00
jackandClaude Fable 5 5aaa209020 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>
2026-06-12 11:13:16 +02:00
95 changed files with 4721 additions and 1257 deletions
+34 -2
View File
@@ -12,7 +12,10 @@ jobs:
runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue).
timeout-minutes: 15
# The long steps carry tighter individual bounds (5-minute test watchdog,
# 6-minute benchmark step, 10-minute floor gate that may re-run the
# benchmarks up to twice on a noisy runner); this is the backstop.
timeout-minutes: 25
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -102,9 +105,14 @@ jobs:
# Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this
# catches algorithmic regressions, never runner variance.
# catches algorithmic regressions, never runner variance. If a metric
# still lands below floor (a saturated runner can dip one), the script
# re-runs the benchmarks — appending to the same log and keeping each
# benchmark's best value per metric — so noise clears on retry while a
# real regression fails every attempt. Floors are never lowered by this.
- name: Performance floor gate
if: matrix.name == 'app'
timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the
@@ -145,3 +153,27 @@ jobs:
ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \
build
# Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
# it can never break the documented xcodebuild path or block a merge.
lint:
name: SwiftLint (advisory)
runs-on: ubuntu-latest
timeout-minutes: 15
# This job runs a third-party container image, so give it the least
# privilege we can: a read-only token, and no credentials left in the
# checkout for the container to find.
permissions:
contents: read
container:
# Tag for readability, digest for immutability (tags can be repointed).
# Bump both together, deliberately — never a floating tag.
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
continue-on-error: true
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Run SwiftLint
run: swiftlint lint --reporter github-actions-logging
+33
View File
@@ -0,0 +1,33 @@
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
# (CI checkouts are fresh, so this only matters in a working tree).
excluded:
- .build
- .swiftpm
- .DerivedData
- DerivedData
- build
- localPackages/*/.build
disabled_rules:
- line_length
- type_name
- identifier_name
- statement_position
- implicit_optional_initialization
- force_try
- vertical_whitespace
- for_where
- control_statement
- void_function_in_ternary
- redundant_discardable_let # SwiftUI breaks without it
# To be enabled as we fix the issues
- trailing_whitespace
- cyclomatic_complexity
- function_body_length
- function_parameter_count
- type_body_length
- file_length
- large_tuple
- force_cast
- multiple_closures_with_trailing_closure
- nesting
+2 -2
View File
@@ -13,9 +13,9 @@ let package = Package(
.executable(
name: "bitchat",
targets: ["bitchat"]
),
)
],
dependencies:[
dependencies: [
.package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"),
+20
View File
@@ -49,6 +49,14 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendMessage(message)
}
/// Resends a failed private message through the normal send path,
/// removing the failed original so the re-submission replaces it
/// instead of stacking a duplicate under the red bubble.
func resendFailedPrivateMessage(_ message: BitchatMessage) {
chatViewModel.removePrivateMessage(withID: message.id)
chatViewModel.sendMessage(message.content)
}
func clearCurrentConversation() {
chatViewModel.sendMessage("/clear")
}
@@ -67,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
// Mesh: block the peer's stable Noise identity resolved from the
// tapped peerID rather than re-resolving a display-name string.
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
} else {
chatViewModel.sendMessage("/block \(displayName)")
}
}
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
/// the tapped peer's stable identity so the exact row is unblocked this
/// also works for offline peers, which the `/unblock <displayName>` command
/// cannot resolve.
func unblock(peerID: PeerID, displayName: String) {
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
}
+7 -1
View File
@@ -232,7 +232,13 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
let availability = resolveAvailability(for: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// never resolve to a reachable mesh peer, so resolveAvailability would
// report .offline. Report .nostrAvailable so the header shows the
// globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM
? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer)
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID)
+1 -1
View File
@@ -71,7 +71,7 @@ struct BitchatApp: App {
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
true
}
+4 -4
View File
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
}
enum TrustLevel: String, Codable {
case unknown = "unknown"
case casual = "casual"
case trusted = "trusted"
case verified = "verified"
case unknown
case casual
case trusted
case verified
}
// MARK: - Identity Cache
File diff suppressed because it is too large Load Diff
+19 -12
View File
@@ -11,33 +11,38 @@ import Foundation
// MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable {
// Raw values must match the aliases CommandProcessor actually accepts
// the suggestion panel is the app's only command-discovery surface, and
// suggesting a spelling the processor rejects teaches users dead ends.
case block
case clear
case help
case hug
case message = "dm"
case message = "msg"
case slap
case unblock
case who
case favorite
case unfavorite
case favorite = "fav"
case unfavorite = "unfav"
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who:
case .clear, .help, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
@@ -47,12 +52,14 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite]
return baseCommands
}
return baseCommands
return baseCommands + [.favorite, .unfavorite]
}
}
+7 -1
View File
@@ -93,6 +93,7 @@ enum NoisePattern {
case XX // Most versatile, mutual authentication
case IK // Initiator knows responder's static key
case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
}
enum NoiseRole {
@@ -601,7 +602,7 @@ final class NoiseHandshakeState {
switch pattern {
case .XX:
break // No pre-message keys
case .IK, .NK:
case .IK, .NK, .X:
if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic {
@@ -904,6 +905,7 @@ extension NoisePattern {
case .XX: return "XX"
case .IK: return "IK"
case .NK: return "NK"
case .X: return "X"
}
}
@@ -925,6 +927,10 @@ extension NoisePattern {
[.e, .es], // -> e, es
[.e, .ee] // <- e, ee
]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
}
}
}
+1 -1
View File
@@ -648,7 +648,7 @@ private extension NostrProtocol {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: "nip44-v2".data(using: .utf8)!,
info: Data("nip44-v2".utf8),
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
+41 -240
View File
@@ -48,14 +48,7 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
let task = base.webSocketTask(with: url)
// Byte bound per inbound frame; without it the per-relay buffer cap
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
// hostile relay could pile up cap × 1 MiB (URLSession default) per
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
// sizing rationale. Oversized frames fail the receive with an error.
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
return URLSessionWebSocketTaskAdapter(base: task)
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
}
}
@@ -113,10 +106,7 @@ private extension NostrRelayManagerDependencies {
@MainActor
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
// Entries are removed only on OK acks (or panic wipe); relays that never
// ack leave entries behind for the process lifetime. Observability-only
// state, bounded in practice by outbound DM volume.
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id)
@@ -222,33 +212,7 @@ final class NostrRelayManager: ObservableObject {
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
// Schnorr-verified in arrival order OFF the main actor (this is the single
// signature verification for the whole inbound path downstream handlers
// receive only verified events), then hop back to the main actor for dedup
// recording and handler dispatch.
//
// Each relay connection owns its OWN AsyncStream + consumer task, so N
// relays verify in parallel while every relay's frames stay in arrival
// order (a single subscription's events for a relay all arrive on that
// relay's socket, so per-relay ordering preserves per-subscription
// ordering). A burst of EVENT frames from one busy/malicious relay only
// blocks that relay's own verification backlog DMs, OKs, EOSEs, and
// events from every other relay keep flowing on their own pipelines.
//
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
// than its verification drains sheds its own oldest frames instead of
// growing memory without bound; it can never starve other relays.
//
// Continuations live in a lock-guarded, `Sendable` router (see
// `InboundFrameRouter` at file scope) so the raw socket receive callback
// (which is NOT main-actor isolated) can route a frame to the right relay
// stream without a per-frame main hop, while the main actor owns pipeline
// creation/teardown. The expensive work (Schnorr verify) is what runs
// off-main; the yield stays cheap.
private let inboundRouter = InboundFrameRouter()
init() {
self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites()
@@ -302,70 +266,7 @@ final class NostrRelayManager: ObservableObject {
}
.store(in: &cancellables)
}
deinit {
inboundRouter.finishAll()
}
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
/// the main actor when a socket is (re)armed for receiving. Idempotent.
///
/// Ordering within the relay is deliberate and security/performance-critical:
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
/// duplicate LOOKUP duplicate fan-in from multiple relays dominates
/// real traffic and must never pay for Schnorr verification.
/// 2. `isValidSignature()` runs here, off the main actor the ONLY
/// signature verification on the inbound path (JSON re-serialization +
/// SHA-256 + secp256k1 Schnorr per event).
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
/// check-and-RECORD plus handler dispatch. Recording only after
/// verification means a forged-signature copy can never poison the
/// dedup cache and suppress the genuine event.
private func ensureRelayInboundPipeline(for relayUrl: String) {
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
Task.detached(priority: .userInitiated) {
for await frame in stream {
guard let parsed = ParsedInbound(frame.message) else { continue }
guard let self else { return }
switch parsed {
case .event(let subId, let event):
guard await self.precheckInboundEvent(
subscriptionID: subId,
eventID: event.id,
relayUrl: relayUrl
) else {
continue
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
continue
}
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
case .eose, .ok, .notice:
await self.handleParsedMessage(parsed, from: relayUrl)
}
}
}
}
if started {
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
}
}
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
/// consumer drains any already-buffered frames before finishing, so
/// in-flight verified events are still delivered.
private func teardownRelayInboundPipeline(for relayUrl: String) {
inboundRouter.finishPipeline(for: relayUrl)
}
private func teardownAllRelayInboundPipelines() {
inboundRouter.finishAll()
}
/// Connect to all configured relays
func connect() {
// Global network policy gate
@@ -380,8 +281,6 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
// Sockets are gone; drop every relay's inbound verify pipeline.
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked
@@ -411,7 +310,6 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: true)
subscriptions.removeAll()
pendingSubscriptions.removeAll()
@@ -662,7 +560,6 @@ final class NostrRelayManager: ObservableObject {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
}
@@ -1002,11 +899,7 @@ final class NostrRelayManager: ObservableObject {
connections[urlString] = task
task.resume()
// Bring up this relay's own serial verify pipeline before arming the
// socket, so inbound frames have somewhere to land.
ensureRelayInboundPipeline(for: urlString)
// Start receiving messages
receiveMessage(from: task, relayUrl: urlString)
@@ -1065,13 +958,14 @@ final class NostrRelayManager: ObservableObject {
switch result {
case .success(let message):
// Hand the raw frame to this relay's serial inbound pipeline:
// parsing and signature verification run off-main, in arrival
// order, independently of every other relay's pipeline. Routing
// through the lock-guarded router keeps this off the main actor
// (no per-frame main hop).
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Continue receiving
Task { @MainActor in
self.receiveMessage(from: task, relayUrl: relayUrl)
@@ -1089,55 +983,35 @@ final class NostrRelayManager: ObservableObject {
// Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
/// relays never pays for Schnorr verification. Recording happens only
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
/// forged-signature copy can never poison the dedup cache and suppress
/// the genuine event.
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].messagesReceived += 1
}
guard !eventID.isEmpty else { return true }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
if recentInboundEventKeys.contains(key) {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
}
return true
}
/// Second main-actor hop, after off-main signature verification:
/// authoritative check-and-record (the serial pipeline means the same
/// event is never in flight twice, but the record must stay atomic with
/// delivery) and handler dispatch.
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
}
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
// Handle parsed message on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event:
// Events flow through the serial inbound pipeline (precheck
// off-main signature verification deliverVerifiedInboundEvent)
// and never reach this fallback.
assertionFailure("inbound EVENT bypassed the verified pipeline")
case .event(let subId, let event):
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
return
}
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
@@ -1232,7 +1106,6 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) {
connections.removeValue(forKey: relayUrl)
teardownRelayInboundPipeline(for: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
@@ -1321,9 +1194,8 @@ final class NostrRelayManager: ObservableObject {
if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
teardownRelayInboundPipeline(for: normalizedRelayUrl)
}
// Attempt immediate reconnection
connectToRelay(normalizedRelayUrl)
}
@@ -1416,77 +1288,6 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
private struct InboundFrame: Sendable {
let message: URLSessionWebSocketTask.Message
}
/// Lock-guarded registry of per-relay inbound streams.
///
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
/// `Sendable` path to route a frame to the correct relay's stream without a
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
/// driven from the main actor; frame delivery (`yield`) can come from any
/// thread. All access is serialized by a single lock contention is negligible
/// because the guarded critical section is only a dictionary lookup + yield.
private final class InboundFrameRouter: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
/// Start a relay's stream + consumer if one does not already exist.
/// Returns true when a new pipeline was created. The bounded
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
/// frames under a flood, never other relays' frames. Buffered memory per
/// relay is bounded (not eliminated) at the frame cap times the per-frame
/// byte cap (`maximumMessageSize`) see TransportConfig.
func startPipeline(
for relayUrl: String,
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
) -> Bool {
lock.lock()
defer { lock.unlock() }
if continuations[relayUrl] != nil { return false }
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
)
continuations[relayUrl] = continuation
tasks[relayUrl] = makeConsumer(stream)
return true
}
/// Route a frame to a relay's stream. No-op if the relay has no live
/// pipeline (socket already torn down) the frame is simply dropped, which
/// is safe for best-effort Nostr inbound.
func yield(_ frame: InboundFrame, to relayUrl: String) {
lock.lock()
let continuation = continuations[relayUrl]
lock.unlock()
continuation?.yield(frame)
}
/// Finish a relay's stream. The consumer drains any already-buffered frames
/// before exiting, so in-flight verified events are still delivered.
func finishPipeline(for relayUrl: String) {
lock.lock()
let continuation = continuations.removeValue(forKey: relayUrl)
tasks.removeValue(forKey: relayUrl)
lock.unlock()
continuation?.finish()
}
func finishAll() {
lock.lock()
let allContinuations = continuations
continuations.removeAll()
tasks.removeAll()
lock.unlock()
for continuation in allContinuations.values {
continuation.finish()
}
}
}
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
@@ -132,4 +132,3 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .city: return 5
case .province: return 4
case .region: return 2
}
}
}
var displayName: String {
+22 -5
View File
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
let scheduleAfterglow: (TimeInterval) -> Void
}
/// Outcome of an accepted announce, surfaced so the service can run
/// follow-up work (e.g. courier handover) that keys off the announce.
struct BLEAnnounceHandlingResult {
let peerID: PeerID
let announcement: AnnouncementPacket
let isDirectAnnounce: Bool
let isVerified: Bool
}
/// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response.
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
let env = environment
let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate(
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
announcement = acceptance.announcement
case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return
return nil
case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return
return nil
case .reject(.selfAnnounce):
return
return nil
case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
return nil
}
// Suppress announce logs to reduce noise
@@ -210,5 +220,12 @@ final class BLEAnnounceHandler {
let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay)
}
return BLEAnnounceHandlingResult(
peerID: peerID,
announcement: announcement,
isDirectAnnounce: isDirectAnnounce,
isVerified: verifiedAnnounce
)
}
}
+64 -6
View File
@@ -19,13 +19,35 @@ enum BLEFanoutSelector {
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
let rawAllowed = allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
)
if let directedPeerHint,
let directedSelection = directLinks(
to: directedPeerHint,
links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return directedSelection
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
let allowed = collapseDuplicateLinksPerPeer(
allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
),
rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs)
}
private static func directLinks(
to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer(
(
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
return nil
}
return BLEFanoutSelection(
peripheralIDs: Set(directLinks.peripheralIDs),
centralIDs: Set(directLinks.centralIDs)
)
}
private static func hasBoundLink(
to peerID: PeerID,
peripheralIDs: [String],
centralIDs: [String],
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> Bool {
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
}
// Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
return false
}
}
+177 -8
View File
@@ -46,6 +46,20 @@ final class BLEService: NSObject {
// 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator()
// Courier store-and-forward: envelopes this device carries for offline
// third parties, and the trust gate for accepting deposits. Injectable
// for tests; main-actor policy because favorites live on the main actor.
var courierStore: CourierStore = .shared
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
}
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
// packets between in-process service instances.
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -888,6 +902,10 @@ final class BLEService: NSObject {
} else {
packetToSend = packet
}
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
@@ -1047,6 +1065,9 @@ final class BLEService: NSObject {
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
}
@@ -1305,7 +1326,7 @@ extension BLEService: GossipSyncManager.Delegate {
extension BLEService: CBCentralManagerDelegate {
#if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
@@ -1987,7 +2008,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
#if os(iOS)
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) {
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
@@ -2468,7 +2489,142 @@ extension BLEService {
ttl: messageTTL
)
}
// MARK: Courier Store-and-Forward
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
/// the envelope to the given couriers for physical delivery. Returns false
/// when no courier is connected, the payload cannot be built, or sealing
/// fails; link writes are queued asynchronously after the envelope is ready.
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
let connected = couriers.filter { isPeerConnected($0) }
guard !connected.isEmpty,
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
return false
}
let payload: Data
do {
let now = Date()
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed
)
guard let encoded = envelope.encode() else { return false }
payload = encoded
} catch {
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
return false
}
messageQueue.async { [weak self] in
guard let self else { return }
for courier in connected {
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
}
}
return true
}
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
}
/// Handles both courier roles for an incoming envelope addressed to us:
/// recipient (the rotating tag matches our static key open and deliver)
/// or courier (a trusted peer is depositing mail for someone else store).
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
// Directed packets only; envelopes addressed elsewhere ride the
// generic relay path untouched.
guard packet.recipientID == myPeerIDData else { return }
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
let myKey = noiseService.getStaticPublicKeyData()
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
openCourierEnvelope(envelope)
} else {
acceptCourierDeposit(envelope, from: peerID)
}
}
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
do {
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
guard let typeRaw = typedPayload.first,
let payloadType = NoisePayloadType(rawValue: typeRaw),
payloadType == .privateMessage else {
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
return
}
// Couriered mail arrives while the sender is absent, so the UI's
// block check can't resolve their fingerprint from a live session.
// Gate here, where the full static key is in hand.
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
return
}
// Mesh peer IDs are derived from Noise static keys, so the sender
// resolves to the same DM thread whether or not they're present.
let senderPeerID = PeerID(publicKey: senderStaticKey)
let payload = Data(typedPayload.dropFirst())
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))", category: .session)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: senderPeerID,
type: payloadType,
payload: payload,
timestamp: Date()
))
}
} catch {
// Tag collision or stale key: not addressed to us after all.
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
}
}
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
return
}
let store = courierStore
let policy = courierDepositPolicy
Task { @MainActor in
guard policy(depositorKey) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
return
}
if store.deposit(envelope, from: depositorKey) {
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))", category: .session)
}
}
}
/// Hand over any carried envelopes addressed to a peer we just heard from.
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
guard !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
}
}
// MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -2700,7 +2856,7 @@ extension BLEService {
// Notify delegate of failure
notifyUI { [weak self] in
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: "Encryption failed")))
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
}
}
}
@@ -2953,13 +3109,15 @@ extension BLEService {
case .fileTransfer:
handleFileTransfer(packet, from: senderID)
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .leave:
handleLeave(packet, from: senderID)
case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break
}
if forwardAlongRouteIfNeeded(packet) {
@@ -3016,7 +3174,18 @@ extension BLEService {
}
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
announceHandler.handle(packet, from: peerID)
let result = announceHandler.handle(packet, from: peerID)
// Courier handover: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them.
// Direct announces only: envelopes are removed from the store
// optimistically, so handover must ride an established link rather
// than a speculative multi-hop send toward a relayed announce.
guard !courierStore.isEmpty,
let result,
result.isVerified,
result.isDirectAnnounce else { return }
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
}
/// Builds the announce handler environment. All queue hops stay here so
+18 -1
View File
@@ -105,11 +105,28 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
case "/help":
return .success(message: Self.helpText)
default:
return .error(message: "unknown command: \(cmd)")
return .error(message: "unknown command: \(cmd) — type /help for commands")
}
}
/// Local-only command reference, printed as a system message. The
/// suggestion panel hides once arguments are typed, and typos used to
/// dead-end in a bare "unknown command" this is the way out.
static let helpText = """
commands:
/msg @name [message] start a private chat
/who list who's here
/clear clear this chat
/hug @name send a hug
/slap @name slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name favorites (mesh only)
/help this list
"""
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
+219
View File
@@ -0,0 +1,219 @@
//
// CourierStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Combine
import Foundation
/// Holds courier envelopes this device is carrying for offline third parties.
///
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
/// never learns sender, recipient, or content. Strict quotas keep the device
/// from becoming a public mailbag: bounded count, bounded per-depositor
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
/// retention policy. Carried mail is included in the panic wipe.
final class CourierStore {
struct StoredEnvelope: Codable, Equatable {
let recipientTag: Data
let expiry: UInt64
let ciphertext: Data
let depositorNoiseKey: Data
let storedAt: Date
var envelope: CourierEnvelope {
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
}
}
enum Limits {
static let maxEnvelopes = 20
static let maxPerDepositor = 5
/// Slack on top of the 24h lifetime for depositor clock skew.
static let maxExpirySlack: TimeInterval = 60 * 60
}
static let shared = CourierStore()
/// Number of envelopes currently carried, published on the main thread
/// so the UI can show a "carrying mail" indicator.
@Published private(set) var carriedCount: Int = 0
/// Fast path so hot code (announce handling) can skip tag computation.
var isEmpty: Bool {
queue.sync { envelopes.isEmpty }
}
private var envelopes: [StoredEnvelope] = []
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Depositing (courier side)
/// Accept an envelope from a depositor. Returns false when quotas or
/// validity checks reject it. Trust policy (mutual favorite) is the
/// caller's responsibility; this store only enforces resource bounds.
@discardableResult
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
let date = now()
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
!envelope.ciphertext.isEmpty,
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
!envelope.isExpired(at: date) else {
return false
}
// Reject expiries beyond the policy lifetime so depositors can't pin
// storage longer than the outbox would retain the message itself.
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
return false
}
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently.
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
return true
}
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
return false
}
if envelopes.count >= Limits.maxEnvelopes {
// Oldest-first eviction, matching outbox overflow behavior.
let evicted = envelopes.removeFirst()
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
}
envelopes.append(StoredEnvelope(
recipientTag: envelope.recipientTag,
expiry: envelope.expiry,
ciphertext: envelope.ciphertext,
depositorNoiseKey: depositorNoiseKey,
storedAt: date
))
persistLocked()
return true
}
}
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
}
}
// MARK: - Maintenance
func pruneExpired() {
let date = now()
queue.sync {
pruneExpiredLocked(at: date)
persistLocked()
}
}
/// Panic wipe: drop all carried mail from memory and disk.
func wipe() {
queue.sync {
envelopes.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
publishCountLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
let before = envelopes.count
envelopes.removeAll { $0.envelope.isExpired(at: date) }
if envelopes.count != before {
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
}
}
private func publishCountLocked() {
let count = envelopes.count
DispatchQueue.main.async { [weak self] in
self?.carriedCount = count
}
}
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist courier store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
return
}
envelopes = stored
pruneExpiredLocked(at: now())
publishCountLocked()
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("courier", isDirectory: true)
.appendingPathComponent("envelopes.json")
}
}
+64 -2
View File
@@ -2,11 +2,33 @@ import BitLogger
import BitFoundation
import Foundation
/// Trust and identity lookups the router needs to pick couriers. Backed by
/// the favorites store in production; injectable for tests.
struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data?
/// Whether a peer (by Noise static key) may carry our mail.
var isTrustedCourier: (Data) -> Bool
@MainActor
static func favoritesBacked() -> CourierDirectory {
CourierDirectory(
noiseKey: { peerID in
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
},
isTrustedCourier: { noiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
}
)
}
}
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
private let transports: [Transport]
private let now: () -> Date
private let courierDirectory: CourierDirectory
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -14,6 +36,12 @@ final class MessageRouter {
/// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Invoked when a message with no reachable transport was handed to at
/// least one courier (a connected mutual favorite who will physically
/// carry the sealed envelope). Delivery stays best-effort: the outbox
/// retains the message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
@@ -31,10 +59,17 @@ final class MessageRouter {
// Bound resends of messages sent on a weak reachability signal that never
// get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8
// Redundant couriers improve delivery odds; receivers dedup by message ID.
private static let maxCouriersPerMessage = 3
init(transports: [Transport], now: @escaping () -> Date = Date.init) {
init(
transports: [Transport],
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil
) {
self.transports = transports
self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -51,7 +86,7 @@ final class MessageRouter {
}
// Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
note.userInfo?["isKeyUpdate"] is Bool {
let peerID = PeerID(publicKey: newKey)
Task { @MainActor in
self.flushOutbox(for: peerID)
@@ -94,6 +129,33 @@ final class MessageRouter {
unsent.sendAttempts = 0
enqueue(unsent, for: peerID)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
}
}
/// Last resort when no transport can reach the peer: seal the message to
/// their known static key and hand it to connected mutual favorites who
/// may physically encounter them. The queued copy above stays retained,
/// so direct delivery still wins if the peer reappears first (receivers
/// dedup by message ID).
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
for transport in transports {
let couriers = transport.currentPeerSnapshots()
.filter { snapshot in
guard snapshot.isConnected,
let key = snapshot.noisePublicKey,
key != recipientKey else { return false }
return courierDirectory.isTrustedCourier(key)
}
.prefix(Self.maxCouriersPerMessage)
.map(\.peerID)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
onMessageCarried?(messageID, peerID)
return
}
}
}
+45 -2
View File
@@ -369,6 +369,49 @@ final class NoiseEncryptionService {
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
}
// MARK: - Courier Envelopes (one-way Noise X)
/// Domain separation for courier envelopes so X-pattern transcripts can
/// never be confused with interactive XX handshakes.
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
/// Encrypt a payload to a peer's known static key without an interactive
/// handshake (Noise X pattern). Used for store-and-forward envelopes
/// carried by couriers while the recipient is offline.
/// - Warning: One-way messages have no forward secrecy: a later compromise
/// of the recipient's static key exposes envelopes captured in transit.
/// Use established sessions whenever the peer is reachable.
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.courierPrologue
)
return try handshake.writeMessage(payload: payload)
}
/// Decrypt a courier envelope addressed to our static key. Returns the
/// payload and the sender's authenticated static public key (the `ss`
/// DH in the X pattern binds the sender's identity to the ciphertext).
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
prologue: Self.courierPrologue
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
}
/// Clear persistent identity (for panic mode)
func clearPersistentIdentity() {
@@ -428,7 +471,7 @@ final class NoiseEncryptionService {
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
var out = Data()
// context
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
let context = Data("bitchat-announce-v1".utf8)
out.append(UInt8(min(context.count, 255)))
out.append(context.prefix(255))
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
@@ -444,7 +487,7 @@ final class NoiseEncryptionService {
out.append(ed32)
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
// nickname length + bytes
let nickData = nickname.data(using: .utf8) ?? Data()
let nickData = Data(nickname.utf8)
out.append(UInt8(min(nickData.count, 255)))
out.append(nickData.prefix(255))
// timestamp
+1 -1
View File
@@ -137,7 +137,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] }
func getPeerNicknames() -> [PeerID: String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
+1 -1
View File
@@ -111,7 +111,7 @@ final class NotificationService {
func requestAuthorization() {
guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted {
// Permission granted
} else {
+1 -1
View File
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
case .read, .delivered:
externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent:
case .failed, .partiallyDelivered, .sending, .sent, .carried:
break
}
}
+7
View File
@@ -95,6 +95,12 @@ protocol Transport: AnyObject {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ transferId: String)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -120,6 +126,7 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
-20
View File
@@ -55,26 +55,6 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Bounded per-relay inbound frame buffer. Each relay connection owns its
// own serial verify pipeline; if a relay floods faster than its Schnorr
// verification drains, the oldest buffered frames for THAT relay are
// dropped (bufferingNewest) so one relay cannot stall other relays.
// Nostr inbound is already best-effort (relays are redundant and events
// replay), so dropping a flooding relay's backlog is safe. Together with
// nostrInboundMaxFrameBytes this caps buffered inbound bytes at
// cap × maxFrameBytes (128 MiB) per hostile relay bounded, not zero.
static let nostrInboundPerRelayBufferCap: Int = 256
// Hard per-frame byte bound, applied as URLSessionWebSocketTask
// .maximumMessageSize (oversized frames fail the receive instead of
// buffering). BitChat's legitimate Nostr traffic is small: geohash chat /
// presence events (kind 20000/20001), kind-1 notes, and NIP-17
// gift-wrapped DMs carrying text payloads or receipts are all a few KiB,
// and most public relays reject events beyond ~64256 KiB anyway. 512 KiB
// leaves an order-of-magnitude margin over anything we produce or expect
// while halving the URLSession default (1 MiB), so a hostile relay's
// worst-case buffered pile-up per connection is
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
+23 -1
View File
@@ -257,7 +257,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return false
}
/// Block or unblock a mesh peer by its stable Noise identity.
///
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
/// (cache / mesh session / known-peer Noise key). This works even when the
/// peer is offline including offline favorites so the exact tapped peer
/// is (un)blocked unambiguously instead of being re-resolved by a
/// display-name string that two peers could share.
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
@discardableResult
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
guard let fingerprint = getFingerprint(for: peerID) else {
SecureLogger.warning(
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
category: .session
)
return nil
}
identityManager.setBlocked(fingerprint, isBlocked: blocked)
updatePeers()
return fingerprint
}
/// Toggle favorite status
func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else {
+3
View File
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
}
}
@@ -27,4 +27,3 @@ struct PeerDisplayNameResolver {
return result
}
}
@@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator {
case .failed: return 1
case .sending: return 2
case .sent: return 3
case .partiallyDelivered: return 4
case .delivered: return 5
case .read: return 6
case .carried: return 4
case .partiallyDelivered: return 5
case .delivered: return 6
case .read: return 7
}
}
}
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
}
}
}
@@ -71,7 +71,7 @@ final class ChatNostrCoordinator {
key: Data?
) {
guard let context else { return }
if let _ = key {
if key != nil {
if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
}
@@ -84,7 +84,7 @@ final class ChatNostrCoordinator {
}
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key {
if key != nil {
if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
}
+61 -8
View File
@@ -988,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
}
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
// command, these resolve and persist the block by the peer's stable
// fingerprint (derived from `peerID`), so the exact tapped peer is
// (un)blocked unambiguous across nickname collisions and functional for
// offline peers that can no longer be resolved through the mesh service.
@MainActor
func blockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
}
@MainActor
func unblockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
}
@MainActor
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
),
locale: .current,
displayName
)
)
return
}
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
comment: "System message shown when a mesh peer is blocked or unblocked"
),
locale: .current,
displayName
)
)
}
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
}
@@ -1147,6 +1189,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk)
CourierStore.shared.wipe()
// Identity manager has cleared persisted identity data above
// Clear autocomplete state
@@ -1177,11 +1222,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here.
NostrRelayManager.shared.resetForPanicWipe()
// Clearing relay handlers stops NEW events, but a detached gift-wrap
// decrypt spawned just before the wipe still holds a pre-wipe key and
// ciphertext; bump the pipeline's wipe generation so its result is
// dropped at the main-actor delivery hop instead of landing here.
nostrCoordinator.inbound.invalidateInFlightDecrypts()
nostrRelayManager = nil
// Clear Nostr identity associations
@@ -1440,7 +1480,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Processes IRC-style commands starting with '/'.
/// - Parameter command: The full command string including the leading slash
/// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help
/// - Note: Supports commands like /msg, /who, /slap, /clear, /help
@MainActor
func handleCommand(_ command: String) {
let result = commandProcessor.process(command)
@@ -1448,16 +1488,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
switch result {
case .success(let message):
if let msg = message {
addSystemMessage(msg)
addCommandOutput(msg)
}
case .error(let message):
addSystemMessage(message)
addCommandOutput(message)
case .handled:
// Command was handled, no message needed
break
}
}
/// Command output belongs in the conversation where the user typed the
/// command; the public timeline is invisible while a DM is open. The DM
/// selection is read *after* processing so commands that switch chats
/// (`/msg`) print into the conversation they just opened.
@MainActor
private func addCommandOutput(_ content: String) {
if let peerID = selectedPrivateChatPeer {
addLocalPrivateSystemMessage(content, to: peerID)
} else {
addSystemMessage(content)
}
}
// MARK: - Message Reception
@MainActor
@@ -100,11 +100,28 @@ private extension ChatViewModelBootstrapper {
category: .session
)
viewModel.conversations.setDeliveryStatus(
.failed(reason: "Not delivered"),
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
forMessageID: messageID
)
}
}
// A message with no reachable transport that was handed to a courier
// shows a distinct "carried" state instead of sitting in "sending"
// forever. Never downgrade a confirmed receipt: the courier copy can
// race direct delivery when the peer reappears.
viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
guard let viewModel else { return }
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
case .delivered, .read:
break
default:
SecureLogger.debug(
"📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
category: .session
)
viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
}
}
viewModel.commandProcessor.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel)
+1 -2
View File
@@ -103,8 +103,7 @@ final class GeoPresenceTracker {
else {
return
}
// The signature was already verified (exactly once, off the main
// actor) by NostrRelayManager before delivery.
guard event.isValidSignature() else { return }
guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = context.geoParticipantCount(for: gh)
+89 -133
View File
@@ -75,38 +75,20 @@ extension ChatViewModel: NostrInboundPipelineContext {
}
}
/// The inbound Nostr hot path: verified relay events in, chat messages /
/// Noise payloads out. Pure transformation plus dedup no relay lifecycle.
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
/// payloads out. Pure transformation plus dedup no relay lifecycle.
///
/// Every event arriving here already had its Schnorr signature verified
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
/// pipeline (which records events into its own dedup cache only AFTER
/// verification, so forged copies can't suppress genuine events). This
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup
/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption two
/// ECDH+ChaCha layers off the main actor with an atomic main-actor
/// check-and-record.
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
/// dominate real relay traffic; events are recorded only AFTER verification so
/// a forged-signature copy can never poison the dedup set; gift-wrap
/// verification for the account mailbox runs off-main with an atomic
/// main-actor check-and-record.
final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
private var geoEventLogCount = 0
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
/// relay handlers so no NEW events flow, but a detached decrypt task
/// spawned just BEFORE the wipe which strongly captures a pre-wipe Nostr
/// private key and ciphertext survives it. Spawn sites capture this
/// value; the task compares it at its main-actor hops and drops its result
/// (no delivery; the captured identity and plaintext die with the task)
/// if `invalidateInFlightDecrypts()` bumped it in between.
@MainActor private(set) var wipeGeneration: UInt64 = 0
/// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted
/// with pre-wipe keys can never land in post-wipe state.
@MainActor
func invalidateInFlightDecrypts() {
wipeGeneration &+= 1
}
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
self.context = context
self.presence = presence
@@ -115,15 +97,17 @@ final class NostrInboundPipeline {
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) duplicates dominate real
// traffic. The signature was already verified (exactly once, off the
// main actor) by NostrRelayManager before delivery.
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
// Only verified events are recorded, so a forged-signature copy can
// never poison the dedup set and suppress the genuine event.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!context.hasProcessedNostrEvent(event.id)
else {
return
}
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
@@ -192,14 +176,15 @@ final class NostrInboundPipeline {
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) the signature was already
// verified (exactly once, off the main actor) by NostrRelayManager.
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
if context.hasProcessedNostrEvent(event.id) { return }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
@@ -279,126 +264,104 @@ final class NostrInboundPipeline {
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Cheap dedup pre-check only; processGeohashGiftWrap does the
// authoritative main-actor check-and-record before the off-main
// NIP-17 unwrap. The outer signature was already verified (exactly
// once, off the main actor) by NostrRelayManager.
// Dedup lookup before Schnorr verification; record only after it passes.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
// Capture the wipe generation at spawn, alongside the per-geohash
// identity (private key) the detached task strongly captures. A panic
// wipe between spawn and delivery bumps the generation, and the task
// drops its result instead of delivering plaintext post-wipe.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch noisePayload.type {
case .privateMessage:
context.handlePrivateMessage(
noisePayload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Cheap dedup pre-check only; see subscribeGiftWrap.
// Dedup lookup before Schnorr verification; record only after it passes.
if context.hasProcessedNostrEvent(giftWrap.id) {
return
}
// Spawn-time wipe-generation capture; see subscribeGiftWrap.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration)
}
}
/// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha
/// layers) runs off the main actor; results hop back for state updates.
/// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it
/// to the sampling path.
///
/// `wipeGeneration` is this pipeline's generation captured at spawn (the
/// moment the pre-wipe `id` was captured); a mismatch at either main-actor
/// hop means a panic wipe happened in between, so the task bails without
/// decrypting (first hop) or without delivering the plaintext (second
/// hop) the captured identity and any decrypted material are simply
/// dropped with the task.
private func processGeohashGiftWrap(
_ giftWrap: NostrEvent,
id: NostrIdentity,
verbose: Bool,
wipeGeneration: UInt64
) async {
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
let alreadyProcessed: Bool = await MainActor.run {
guard self.wipeGeneration == wipeGeneration else { return true }
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id)
return false
}
if alreadyProcessed { return }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
) else {
if verbose {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
}
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
if verbose {
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
await MainActor.run {
// A panic wipe during the off-main decrypt must not let the
// pre-wipe plaintext reach post-wipe state; drop it here, atomic
// with the wipe on the main actor.
guard self.wipeGeneration == wipeGeneration else { return }
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
guard let context else { return }
// Cheap dedup pre-check only; processNostrMessage does the
// authoritative check-and-record before the off-main NIP-17 unwrap.
// The outer signature was already verified (exactly once, off the
// main actor) by NostrRelayManager, and only verified events are
// recorded, so a forged-signature copy can never poison the dedup
// set and suppress the genuine event.
// Cheap dedup pre-check only; Schnorr verification runs off-main in
// processNostrMessage, which then does the authoritative
// check-and-record. Recording stays after verification so a
// forged-signature copy can never poison the dedup set and suppress
// the genuine event.
if context.hasProcessedNostrEvent(giftWrap.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in
@@ -407,6 +370,7 @@ final class NostrInboundPipeline {
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
@@ -416,13 +380,8 @@ final class NostrInboundPipeline {
return false
}
if alreadyProcessed { return }
// Fetch the identity and the wipe generation in ONE main-actor hop:
// the generation then vouches for exactly this identity. A wipe after
// this point bumps the generation and the delivery hop below drops
// the decrypted result (same guard as processGeohashGiftWrap; this
// account-mailbox path had the identical hazard).
let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run {
(context.currentNostrIdentity(), self.wipeGeneration)
let currentIdentity: NostrIdentity? = await MainActor.run {
context.currentNostrIdentity()
}
guard let currentIdentity else { return }
@@ -454,9 +413,6 @@ final class NostrInboundPipeline {
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
// Drop pre-wipe plaintext if a panic wipe landed
// during the off-main decrypt (see above).
guard self.wipeGeneration == wipeGeneration else { return }
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
+44 -8
View File
@@ -55,6 +55,26 @@ struct AppInfoView: View {
)
}
enum Legend {
static let title: LocalizedStringKey = "app_info.legend.title"
/// Every glyph the peer lists and headers use, in one place
/// nothing else in the app defines them.
static let items: [(icon: String, text: LocalizedStringKey)] = [
("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"),
("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"),
("globe", "app_info.legend.nostr"),
("person", "app_info.legend.offline"),
("mappin.and.ellipse", "app_info.legend.location_nearby"),
("face.dashed", "app_info.legend.teleported"),
("lock.fill", "app_info.legend.encrypted"),
("lock.slash", "app_info.legend.encryption_failed"),
("checkmark.seal.fill", "app_info.legend.verified"),
("star.fill", "app_info.legend.favorite"),
("envelope.fill", "app_info.legend.unread"),
("nosign", "app_info.legend.blocked")
]
}
enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo(
@@ -118,14 +138,8 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("app_info.close")
SheetCloseButton { dismiss() }
.foregroundColor(textColor)
}
}
}
@@ -202,6 +216,28 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Features.mentions)
}
// Symbols legend
VStack(alignment: .leading, spacing: 10) {
SectionHeader(Strings.Legend.title)
ForEach(Strings.Legend.items, id: \.icon) { item in
HStack(alignment: .top, spacing: 12) {
Image(systemName: item.icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.frame(width: 30)
Text(item.text)
.bitchatFont(size: 13)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.accessibilityElement(children: .combine)
}
}
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title)
@@ -14,29 +14,46 @@ struct CommandSuggestionsView: View {
@Binding var messageText: String
/// The command already typed in full, once arguments have begun.
private var typedCommandAlias: String? {
guard messageText.hasPrefix("/"),
let spaceIndex = messageText.firstIndex(of: " ")
else { return nil }
return String(messageText[..<spaceIndex]).lowercased()
}
private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
guard messageText.hasPrefix("/") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
// While arguments are being typed, keep the matched command's usage
// row visible instead of vanishing at the first space.
if let typed = typedCommandAlias {
return commands.filter { $0.alias == typed && $0.placeholder != nil }
}
return commands.filter { command in
command.alias.starts(with: messageText.lowercased())
}
}
var body: some View {
// Render nothing when there are no matches: a zero-height view would
// still receive the composer VStack's spacing and push the input row
// off-center.
if !filteredCommands.isEmpty {
let isUsageReminder = typedCommandAlias != nil
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
// In usage-reminder mode the row is informational; an
// insert here would wipe the arguments being typed.
guard !isUsageReminder else { return }
messageText = command.alias + " "
} label: {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.themedOverlayPanel()
@@ -9,6 +9,47 @@
import SwiftUI
import BitFoundation
extension DeliveryStatus {
/// Localized, user-facing description of the status. Used for macOS
/// tooltips, the tap-to-reveal caption under a message, and VoiceOver
/// the glyphs alone are unexplained 10pt icons.
var bitchatDescription: String {
switch self {
case .sending:
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
case .sent:
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
case .carried:
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
case .delivered(let nickname, _):
return String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
case .read(let nickname, _):
return String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
case .failed(let reason):
return String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
case .partiallyDelivered(let reached, let total):
return String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
}
struct DeliveryStatusView: View {
@ThemedPalette private var palette
let status: DeliveryStatus
@@ -19,56 +60,34 @@ struct DeliveryStatusView: View {
private var secondaryTextColor: Color { palette.secondary }
private enum Strings {
static func delivered(to nickname: String) -> String {
String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
}
static func read(by nickname: String) -> String {
String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
}
static func failed(_ reason: String) -> String {
String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
}
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
// MARK: - Body
var body: some View {
statusGlyph
.help(status.bitchatDescription)
.accessibilityElement(children: .ignore)
.accessibilityLabel(status.bitchatDescription)
}
@ViewBuilder
private var statusGlyph: some View {
switch status {
case .sending:
Image(systemName: "circle")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .sent:
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .delivered(let nickname, _):
case .carried:
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.8))
case .delivered:
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
@@ -76,24 +95,22 @@ struct DeliveryStatusView: View {
.font(.bitchatSystem(size: 10))
}
.foregroundColor(textColor.opacity(0.8))
.help(Strings.delivered(to: nickname))
case .read(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
case .read:
// Filled variant so read vs delivered is legible without color.
HStack(spacing: 0) {
Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 9, weight: .bold))
Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 9, weight: .bold))
}
.foregroundColor(palette.accentBlue)
.help(Strings.read(by: nickname))
case .failed(let reason):
case .failed:
Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10))
.foregroundColor(Color.red.opacity(0.8))
.help(Strings.failed(reason))
case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) {
Image(systemName: "checkmark")
@@ -102,7 +119,6 @@ struct DeliveryStatusView: View {
.bitchatFont(size: 10)
}
.foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
}
}
}
@@ -111,6 +127,7 @@ struct DeliveryStatusView: View {
let statuses: [DeliveryStatus] = [
.sending,
.sent,
.carried,
.delivered(to: "John Doe", at: Date()),
.read(by: "Jane Doe", at: Date()),
.failed(reason: "Offline"),
@@ -57,7 +57,7 @@ struct PaymentChipView: View {
private var fgColor: Color { palette.primary }
private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
}
private var border: Color { fgColor.opacity(0.25) }
@@ -0,0 +1,29 @@
//
// SheetCloseButton.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// The close "X" every sheet and header shares. One glyph size and weight
/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box
/// so existing header metrics don't move, and a hit target extended to 44pt
/// per platform guidelines. Tint comes from the environment, so callers keep
/// their own foreground color.
struct SheetCloseButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
.contentShape(Rectangle().inset(by: -6))
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
}
}
+53 -5
View File
@@ -12,6 +12,7 @@ import BitFoundation
struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
@@ -24,6 +25,7 @@ struct TextMessageView: View {
/// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus?
@State private var expandedMessageIDs: Set<String> = []
@State private var showDeliveryDetail = false
init(message: BitchatMessage) {
self.message = message
@@ -35,19 +37,59 @@ struct TextMessageView: View {
// Precompute heavy token scans once per row
let cashuLinks = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) {
// Baseline alignment keeps the lock and delivery glyphs on the
// first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.trailing, 4)
.accessibilityHidden(true)
}
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
}
}
@@ -60,7 +102,7 @@ struct TextMessageView: View {
else { expandedMessageIDs.insert(message.id) }
}
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(Color.blue)
.foregroundColor(palette.accentBlue)
.padding(.top, 4)
}
@@ -78,6 +120,12 @@ struct TextMessageView: View {
.padding(.leading, 2)
}
}
// Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another.
.onChange(of: deliveryStatus) { _ in
showDeliveryDetail = false
}
}
}
+67 -8
View File
@@ -6,6 +6,7 @@ import UIKit
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@@ -43,7 +44,6 @@ struct ContentComposerView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.themedOverlayPanel()
@@ -60,10 +60,8 @@ struct ContentComposerView: View {
TextField(
"",
text: $messageText,
prompt: Text(
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
)
.foregroundColor(palette.secondary.opacity(0.6))
prompt: Text(placeholderText)
.foregroundColor(palette.secondary.opacity(0.6))
)
.textFieldStyle(.plain)
.bitchatFont(size: 15)
@@ -110,6 +108,33 @@ struct ContentComposerView: View {
}
private extension ContentComposerView {
/// States where a message will land: the DM partner's name for private
/// chats, the channel (and its public nature) otherwise so a stressed
/// user never has to guess who can read what they're typing.
var placeholderText: String {
if let header = privateConversationModel.selectedHeaderState {
// A geohash-DM display name already carries its own "#geohash/@name"
// form, so it must not get another "@" prefix; a mesh nickname does.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
return String(
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
locale: .current,
target
)
}
switch locationChannelsModel.selectedChannel {
case .mesh:
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
case .location(let channel):
return String(
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
locale: .current,
channel.geohash
)
}
}
var recordingIndicator: some View {
HStack(spacing: 12) {
Image(systemName: "waveform.circle.fill")
@@ -158,7 +183,19 @@ private extension ContentComposerView {
imagePickerSourceType = .camera
showImagePicker = true
}
.accessibilityLabel("Tap for library, long press for camera")
.accessibilityLabel(
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
)
.accessibilityHint(
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
)
.accessibilityAddTraits(.isButton)
// The long-press camera path is unreachable for VoiceOver users;
// mirror it as a named action.
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
imagePickerSourceType = .camera
showImagePicker = true
}
#else
Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill")
@@ -167,7 +204,9 @@ private extension ContentComposerView {
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.accessibilityLabel("Choose photo")
.accessibilityLabel(
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
)
#endif
}
@@ -209,7 +248,27 @@ private extension ContentComposerView {
}
)
)
.accessibilityLabel("Hold to record a voice note")
.accessibilityLabel(
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
)
.accessibilityValue(
voiceRecordingVM.state.isActive
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
: ""
)
.accessibilityHint(
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
)
.accessibilityAddTraits(.isButton)
// Press-and-hold drag gestures can't be activated by VoiceOver;
// give it a start/stop toggle as the default action.
.accessibilityAction {
if voiceRecordingVM.state.isActive {
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
} else {
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
}
}
}
func sendButtonView(enabled: Bool) -> some View {
+61 -11
View File
@@ -22,6 +22,9 @@ struct ContentHeaderView: View {
let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
@@ -33,6 +36,16 @@ struct ContentHeaderView: View {
.onTapGesture(count: 1) {
appChromeModel.presentAppInfo()
}
// This is the only entry point to App Info, but it reads as
// static text; surface the tap. (The triple-tap panic wipe
// stays undiscoverable on purpose it's destructive.)
.accessibilityAddTraits(.isButton)
.accessibilityHint(
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
)
.accessibilityAction {
appChromeModel.presentAppInfo()
}
HStack(spacing: 0) {
Text(verbatim: "@")
@@ -78,6 +91,23 @@ struct ContentHeaderView: View {
}()
HStack(spacing: 2) {
if carriedMailCount > 0 {
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.8))
.headerTapTarget()
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"),
locale: .current,
carriedMailCount
)
)
.help(
String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator")
)
}
if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
@@ -183,6 +213,13 @@ struct ContentHeaderView: View {
headerOtherPeersCount
)
)
// Connected-vs-nobody is otherwise encoded only in the icon's
// color; say it.
.accessibilityValue(
headerPeersReachable
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
)
}
.layoutPriority(3)
.sheet(isPresented: $showVerifySheet) {
@@ -190,8 +227,16 @@ struct ContentHeaderView: View {
.environmentObject(verificationModel)
}
}
// Fixed height is load-bearing: children fill the bar with
// .frame(maxHeight: .infinity) tap targets, so an open-ended
// minHeight lets the header expand to swallow the whole screen.
// headerHeight is a @ScaledMetric, so it still grows with Dynamic
// Type.
.frame(height: headerHeight)
.padding(.horizontal, 12)
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel)
@@ -266,14 +311,25 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
/// Whether anyone is actually reachable on the current channel the
/// state the count icon's color encodes visually.
var headerPeersReachable: Bool {
switch locationChannelsModel.selectedChannel {
case .location:
return peerListModel.visibleGeohashPeerCount > 0
case .mesh:
return peerListModel.connectedMeshPeerCount > 0
}
}
func channelPeopleCountAndColor() -> (Int, Color) {
switch locationChannelsModel.selectedChannel {
case .location:
let count = peerListModel.visibleGeohashPeerCount
return (count, count > 0 ? palette.locationAccent : Color.secondary)
return (count, count > 0 ? palette.locationAccent : palette.secondary)
case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary
return (peerListModel.reachableMeshPeerCount, color)
}
}
@@ -293,16 +349,10 @@ private struct ContentLocationNotesUnavailableView: View {
Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold)
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
SheetCloseButton { showLocationNotes = false }
.foregroundColor(palette.primary)
}
.frame(height: headerHeight)
.frame(minHeight: headerHeight)
.padding(.horizontal, 12)
.themedChromePanel(edge: .top)
Text("content.notes.location_unavailable")
+104 -20
View File
@@ -134,6 +134,7 @@ private struct ContentPeopleListView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss
@@ -159,24 +160,23 @@ private struct ContentPeopleListView: View {
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
// .help maps to the accessibility *hint* on iOS, so the
// button still needs a spoken name.
.accessibilityLabel(
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
)
.help(
String(localized: "content.help.verification", comment: "Help text for verification button")
)
}
Button(action: {
SheetCloseButton {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
dismiss()
showSidebar = false
showVerifySheet = false
privateConversationModel.endConversation()
}
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
let activeText = String.localizedStringWithFormat(
@@ -198,13 +198,13 @@ private struct ContentPeopleListView: View {
Text(subtitle)
.foregroundColor(subtitleColor)
Text(activeText)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
.bitchatFont(size: 12)
} else {
Text(activeText)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
}
.padding(.horizontal, 16)
@@ -231,6 +231,13 @@ private struct ContentPeopleListView: View {
},
onShowFingerprint: { peerID in
appChromeModel.showFingerprint(for: peerID)
},
onToggleBlock: { peer in
if peer.isBlocked {
conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName)
} else {
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
}
}
)
}
@@ -333,6 +340,9 @@ private struct ContentPrivateChatSheetView: View {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
// Same visual box + 44pt hit target as SheetCloseButton.
.frame(width: 32, height: 32)
.contentShape(Rectangle().inset(by: -6))
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -346,24 +356,20 @@ private struct ContentPrivateChatSheetView: View {
Spacer(minLength: 0)
Button(action: {
SheetCloseButton {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation()
showSidebar = true
}
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: headerHeight)
// minHeight so scaled text at accessibility sizes grows the
// bar instead of clipping inside it.
.frame(minHeight: headerHeight)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.themedSurface()
.modifier(PrivateHeaderChrome())
}
MessageListView(
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
Divider()
}
privacyCaption
#if os(iOS)
ContentComposerView(
messageText: $messageText,
@@ -421,6 +429,69 @@ private struct ContentPrivateChatSheetView: View {
}
)
}
/// Persistent one-line reminder that this composer feeds a private
/// conversation the DM sheet otherwise renders identically to the
/// public timeline. Claims end-to-end encryption only once the session
/// is actually secured.
private var privacyCaption: some View {
HStack(spacing: 5) {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 9))
// Optical centering: lock.fill's ink is bottom-heavy, so
// geometric centering reads low next to the caption text.
.offset(y: -1)
Text(verbatim: privacyCaptionText)
.bitchatFont(size: 11, weight: .medium)
}
.foregroundColor(Color.orange)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
// The orange text is signature enough; a tinted band here reads as a
// stray strip against the untinted composer chrome below it, so the
// caption sits on the same surface as the rest of the bottom chrome.
.themedSurface()
.accessibilityElement(children: .combine)
}
private var privacyCaptionText: String {
// Geohash DMs are NIP-17 gift-wrapped always end-to-end encrypted,
// even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let noiseSecured: Bool = {
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
case .noiseSecured, .noiseVerified: return true
default: return false
}
}()
if isGeoDM || noiseSecured {
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
}
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
}
}
/// Chrome for the private-chat header. Matrix keeps its orange privacy wash
/// over an opaque themed surface. Glass gets the same floating panel as the
/// main header instead: an orange wash over the backdrop gradient reads as a
/// muddy gray-beige band, and the DM signature is already carried by the
/// orange lock, caption, and composer accents.
private struct PrivateHeaderChrome: ViewModifier {
@Environment(\.appTheme) private var theme
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content.themedChromePanel(edge: .top)
} else {
// Orange tint before themedSurface so it layers in front of the
// opaque themed background rather than behind it.
content
.background(Color.orange.opacity(0.06))
.themedSurface()
}
}
}
private struct ContentPrivateHeaderInfoButton: View {
@@ -452,17 +523,30 @@ private struct ContentPrivateHeaderInfoButton: View {
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
EmptyView()
// Absence of a glyph was the only offline signal; say it.
Text("mesh_peers.state.offline")
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
Text(headerState.displayName)
.bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary)
// Middle truncation keeps the identity suffix visible on
// long nicknames instead of wrapping into the fixed-height
// header.
.lineLimit(1)
.truncationMode(.middle)
if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
// Optical centering: the lock glyphs' ink is bottom-heavy
// (solid body, thin shackle), so geometric centering reads
// ~1pt low next to the name. The seal badge is symmetric
// and needs no lift.
.offset(y: icon.hasPrefix("lock") ? -1 : 0)
.foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? palette.primary
@@ -489,6 +573,6 @@ private struct ContentPrivateHeaderInfoButton: View {
.accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
.frame(height: headerHeight)
.frame(minHeight: headerHeight)
}
}
+5 -8
View File
@@ -54,11 +54,8 @@ struct FingerprintView: View {
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
SheetCloseButton { dismiss() }
.foregroundColor(textColor)
}
.padding()
@@ -83,7 +80,7 @@ struct FingerprintView: View {
Spacer()
}
.padding()
.background(Color.gray.opacity(0.1))
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
// Their fingerprint
@@ -101,7 +98,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1))
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
.contextMenu {
Button(Strings.copy) {
@@ -135,7 +132,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1))
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
.contextMenu {
Button(Strings.copy) {
+45 -1
View File
@@ -13,6 +13,13 @@ struct GeohashPeopleList: View {
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
static let block: LocalizedStringKey = "geohash_people.action.block"
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
}
var body: some View {
@@ -46,7 +53,12 @@ struct GeohashPeopleList: View {
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
let rowColor: Color = person.isMe ? .orange : assignedColor
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
Image(systemName: icon)
// Size 10 to match the mesh rows' leading glyphs
// both lists share the sidebar.
.font(.bitchatSystem(size: 10))
.foregroundColor(rowColor)
.help(person.isTeleported ? Strings.teleported : Strings.nearby)
let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) {
@@ -54,6 +66,8 @@ struct GeohashPeopleList: View {
.bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
@@ -105,6 +119,27 @@ struct GeohashPeopleList: View {
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: person))
.accessibilityAddTraits(person.isMe ? [] : .isButton)
.accessibilityHint(person.isMe ? "" : Strings.openDMHint)
.accessibilityActions {
if !person.isMe {
Button(person.isBlocked ? Strings.unblockText : Strings.blockText) {
if person.isBlocked {
peerListModel.unblockGeohashUser(
pubkeyHexLowercased: person.id,
displayName: person.displayName
)
} else {
peerListModel.blockGeohashUser(
pubkeyHexLowercased: person.id,
displayName: person.displayName
)
}
}
}
}
}
}
// Seed and update order outside result builder
@@ -119,4 +154,13 @@ struct GeohashPeopleList: View {
}
}
}
/// One spoken sentence per row: name, presence type, and block state.
private func accessibilityDescription(for person: GeohashPersonRow) -> String {
var parts: [String] = [person.displayName]
if person.isMe { parts.append(Strings.youState) }
parts.append(person.isTeleported ? Strings.teleported : Strings.nearby)
if person.isBlocked { parts.append(Strings.blockedState) }
return parts.joined(separator: ", ")
}
}
+38 -20
View File
@@ -31,6 +31,9 @@ struct LocationChannelsSheet: View {
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
@@ -103,7 +106,7 @@ struct LocationChannelsSheet: View {
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
Group {
switch locationChannelsModel.permissionState {
@@ -122,7 +125,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
}
@@ -169,13 +172,7 @@ struct LocationChannelsSheet: View {
}
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
SheetCloseButton { isPresented = false }
}
private var channelList: some View {
@@ -210,7 +207,10 @@ struct LocationChannelsSheet: View {
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
) {
locationChannelsModel.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel))
@@ -277,7 +277,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 2) {
Text(verbatim: "#")
.bitchatFont(size: 14)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
.textInputAutocapitalization(.never)
@@ -319,7 +319,7 @@ struct LocationChannelsSheet: View {
.bitchatFont(size: 14)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12))
.background(palette.secondary.opacity(0.12))
.cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4)
.disabled(!isValid)
@@ -336,7 +336,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count)
@@ -357,7 +357,10 @@ struct LocationChannelsSheet: View {
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
) {
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
@@ -399,6 +402,8 @@ struct LocationChannelsSheet: View {
titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
accessoryActionTitle: String? = nil,
accessoryAction: (() -> Void)? = nil,
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
@@ -409,17 +414,17 @@ struct LocationChannelsSheet: View {
Text(parts.base)
.bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
.foregroundColor(titleColor ?? palette.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
}
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
@@ -434,6 +439,19 @@ struct LocationChannelsSheet: View {
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(perform: action)
// The row is a plain HStack with a tap gesture, which VoiceOver reads
// as disconnected static text. Expose it as one activatable button;
// the visible bookmark accessory is mirrored as a named action.
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))"))
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton])
.accessibilityHint(Strings.switchChannelHint)
.accessibilityAction(.default, action)
.accessibilityActions {
if let accessoryActionTitle, let accessoryAction {
Button(accessoryActionTitle, action: accessoryAction)
}
}
}
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
@@ -477,16 +495,16 @@ extension LocationChannelsSheet {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary)
.foregroundColor(palette.primary)
Text(Strings.torSubtitle)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.background(palette.secondary.opacity(0.12))
.cornerRadius(8)
}
+7 -14
View File
@@ -30,7 +30,6 @@ struct LocationNotesView: View {
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings {
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
static let description: LocalizedStringKey = "location_notes.description"
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
@@ -97,13 +96,7 @@ struct LocationNotesView: View {
}
private var closeButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.closeAccessibility)
SheetCloseButton { dismiss() }
}
private var headerSection: some View {
@@ -126,12 +119,12 @@ struct LocationNotesView: View {
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays {
Text(Strings.relaysPaused)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
}
.padding(.horizontal, 16)
@@ -180,7 +173,7 @@ struct LocationNotesView: View {
if !ts.isEmpty {
Text(ts)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
Spacer()
}
@@ -197,7 +190,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
Button(Strings.retry) { manager.refresh() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
@@ -210,7 +203,7 @@ struct LocationNotesView: View {
ProgressView()
Text(Strings.loadingNotes)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
Spacer()
}
.padding(.vertical, 8)
@@ -222,7 +215,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
.padding(.vertical, 6)
}
+136 -28
View File
@@ -9,6 +9,7 @@ private typealias PlatformImage = NSImage
#endif
struct BlockRevealImageView: View {
@ThemedPalette private var palette
private let url: URL
private let revealProgress: Double?
private let isSending: Bool
@@ -20,6 +21,25 @@ struct BlockRevealImageView: View {
@State private var platformImage: PlatformImage?
@State private var aspectRatio: CGFloat = 1
@State private var isBlurred: Bool = false
@State private var showDeleteConfirmation = false
@State private var loadFailed = false
private enum Strings {
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
}
init(
url: URL,
@@ -69,20 +89,32 @@ struct BlockRevealImageView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35))
.overlay(
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(.white.opacity(0.85))
VStack(spacing: 6) {
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
Text(verbatim: Strings.tapToReveal)
// Themed: monospaced under matrix,
// system under liquid glass.
.bitchatFont(size: 12, weight: .medium)
}
.foregroundColor(.white.opacity(0.85))
)
}
}
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.gray.opacity(0.2))
.fill(palette.secondary.opacity(0.2))
.frame(height: 200)
.overlay(
ProgressView()
.progressViewStyle(.circular)
)
.overlay {
if loadFailed {
Image(systemName: "photo")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(palette.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
}
if let onCancel = onCancel, isSending {
@@ -95,6 +127,7 @@ struct BlockRevealImageView: View {
.padding(8)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
}
}
.onAppear {
@@ -106,30 +139,105 @@ struct BlockRevealImageView: View {
loadImage()
}
.gesture(mainGesture)
}
private func loadImage() {
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
guard let image = UIImage(contentsOfFile: url.path) else { return }
#else
guard let image = NSImage(contentsOf: url) else { return }
#endif
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
DispatchQueue.main.async {
self.platformImage = image
self.aspectRatio = ratio
.contextMenu {
if isSending {
cancelSendAction
} else {
imageActions
}
}
.confirmationDialog(
Strings.deleteConfirmTitle,
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(Strings.delete, role: .destructive) {
onDelete?()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text(verbatim: Strings.deleteConfirmMessage)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabelText)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(isSending || loadFailed ? [] : .isButton)
.accessibilityActions {
if isSending {
// children: .ignore collapses the visible cancel button, so
// expose it as an action while the send is in flight.
cancelSendAction
} else {
imageActions
}
}
}
private var mainGesture: some Gesture {
let doubleTap = TapGesture(count: 2).onEnded {
guard !isSending else { return }
onDelete?()
@ViewBuilder
private var cancelSendAction: some View {
if let onCancel {
Button(Strings.cancelSend, action: onCancel)
}
}
@ViewBuilder
private var imageActions: some View {
// Open/reveal/hide would act on a file that failed to load, so only
// offer delete (when available) to let users clean up the attachment.
if !loadFailed {
if isBlurred {
Button(Strings.reveal) {
withAnimation(.easeOut(duration: 0.2)) { isBlurred = false }
}
} else {
Button(Strings.open) { onOpen?() }
Button(Strings.hide) {
withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true }
}
}
}
if onDelete != nil {
Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true }
}
}
private var accessibilityLabelText: String {
if isSending { return Strings.sendingImage }
if loadFailed { return Strings.unavailableImage }
return isBlurred ? Strings.hiddenImage : Strings.revealedImage
}
private var accessibilityHintText: String {
if isSending || loadFailed { return "" }
return isBlurred ? Strings.revealHint : Strings.openHint
}
private func loadImage() {
loadFailed = false
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
let image = UIImage(contentsOfFile: url.path)
#else
let image = NSImage(contentsOf: url)
#endif
DispatchQueue.main.async {
guard let image else {
self.loadFailed = true
return
}
self.platformImage = image
self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1
}
}
}
// Double-tap used to permanently delete the image the most ingrained
// photo gesture on mobile, racing the reveal tap, with no confirmation
// and no way to get the file back. Delete now lives in the context menu
// behind a confirmation; taps only reveal and open.
private var mainGesture: some Gesture {
let singleTap = TapGesture().onEnded {
guard !isSending else { return }
guard !isSending, !loadFailed else { return }
if isBlurred {
withAnimation(.easeOut(duration: 0.2)) {
isBlurred = false
@@ -139,7 +247,7 @@ struct BlockRevealImageView: View {
}
}
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
guard !isSending else { return }
guard !isSending, !loadFailed else { return }
let horizontal = value.translation.width
let vertical = value.translation.height
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
@@ -149,7 +257,7 @@ struct BlockRevealImageView: View {
}
}
}
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
return singleTap.simultaneously(with: swipe)
}
}
+82 -33
View File
@@ -11,6 +11,7 @@ import BitFoundation
struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
let media: BitchatMessage.Media
@@ -20,6 +21,7 @@ struct MediaMessageView: View {
/// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus?
@State private var showDeliveryDetail = false
@Binding var imagePreviewURL: URL?
@@ -35,46 +37,93 @@ struct MediaMessageView: View {
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Baseline alignment (via the header text inside the VStack) keeps the
// lock on the header line; a fixed top padding left its solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.trailing, 4)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
}
}
}
.padding(.vertical, 4)
// Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another.
.onChange(of: deliveryStatus) { _ in
showDeliveryDetail = false
}
}
private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
@@ -90,7 +139,7 @@ struct MediaMessageView: View {
isSending = true
progress = Double(reached) / Double(total)
}
case .sent, .read, .delivered, .failed:
case .sent, .carried, .read, .delivered, .failed:
break
}
}
+13 -2
View File
@@ -28,7 +28,9 @@ struct VoiceNoteView: View {
}
private var backgroundColor: Color {
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
// Palette-based and slightly translucent so the card doesn't sit as
// an opaque white/black box over the glass gradient.
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
}
private var borderColor: Color {
@@ -50,6 +52,12 @@ struct VoiceNoteView: View {
.background(Circle().fill(palette.accent))
}
.buttonStyle(.plain)
.accessibilityLabel(
playback.isPlaying
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
)
.accessibilityValue(playbackLabel)
WaveformView(
samples: samples,
@@ -63,7 +71,7 @@ struct VoiceNoteView: View {
Text(playbackLabel)
.bitchatFont(size: 13)
.foregroundColor(Color.secondary)
.foregroundColor(palette.secondary)
if let onCancel = onCancel, isSending {
Button(action: onCancel) {
@@ -74,6 +82,9 @@ struct VoiceNoteView: View {
.foregroundColor(.white)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
)
}
}
.padding(12)
+1 -1
View File
@@ -42,7 +42,7 @@ struct WaveformView: View {
} else if let send = clampedSend, binPosition <= send {
color = palette.accentBlue
} else {
color = Color.gray.opacity(0.35)
color = palette.secondary.opacity(0.35)
}
context.fill(Path(rect), with: .color(color))
}
+100 -1
View File
@@ -7,6 +7,9 @@ struct MeshPeerList: View {
let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (PeerID) -> Void
/// Optional so existing call sites (and previews/tests) keep compiling;
/// when absent the block/unblock context-menu entry is hidden.
var onToggleBlock: ((MeshPeerRow) -> Void)? = nil
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
@@ -15,6 +18,20 @@ struct MeshPeerList: View {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
}
var body: some View {
@@ -49,21 +66,25 @@ struct MeshPeerList: View {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
.help(Strings.connected)
} else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
.help(Strings.reachable)
} else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe")
.font(.bitchatSystem(size: 10))
.foregroundColor(.purple)
.help(Strings.nostr)
} else {
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary)
.help(Strings.offline)
}
let (base, suffix) = peer.displayName.splitSuffix()
@@ -71,6 +92,8 @@ struct MeshPeerList: View {
Text(base)
.bitchatFont(size: 14)
.foregroundColor(baseColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix)
@@ -91,6 +114,10 @@ struct MeshPeerList: View {
if let icon = peer.encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
// Optical centering: lock glyph ink is
// bottom-heavy, so geometric centering
// reads low next to the name.
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor)
}
} else {
@@ -103,6 +130,7 @@ struct MeshPeerList: View {
// Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor)
}
}
@@ -123,6 +151,11 @@ struct MeshPeerList: View {
Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
// Widen the tap target beyond the bare glyph;
// height stays row-bound so neighboring rows
// keep their own taps.
.frame(width: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@@ -131,8 +164,53 @@ struct MeshPeerList: View {
.padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
// count:2 must attach before count:1 or the single tap
// shadows it (same ordering the header logo relies on).
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
.contextMenu {
if !isMe {
Button(Strings.directMessage) {
onTapPeer(peer.peerID)
}
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}
Button(Strings.showFingerprint) {
onShowFingerprint(peer.peerID)
}
if let onToggleBlock {
if peer.isBlocked {
Button(Strings.unblock) {
onToggleBlock(peer)
}
} else {
Button(Strings.block, role: .destructive) {
onToggleBlock(peer)
}
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: peer))
.accessibilityAddTraits(isMe ? [] : .isButton)
.accessibilityHint(isMe ? "" : Strings.openDMHint)
.accessibilityActions {
if !isMe {
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}
Button(Strings.showFingerprint) {
onShowFingerprint(peer.peerID)
}
if let onToggleBlock {
Button(peer.isBlocked ? Strings.unblock : Strings.block) {
onToggleBlock(peer)
}
}
}
}
}
}
// Seed and update order outside result builder
@@ -147,4 +225,25 @@ struct MeshPeerList: View {
}
}
}
/// One spoken sentence per row: name, how they're reachable, and any
/// state badges the visual row is icon soup for VoiceOver otherwise.
private func accessibilityDescription(for peer: MeshPeerRow) -> String {
var parts: [String] = [peer.displayName]
if !peer.isMe {
if peer.isConnected {
parts.append(Strings.connected)
} else if peer.isReachable {
parts.append(Strings.reachable)
} else if peer.isMutualFavorite {
parts.append(Strings.nostr)
} else {
parts.append(Strings.offline)
}
}
if peer.isFavorite { parts.append(Strings.favorite) }
if peer.hasUnread { parts.append(Strings.unread) }
if peer.isBlocked { parts.append(Strings.blocked) }
return parts.joined(separator: ", ")
}
}
+241 -24
View File
@@ -36,8 +36,17 @@ struct MessageListView: View {
var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false
@State private var showClearConfirmation = false
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
@State private var unseenCount = 0
@State private var lastSeenMessageCount = 0
/// Context key the unseen counters were baselined against. Channel
/// switches swap the timeline wholesale, so a count delta is only a
/// "new messages" signal while the context is unchanged.
@State private var unseenBaselineKey = ""
@ThemedPalette private var palette
var body: some View {
let currentWindowCount: Int = {
@@ -65,6 +74,9 @@ struct MessageListView: View {
ScrollViewReader { proxy in
ScrollView {
if messageItems.isEmpty && privatePeer == nil {
publicEmptyState
}
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
let message = item.message
@@ -72,6 +84,7 @@ struct MessageListView: View {
.onAppear {
if message.id == windowedMessages.last?.id {
isAtBottom = true
unseenCount = 0
}
if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count {
@@ -89,13 +102,32 @@ struct MessageListView: View {
}
}
.contentShape(Rectangle())
.onTapGesture {
if message.sender != "system" {
messageText = "@\(message.sender) "
isTextFieldFocused.wrappedValue = true
}
}
.contextMenu {
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message)
if showsUserActions {
// Mention and DM are redundant inside a 1:1 conversation:
// mentioning the only other participant is noise, and "DM"
// would just reopen the conversation that is already open.
if privatePeer == nil {
Button("content.actions.mention") {
insertMention(message.sender)
}
if let peerID = message.senderPeerID {
Button("content.actions.direct_message") {
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
}
Button("content.actions.hug") {
conversationUIModel.sendHug(to: message.sender)
}
Button("content.actions.slap") {
conversationUIModel.sendSlap(to: message.sender)
}
}
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
@@ -105,6 +137,16 @@ struct MessageListView: View {
pb.setString(message.content, forType: .string)
#endif
}
if isResendableFailedMessage(message) {
Button("content.actions.resend") {
conversationUIModel.resendFailedPrivateMessage(message)
}
}
if showsUserActions {
Button("content.actions.block", role: .destructive) {
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 1)
@@ -113,9 +155,24 @@ struct MessageListView: View {
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2)
}
.overlay(alignment: .bottomTrailing) {
if !isAtBottom && !messageItems.isEmpty {
jumpToLatestPill(proxy: proxy)
}
}
.onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) {
conversationUIModel.clearCurrentConversation()
showClearConfirmation = true
}
.confirmationDialog(
"content.clear.confirm_title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("content.clear.confirm_action", role: .destructive) {
conversationUIModel.clearCurrentConversation()
}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
scrollToBottom(on: proxy)
@@ -139,9 +196,7 @@ struct MessageListView: View {
) {
Button("content.actions.mention") {
if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field
messageText = "@\(sender) "
isTextFieldFocused.wrappedValue = true
insertMention(sender)
}
}
@@ -208,6 +263,142 @@ struct MessageListView: View {
}
private extension MessageListView {
var currentContextKey: String {
if let peer = privatePeer {
return "dm:\(peer)"
}
return locationChannelsModel.selectedChannel.contextKey
}
/// Terminal-styled narration for an empty public timeline: says which
/// channel this is, that the app is waiting for peers, and where to go
/// next. Rendered inside the ScrollView; disappears with the first row.
var publicEmptyState: some View {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
case .location(let channel):
emptyStateLine(
String(
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
locale: .current,
channel.geohash
)
)
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
}
}
.padding(.horizontal, 12)
.padding(.top, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
func emptyStateLine(_ text: String) -> some View {
// Non-breaking space before the closing asterisk so a tight wrap
// can't orphan a lone "*" onto its own line.
Text(verbatim: "* \(text)\u{00A0}*")
.bitchatFont(size: 13)
.foregroundColor(palette.secondary.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
}
/// Messages the unseen counters may book as "new": rows that render as
/// human messages. System lines render as narration and whitespace-only
/// content never renders at all, so neither belongs in the pill count.
func unseenEligibleCount(in messages: [BitchatMessage]) -> Int {
messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count
}
/// Updates the unseen-count baseline for the current context and returns
/// how many messages were appended since the last observation. A context
/// change (timeline swapped wholesale) re-baselines and reports zero, so
/// cross-channel count differences are never booked as "new" messages.
func rebaselinedAppendedCount(newCount: Int) -> Int {
let key = currentContextKey
if unseenBaselineKey != key {
unseenBaselineKey = key
unseenCount = 0
lastSeenMessageCount = newCount
return 0
}
let appended = max(0, newCount - lastSeenMessageCount)
lastSeenMessageCount = newCount
return appended
}
/// A failed private text message of our own can be resent through the
/// normal send path (the context menu removes the failed original and
/// re-submits its content).
func isResendableFailedMessage(_ message: BitchatMessage) -> Bool {
guard message.isPrivate,
conversationUIModel.isSentByCurrentUser(message),
conversationUIModel.mediaAttachment(for: message) == nil,
case .some(.failed) = message.deliveryStatus
else { return false }
return true
}
/// Appends an @mention to the composer draft (never overwrites what the
/// user has already typed) and focuses the input field.
func insertMention(_ sender: String) {
let mention = "@\(sender) "
if messageText.isEmpty {
messageText = mention
} else if messageText.hasSuffix(" ") {
messageText += mention
} else {
messageText += " " + mention
}
isTextFieldFocused.wrappedValue = true
}
/// Floating pill shown while scrolled up: re-presents the isAtBottom /
/// unseenCount state the view already tracks, and jumps to the newest
/// message via the existing scrollToBottom helper.
func jumpToLatestPill(proxy: ScrollViewProxy) -> some View {
Button {
scrollToBottom(on: proxy)
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.down")
.font(.bitchatSystem(size: 11, weight: .semibold))
if unseenCount > 0 {
Text(
String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
)
.bitchatFont(size: 12, weight: .medium)
}
}
.foregroundColor(palette.primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.themedOverlayPanel()
.padding(.trailing, 12)
.padding(.bottom, 10)
.accessibilityLabel(jumpToLatestAccessibilityLabel)
}
var jumpToLatestAccessibilityLabel: String {
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
guard unseenCount > 0 else { return base }
let count = String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
return "\(base), \(count)"
}
@ViewBuilder
func messageRow(for message: BitchatMessage) -> some View {
Group {
@@ -294,6 +485,9 @@ private extension MessageListView {
func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true
unseenCount = 0
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
unseenBaselineKey = currentContextKey
if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom)
}
@@ -316,15 +510,23 @@ private extension MessageListView {
}
func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil else { return }
let messages = publicChatModel.messages
guard privatePeer == nil, let lastMsg = messages.last else { return }
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
guard let lastMsg = messages.last else {
// Timeline emptied (e.g. /clear): nothing below to jump to.
unseenCount = 0
return
}
// If the newest message is from me, always scroll to bottom
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return
} else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true
unseenCount = 0
}
func scrollIfNeeded(date: Date) {
@@ -352,18 +554,23 @@ private extension MessageListView {
}
func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer,
let lastMsg = privateInboxModel.messages(for: peerID).last else {
guard let peerID = privatePeer else { return }
let messages = privateInboxModel.messages(for: peerID)
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
guard let lastMsg = messages.last else {
// Timeline emptied (e.g. /clear): nothing below to jump to.
unseenCount = 0
return
}
let messages = privateInboxModel.messages(for: peerID)
// If the newest private message is from me, always scroll
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return
} else {
isAtBottom = true
unseenCount = 0
}
func scrollIfNeeded(date: Date) {
@@ -391,17 +598,27 @@ private extension MessageListView {
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
// Invalidate the unseen baseline: the timeline is about to swap (or
// already has the ordering of this onChange vs the count onChange
// is not guaranteed), so the next count observation re-baselines
// instead of booking the cross-channel difference as "new".
unseenCount = 0
unseenBaselineKey = ""
// Entering any public channel shows its latest messages: a channel
// switch swaps the timeline wholesale, so the prior scroll offset is
// meaningless. Landing at the bottom keeps isAtBottom honest (no
// stale jump-to-latest pill) and matches standard chat behavior.
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey: String
switch channel {
case .mesh:
break
contextKey = "mesh"
case .location(let ch):
// Reset window size
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)"
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
contextKey = "geo:\(ch.geohash)"
}
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
@@ -426,6 +643,6 @@ private extension ChannelID {
}
}
//#Preview {
// #Preview {
// MessageListView()
//}
// }
+13 -12
View File
@@ -11,7 +11,10 @@ import AppKit
struct MyQRView: View {
let qrString: String
@Environment(\.colorScheme) var colorScheme
private var boxColor: Color { Color.gray.opacity(0.1) }
@ThemedPalette private var palette
// Palette-tinted so the box follows the theme (green under matrix)
// instead of a fixed gray band over the glass gradient.
private var boxColor: Color { palette.secondary.opacity(0.1) }
private enum Strings {
static let title: LocalizedStringKey = "verification.my_qr.title"
@@ -50,6 +53,7 @@ struct MyQRView: View {
struct QRCodeImage: View {
let data: String
let size: CGFloat
@ThemedPalette private var palette
private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator()
@@ -65,12 +69,12 @@ struct QRCodeImage: View {
.frame(width: size, height: size)
} else {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
.stroke(palette.secondary.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size)
.overlay(
Text(Strings.unavailable)
.bitchatFont(size: 12)
.foregroundColor(.gray)
.foregroundColor(palette.secondary)
)
}
}
@@ -108,6 +112,7 @@ struct ImageWrapper: View {
/// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = ""
@@ -153,7 +158,7 @@ struct QRScanView: View {
.bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input)
.frame(height: 100)
.border(Color.gray.opacity(0.4))
.border(palette.secondary.opacity(0.4))
Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else {
@@ -265,7 +270,7 @@ struct CameraScannerView: UIViewRepresentable {
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) {
super.init(frame: frame)
@@ -285,7 +290,7 @@ struct VerificationSheetView: View {
private var backgroundColor: Color { palette.background }
private var accentColor: Color { palette.accent }
private var boxColor: Color { Color.gray.opacity(0.1) }
private var boxColor: Color { palette.secondary.opacity(0.1) }
var body: some View {
VStack(spacing: 0) {
@@ -295,15 +300,11 @@ struct VerificationSheetView: View {
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor)
Spacer()
Button(action: {
SheetCloseButton {
showingScanner = false
isPresented = false
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
.foregroundColor(accentColor)
}
.buttonStyle(.plain)
.foregroundColor(accentColor)
}
.padding(.horizontal, 16)
.padding(.top, 12)
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
self.loadFirstPlainText(from: providers) { text in
if let t = text, !t.isEmpty {
// Treat as URL if parseable http(s), else plain text
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") {
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else {
self.saveAndFinish(text: t)
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "bob",
PeerID(str: "3333333333333333"): "me",
PeerID(str: "3333333333333333"): "me"
]
// Matching query: suggestions and range are published, index resets.
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
"aaaabbbbccccdddd": "carol",
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
// identity's public key hex ends in "2222".
"ffffeeeeddddcccc2222": "me",
"ffffeeeeddddcccc2222": "me"
]
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
// Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2),
makePrivateMessage(id: "m2", timestamp: t2)
]
context.privateChats[stablePeerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2))
]
let merged = coordinator.getPrivateChatMessages(for: peerID)
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID)
]
coordinator.markPrivateMessagesAsRead(from: convKey)
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
)
context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: peerID),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true)
]
coordinator.markPrivateMessagesAsRead(from: peerID)
@@ -382,12 +382,10 @@ struct ChatNostrCoordinatorContextTests {
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
let convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey)
@@ -400,76 +398,30 @@ struct ChatNostrCoordinatorContextTests {
// The same gift wrap is dropped on replay.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.handledPrivateMessages.count == 1)
}
@Test @MainActor
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "pre-wipe secret",
messageID: "gm-wipe-1",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Spawn the detached decrypt (it strongly captures the pre-wipe
// identity), then panic-wipe in the SAME main-actor turn guaranteed
// to land before the task's first main-actor hop.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
coordinator.inbound.invalidateInFlightDecrypts()
// Give the detached task ample time to have delivered if the wipe
// guard were broken.
try? await Task.sleep(nanoseconds: 200_000_000)
await drainMainQueue()
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs.isEmpty)
// The pipeline itself stays usable: a gift wrap spawned AFTER the
// wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(delivered)
}
// NOTE: Inbound Schnorr signature verification (and the forged-copy
// dedup-poisoning invariant) is enforced once, off the main actor, at the
// relay boundary see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
// The inbound pipeline only ever sees verified events.
@Test @MainActor
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
// Fan-in of the same (already verified) gift wrap from several relays
// records and processes exactly once.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
// A forged-signature copy is rejected WITHOUT entering the dedup set...
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
#expect(context.recordedNostrEventIDs.isEmpty)
// ...so the genuine event with the same ID still processes and records.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice"
),
)
]
context.unreadPrivateMessages = [
currentPeer,
staleShortPeer,
geoDMWithMessages,
geoDMWithoutMessages,
noiseKeyWithMessages,
noiseKeyWithMessages
]
context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")]
]
coordinator.didUpdatePeerList([currentPeer])
@@ -352,7 +352,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
context.privateChats[convKey] = [
makeIncomingMessage(id: "mine-1", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me")
]
coordinator.handleDelivered(
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID)
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID)
]
coordinator.didDisconnectFromPeer(peerID)
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor
func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < partiallyDelivered < delivered < read
// failed < sending < sent < carried < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [
.failed(reason: "test"),
.sending,
.sent,
.carried,
.partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()),
.read(by: "C", at: Date())
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
case .failed: #expect(index == 0)
case .sending: #expect(index == 1)
case .sent: #expect(index == 2)
case .partiallyDelivered: #expect(index == 3)
case .delivered: #expect(index == 4)
case .read: #expect(index == 5)
case .carried: #expect(index == 3)
case .partiallyDelivered: #expect(index == 4)
case .delivered: #expect(index == 5)
case .read: #expect(index == 6)
}
}
}
+52 -18
View File
@@ -297,8 +297,23 @@ struct ChatViewModelNostrExtensionTests {
let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded()
return viewModel.messages.contains { $0.content == "Hello Geo" }
})
if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true }
// LocationChannelManager is a process-wide singleton: a suite
// running in parallel (e.g. CommandProcessorTests) can flip the
// selected channel mid-test, which reroutes or drops the event
// permanently no amount of waiting recovers it. Re-assert the
// channel and redeliver on each poll: every channel switch clears
// the processed-event set and the store dedups by message ID, so
// redelivery is idempotent and interference heals on the next
// poll while a genuine failure still times out.
if LocationChannelManager.shared.selectedChannel != channel {
LocationChannelManager.shared.select(channel)
}
if viewModel.activeChannel == channel {
viewModel.handleNostrEvent(signed)
}
return false
}, timeout: TestConstants.longTimeout)
#expect(didAppend)
}
@@ -352,11 +367,31 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
}
// NOTE: Tampered-signature rejection is enforced once, off the main
// actor, at the relay boundary (events only reach the inbound pipeline
// after verification) see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
@Test @MainActor
func handleNostrEvent_rejectsInvalidSignature() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Valid"
)
var signed = try event.sign(with: identity.schnorrSigningKey())
signed.id = "deadbeef"
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
}
@Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
@@ -545,14 +580,9 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleGiftWrap(giftWrap, id: recipient)
// Gift-wrap decryption runs off the main actor; wait for the ack
// (sent even for blocked senders) to know processing finished.
let didAck = await TestHelpers.waitUntil(
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
timeout: 5.0
)
#expect(didAck)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
@@ -985,7 +1015,11 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
// Media sends hop through Task.detached; the global executor is
// shared with every parallel test worker, so a loaded runner can
// exceed the 5s default. waitUntil returns as soon as the condition
// holds, so passing runs never pay the longer timeout.
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
@@ -1005,7 +1039,7 @@ struct ChatViewModelMediaTransferTests {
let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: 5.0)
}, timeout: TestConstants.longTimeout)
#expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty)
@@ -1021,7 +1055,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@@ -1042,7 +1076,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: 5.0)
}, timeout: TestConstants.longTimeout)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
+53
View File
@@ -263,6 +263,59 @@ struct ChatViewModelCommandTests {
#expect(transport.sentPrivateMessages.isEmpty)
}
}
@Test @MainActor
func handleCommand_outputRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/help")
#expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText)
#expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText })
}
@Test @MainActor
func handleCommand_errorRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/bogus")
let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? []
#expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") })
#expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") })
}
@Test @MainActor
func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.handleCommand("/bogus")
#expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true)
}
@Test @MainActor
func handleCommand_msgSuccessLandsInNewlyOpenedChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
let resolved = await TestHelpers.waitUntil({
viewModel.getPeerIDForNickname("Alice") == peerID
}, timeout: TestConstants.defaultTimeout)
#expect(resolved)
viewModel.handleCommand("/msg Alice")
#expect(viewModel.selectedPrivateChatPeer == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice")
#expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" })
}
}
// MARK: - Composer Tests
+176
View File
@@ -0,0 +1,176 @@
//
// CourierStoreTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct CourierStoreTests {
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
private func makeStore(now: Date = baseDate) -> CourierStore {
CourierStore(persistsToDisk: false, now: { now })
}
/// Store whose clock can be advanced by tests.
private final class Clock {
var now: Date
init(_ now: Date) { self.now = now }
}
private func makeEnvelope(
recipientKey: Data = Data(repeating: 0xB0, count: 32),
sealedAt: Date = baseDate,
lifetime: TimeInterval = 60 * 60,
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
) -> CourierEnvelope {
CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientKey,
epochDay: CourierEnvelope.epochDay(for: sealedAt)
),
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
ciphertext: ciphertext
)
}
private let depositorA = Data(repeating: 0xA1, count: 32)
private let depositorB = Data(repeating: 0xA2, count: 32)
// MARK: - Deposit and handover
@Test func depositThenTakeForRecipient() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
let taken = store.takeEnvelopes(for: recipientKey)
#expect(taken == [envelope])
// Handover removes the envelope.
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
@Test func takeIgnoresOtherRecipients() {
let store = makeStore()
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
store.deposit(envelope, from: depositorA)
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
}
@Test func duplicateDepositIsIdempotent() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
#expect(store.deposit(envelope, from: depositorA))
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
}
// MARK: - Validity
@Test func rejectsExpiredAndOversizedAndMalformed() {
let store = makeStore()
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
#expect(!store.deposit(expired, from: depositorA))
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
#expect(!store.deposit(oversized, from: depositorA))
let badTag = CourierEnvelope(
recipientTag: Data(repeating: 0, count: 4),
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
ciphertext: Data(repeating: 1, count: 16)
)
#expect(!store.deposit(badTag, from: depositorA))
}
@Test func rejectsExpiryBeyondPolicyLifetime() {
let store = makeStore()
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
#expect(!store.deposit(pinned, from: depositorA))
}
// MARK: - Quotas
@Test func perDepositorQuota() {
let store = makeStore()
for _ in 0..<CourierStore.Limits.maxPerDepositor {
#expect(store.deposit(makeEnvelope(), from: depositorA))
}
#expect(!store.deposit(makeEnvelope(), from: depositorA))
// A different depositor still has room.
#expect(store.deposit(makeEnvelope(), from: depositorB))
}
@Test func totalQuotaEvictsOldestFirst() {
let store = makeStore()
let firstRecipient = Data(repeating: 0xD0, count: 32)
let first = makeEnvelope(recipientKey: firstRecipient)
store.deposit(first, from: depositorA)
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
var deposited = 1
var depositorByte: UInt8 = 1
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
#expect(store.deposit(makeEnvelope(), from: depositor))
deposited += 1
}
depositorByte += 1
}
// The first envelope was evicted to make room.
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
}
// MARK: - Expiry over time
@Test func expiredEnvelopesAreNotHandedOver() {
let clock = Clock(Self.baseDate)
let store = CourierStore(persistsToDisk: false, now: { clock.now })
let recipientKey = Data(repeating: 0xB0, count: 32)
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
clock.now = Self.baseDate.addingTimeInterval(7200)
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
// MARK: - Panic wipe
@Test func wipeDropsEverything() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
store.wipe()
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
// MARK: - Persistence
@Test func persistsAndReloadsAcrossInstances() throws {
// Isolated on-disk location so the test never touches the real store.
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("envelopes.json")
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
let recipientKey = Data(repeating: 0xE0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(first.deposit(envelope, from: depositorA))
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
}
}
@@ -0,0 +1,636 @@
//
// CourierEndToEndTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import Combine
import CoreBluetooth
import BitFoundation
@testable import bitchat
/// Three-node courier flow exercised through real BLEService instances with
/// packets ferried in-process: Alice deposits a sealed envelope with Carol
/// while Bob is unreachable; Carol hands it over when Bob announces; Bob
/// opens it and sees Alice's message in the right DM thread.
struct CourierEndToEndTests {
// MARK: - Helpers
private final class PacketTap {
private let lock = NSLock()
private var packets: [BitchatPacket] = []
func record(_ packet: BitchatPacket) {
lock.lock(); packets.append(packet); lock.unlock()
}
func first(ofType type: MessageType) -> BitchatPacket? {
lock.lock(); defer { lock.unlock() }
return packets.first { $0.type == type.rawValue }
}
func count(ofType type: MessageType) -> Int {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count
}
func all(ofType type: MessageType) -> [BitchatPacket] {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }
}
}
private final class NoiseCaptureDelegate: BitchatDelegate {
private let lock = NSLock()
private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
}
func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
lock.lock(); defer { lock.unlock() }
return payloads
}
// Unused BitchatDelegate requirements.
func didReceiveMessage(_ message: BitchatMessage) {}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
}
private func makeService(identityManager: MockIdentityManager? = nil) -> BLEService {
let keychain = MockKeychain()
let identityManager = identityManager ?? MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false
)
service.courierStore = CourierStore(persistsToDisk: false)
return service
}
/// Handling any packet from a peer preseeds it as a connected,
/// verified entry in the receiving service's registry.
private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data("ping".utf8),
signature: nil,
ttl: 1
)
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
}
// MARK: - Tests
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
// Alice and Carol are mutual favorites; trust policy is exercised
// separately in depositFromUntrustedPeerIsRejected.
carol.courierDepositPolicy = { _ in true }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
// Alice can see Carol; Bob is nowhere on the mesh.
preseedConnectedPeer(carol, in: alice)
// 1. Alice seals to Bob's static key and deposits with Carol.
#expect(alice.sendCourierMessage(
"the camp moved north",
messageID: "courier-msg-1",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
// 3. Later, Bob announces near Carol handover fires.
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
// 4. Ferry the handover to Bob; he opens the envelope.
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(received)
let delivered = try #require(bobDelegate.snapshot().first)
#expect(delivered.type == .privateMessage)
// Sender resolves to Alice's stable mesh identity, not the courier's.
#expect(delivered.peerID == alice.myPeerID)
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
#expect(message.messageID == "courier-msg-1")
#expect(message.content == "the camp moved north")
}
@Test func courieredMailFromBlockedSenderIsDropped() async throws {
let alice = makeService()
let carol = makeService()
let bobIdentity = MockIdentityManager(MockKeychain())
let bob = makeService(identityManager: bobIdentity)
carol.courierDepositPolicy = { _ in true }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
preseedConnectedPeer(carol, in: alice)
// Bob blocked Alice by her stable Noise identity while she was away.
bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true)
#expect(alice.sendCourierMessage(
"you should not see this",
messageID: "courier-msg-blocked-sender",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
// Bob opens the envelope but the sealed sender is blocked, and it
// must never reach the UI. The live block check can't cover this: the
// sender is absent from Bob's registry, so no fingerprint resolves at
// delivery time.
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let delivered = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!delivered)
}
@Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
carol.courierDepositPolicy = { _ in true }
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
preseedConnectedPeer(carol, in: alice)
#expect(alice.sendCourierMessage(
"hold until verified",
messageID: "courier-msg-unverified-announce",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
let forgedAnnounce = try makeUnsignedAnnounce(from: bob)
carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!leakedOnUnverifiedAnnounce)
#expect(!carol.courierStore.isEmpty)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
}
@Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
carol.courierDepositPolicy = { _ in true }
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
preseedConnectedPeer(carol, in: alice)
#expect(alice.sendCourierMessage(
"hold for a direct encounter",
messageID: "courier-msg-relayed-announce",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let directAnnounce = try #require(bobOut.first(ofType: .announce))
// A relayed copy has a decremented TTL but a still-valid signature
// (TTL is excluded from announce signatures). Envelopes are removed
// from the store optimistically, so handover must wait for a direct
// encounter instead of chasing a multi-hop path.
var relayedAnnounce = directAnnounce
relayedAnnounce.ttl = directAnnounce.ttl - 1
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!leakedOnRelayedAnnounce)
#expect(!carol.courierStore.isEmpty)
// The relayed copy consumed the original announce's dedup key
// (sender/timestamp/payload TTL excluded), so the direct handover
// needs a fresh announce. Wait out the 1s announce throttle first.
try await Task.sleep(nanoseconds: 1_100_000_000)
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
timeout: TestConstants.defaultTimeout
)
#expect(reannounced)
let freshAnnounce = try #require(
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
)
carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
}
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
let alice = makeService()
let carol = makeService()
preseedConnectedPeer(carol, in: alice)
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
#expect(!alice.sendCourierMessage(
"this cannot be sealed",
messageID: "courier-msg-invalid-key",
recipientNoiseKey: Data(repeating: 0x01, count: 8),
via: [carol.myPeerID]
))
let queuedPacket = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.shortTimeout
)
#expect(!queuedPacket)
}
@Test func depositFromUntrustedPeerIsRejected() async throws {
let carol = makeService()
carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1"))
let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
let now = Date()
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: bobKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
ciphertext: sealed
)
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: carol.myPeerID.id),
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: try #require(envelope.encode()),
signature: nil,
ttl: 1
)
carol._test_handlePacket(packet, fromPeerID: alicePeerID)
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!stored)
}
@Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws {
let alice = makeService()
let carol = makeService()
let mallory = makeService()
preseedConnectedPeer(alice, in: carol)
preseedConnectedPeer(mallory, in: carol)
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
carol.courierDepositPolicy = { depositorKey in
depositorKey == trustedAliceKey
}
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof"))
let sealed = try aliceNoise.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
let now = Date()
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: bobKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
ciphertext: sealed
)
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alice.myPeerID.id) ?? Data(),
recipientID: Data(hexString: carol.myPeerID.id),
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: try #require(envelope.encode()),
signature: nil,
ttl: 1
)
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!stored)
}
private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: "Unsigned",
noisePublicKey: service.noiseStaticPublicKeyData(),
signingPublicKey: service.noiseSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
return BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
}
}
// MARK: - Router courier selection
/// Minimal transport stub for exercising MessageRouter's courier deposit
/// logic without BLE plumbing.
private final class CourierCaptureTransport: Transport {
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var snapshots: [TransportPeerSnapshot] = []
private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = []
private(set) var directSends: [String] = []
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just(snapshots).eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots }
var myPeerID = PeerID(str: "00000000000000aa")
var myNickname = "stub"
func setNickname(_ nickname: String) {}
func startServices() {}
func stopServices() {}
func emergencyDisconnectAll() {}
func isPeerConnected(_ peerID: PeerID) -> Bool {
snapshots.contains { $0.peerID == peerID && $0.isConnected }
}
func isPeerReachable(_ peerID: PeerID) -> Bool { isPeerConnected(peerID) }
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID: String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: PeerID) {}
func sendMessage(_ content: String, mentions: [String]) {}
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
directSends.append(messageID)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
func sendBroadcastAnnounce() {}
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
courierSends.append((messageID, recipientNoiseKey, couriers))
return true
}
}
struct MessageRouterCourierTests {
@Test @MainActor
func unreachablePeerMessageGoesToTrustedCouriersOnly() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let carolKey = Data(repeating: 0xC0, count: 32)
let carolID = PeerID(publicKey: carolKey)
let daveKey = Data(repeating: 0xD0, count: 32)
let daveID = PeerID(publicKey: daveKey)
let transport = CourierCaptureTransport()
transport.snapshots = [
// Carol: connected mutual favorite eligible courier.
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()),
// Dave: connected but not trusted never a courier.
TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date())
]
let directory = CourierDirectory(
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
isTrustedCourier: { $0 == carolKey }
)
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1")
#expect(transport.directSends.isEmpty)
#expect(transport.courierSends.count == 1)
#expect(transport.courierSends.first?.messageID == "m1")
#expect(transport.courierSends.first?.recipientKey == bobKey)
#expect(transport.courierSends.first?.couriers == [carolID])
#expect(carried == ["m1"])
}
@Test @MainActor
func noCourierDepositWithoutKnownRecipientKey() {
let transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date())
]
let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true })
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2")
#expect(transport.courierSends.isEmpty)
#expect(carried.isEmpty)
}
@Test @MainActor
func reachablePeerSkipsCourier() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date())
]
let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true })
let router = MessageRouter(transports: [transport], courierDirectory: directory)
router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3")
#expect(transport.directSends == ["m3"])
#expect(transport.courierSends.isEmpty)
}
}
+20 -5
View File
@@ -326,11 +326,26 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
}
// NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning
// invariant) is enforced once, off the main actor, at the relay boundary
// the sampling path only ever sees verified events. See
// NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`.
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
let (viewModel, _) = makeTestableViewModel()
let sampleGeohash = "u4pru"
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", sampleGeohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
var invalid = signed
invalid.sig = String(repeating: "0", count: 128)
viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash)
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
}
// MARK: - Test Helper
@@ -174,7 +174,7 @@ struct IntegrationTests {
}
// Encrypted path: use NoiseSessionManager explicitly
let plaintext = "Encrypted message".data(using: .utf8)!
let plaintext = Data("Encrypted message".utf8)
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
@@ -206,7 +206,7 @@ struct IntegrationTests {
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
// David tracks received messages
helper.nodes["David"]!.messageDeliveryHandler = { message in
helper.nodes["David"]!.messageDeliveryHandler = { _ in
completion()
}
@@ -288,7 +288,7 @@ struct IntegrationTests {
}
do {
let plaintext = "After restart success".data(using: .utf8)!
let plaintext = Data("After restart success".utf8)
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
@@ -121,4 +121,3 @@ final class TestNetworkHelper {
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
}
}
+6 -6
View File
@@ -52,19 +52,19 @@ struct MimeTypeTests {
@Test(arguments: [
// === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes)
+1 -1
View File
@@ -172,7 +172,7 @@ struct NoiseCoverageTests {
Data(),
Data(repeating: 0x00, count: 32),
Data([0x01] + Array(repeating: 0x00, count: 31)),
Data(repeating: 0xFF, count: 32),
Data(repeating: 0xFF, count: 32)
]
for invalidKey in invalidKeys {
+27 -28
View File
@@ -151,7 +151,7 @@ struct NoiseProtocolTests {
@Test func basicEncryptionDecryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Hello, Bob!".data(using: .utf8)!
let plaintext = Data("Hello, Bob!".utf8)
// Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext)
@@ -167,13 +167,13 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob
let aliceMessage = "Hello from Alice".data(using: .utf8)!
let aliceMessage = Data("Hello from Alice".utf8)
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext)
#expect(bobReceived == aliceMessage)
// Bob -> Alice
let bobMessage = "Hello from Bob".data(using: .utf8)!
let bobMessage = Data("Hello from Bob".utf8)
let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
#expect(aliceReceived == bobMessage)
@@ -193,7 +193,7 @@ struct NoiseProtocolTests {
}
@Test func encryptionBeforeHandshake() {
let plaintext = "test".data(using: .utf8)!
let plaintext = Data("test".utf8)
#expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.encrypt(plaintext)
@@ -270,7 +270,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Encrypt with manager
let plaintext = "Test message".data(using: .utf8)!
let plaintext = Data("Test message".utf8)
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager
@@ -283,7 +283,7 @@ struct NoiseProtocolTests {
@Test func tamperedCiphertextDetection() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Secret message".data(using: .utf8)!
let plaintext = Data("Secret message".utf8)
var ciphertext = try aliceSession.encrypt(plaintext)
// Tamper with ciphertext
@@ -304,7 +304,7 @@ struct NoiseProtocolTests {
@Test func replayPrevention() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Test message".data(using: .utf8)!
let plaintext = Data("Test message".utf8)
let ciphertext = try aliceSession.encrypt(plaintext)
// First decryption should succeed
@@ -337,7 +337,7 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession2, responder: bobSession2)
// Encrypt with session 1
let plaintext = "Secret".data(using: .utf8)!
let plaintext = Data("Secret".utf8)
let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2
@@ -366,10 +366,10 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID)
_ = try bobManager.decrypt(message1, from: bobPeerID)
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID)
_ = try aliceManager.decrypt(message2, from: alicePeerID)
// Simulate Bob restart by creating new manager with same key
@@ -391,7 +391,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions
let testMessage = "After restart".data(using: .utf8)!
let testMessage = Data("After restart".utf8)
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
#expect(decrypted == testMessage)
@@ -409,17 +409,17 @@ struct NoiseProtocolTests {
// Exchange messages to advance nonces
for i in 0..<5 {
let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!)
let msg = try aliceSession.encrypt(Data("Message \(i)".utf8))
_ = try bobSession.decrypt(msg)
}
// Simulate desynchronization by encrypting but not decrypting
for i in 0..<3 {
_ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!)
_ = try aliceSession.encrypt(Data("Lost message \(i)".utf8))
}
// With per-packet nonce carried, decryption should not throw here
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8))
#expect(throws: Never.self) {
try bobSession.decrypt(desyncMessage)
}
@@ -434,12 +434,11 @@ struct NoiseProtocolTests {
let messageCount = 100
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount)
{ completion in
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
var encryptedMessages: [Int: Data] = [:]
// Encrypt messages sequentially to avoid nonce races in manager
for i in 0..<messageCount {
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
let plaintext = Data("Concurrent message \(i)".utf8)
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
encryptedMessages[i] = encrypted
}
@@ -452,7 +451,7 @@ struct NoiseProtocolTests {
return
}
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
let expected = "Concurrent message \(i)".data(using: .utf8)!
let expected = Data("Concurrent message \(i)".utf8)
#expect(decrypted == expected)
completion()
} catch {
@@ -485,7 +484,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Create a corrupted message
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
var encrypted = try aliceManager.encrypt(Data("Test".utf8), for: alicePeerID)
encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail
@@ -516,7 +515,7 @@ struct NoiseProtocolTests {
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// Exchange messages to verify sessions work
let testMessage = "Session works".data(using: .utf8)!
let testMessage = Data("Session works".utf8)
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
#expect(decrypted == testMessage)
@@ -539,7 +538,7 @@ struct NoiseProtocolTests {
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// Verify new sessions work
let testMessage2 = "New session works".data(using: .utf8)!
let testMessage2 = Data("New session works".utf8)
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
#expect(decrypted2 == testMessage2)
@@ -555,18 +554,18 @@ struct NoiseProtocolTests {
// Exchange messages normally
for i in 0..<5 {
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
let msg = try aliceManager.encrypt(Data("Message \(i)".utf8), for: alicePeerID)
_ = try bobManager.decrypt(msg, from: bobPeerID)
}
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
for i in 0..<3 {
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
_ = try aliceManager.encrypt(Data("Lost message \(i)".utf8), for: alicePeerID)
}
// With nonce carried in packet, decryption should not throw here
let desyncMessage = try aliceManager.encrypt(
"This now succeeds".data(using: .utf8)!, for: alicePeerID)
Data("This now succeeds".utf8), for: alicePeerID)
#expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID)
}
@@ -587,7 +586,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again
let testResynced = "Resynced".data(using: .utf8)!
let testResynced = Data("Resynced".utf8)
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
#expect(decryptedResync == testResynced)
@@ -900,20 +899,20 @@ struct NoiseProtocolTests {
#expect(trackingKeychain.secureClearDataCallCount > 0)
// Test encryption from Alice to Bob
let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)!
let plaintext1 = Data("Hello from Alice after secureClear!".utf8)
let ciphertext1 = try alice.encrypt(plaintext1)
let decrypted1 = try bob.decrypt(ciphertext1)
#expect(decrypted1 == plaintext1)
// Test encryption from Bob to Alice
let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)!
let plaintext2 = Data("Hello from Bob after secureClear!".utf8)
let ciphertext2 = try bob.encrypt(plaintext2)
let decrypted2 = try alice.decrypt(ciphertext2)
#expect(decrypted2 == plaintext2)
// Test multiple messages to verify cipher state is correct
for i in 1...10 {
let msg = "Message \(i) from Alice".data(using: .utf8)!
let msg = Data("Message \(i) from Alice".utf8)
let cipher = try alice.encrypt(msg)
let dec = try bob.decrypt(cipher)
#expect(dec == msg)
+107
View File
@@ -0,0 +1,107 @@
//
// NoiseCourierTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
/// One-way Noise X envelopes: encryption to a known static key without an
/// interactive handshake, used by the courier store-and-forward path.
struct NoiseCourierTests {
@Test func sealAndOpenRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let payload = Data("meet at the north gate".utf8)
let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
let opened = try bob.openCourierPayload(sealed)
#expect(opened.payload == payload)
// The X pattern authenticates the sender: Bob learns Alice's real static key.
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
}
@Test func wrongRecipientCannotOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let carol = NoiseEncryptionService(keychain: MockKeychain())
let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
#expect(throws: (any Error).self) {
_ = try carol.openCourierPayload(sealed)
}
}
@Test func tamperedEnvelopeFailsToOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
sealed[sealed.count - 1] ^= 0x01
#expect(throws: (any Error).self) {
_ = try bob.openCourierPayload(sealed)
}
}
@Test func senderIdentityCannotBeForged() throws {
// The encrypted static key inside the envelope is bound by the ss DH;
// splicing one envelope's ephemeral prefix onto another must fail.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = bob.getStaticPublicKeyData()
let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
// e (32 bytes) from Mallory's envelope + rest from Alice's.
let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32)
#expect(throws: (any Error).self) {
_ = try bob.openCourierPayload(Data(spliced))
}
}
@Test func sealRejectsInvalidRecipientKey() {
let alice = NoiseEncryptionService(keychain: MockKeychain())
#expect(throws: (any Error).self) {
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32))
}
#expect(throws: (any Error).self) {
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8))
}
}
@Test func emptyAndLargePayloadsRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = bob.getStaticPublicKeyData()
let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey)
#expect(try bob.openCourierPayload(empty).payload.isEmpty)
let large = Data((0..<8192).map { UInt8($0 % 251) })
let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey)
#expect(try bob.openCourierPayload(sealed).payload == large)
}
@Test func envelopesAreNotLinkableAcrossSends() throws {
// Fresh ephemeral per seal: same payload to the same recipient must
// produce entirely different ciphertexts.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let payload = Data("same message".utf8)
let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
#expect(a != b)
#expect(a.prefix(32) != b.prefix(32))
}
}
@@ -77,10 +77,8 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (kind 20000): dedup record, presence/nickname bookkeeping, and
/// public-message ingest scheduling. Schnorr signature verification is
/// NOT part of this path anymore it runs exactly once, off the main
/// actor, in `NostrRelayManager` before delivery.
/// (kind 20000): signature verification, dedup record, presence/nickname
/// bookkeeping, and public-message ingest scheduling.
func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the
@@ -108,9 +106,8 @@ final class PerformanceBaselineTests: XCTestCase {
/// The dedup-hit path: identical events replayed. Duplicates dominate
/// real relay traffic (the same event arrives from several relays), so
/// this path runs hundreds of times a minute in busy geohashes. It is a
/// pure dedup lookup: no crypto (verification happens upstream in
/// `NostrRelayManager`, and only for the first-seen copy).
/// this path runs hundreds of times a minute in busy geohashes. Note it
/// still pays full Schnorr signature verification before the dedup check.
func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext()
@@ -304,7 +301,7 @@ final class PerformanceBaselineTests: XCTestCase {
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
("checking in from the harbor #bitchat #mesh", nil),
("@bob#0042 ping me when you get this", ["bob#0042"]),
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil),
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil)
]
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
(0..<batchSize).map { i in
@@ -550,7 +547,7 @@ final class PerformanceBaselineTests: XCTestCase {
"anyone near the station?",
"@bob#0042 are you on mesh too?",
"check this out https://example.com/p/123 #bitchat",
"teleport check, who's local?",
"teleport check, who's local?"
]
return try (0..<count).map { i in
try NostrProtocol.createEphemeralGeohashEvent(
+10 -5
View File
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
struct ProtocolContractTests {
@Test
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
#expect(CommandInfo.message.id == "dm")
#expect(CommandInfo.message.alias == "/dm")
// Aliases must match what CommandProcessor actually accepts
// the suggestion panel is the only command-discovery surface.
#expect(CommandInfo.message.id == "msg")
#expect(CommandInfo.message.alias == "/msg")
#expect(CommandInfo.message.placeholder != nil)
#expect(CommandInfo.clear.placeholder == nil)
#expect(CommandInfo.favorite.description.isEmpty == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
// Favorites are rejected by the processor in geohash contexts, so
// they are suggested only in mesh.
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite))
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false)
}
@Test
@@ -98,8 +98,12 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isDirectAnnounce == true)
#expect(result?.isVerified == true)
#expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
#expect(recorder.barrierCount == 1)
@@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.isEmpty)
#expect(recorder.barrierCount == 1)
#expect(recorder.upsertCalls.isEmpty)
@@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests {
recorder.signatureValid = false
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
@@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@@ -242,8 +251,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@@ -311,8 +322,10 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.isDirectAnnounce == false)
#expect(result?.isVerified == true)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false)
#expect(recorder.uiEventDeliveries.count == 1)
@@ -384,8 +397,9 @@ struct BLEAnnounceHandlerTests {
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
@@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests {
#expect(selection.centralIDs == Set(["c2"]))
}
@Test
func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["target-p", "bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: nil,
peripheralPeerBindings: [
"target-p": target,
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs == Set(["target-p"]))
#expect(selection.centralIDs.isEmpty)
}
@Test
func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: nil,
peripheralPeerBindings: [
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs.isEmpty)
#expect(selection.centralIDs == Set(["target-c"]))
}
@Test
func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: .central("target-c"),
peripheralPeerBindings: [
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs.isEmpty)
#expect(selection.centralIDs.isEmpty)
}
@Test
func directedSendExcludesAllLinksToIngressPeer() {
let selection = BLEFanoutSelector.selectLinks(
@@ -639,15 +639,11 @@ final class NostrRelayManagerTests: XCTestCase {
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
// Wait on the DELIVERY-side state: handler dispatch and the duplicate
// drop both land on the second main hop, after off-main verification.
let settled = await waitUntil {
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [event.id] &&
context.manager.debugDuplicateInboundEventDropCount == 1
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(settled)
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
@@ -680,17 +676,12 @@ final class NostrRelayManagerTests: XCTestCase {
)
}
// Wait on the DELIVERY-side state: the winner's handler dispatch and
// the losers' duplicate drops both land on the second main hop, after
// off-main verification messagesReceived (first hop) settles sooner.
let settled = await waitUntil {
let countedOnEveryRelay = await waitUntil {
relayURLs.allSatisfy { relayURL in
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
} &&
receivedIDs == [event.id] &&
context.manager.debugDuplicateInboundEventDropCount == relayURLs.count - 1
}
}
XCTAssertTrue(settled)
XCTAssertTrue(countedOnEveryRelay)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
XCTAssertEqual(
@@ -723,153 +714,16 @@ final class NostrRelayManagerTests: XCTestCase {
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
// Wait on the DELIVERY-side state (second main hop, after off-main
// verification), not just messagesReceived (first main hop) the
// handler only fires after verify + a second hop.
let genuineDelivered = await waitUntil {
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [event.id]
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(genuineDelivered)
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
}
/// The relay boundary is the single signature-verification point for the
/// whole inbound path (downstream pipelines no longer re-verify), so a
/// tampered gift wrap (kind 1059, the DM/mailbox path) must be dropped
/// here and must not poison the dedup cache against the genuine copy.
func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws {
let firstRelayURL = "wss://giftwrap-one.example"
let secondRelayURL = "wss://giftwrap-two.example"
let context = makeContext(permission: .denied)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "psst",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let tampered = invalidSignatureCopy(of: giftWrap)
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "gift-wraps",
relayUrls: [firstRelayURL, secondRelayURL]
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionsSent)
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: tampered)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: giftWrap)
// Wait on the DELIVERY-side state (second main hop, after off-main
// verification), not just messagesReceived (first main hop) the
// handler only fires after verify + a second hop.
let genuineDelivered = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [giftWrap.id]
}
XCTAssertTrue(genuineDelivered)
XCTAssertEqual(receivedIDs, [giftWrap.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
}
/// Signature verification runs off-main in a per-relay serial consumer;
/// several frames buffered on one socket must still be delivered to the
/// handler in that relay's arrival order.
func test_receiveEvent_deliversBackToBackEventsInArrivalOrder() async throws {
let relayURL = "wss://ordered.example"
let context = makeContext(permission: .denied)
let events = try (0..<12).map { try makeSignedEvent(content: "ordered-\($0)") }
var receivedIDs: [String] = []
context.manager.subscribe(filter: makeFilter(), id: "ordered", relayUrls: [relayURL]) { event in
receivedIDs.append(event.id)
}
let subscriptionSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionSent)
for event in events {
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
}
let allDelivered = await waitUntil(timeout: 5.0) {
receivedIDs.count == events.count
}
XCTAssertTrue(allDelivered)
XCTAssertEqual(receivedIDs, events.map(\.id))
}
/// Each relay owns its own off-main verify pipeline, so a large backlog of
/// EVENT frames on one relay must NOT head-of-line-block a frame that
/// arrives on a different relay. Under the previous single global consumer,
/// relay B's single event (emitted after relay A's whole burst) could only
/// be delivered once every frame in A's backlog had been Schnorr-verified;
/// with per-relay pipelines B verifies concurrently and lands before A's
/// backlog drains.
func test_receiveEvent_busyRelayDoesNotBlockOtherRelayDelivery() async throws {
let busyRelayURL = "wss://busy-relay.example"
let quietRelayURL = "wss://quiet-relay.example"
let context = makeContext(permission: .denied)
// Distinct subscriptions per relay so dedup never coalesces A vs. B.
let busyEvents = try (0..<200).map { try makeSignedEvent(content: "busy-\($0)") }
let quietEvent = try makeSignedEvent(content: "quiet")
var busyDeliveredCount = 0
var quietDeliveredAfterBusyCount = -1 // busy-count observed when B lands
context.manager.subscribe(filter: makeFilter(), id: "busy", relayUrls: [busyRelayURL]) { _ in
busyDeliveredCount += 1
}
context.manager.subscribe(filter: makeFilter(), id: "quiet", relayUrls: [quietRelayURL]) { _ in
if quietDeliveredAfterBusyCount < 0 {
quietDeliveredAfterBusyCount = busyDeliveredCount
}
}
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: busyRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: quietRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
// Flood relay A first, then emit a single frame on relay B.
for event in busyEvents {
try context.sessionFactory.latestConnection(for: busyRelayURL)?.emitEventMessage(subscriptionID: "busy", event: event)
}
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 }
XCTAssertTrue(quietDelivered, "relay B's event was never delivered")
// The signal: B did not have to wait for A's entire backlog. If the two
// pipelines were globally serialized, B could only land after all 200 of
// A's frames, so busyDeliveredCount would be 200 when B arrived.
XCTAssertLessThan(
quietDeliveredAfterBusyCount,
busyEvents.count,
"relay B was head-of-line blocked behind relay A's backlog"
)
// Both relays still drain fully and in order.
let allDelivered = await waitUntil(timeout: 5.0) {
busyDeliveredCount == busyEvents.count
}
XCTAssertTrue(allDelivered)
}
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
let relayURL = "wss://missing-handler.example"
let context = makeContext(permission: .denied)
@@ -1841,11 +1695,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
if !pendingResults.isEmpty {
completionHandler(pendingResults.removeFirst())
} else {
receiveHandler = completionHandler
}
receiveHandler = completionHandler
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
@@ -1878,24 +1728,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
func emitRawString(_ string: String) throws {
deliver(.success(.string(string)))
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.string(string)))
}
private func emit(jsonObject: Any) throws {
let data = try JSONSerialization.data(withJSONObject: jsonObject)
deliver(.success(.data(data)))
}
// Frames emitted before the manager re-arms `receive` are queued so
// back-to-back emissions model a socket with several buffered frames.
private var pendingResults: [Result<URLSessionWebSocketTask.Message, Error>] = []
private func deliver(_ result: Result<URLSessionWebSocketTask.Message, Error>) {
if let handler = receiveHandler {
receiveHandler = nil
handler(result)
} else {
pendingResults.append(result)
}
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.data(data)))
}
}
@@ -41,6 +41,44 @@ struct UnifiedPeerServiceTests {
#expect(service.isBlocked(peerID))
}
@Test @MainActor
func setBlocked_persistsByFingerprintAndToggles() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
let peerID = PeerID(str: "00000000000000EE")
let fingerprint = "fp-target"
transport.peerFingerprints[peerID] = fingerprint
// Blocking resolves and persists by the peer's fingerprint.
let resolved = service.setBlocked(peerID, blocked: true)
#expect(resolved == fingerprint)
#expect(identity.isBlocked(fingerprint: fingerprint))
#expect(service.isBlocked(peerID))
// Unblocking clears it against the same identity.
let unresolved = service.setBlocked(peerID, blocked: false)
#expect(unresolved == fingerprint)
#expect(!identity.isBlocked(fingerprint: fingerprint))
#expect(!service.isBlocked(peerID))
}
@Test @MainActor
func setBlocked_unknownIdentityReturnsNil() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
// No fingerprint resolvable for this peer (offline & unknown).
let peerID = PeerID(str: "00000000000000FF")
#expect(service.setBlocked(peerID, blocked: true) == nil)
#expect(!service.isBlocked(peerID))
}
}
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
@@ -12,6 +12,11 @@ import Foundation
struct TestConstants {
static let defaultTimeout: TimeInterval = 5.0
static let shortTimeout: TimeInterval = 1.0
/// For positive waits on work that hops through `Task.detached` or
/// background queues: those contend with every parallel test worker for
/// the global executor, so a loaded CI runner can exceed
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
/// so passing runs never pay the longer timeout.
static let longTimeout: TimeInterval = 10.0
static let testNickname1 = "Alice"
+2 -2
View File
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!,
payload: Data = Data("test payload".utf8),
signature: Data? = nil,
ttl: UInt8 = 3
) -> BitchatPacket {
return BitchatPacket(
type: type,
senderID: senderID.id.data(using: .utf8)!,
senderID: Data(senderID.id.utf8),
recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
+13 -3
View File
@@ -556,11 +556,19 @@ struct ViewSmokeTests {
@Test
func voiceAndMediaViews_renderAndWarmCaches() async throws {
let audioURL = try makeTemporaryAudioURL()
// Probed directly below. Deliberately a separate file from `audioURL`:
// `WaveformCache.shared` is process-wide and the mounted
// `VoiceNoteView` warms it for `audioURL` at the view's default bin
// width concurrently, so asserting an exact bin count for that URL
// races with the view's own cache write.
let waveformProbeURL = try makeTemporaryAudioURL()
let imageURL = try makeTemporaryImageURL()
defer {
try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: waveformProbeURL)
try? FileManager.default.removeItem(at: imageURL)
WaveformCache.shared.purge(url: audioURL)
WaveformCache.shared.purge(url: waveformProbeURL)
}
let waveformView = WaveformView(
@@ -594,12 +602,14 @@ struct ViewSmokeTests {
_ = mount(voiceNoteView)
let bins = await withCheckedContinuation { continuation in
WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in
WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in
continuation.resume(returning: values)
}
}
playback.loadDuration()
try? await Task.sleep(nanoseconds: 250_000_000)
// loadDuration hops through a background queue and back to main; poll
// instead of a fixed sleep so a loaded runner can't outlast the wait.
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
playback.seek(to: 1.25)
playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback)
@@ -607,7 +617,7 @@ struct ViewSmokeTests {
await VoiceRecorder.shared.cancelRecording()
#expect(bins.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
#expect(playback.duration > 0)
#expect(playback.progress == 0)
}
+12 -12
View File
@@ -13,7 +13,7 @@ import struct Foundation.Data
struct XChaCha20Poly1305CompatTests {
@Test func sealAndOpenRoundtrip() throws {
let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)!
let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8)
let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24)
@@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealAndOpenWithAAD() throws {
let plaintext = "Secret message".data(using: .utf8)!
let plaintext = Data("Secret message".utf8)
let key = Data(repeating: 0xAB, count: 32)
let nonce = Data(repeating: 0xCD, count: 24)
let aad = "additional authenticated data".data(using: .utf8)!
let aad = Data("additional authenticated data".utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
let decrypted = try XChaCha20Poly1305Compat.open(
@@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {
let plaintext = "Same plaintext".data(using: .utf8)!
let plaintext = Data("Same plaintext".utf8)
let key = Data(repeating: 0x42, count: 32)
let nonce1 = Data(repeating: 0x01, count: 24)
let nonce2 = Data(repeating: 0x02, count: 24)
@@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnShortKey() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let shortKey = Data(repeating: 0x42, count: 16)
let nonce = Data(repeating: 0x24, count: 24)
@@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnLongKey() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let longKey = Data(repeating: 0x42, count: 64)
let nonce = Data(repeating: 0x24, count: 24)
@@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnEmptyKey() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let emptyKey = Data()
let nonce = Data(repeating: 0x24, count: 24)
@@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnShortNonce() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32)
let shortNonce = Data(repeating: 0x24, count: 12)
@@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnLongNonce() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32)
let longNonce = Data(repeating: 0x24, count: 32)
@@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnEmptyNonce() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32)
let emptyNonce = Data()
@@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func openFailsWithWrongKey() throws {
let plaintext = "Secret".data(using: .utf8)!
let plaintext = Data("Secret".utf8)
let correctKey = Data(repeating: 0x42, count: 32)
let wrongKey = Data(repeating: 0x43, count: 32)
let nonce = Data(repeating: 0x24, count: 24)
@@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func openFailsWithTamperedCiphertext() throws {
let plaintext = "Secret".data(using: .utf8)!
let plaintext = Data("Secret".utf8)
let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24)
+7 -7
View File
@@ -5,16 +5,16 @@ let package = Package(
name: "Tor", // Keep name "Tor" for drop-in compatibility
platforms: [
.iOS(.v16),
.macOS(.v13),
.macOS(.v13)
],
products: [
.library(
name: "Tor",
targets: ["Tor"]
),
)
],
dependencies: [
.package(path: "../BitLogger"),
.package(path: "../BitLogger")
],
targets: [
// Main Swift target
@@ -22,19 +22,19 @@ let package = Package(
name: "Tor",
dependencies: [
"arti",
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "BitLogger", package: "BitLogger")
],
path: "Sources",
exclude: ["C"],
sources: [
"TorManager.swift",
"TorURLSession.swift",
"TorNotifications.swift",
"TorNotifications.swift"
],
linkerSettings: [
.linkedLibrary("resolv"),
.linkedLibrary("z"),
.linkedLibrary("sqlite3"),
.linkedLibrary("sqlite3")
]
),
// Binary framework containing the Rust static library.
@@ -42,6 +42,6 @@ let package = Package(
.binaryTarget(
name: "arti",
path: "Frameworks/arti.xcframework"
),
)
]
)
+1 -1
View File
@@ -21,7 +21,7 @@ let package = Package(
.target(
name: "BitFoundation",
dependencies: [
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "BitLogger", package: "BitLogger")
],
path: "Sources"
),
@@ -0,0 +1,147 @@
//
// CourierEnvelope.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
private import CryptoKit
/// TLV payload for store-and-forward courier envelopes.
///
/// A courier envelope lets a mutual favorite physically carry an encrypted
/// message to a peer who is currently offline. The envelope is opaque to the
/// courier: the only routing information is a rotating recipient tag derived
/// from the recipient's Noise static public key and the UTC day, so envelopes
/// addressed to the same peer on different days do not correlate for
/// observers who don't already know that peer's public key.
public struct CourierEnvelope: Equatable {
/// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated.
public let recipientTag: Data
/// Milliseconds since epoch after which the envelope must be discarded.
public let expiry: UInt64
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
public let ciphertext: Data
public static let tagLength = 16
/// Couriered messages are text-sized; media transfers are out of scope.
public static let maxCiphertextBytes = 16 * 1024
/// Matches the outbox retention policy in MessageRouter.
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
private enum TLVType: UInt8 {
case recipientTag = 0x01
case expiry = 0x02
case ciphertext = 0x03
}
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
}
public var isExpired: Bool {
isExpired(at: Date())
}
public func isExpired(at date: Date) -> Bool {
UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry
}
public func encode() -> Data? {
guard recipientTag.count == Self.tagLength else { return nil }
guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
}
var encoded = Data()
encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count)
encoded.append(TLVType.recipientTag.rawValue)
appendBE(UInt16(recipientTag.count), into: &encoded)
encoded.append(recipientTag)
encoded.append(TLVType.expiry.rawValue)
appendBE(UInt16(8), into: &encoded)
appendBE(expiry, into: &encoded)
encoded.append(TLVType.ciphertext.rawValue)
appendBE(UInt16(ciphertext.count), into: &encoded)
encoded.append(ciphertext)
return encoded
}
public static func decode(_ data: Data) -> CourierEnvelope? {
var cursor = data.startIndex
let end = data.endIndex
var recipientTag: Data?
var expiry: UInt64?
var ciphertext: Data?
while cursor < end {
let typeRaw = data[cursor]
cursor = data.index(after: cursor)
guard data.distance(from: cursor, to: end) >= 2 else { return nil }
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
cursor = data.index(cursor, offsetBy: 2)
guard data.distance(from: cursor, to: end) >= length else { return nil }
let value = data[cursor..<data.index(cursor, offsetBy: length)]
cursor = data.index(cursor, offsetBy: length)
switch TLVType(rawValue: typeRaw) {
case .recipientTag:
guard length == tagLength else { return nil }
recipientTag = Data(value)
case .expiry:
guard length == 8 else { return nil }
expiry = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .ciphertext:
guard length > 0, length <= maxCiphertextBytes else { return nil }
ciphertext = Data(value)
case nil:
// Unknown TLV: skip for forward compatibility.
continue
}
}
guard let recipientTag, let expiry, let ciphertext else { return nil }
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
}
// MARK: - Recipient Tags
private static let tagContext = Data("bitchat-courier-tag-v1".utf8)
/// UTC day number used to rotate recipient tags.
public static func epochDay(for date: Date) -> UInt32 {
UInt32(max(0, date.timeIntervalSince1970) / 86_400)
}
/// Rotating recipient hint for a given day. Computable only by parties
/// who already know the recipient's Noise static public key.
public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data {
var message = tagContext
withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) }
let mac = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey))
return Data(mac).prefix(tagLength)
}
/// Tags to test when checking whether an envelope is addressed to a peer.
/// Covers the adjacent days so envelopes sealed near midnight (or across
/// modest clock skew) still match while being carried.
public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] {
let day = epochDay(for: date)
return [day == 0 ? 0 : day - 1, day, day + 1].map {
recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0)
}
}
}
@@ -11,17 +11,20 @@ import struct Foundation.Date
public enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case carried // Sealed envelope handed to a courier; best-effort physical delivery
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
public var displayText: String {
switch self {
case .sending:
return "Sending..."
case .sent:
return "Sent"
case .carried:
return "Carried by a friend"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
@@ -12,8 +12,9 @@
public enum MessageType: UInt8 {
// Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
@@ -46,7 +46,8 @@ struct BinaryProtocolTests {
// Verify recipient
#expect(decodedPacket.recipientID != nil)
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
// TODO: Check if this is intended that the decoding only gets the first 8
// Recipient IDs are a fixed 8-byte wire field: encode pads or truncates
// to BinaryProtocol.recipientIDSize, so only the first 8 bytes survive.
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
}
@@ -294,7 +295,7 @@ struct BinaryProtocolTests {
@Test("Create a large, compressible payload above current threshold (2048B)")
func payloadCompression() throws {
let repeatedString = String(repeating: "This is a test message. ", count: 200)
let largePayload = repeatedString.data(using: .utf8)!
let largePayload = Data(repeatedString.utf8)
let packet = TestHelpers.createTestPacket(payload: largePayload)
@@ -314,7 +315,7 @@ struct BinaryProtocolTests {
@Test("Small payloads should not be compressed")
func smallPayloadNoCompression() throws {
let smallPayload = "Hi".data(using: .utf8)!
let smallPayload = Data("Hi".utf8)
let packet = TestHelpers.createTestPacket(payload: smallPayload)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
@@ -362,7 +363,7 @@ struct BinaryProtocolTests {
var encodedSizes = Set<Int>()
for payload in payloads {
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
let packet = TestHelpers.createTestPacket(payload: Data(payload.utf8))
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
@@ -0,0 +1,122 @@
//
// CourierEnvelopeTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct CourierEnvelopeTests {
private func makeEnvelope(
tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength),
expiry: UInt64 = 1_900_000_000_000,
ciphertext: Data = Data(repeating: 0x42, count: 128)
) -> CourierEnvelope {
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
}
// MARK: - Codec
@Test func roundTrip() throws {
let envelope = makeEnvelope()
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
}
@Test func roundTripAtMaxCiphertextSize() throws {
let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes))
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
}
@Test func encodeRejectsInvalidFields() {
#expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil)
#expect(makeEnvelope(ciphertext: Data()).encode() == nil)
#expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil)
}
@Test func decodeRejectsMissingFields() throws {
// Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only.
let encoded = try #require(makeEnvelope().encode())
let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8)
#expect(CourierEnvelope.decode(Data(truncated)) == nil)
}
@Test func decodeRejectsTruncatedValue() throws {
let encoded = try #require(makeEnvelope().encode())
#expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil)
}
@Test func decodeSkipsUnknownTLVs() throws {
var encoded = try #require(makeEnvelope().encode())
// Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it.
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == makeEnvelope())
}
@Test func decodeOffsetSlice() throws {
// Decoder must handle slices with non-zero startIndex.
let encoded = try #require(makeEnvelope().encode())
let padded = Data([0xFF, 0xFF]) + encoded
let slice = padded.dropFirst(2)
#expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope())
#expect(CourierEnvelope.decode(slice) == makeEnvelope())
}
// MARK: - Expiry
@Test func expiryComparison() {
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
#expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired)
#expect(makeEnvelope(expiry: nowMs - 60_000).isExpired)
#expect(makeEnvelope(expiry: 0).isExpired)
}
// MARK: - Recipient Tags
@Test func tagIsDeterministicPerKeyAndDay() {
let key = Data(repeating: 0x11, count: 32)
let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
#expect(tag1 == tag2)
#expect(tag1.count == CourierEnvelope.tagLength)
}
@Test func tagRotatesAcrossDaysAndKeys() {
let key = Data(repeating: 0x11, count: 32)
let otherKey = Data(repeating: 0x22, count: 32)
let day: UInt32 = 20_000
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
!= CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
!= CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day))
}
@Test func candidateTagsCoverAdjacentDays() {
let key = Data(repeating: 0x33, count: 32)
let date = Date(timeIntervalSince1970: 1_750_000_000)
let day = CourierEnvelope.epochDay(for: date)
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date)
#expect(candidates.count == 3)
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1)))
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)))
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)))
}
@Test func sealedYesterdayMatchesToday() {
// An envelope sealed late on day D must still match the recipient on day D+1.
let key = Data(repeating: 0x44, count: 32)
let sealedAt = Date(timeIntervalSince1970: 1_750_000_000)
let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60)
let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt))
#expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag))
}
}
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!,
payload: Data = Data("test payload".utf8),
signature: Data? = nil,
ttl: UInt8 = 3
) -> BitchatPacket {
return BitchatPacket(
type: type,
senderID: senderID.id.data(using: .utf8)!,
senderID: Data(senderID.id.utf8),
recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
+86 -13
View File
@@ -11,16 +11,34 @@
# never runner variance. Raise floors deliberately after intentional
# improvements; never tune them to chase noise.
#
# Retry-on-noise: even generous floors can be dipped under by a saturated
# runner (observed: gcs.buildAndDecode at 85% of floor on a loaded GitHub
# macOS runner). When a benchmark lands below its floor, the gate re-runs the
# benchmark suite — appending to the same PERF log — and keeps each
# benchmark's BEST observed value across attempts. Runner noise clears on a
# retry; a real algorithmic regression stays below floor on every attempt and
# still fails. Floors themselves are never lowered by this mechanism.
#
# Usage: scripts/check-perf-floors.sh <test-output-file> [floors-file]
#
# Environment:
# BITCHAT_PERF_GATE_ATTEMPTS total measurement attempts (default 3)
# BITCHAT_PERF_REMEASURE_CMD command run to re-measure on a below-floor
# result (default: swift test --quiet
# --filter PerformanceBaselineTests). The
# command runs with BITCHAT_PERF_LOG pointed at
# the output file so new PERF lines append.
#
# Skips gracefully (exit 0) when:
# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or
# - the output contains no PERF lines (e.g. package-only matrix entries).
#
# Fails (exit 1) when:
# - any benchmark reports throughput below its floor, or
# - PERF lines are present but a floored benchmark is missing
# (a silently-dropped benchmark must be an explicit floors-file change).
# Fails when:
# - any benchmark reports throughput below its floor on every attempt
# (exit 1), or
# - PERF lines are present but a floored benchmark is missing — a
# silently-dropped benchmark must be an explicit floors-file change and
# is not retried (exit 3).
set -euo pipefail
@@ -31,6 +49,8 @@ fi
OUTPUT_FILE="$1"
FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}"
MAX_ATTEMPTS="${BITCHAT_PERF_GATE_ATTEMPTS:-3}"
REMEASURE_CMD="${BITCHAT_PERF_REMEASURE_CMD:-swift test --quiet --filter PerformanceBaselineTests}"
if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then
echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate."
@@ -52,7 +72,17 @@ if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then
exit 0
fi
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
# Absolute path so re-measurement appends to the same file regardless of the
# working directory the test process runs in.
case "$OUTPUT_FILE" in
/*) ;;
*) OUTPUT_FILE="$(pwd)/$OUTPUT_FILE" ;;
esac
# Exit codes: 0 = all floors met, 1 = below floor (retryable — noise vs
# regression undecided), 3 = floored benchmark missing (not retryable).
check_floors() {
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
import json
import os
import re
@@ -72,15 +102,20 @@ with open(output_file, errors="replace") as f:
for line in f:
m = pattern.search(line)
if m:
# Keep the last reported value if a benchmark prints twice.
measured[m.group(1)] = (float(m.group(2)), m.group(3))
# Keep the BEST reported value: measurement retries append to the
# same log, and a healthy benchmark only needs to clear its floor
# once — a real regression never does.
name, value, unit = m.group(1), float(m.group(2)), m.group(3)
if name not in measured or value > measured[name][0]:
measured[name] = (value, unit)
failures = []
below_floor = []
missing = []
print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)")
for name in sorted(set(floors) | set(measured)):
floor = floors.get(name)
if name not in measured:
failures.append(
missing.append(
f" MISSING {name}: floored benchmark reported no PERF line "
f"(removed/renamed? update perf-floors.json in the same change)")
continue
@@ -92,13 +127,18 @@ for name in sorted(set(floors) | set(measured)):
line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})"
print(line)
if value < floor:
failures.append(
below_floor.append(
f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} "
f"({value / floor * 100:.0f}% of floor)")
if failures:
print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:")
print("\n".join(failures))
if missing:
print("\nperf-floors: FAILED — floored benchmark(s) missing from the output:")
print("\n".join(missing + below_floor))
sys.exit(3)
if below_floor:
print("\nperf-floors: below floor — order-of-magnitude-class regression suspected:")
print("\n".join(below_floor))
print("\nFloors are ~25% of healthy local throughput; falling below one means an")
print("algorithmic regression, not runner noise. If the change is intentional,")
print("update bitchatTests/Performance/perf-floors.json deliberately.")
@@ -106,3 +146,36 @@ if failures:
print("perf-floors: all benchmarks at or above their floors.")
PYEOF
}
attempt=1
while true; do
gate_status=0
check_floors || gate_status=$?
case "$gate_status" in
0)
exit 0
;;
1)
# Below floor: retry to separate runner noise from regression.
;;
*)
# Missing benchmark or parse/setup error: re-measuring can't help.
exit "$gate_status"
;;
esac
if (( attempt >= MAX_ATTEMPTS )); then
echo "perf-floors: still below floor after $attempt measurement attempt(s) — treating as a real regression." >&2
exit 1
fi
attempt=$((attempt + 1))
echo "perf-floors: re-measuring (attempt $attempt of $MAX_ATTEMPTS) to separate runner noise from a real regression."
# Word splitting of REMEASURE_CMD is deliberate: it is a command line.
if ! BITCHAT_PERF_LOG="$OUTPUT_FILE" $REMEASURE_CMD; then
echo "perf-floors: re-measurement command failed: $REMEASURE_CMD" >&2
exit 1
fi
done