Compare commits

...
35 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
68eeba97ff Use Xcode-bundled Swift in CI instead of a standalone toolchain (#1353)
The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:51:58 +02:00
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
GitHub Action f688e529f6 Automated update of relay data - Sun Jun 21 07:37:42 UTC 2026 2026-06-21 07:37:42 +00: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
jackandGitHub 96e32ba990 Merge pull request #1345 from permissionlesstech/fix/security-audit-critical-high-batch
Fix security audit findings: 3 critical, 7 high
2026-06-17 09:44:26 +02:00
jack 0a2f4d9c9d Tighten panic wipe and NIP-17 regressions 2026-06-17 09:27:13 +02:00
jack 914135adb0 Fix panic wipe relay and geohash state 2026-06-16 13:56:15 +02:00
jackandGitHub cd7ffa0df9 Merge branch 'main' into fix/security-audit-critical-high-batch 2026-06-16 13:52:10 +02:00
GitHub Action bbe1ed0652 Automated update of relay data - Sun Jun 14 07:34:58 UTC 2026 2026-06-14 07:34:58 +00:00
jackandClaude Fable 5 f07b032b99 Fix CI exit hang: sign reassembled public packets in FragmentationTests
Bisecting (base was 4/4 clean, branch 3/3 hung, reliably reproducible)
pinned the parallel-suite exit hang to the public-message signature
requirement (security fix #2), via FragmentationTests:

reassemblyFromFragmentsDeliversPublicMessage and
duplicateFragmentDoesNotBreakReassembly send fragments of an UNSIGNED
public message and `await capture.waitForPublicMessages(...)`. With #2 the
reassembled unsigned message is now (correctly) dropped, so
didReceivePublicMessage never fires. The helper then trips a latent bug:
on timeout it cancels the waiter task but never resumes its
CheckedContinuation, so the throwing task group's teardown awaits a child
that never completes and the whole test process hangs at exit (SIGKILL'd
by CI). Base never hit it because the message always arrived in time.

Fix matches the security model — real public broadcasts are signed: sign
the reassembled packet with a NoiseEncryptionService and preseed the
sender's signing key (same pattern as duplicatePacket_isDeduped), so #2
verifies and delivers it. Full parallel suite now exits cleanly 5/5 locally
(branch was 3/3 hung before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:44:12 +02:00
jackandClaude Fable 5 2cbcb290f7 Remove lingering save timer from SecureIdentityStateManager (CI exit hang)
The app test job hung at process exit (all tests pass, then SIGKILL at the
CI timeout). Root cause: fix #5 replaced the dead Timer.scheduledTimer with
a real DispatchSourceTimer, created per manager instance, resumed and never
cancelled. Those live timer sources kept the dispatch machinery alive so the
swift-testing process never exited. The earlier `isRunningTests` guard was
fragile (it does not reliably detect the swift-testing-only runner on CI).

Drop the debounce timer entirely. Mutations now persist via the same
serialized `queue` barrier their callers already run on (saveIdentityCache ->
performSave directly); forceSave is a direct, non-blocking call (no
queue.sync, which is unsafe on the cooperative pool). No timer is left
scheduled, so nothing keeps the process alive. The original bug is still
fixed — saves now actually happen, unlike the never-firing Timer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:55:51 +02:00
jackandClaude Fable 5 9cf7c80518 Reduce test-process churn to fix flaky CI exit hang
The app test job intermittently hung at process exit. The suite is
load-sensitive and historically prone to cooperative-pool/teardown
deadlocks; the security changes added background work to the test process
that pushed it over the edge. Make the unit-test BLEService/identity
manager quiescent and remove blocking sync:

- forceSave() no longer does queue.sync(.barrier). It is reachable from
  deinit and from async tests on the swift-concurrency cooperative pool,
  where a blocking barrier-sync can starve/deadlock the pool. It now
  cancels the debounce timer and persists directly. (Removed the
  now-unneeded queue-specific-key re-entrancy machinery.)
- SecureIdentityStateManager persists synchronously under tests instead of
  scheduling a DispatchSourceTimer that lingers past process exit.
- Gate gossip-sync start (in addition to the maintenance timer) behind
  real Bluetooth init, so the test BLEService runs no periodic
  sign/broadcast/sync churn.
- Skip the panic Nostr reconnect under tests (connecting the shared relay
  singleton starts network/reconnect work that never completes).

Production behavior is unchanged: real Bluetooth builds run all timers and
the debounced save as before; the debounce save now actually fires
(previously a Timer on a GCD queue that never ran).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:40:39 +02:00
jackandClaude Fable 5 f76fd8a538 Fix CI hang: gate maintenance timer to Bluetooth-enabled; sign dedup test packet
Root cause of the CI app-test hang was a pre-existing bleQueue<->collectionsQueue
lock inversion driven by the periodic maintenance timer (performMaintenance ->
drainAllPendingWrites takes collectionsQueue while another path holds it and
sync-waits on bleQueue via readLinkState). The timer is created unconditionally
in init, so it also ran in the unit-test process (initializeBluetoothManagers:
false), where it only churns BLE writes/notifications/announces that don't exist.
Recent timing changes made the latent deadlock surface reliably.

- Only start the maintenance timer when real CoreBluetooth managers were
  initialized (maintenanceTimerEnabled). Production behavior is unchanged; the
  unit-test process no longer runs the timer and cannot hit the inversion.

Also fix BLEServiceCoreTests.duplicatePacket_isDeduped, which sent an unsigned
public packet that the new signature requirement (security fix #2) correctly
drops. The test now signs the packet and preseeds the sender's signing key
(production sendMessage signs public broadcasts), exercising the dedup path
(security fix #7) end to end. _test_handlePacket gains an optional
signingPublicKey to seed the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:06 +02:00
jackandClaude Fable 5 09c2c12838 Fix deadlock in identity-cache forceSave (CI hang)
The forceSave() rewrite used queue.sync(flags: .barrier), but forceSave
is also called from deinit. The debounce timer's barrier hop captured
self strongly, so when that block dropped the last reference the manager
deallocated *on* the identity queue — deinit -> forceSave -> queue.sync
then deadlocked synchronizing onto the queue it was already running on.
This hung the test process at exit (CI SIGKILL / exit 137).

- forceSave() now detects (via a queue-specific key) when it is already
  executing on the queue and runs the save directly instead of sync-ing
  onto itself.
- The timer's barrier hop now captures self weakly, so it can no longer
  trigger a deallocation on the queue in the first place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:46:24 +02:00
jackandClaude Fable 5 8378ff949a Address Codex review: verify public messages against registry signing key
The public-message signature check fell back to signedSenderDisplayName,
which only searches the asynchronously-persisted identity cache. Because
the peer registry is updated synchronously on a verified announce, a
message arriving immediately after that announce could have a valid
signature and a verified registry entry yet still be dropped (cache not
caught up).

Verify the packet signature against the signing key already present in
the synchronously-updated peer registry first; fall back to the
persisted-identity lookup only for peers not yet in the registry. The
security property is unchanged: a spoofed senderID claiming a registry
peer still fails registry verification and the persisted fallback, and
is dropped.

Adds tests for the race (delivered via registry key before cache
persists) and the spoof case (invalid signature falls back and drops).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:18:44 +02:00
jackandClaude Fable 5 ca63893197 Fix security audit findings: 3 critical, 7 high
A broad audit surfaced ten critical/high issues across the crypto,
transport, identity, and panic-wipe layers. This fixes all ten.

Critical:
- Nostr DMs were unauthenticated. The NIP-17 seal was signed with a
  throwaway ephemeral key and the receiver never verified it, so anyone
  who knows a recipient's npub could forge messages (and delivery/read
  receipts) into an existing trusted conversation. The seal is now
  signed with the sender's real identity key, and the receiver verifies
  the seal signature and that seal.pubkey == rumor.pubkey.
  NOTE: this is a breaking wire-protocol change (see PR).
- Public BLE messages trusted registry membership instead of the packet
  signature. Since senderID is attacker-controlled, any verified peer
  could be impersonated in public chat. A valid signature from the
  claimed sender is now required before any registry identity is used.
- Unverified announces still persisted the announced identity, letting a
  replayed noisePublicKey overwrite a victim's stored signing key and
  nickname. persistIdentity is now gated on verification.

High:
- Noise decrypt trapped on a 16-19 byte ciphertext (negative prefix
  length after nonce extraction) — a remote crash. Now validated.
- Identity-cache debounce save used Timer.scheduledTimer on a GCD queue
  with no run loop, so it never fired; block/verify/favorite changes
  only persisted on explicit forceSave. Replaced with a
  DispatchSourceTimer on the queue; forceSave is now serialized.
- Identity-cache key load couldn't tell "missing" from a transient
  keychain failure and would regenerate (deleting) the key, orphaning
  the cache. Now uses getIdentityKeyWithResult and falls back to a
  session-only ephemeral key without clobbering the persisted key/cache.
- BLE receive-dedup key lacked a payload digest, so post-handshake
  flushes (queued msgs + delivery/read acks in the same ms) were dropped
  as duplicates. Digest added, matching the ingress registry.
- Maintenance timer was created only in init and never recreated after a
  panic stop/start, silently degrading the mesh until app restart. Now
  recreated in startServices.
- Panic wipe left persisted location state (selected channel, teleport
  set, bookmarks) and cached per-geohash Nostr private keys behind. Both
  are now cleared.
- Panic spawned an orphan NostrRelayManager instead of reusing .shared,
  splitting relay state from every other component. Now reuses .shared.

Tests updated to assert the fixed behavior (announce no longer persists
unverified identities; public messages require a signature; receive
dedup ID includes the payload digest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:07:28 +02:00
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
101 changed files with 5546 additions and 1124 deletions
+44 -7
View File
@@ -12,7 +12,10 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute # A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue). # 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: strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -29,17 +32,22 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Set up Swift # Use the Xcode-bundled Swift toolchain: it always matches the SDK on
uses: swift-actions/setup-swift@v2 # the runner image. A standalone swift.org toolchain (setup-swift) broke
# whenever the image's Xcode moved ahead of it ("this SDK is not
# supported by the compiler").
- name: Note toolchain version (cache key)
id: swift-version
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
- name: Cache build artifacts - name: Cache build artifacts
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ${{ matrix.path }}/.build path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }} key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }} ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}- ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
- name: Build tests - name: Build tests
# Built separately so the hang watchdog below times only test # Built separately so the hang watchdog below times only test
@@ -97,9 +105,14 @@ jobs:
# Order-of-magnitude performance regression gate. Floors are deliberately # Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this # 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 - name: Performance floor gate
if: matrix.name == 'app' if: matrix.name == 'app'
timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the # Informational only: surfaces per-file and total line coverage in the
@@ -140,3 +153,27 @@ jobs:
ARCHS=arm64 \ ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
build 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( .executable(
name: "bitchat", name: "bitchat",
targets: ["bitchat"] targets: ["bitchat"]
), )
], ],
dependencies:[ dependencies: [
.package(path: "localPackages/Arti"), .package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
+20
View File
@@ -49,6 +49,14 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendMessage(message) 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() { func clearCurrentConversation() {
chatViewModel.sendMessage("/clear") chatViewModel.sendMessage("/clear")
} }
@@ -67,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
if let peerID, peerID.isGeoChat, if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) { let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName) 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 { } else {
chatViewModel.sendMessage("/block \(displayName)") 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) { func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition) chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
} }
+7 -1
View File
@@ -232,7 +232,13 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) 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 let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil ? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID) : chatViewModel.getEncryptionStatus(for: headerPeerID)
+1 -1
View File
@@ -71,7 +71,7 @@ struct BitchatApp: App {
final class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime? 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 true
} }
+4 -4
View File
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
} }
enum TrustLevel: String, Codable { enum TrustLevel: String, Codable {
case unknown = "unknown" case unknown
case casual = "casual" case casual
case trusted = "trusted" case trusted
case verified = "verified" case verified
} }
// MARK: - Identity Cache // MARK: - Identity Cache
@@ -151,38 +151,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety // Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
// Debouncing for keychain saves // Pending-save coalescing flag. Reads/writes are serialized on `queue`.
private var saveTimer: Timer? // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds // than a retained DispatchSourceTimer: a lingering, never-cancelled timer
// keeps the dispatch machinery alive and prevents the unit-test process from
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
// no run loop, so saves never actually fired.)
private var pendingSave = false private var pendingSave = false
// Encryption key // Encryption key
private let encryptionKey: SymmetricKey private let encryptionKey: SymmetricKey
/// True when `encryptionKey` is a throwaway generated this session because the
/// persisted key could not be read (device locked / access denied). In that
/// state we must NOT persist (it would overwrite the real cache with data the
/// next launch can't decrypt) and must NOT delete the existing cache.
private let encryptionKeyIsEphemeral: Bool
init(_ keychain: KeychainManagerProtocol) { init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain self.keychain = keychain
// Generate or retrieve encryption key from keychain // Retrieve (or, only on genuine first run, generate) the cache
// encryption key. We MUST distinguish "key doesn't exist yet" from a
// transient failure (device locked / access denied): the legacy
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
// new key deletes the existing one first permanently orphaning the
// encrypted cache on a launch that merely couldn't read the key.
let loadedKey: SymmetricKey let loadedKey: SymmetricKey
let keyIsEphemeral: Bool
// Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) { switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
case .success(let keyData):
loadedKey = SymmetricKey(data: keyData) loadedKey = SymmetricKey(data: keyData)
keyIsEphemeral = false
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true) SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
}
// Generate new key if needed case .itemNotFound:
else { // Genuine first run: generate and persist a new key.
loadedKey = SymmetricKey(size: .bits256) let newKey = SymmetricKey(size: .bits256)
let keyData = loadedKey.withUnsafeBytes { Data($0) } let keyData = newKey.withUnsafeBytes { Data($0) }
// Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
loadedKey = newKey
// If even the save failed, treat the key as ephemeral so we don't
// later try to persist a cache the next launch can't read.
keyIsEphemeral = !saved
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved) SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
// Transient/critical read failure. Do NOT overwrite the persisted
// key. Use a session-only ephemeral key; the real key and cache are
// left intact for a healthy launch.
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
loadedKey = SymmetricKey(size: .bits256)
keyIsEphemeral = true
} }
self.encryptionKey = loadedKey self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral
// Load identity cache on init
loadIdentityCache() // Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache.
if !keyIsEphemeral {
loadIdentityCache()
}
} }
deinit { deinit {
@@ -211,23 +241,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
/// and persists it on the same serialized context no timer, nothing left
/// scheduled to keep the process alive.
private func saveIdentityCache() { private func saveIdentityCache() {
// Mark that we need to save
pendingSave = true pendingSave = true
performSave()
// Cancel any existing timer
saveTimer?.invalidate()
// Schedule a new save after the debounce interval
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
self?.performSave()
}
} }
/// Writes the cache to the keychain. Must run on `queue` with exclusive
/// (barrier) access.
private func performSave() { private func performSave() {
guard pendingSave else { return } guard pendingSave else { return }
pendingSave = false pendingSave = false
// Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else {
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
return
}
do { do {
let data = try JSONEncoder().encode(cache) let data = try JSONEncoder().encode(cache)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
@@ -239,10 +274,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
SecureLogger.error(error, context: "Failed to save identity cache", category: .security) SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
} }
} }
// Force immediate save (for app termination) // Force immediate save (for app termination / lifecycle events). Mutations
// already persist synchronously via saveIdentityCache, so this is normally a
// no-op (performSave early-returns when nothing is pending). Runs directly on
// the caller's thread deliberately NOT a `queue.sync(barrier)`, which is
// reachable from `deinit` and from async tests on the swift-concurrency
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
func forceSave() { func forceSave() {
saveTimer?.invalidate()
performSave() performSave()
} }
File diff suppressed because it is too large Load Diff
+19 -12
View File
@@ -11,33 +11,38 @@ import Foundation
// MARK: - CommandInfo Enum // MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable { 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 block
case clear case clear
case help
case hug case hug
case message = "dm" case message = "msg"
case slap case slap
case unblock case unblock
case who case who
case favorite case favorite = "fav"
case unfavorite case unfavorite = "unfav"
var id: String { rawValue } var id: String { rawValue }
var alias: String { "/" + rawValue } var alias: String { "/" + rawValue }
var placeholder: String? { var placeholder: String? {
switch self { switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">" return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who: case .clear, .help, .who:
return nil return nil
} }
} }
var description: String { var description: String {
switch self { switch self {
case .block: String(localized: "content.commands.block") case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear") case .clear: String(localized: "content.commands.clear")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug") case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message") case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap") case .slap: String(localized: "content.commands.slap")
@@ -47,12 +52,14 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite") case .unfavorite: String(localized: "content.commands.unfavorite")
} }
} }
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { 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 { if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite] return baseCommands
} }
return baseCommands return baseCommands + [.favorite, .unfavorite]
} }
} }
+14 -1
View File
@@ -93,6 +93,7 @@ enum NoisePattern {
case XX // Most versatile, mutual authentication case XX // Most versatile, mutual authentication
case IK // Initiator knows responder's static key case IK // Initiator knows responder's static key
case NK // Anonymous initiator case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
} }
enum NoiseRole { enum NoiseRole {
@@ -322,6 +323,13 @@ final class NoiseCipherState {
throw NoiseError.replayDetected throw NoiseError.replayDetected
} }
// The 4-byte nonce prefix has been stripped, so the remaining bytes
// must still hold at least the 16-byte Poly1305 tag. The up-front
// `ciphertext.count >= 16` guard is not sufficient here (it counts
// the nonce), and `prefix(count - 16)` would trap on a short payload.
guard actualCiphertext.count >= 16 else {
throw NoiseError.invalidCiphertext
}
// Split ciphertext and tag // Split ciphertext and tag
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16) encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
tag = actualCiphertext.suffix(16) tag = actualCiphertext.suffix(16)
@@ -594,7 +602,7 @@ final class NoiseHandshakeState {
switch pattern { switch pattern {
case .XX: case .XX:
break // No pre-message keys break // No pre-message keys
case .IK, .NK: case .IK, .NK, .X:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic { } else if role == .responder, let localStatic = localStaticPublic {
@@ -897,6 +905,7 @@ extension NoisePattern {
case .XX: return "XX" case .XX: return "XX"
case .IK: return "IK" case .IK: return "IK"
case .NK: return "NK" case .NK: return "NK"
case .X: return "X"
} }
} }
@@ -918,6 +927,10 @@ extension NoisePattern {
[.e, .es], // -> e, es [.e, .es], // -> e, es
[.e, .ee] // <- e, ee [.e, .ee] // <- e, ee
] ]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
} }
} }
} }
+7
View File
@@ -82,6 +82,13 @@ final class NostrIdentityBridge {
} }
deviceSeedCache = nil deviceSeedCache = nil
// Also drop the in-memory derived per-geohash identities. These hold the
// actual secp256k1 private keys; if left cached, post-panic geohash
// messages would still be signed with pre-panic keys (linkable across the
// wipe) until the app is force-quit.
cacheLock.lock()
derivedIdentityCache.removeAll()
cacheLock.unlock()
} }
// MARK: - Per-Geohash Identities (Location Channels) // MARK: - Per-Geohash Identities (Location Channels)
+78 -17
View File
@@ -39,22 +39,23 @@ struct NostrProtocol {
content: content content: content
) )
// 2. Create ephemeral key for this message // 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
let ephemeralKey = try P256K.Schnorr.PrivateKey() // real identity key. NIP-17 requires the seal be signed by the sender
// Created ephemeral key for seal // so the recipient can authenticate who sent the message; signing with
// a throwaway key leaves DMs forgeable/impersonatable.
// 3. Seal the rumor (encrypt to recipient) let senderKey = try senderIdentity.schnorrSigningKey()
let sealedEvent = try createSeal( let sealedEvent = try createSeal(
rumor: rumor, rumor: rumor,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderKey: ephemeralKey senderKey: senderKey
) )
// 4. Gift wrap the sealed event (encrypt to recipient again) // 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
// layer hides the sender's identity from relays; createGiftWrap mints
// its own ephemeral key internally).
let giftWrap = try createGiftWrap( let giftWrap = try createGiftWrap(
seal: sealedEvent, seal: sealedEvent,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey
senderKey: ephemeralKey
) )
// Created gift wrap // Created gift wrap
@@ -84,7 +85,15 @@ struct NostrProtocol {
throw error throw error
} }
// 2. Open the seal // 2. Authenticate the seal. The seal MUST be signed by the sender's real
// identity key (NIP-17); without this check a DM is forgeable by anyone
// who knows the recipient's npub. Verify the seal's own signature.
guard seal.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
throw NostrError.invalidEvent
}
// 3. Open the seal
let rumor: NostrEvent let rumor: NostrEvent
do { do {
rumor = try openSeal( rumor = try openSeal(
@@ -96,10 +105,63 @@ struct NostrProtocol {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session) SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error throw error
} }
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at) // 4. The sender claimed inside the rumor must match the key that actually
// signed the seal, otherwise the sender field is unauthenticated and
// spoofable.
guard seal.pubkey == rumor.pubkey else {
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
throw NostrError.invalidEvent
}
// Return the seal signer's pubkey as the authenticated sender.
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
} }
#if DEBUG
static func createPrivateMessageWithInvalidSealSignatureForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
tags: [],
content: content
)
var seal = try createSeal(
rumor: rumor,
recipientPubkey: recipientPubkey,
senderKey: senderIdentity.schnorrSigningKey()
)
seal.sig = String(repeating: "0", count: 128)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
}
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
content: String,
recipientPubkey: String,
rumorIdentity: NostrIdentity,
sealSignerIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
pubkey: rumorIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
tags: [],
content: content
)
let seal = try createSeal(
rumor: rumor,
recipientPubkey: recipientPubkey,
senderKey: sealSignerIdentity.schnorrSigningKey()
)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
}
#endif
/// Create a geohash-scoped ephemeral public message (kind 20000) /// Create a geohash-scoped ephemeral public message (kind 20000)
static func createEphemeralGeohashEvent( static func createEphemeralGeohashEvent(
content: String, content: String,
@@ -195,10 +257,9 @@ struct NostrProtocol {
private static func createGiftWrap( private static func createGiftWrap(
seal: NostrEvent, seal: NostrEvent,
recipientPubkey: String, recipientPubkey: String
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
) throws -> NostrEvent { ) throws -> NostrEvent {
let sealJSON = try seal.jsonString() let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap // Create new ephemeral key for gift wrap
@@ -587,7 +648,7 @@ private extension NostrProtocol {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData), inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(), salt: Data(),
info: "nip44-v2".data(using: .utf8)!, info: Data("nip44-v2".utf8),
outputByteCount: 32 outputByteCount: 32
) )
return derivedKey.withUnsafeBytes { Data($0) } return derivedKey.withUnsafeBytes { Data($0) }
+67
View File
@@ -281,6 +281,7 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but // Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked // durable intent (subscriptionRequestState, messageHandlers, parked
// EOSE callbacks) is kept so REQs replay when relays reconnect // EOSE callbacks) is kept so REQs replay when relays reconnect
@@ -298,6 +299,60 @@ final class NostrRelayManager: ObservableObject {
torReadyWaitAttempts = 0 torReadyWaitAttempts = 0
updateConnectionStatus() updateConnectionStatus()
} }
/// Panic wipe reset: close sockets and drop every user/session-specific
/// relay intent without invoking old callbacks. Unlike `disconnect()`, this
/// must not preserve subscription replay state because geohash DM handlers
/// can capture pre-wipe Nostr private keys.
func resetForPanicWipe() {
connectionGeneration &+= 1
for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
markRelaySocketsClosed(resetState: true)
subscriptions.removeAll()
pendingSubscriptions.removeAll()
messageHandlers.removeAll()
subscriptionRequestState.removeAll()
subscribeCoalesce.removeAll()
eoseTrackers.removeAll()
pendingEOSECallbacks.removeAll()
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
torReadyWaitAttempts = 0
recentInboundEventKeys.removeAll()
recentInboundEventKeyOrder.removeAll()
duplicateInboundEventDropCount = 0
duplicateInboundEventDropCountBySubscription.removeAll()
inboundEventLogCount = 0
Self.pendingGiftWrapIDs.removeAll()
messageQueueLock.lock()
messageQueue.removeAll()
pendingSendDropCount = 0
messageQueueLock.unlock()
updateConnectionStatus()
}
private func markRelaySocketsClosed(resetState: Bool) {
let now = dependencies.now()
for index in relays.indices {
relays[index].isConnected = false
relays[index].nextReconnectTime = nil
if resetState {
relays[index].lastError = nil
relays[index].lastConnectedAt = nil
relays[index].lastDisconnectedAt = nil
relays[index].messagesSent = 0
relays[index].messagesReceived = 0
relays[index].reconnectAttempts = 0
} else {
relays[index].lastDisconnectedAt = now
}
}
}
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
@@ -1170,6 +1225,18 @@ final class NostrRelayManager: ObservableObject {
return Set(map.keys) return Set(map.keys)
} }
var debugMessageHandlerCount: Int {
messageHandlers.count
}
var debugSubscriptionRequestCount: Int {
subscriptionRequestState.count
}
var debugPendingEOSECallbackCount: Int {
pendingEOSECallbacks.count
}
var debugDuplicateInboundEventDropCount: Int { var debugDuplicateInboundEventDropCount: Int {
duplicateInboundEventDropCount duplicateInboundEventDropCount
} }
@@ -132,4 +132,3 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes) replaceSubrange(offset..<(offset+4), with: bytes)
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .city: return 5 case .city: return 5
case .province: return 4 case .province: return 4
case .region: return 2 case .region: return 2
} }
} }
var displayName: String { var displayName: String {
+29 -7
View File
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
let scheduleAfterglow: (TimeInterval) -> Void 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 /// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification, /// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response. /// gossip tracking, and the reciprocal announce response.
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
self.environment = environment self.environment = environment
} }
func handle(_ packet: BitchatPacket, from peerID: PeerID) { @discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
let env = environment let env = environment
let now = env.now() let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate( let preflight = BLEAnnouncePreflightPolicy.evaluate(
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
announcement = acceptance.announcement announcement = acceptance.announcement
case .reject(.malformed): case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session) SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return return nil
case .reject(.senderMismatch(let derivedFromKey)): case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return return nil
case .reject(.selfAnnounce): case .reject(.selfAnnounce):
return return nil
case .reject(.stale(let ageSeconds)): case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return return nil
} }
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
@@ -168,8 +178,13 @@ final class BLEAnnounceHandler {
env.updateTopology(peerID, neighbors) env.updateTopology(peerID, neighbors)
} }
// Persist cryptographic identity and signing key for robust offline verification // Persist cryptographic identity and signing key for robust offline
env.persistIdentity(announcement) // verification only for verified announces. Persisting unverified
// announces would let an attacker who replays a victim's noisePublicKey
// overwrite the victim's stored signing key/nickname (identity poisoning).
if verifiedAnnounce {
env.persistIdentity(announcement)
}
let announceBackID = "announce-back-\(peerID)" let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !env.dedupContains(announceBackID) let shouldSendBack = !env.dedupContains(announceBackID)
@@ -205,5 +220,12 @@ final class BLEAnnounceHandler {
let delay = Double.random(in: 0.3...0.6) let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay) 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, packetType: UInt8,
messageID: String messageID: String
) -> BLEFanoutSelection { ) -> 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( let allowed = collapseDuplicateLinksPerPeer(
allowedLinks( rawAllowed,
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
),
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings centralPeerBindings: centralPeerBindings
) )
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs) 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 // Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same // peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler // packet down both doubles airtime for nothing the receiver's assembler
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) { switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake: case .noiseEncrypted, .noiseHandshake:
return true return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer: case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
return false return false
} }
} }
@@ -16,6 +16,8 @@ struct BLEPublicMessageHandlerEnvironment {
let now: () -> Date let now: () -> Date
/// Snapshot of known peers keyed by ID (registry read). /// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo] let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Verifies a packet's signature against a known signing public key.
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Resolves a display name from a verified packet signature for peers missing from the registry. /// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast message packet for gossip sync. /// Tracks the broadcast message packet for gossip sync.
@@ -68,14 +70,39 @@ final class BLEPublicMessageHandler {
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = env.peersSnapshot() let peersSnapshot = env.peersSnapshot()
// Public messages are always signed by their sender. `senderID` is
// attacker-controlled, so registry membership alone is NOT proof of
// identity a peer in the registry as "verified" could be impersonated
// by anyone spoofing their senderID. Require a valid packet signature
// from the claimed sender (our own echoes are exempt; they are matched
// by self-broadcast tracking below).
//
// Verify against the signing key already in the (synchronously-updated)
// peer registry first: identity-cache persistence is asynchronous, so a
// message arriving right after a verified announce would otherwise be
// dropped because `signedSenderDisplayName` only searches the persisted
// cache. Fall back to that persisted-identity lookup for peers not (yet)
// in the registry.
let isSelf = peerID == env.localPeerID()
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
let verifiedViaRegistry = !isSelf
&& (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else {
SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))", category: .security)
return
}
// Authenticity is established; prefer the registry's collision-resolved
// display name, then the signature-derived name.
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID, peerID: peerID,
localPeerID: env.localPeerID(), localPeerID: env.localPeerID(),
localNickname: env.localNickname(), localNickname: env.localNickname(),
peers: peersSnapshot, peers: peersSnapshot,
allowConnectedUnverified: false allowConnectedUnverified: false
) ?? env.signedSenderDisplayName(packet, peerID) else { ) ?? signedDisplayName else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security) SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))", category: .security)
return return
} }
@@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable {
struct BLEReceivePipeline { struct BLEReceivePipeline {
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext { static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
let senderID = PeerID(hexData: packet.senderID) let senderID = PeerID(hexData: packet.senderID)
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)" // Include a payload digest so that distinct packets sharing the same
// sender/timestamp(ms)/type are not collapsed as duplicates. The
// post-handshake flush sends queued messages, delivery and read receipts
// back-to-back within a single millisecond; without the digest every
// packet after the first would be silently dropped.
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
let messageType = MessageType(rawValue: packet.type) let messageType = MessageType(rawValue: packet.type)
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
+222 -21
View File
@@ -46,6 +46,20 @@ final class BLEService: NSObject {
// 4. Efficient Message Deduplication // 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator() 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 var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker() private let meshTopology = MeshTopologyTracker()
@@ -135,6 +149,13 @@ final class BLEService: NSObject {
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles private var maintenanceCounter = 0 // Track maintenance cycles
/// Whether real CoreBluetooth managers were initialized. When false (unit
/// tests), periodic mesh background work is not started the maintenance
/// timer and the gossip-sync timers only drain BLE writes/notifications,
/// re-announce, and sign/broadcast sync packets, all meaningless without
/// Bluetooth. Leaving them running in the test process is pure background
/// churn that aggravates flaky exit hangs.
private var meshBackgroundEnabled = false
// MARK: - Connection budget & scheduling (central role) // MARK: - Connection budget & scheduling (central role)
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>() private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
@@ -233,16 +254,10 @@ final class BLEService: NSObject {
#endif #endif
} }
// Single maintenance timer for all periodic tasks (dispatch-based for determinism) // Single maintenance timer for all periodic tasks (dispatch-based for
let timer = DispatchSource.makeTimerSource(queue: bleQueue) // determinism). Only run it when real Bluetooth managers exist.
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, meshBackgroundEnabled = initializeBluetoothManagers
repeating: TransportConfig.bleMaintenanceInterval, startMaintenanceTimer()
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
timer.setEventHandler { [weak self] in
self?.performMaintenance()
}
timer.resume()
maintenanceTimer = timer
// Publish initial empty state // Publish initial empty state
requestPeerDataPublish() requestPeerDataPublish()
@@ -272,7 +287,12 @@ final class BLEService: NSObject {
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
manager.delegate = self manager.delegate = self
manager.start() // Only start the periodic sync timers when real Bluetooth exists. In unit
// tests there is no mesh to sync with, and the periodic sign/broadcast
// churn just keeps the process busy and aggravates flaky exit hangs.
if meshBackgroundEnabled {
manager.start()
}
gossipSyncManager = manager gossipSyncManager = manager
} }
@@ -435,7 +455,29 @@ final class BLEService: NSObject {
// MARK: Lifecycle // MARK: Lifecycle
/// Creates and starts the periodic maintenance timer if it is not already
/// running. Idempotent so it can be called from both `init` and
/// `startServices()` the latter matters after a panic reset, where
/// `stopServices()` cancels and nils the timer.
private func startMaintenanceTimer() {
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
repeating: TransportConfig.bleMaintenanceInterval,
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
timer.setEventHandler { [weak self] in
self?.performMaintenance()
}
timer.resume()
maintenanceTimer = timer
}
func startServices() { func startServices() {
// Restart the maintenance timer if a prior stopServices() cancelled it
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
// and cache cleanup would never resume until app restart.
startMaintenanceTimer()
// Start BLE services if not already running // Start BLE services if not already running
if centralManager?.state == .poweredOn { if centralManager?.state == .poweredOn {
centralManager?.scanForPeripherals( centralManager?.scanForPeripherals(
@@ -860,6 +902,10 @@ final class BLEService: NSObject {
} else { } else {
packetToSend = packet packetToSend = packet
} }
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
@@ -1019,6 +1065,9 @@ final class BLEService: NSObject {
// Directed send helper (unicast to a specific peerID) without altering packet contents // Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return } guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
} }
@@ -1277,7 +1326,7 @@ extension BLEService: GossipSyncManager.Delegate {
extension BLEService: CBCentralManagerDelegate { extension BLEService: CBCentralManagerDelegate {
#if os(iOS) #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 restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
@@ -1602,7 +1651,7 @@ private extension BLEService {
#if DEBUG #if DEBUG
// Test-only helper to inject packets into the receive pipeline // Test-only helper to inject packets into the receive pipeline
extension BLEService { extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) { func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
if preseedPeer { if preseedPeer {
// Ensure the synthetic peer is known and marked verified for public-message tests // Ensure the synthetic peer is known and marked verified for public-message tests
let normalizedID = PeerID(hexData: packet.senderID) let normalizedID = PeerID(hexData: packet.senderID)
@@ -1610,6 +1659,7 @@ extension BLEService {
if var existing = peerRegistry.info(for: normalizedID) { if var existing = peerRegistry.info(for: normalizedID) {
existing.isConnected = true existing.isConnected = true
existing.isVerifiedNickname = true existing.isVerifiedNickname = true
if let signingPublicKey { existing.signingPublicKey = signingPublicKey }
existing.lastSeen = Date() existing.lastSeen = Date()
peerRegistry.upsert(existing) peerRegistry.upsert(existing)
} else { } else {
@@ -1618,7 +1668,7 @@ extension BLEService {
nickname: "TestPeer_\(fromPeerID.id.prefix(4))", nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
isConnected: true, isConnected: true,
noisePublicKey: packet.senderID, noisePublicKey: packet.senderID,
signingPublicKey: nil, signingPublicKey: signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
lastSeen: Date() lastSeen: Date()
)) ))
@@ -1958,7 +2008,7 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
#if os(iOS) #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 restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
@@ -2439,7 +2489,142 @@ extension BLEService {
ttl: messageTTL 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) // MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T { private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -2671,7 +2856,7 @@ extension BLEService {
// Notify delegate of failure // Notify delegate of failure
notifyUI { [weak self] in 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"))))
} }
} }
} }
@@ -2924,13 +3109,15 @@ extension BLEService {
case .fileTransfer: case .fileTransfer:
handleFileTransfer(packet, from: senderID) handleFileTransfer(packet, from: senderID)
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .leave: case .leave:
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
case .none: case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break
} }
if forwardAlongRouteIfNeeded(packet) { if forwardAlongRouteIfNeeded(packet) {
@@ -2987,7 +3174,18 @@ extension BLEService {
} }
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { 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 /// Builds the announce handler environment. All queue hops stay here so
@@ -3110,6 +3308,9 @@ extension BLEService {
guard let self = self else { return [:] } guard let self = self else { return [:] }
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
}, },
verifyPacketSignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
},
signedSenderDisplayName: { [weak self] packet, peerID in signedSenderDisplayName: { [weak self] packet, peerID in
self?.signedSenderDisplayName(for: packet, from: peerID) self?.signedSenderDisplayName(for: packet, from: peerID)
}, },
+18 -1
View File
@@ -105,11 +105,28 @@ final class CommandProcessor {
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
case "/help":
return .success(message: Self.helpText)
default: 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 // MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult { 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")
}
}
@@ -594,6 +594,22 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
} }
} }
/// Removes all persisted location state and resets the in-memory view.
/// Used by the panic wipe selected channel, teleport set and bookmarks
/// (which reveal where the user has been) must not survive on device.
func panicWipe() {
storage.removeObject(forKey: selectedChannelKey)
storage.removeObject(forKey: teleportedStoreKey)
storage.removeObject(forKey: bookmarksKey)
storage.removeObject(forKey: bookmarkNamesKey)
teleportedSet.removeAll()
bookmarkMembership.removeAll()
bookmarks = []
bookmarkNames = [:]
teleported = false
selectedChannel = .mesh
}
private static func normalizeGeohash(_ s: String) -> String { private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s return s
+64 -2
View File
@@ -2,11 +2,33 @@ import BitLogger
import BitFoundation import BitFoundation
import Foundation 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.) /// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
private let transports: [Transport] private let transports: [Transport]
private let now: () -> Date private let now: () -> Date
private let courierDirectory: CourierDirectory
/// Invoked whenever a retained private message is dropped without a /// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -14,6 +36,12 @@ final class MessageRouter {
/// stale "sending/sent" state forever. /// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? 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 // Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage { private struct QueuedMessage {
let content: String let content: String
@@ -31,10 +59,17 @@ final class MessageRouter {
// Bound resends of messages sent on a weak reachability signal that never // 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). // get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8 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.transports = transports
self.now = now self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -51,7 +86,7 @@ final class MessageRouter {
} }
// Handle key updates // Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data, if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool { note.userInfo?["isKeyUpdate"] is Bool {
let peerID = PeerID(publicKey: newKey) let peerID = PeerID(publicKey: newKey)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peerID) self.flushOutbox(for: peerID)
@@ -94,6 +129,33 @@ final class MessageRouter {
unsent.sendAttempts = 0 unsent.sendAttempts = 0
enqueue(unsent, for: peerID) 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) 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? { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation 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) /// Clear persistent identity (for panic mode)
func clearPersistentIdentity() { func clearPersistentIdentity() {
@@ -428,7 +471,7 @@ final class NoiseEncryptionService {
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
var out = Data() var out = Data()
// context // 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(UInt8(min(context.count, 255)))
out.append(context.prefix(255)) out.append(context.prefix(255))
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
@@ -444,7 +487,7 @@ final class NoiseEncryptionService {
out.append(ed32) out.append(ed32)
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
// nickname length + bytes // nickname length + bytes
let nickData = nickname.data(using: .utf8) ?? Data() let nickData = Data(nickname.utf8)
out.append(UInt8(min(nickData.count, 255))) out.append(UInt8(min(nickData.count, 255)))
out.append(nickData.prefix(255)) out.append(nickData.prefix(255))
// timestamp // timestamp
+1 -1
View File
@@ -137,7 +137,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
} }
func peerNickname(peerID: PeerID) -> String? { nil } func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] } func getPeerNicknames() -> [PeerID: String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil } func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
+1 -1
View File
@@ -111,7 +111,7 @@ final class NotificationService {
func requestAuthorization() { func requestAuthorization() {
guard !isRunningTests else { return } guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted { if granted {
// Permission granted // Permission granted
} else { } else {
+1 -1
View File
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
case .read, .delivered: case .read, .delivered:
externalReceipts.insert(message.id) externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent: case .failed, .partiallyDelivered, .sending, .sent, .carried:
break break
} }
} }
+7
View File
@@ -95,6 +95,12 @@ protocol Transport: AnyObject {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ 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) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(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 sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(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 sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
+23 -1
View File
@@ -257,7 +257,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return false 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 /// Toggle favorite status
func toggleFavorite(_ peerID: PeerID) { func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else { guard let peer = getPeer(by: peerID) else {
+3
View File
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5 case .fragment: return 5
case .requestSync: return 6 case .requestSync: return 6
case .fileTransfer: return 7 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 return result
} }
} }
@@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator {
case .failed: return 1 case .failed: return 1
case .sending: return 2 case .sending: return 2
case .sent: return 3 case .sent: return 3
case .partiallyDelivered: return 4 case .carried: return 4
case .delivered: return 5 case .partiallyDelivered: return 5
case .read: return 6 case .delivered: return 6
case .read: return 7
} }
} }
} }
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } 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 { } catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session) SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } 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? key: Data?
) { ) {
guard let context else { return } guard let context else { return }
if let _ = key { if key != nil {
if let identity = context.currentNostrIdentity() { if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
} }
@@ -84,7 +84,7 @@ final class ChatNostrCoordinator {
} }
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID { if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key { if key != nil {
if let identity = context.currentNostrIdentity() { if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
} }
+98 -13
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 { func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
} }
@@ -1129,6 +1171,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
userDefaults.removeObject(forKey: "bitchat.messageRetentionKey") userDefaults.removeObject(forKey: "bitchat.messageRetentionKey")
// Wipe persisted location state (selected channel, teleport set,
// bookmarks). For an activist-safety wipe, where the user has been is
// exactly the data an adversary inspecting the device wants.
LocationStateManager.shared.panicWipe()
// Reset nickname to anonymous // Reset nickname to anonymous
nickname = "anon\(Int.random(in: 1000...9999))" nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname() saveNickname()
@@ -1142,6 +1189,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain // Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites() FavoritesPersistenceService.shared.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk)
CourierStore.shared.wipe()
// Identity manager has cleared persisted identity data above // Identity manager has cleared persisted identity data above
// Clear autocomplete state // Clear autocomplete state
@@ -1153,13 +1203,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear selected private chat // Clear selected private chat
selectedPrivateChatPeer = nil selectedPrivateChatPeer = nil
// Clear live location/geohash session state. Persisted location state
// was wiped above, but the running view model can still be scoped to a
// geohash channel and hold subscriptions tied to the old Nostr identity.
activeChannel = .mesh
setGeoChatSubscriptionID(nil)
setGeoDmSubscriptionID(nil)
_ = clearGeoSamplingSubs()
cachedGeohashIdentity = nil
nostrKeyMapping.removeAll()
// Clear read receipt tracking // Clear read receipt tracking
sentReadReceipts.removeAll() sentReadReceipts.removeAll()
deduplicationService.clearAll() deduplicationService.clearAll()
// IMPORTANT: Clear Nostr-related state // IMPORTANT: Clear Nostr-related state
// Disconnect from Nostr relays and clear subscriptions // Drop relay subscriptions, handlers, pending sends, and replay state.
nostrRelayManager?.disconnect() // Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here.
NostrRelayManager.shared.resetForPanicWipe()
nostrRelayManager = nil nostrRelayManager = nil
// Clear Nostr identity associations // Clear Nostr identity associations
@@ -1175,15 +1237,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// No need to force UserDefaults synchronization // No need to force UserDefaults synchronization
// Reinitialize Nostr with new identity // Reinitialize Nostr with new identity
// This will generate new Nostr keys derived from new Noise keys // This will generate new Nostr keys derived from new Noise keys.
Task { @MainActor in // Skipped under tests: connecting the shared relay singleton starts
// Small delay to ensure cleanup completes // real network/reconnect work that never completes and would keep the
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds // test process alive (the singleton, unlike a discardable instance, is
// never deallocated to cancel it).
if !TestEnvironment.isRunningTests {
Task { @MainActor in
// Small delay to ensure cleanup completes
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
// Reinitialize Nostr relay manager with new identity // Reinitialize Nostr relay manager with new identity. Reuse the
nostrRelayManager = NostrRelayManager() // shared singleton every other component (NostrTransport, geohash
setupNostrMessageHandling() // subscriptions, AppRuntime observers) is bound to `.shared`, so
nostrRelayManager?.connect() // creating a fresh instance here would split relay state and leave
// sends running against a disconnected manager.
nostrRelayManager = NostrRelayManager.shared
setupNostrMessageHandling()
nostrRelayManager?.connect()
}
} }
// Delete ALL media files (incoming and outgoing) in background // Delete ALL media files (incoming and outgoing) in background
@@ -1408,7 +1480,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Processes IRC-style commands starting with '/'. /// Processes IRC-style commands starting with '/'.
/// - Parameter command: The full command string including the leading slash /// - 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 @MainActor
func handleCommand(_ command: String) { func handleCommand(_ command: String) {
let result = commandProcessor.process(command) let result = commandProcessor.process(command)
@@ -1416,16 +1488,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
switch result { switch result {
case .success(let message): case .success(let message):
if let msg = message { if let msg = message {
addSystemMessage(msg) addCommandOutput(msg)
} }
case .error(let message): case .error(let message):
addSystemMessage(message) addCommandOutput(message)
case .handled: case .handled:
// Command was handled, no message needed // Command was handled, no message needed
break 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 // MARK: - Message Reception
@MainActor @MainActor
@@ -100,11 +100,28 @@ private extension ChatViewModelBootstrapper {
category: .session category: .session
) )
viewModel.conversations.setDeliveryStatus( 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 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.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel) viewModel.participantTracker.configure(context: viewModel)
+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 { enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title" static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo( static let noTracking = AppInfoFeatureInfo(
@@ -118,14 +138,8 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) { SheetCloseButton { dismiss() }
Image(systemName: "xmark") .foregroundColor(textColor)
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("app_info.close")
} }
} }
} }
@@ -202,6 +216,28 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Features.mentions) 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 // Privacy
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title) SectionHeader(Strings.Privacy.title)
@@ -14,29 +14,46 @@ struct CommandSuggestionsView: View {
@Binding var messageText: String @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] { private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] } guard messageText.hasPrefix("/") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true 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()) command.alias.starts(with: messageText.lowercased())
} }
} }
var body: some View { var body: some View {
// Render nothing when there are no matches: a zero-height view would // Render nothing when there are no matches: a zero-height view would
// still receive the composer VStack's spacing and push the input row // still receive the composer VStack's spacing and push the input row
// off-center. // off-center.
if !filteredCommands.isEmpty { if !filteredCommands.isEmpty {
let isUsageReminder = typedCommandAlias != nil
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in ForEach(filteredCommands) { command in
Button { 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 + " " messageText = command.alias + " "
} label: { } label: {
buttonRow(for: command) buttonRow(for: command)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
} }
} }
.themedOverlayPanel() .themedOverlayPanel()
@@ -9,6 +9,47 @@
import SwiftUI import SwiftUI
import BitFoundation 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 { struct DeliveryStatusView: View {
@ThemedPalette private var palette @ThemedPalette private var palette
let status: DeliveryStatus let status: DeliveryStatus
@@ -19,56 +60,34 @@ struct DeliveryStatusView: View {
private var secondaryTextColor: Color { palette.secondary } 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 // MARK: - Body
var body: some View { var body: some View {
statusGlyph
.help(status.bitchatDescription)
.accessibilityElement(children: .ignore)
.accessibilityLabel(status.bitchatDescription)
}
@ViewBuilder
private var statusGlyph: some View {
switch status { switch status {
case .sending: case .sending:
Image(systemName: "circle") Image(systemName: "circle")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .sent: case .sent:
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .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) { HStack(spacing: -2) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
@@ -76,24 +95,22 @@ struct DeliveryStatusView: View {
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
} }
.foregroundColor(textColor.opacity(0.8)) .foregroundColor(textColor.opacity(0.8))
.help(Strings.delivered(to: nickname))
case .read:
case .read(let nickname, _): // Filled variant so read vs delivered is legible without color.
HStack(spacing: -2) { HStack(spacing: 0) {
Image(systemName: "checkmark") Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 10, weight: .bold)) .font(.bitchatSystem(size: 9, weight: .bold))
Image(systemName: "checkmark") Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 10, weight: .bold)) .font(.bitchatSystem(size: 9, weight: .bold))
} }
.foregroundColor(palette.accentBlue) .foregroundColor(palette.accentBlue)
.help(Strings.read(by: nickname))
case .failed:
case .failed(let reason):
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(Color.red.opacity(0.8)) .foregroundColor(Color.red.opacity(0.8))
.help(Strings.failed(reason))
case .partiallyDelivered(let reached, let total): case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) { HStack(spacing: 1) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
@@ -102,7 +119,6 @@ struct DeliveryStatusView: View {
.bitchatFont(size: 10) .bitchatFont(size: 10)
} }
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
} }
} }
} }
@@ -111,6 +127,7 @@ struct DeliveryStatusView: View {
let statuses: [DeliveryStatus] = [ let statuses: [DeliveryStatus] = [
.sending, .sending,
.sent, .sent,
.carried,
.delivered(to: "John Doe", at: Date()), .delivered(to: "John Doe", at: Date()),
.read(by: "Jane Doe", at: Date()), .read(by: "Jane Doe", at: Date()),
.failed(reason: "Offline"), .failed(reason: "Offline"),
@@ -57,7 +57,7 @@ struct PaymentChipView: View {
private var fgColor: Color { palette.primary } private var fgColor: Color { palette.primary }
private var bgColor: Color { 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) } 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 { struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage let message: BitchatMessage
@@ -24,6 +25,7 @@ struct TextMessageView: View {
/// the enum makes the change visible to SwiftUI's structural diff. /// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus? private let deliveryStatus: DeliveryStatus?
@State private var expandedMessageIDs: Set<String> = [] @State private var expandedMessageIDs: Set<String> = []
@State private var showDeliveryDetail = false
init(message: BitchatMessage) { init(message: BitchatMessage) {
self.message = message self.message = message
@@ -35,19 +37,59 @@ struct TextMessageView: View {
// Precompute heavy token scans once per row // Precompute heavy token scans once per row
let cashuLinks = message.content.extractCashuLinks() let cashuLinks = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks() 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 isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) 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)) Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading) .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), if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus { let status = deliveryStatus {
DeliveryStatusView(status: status) Button {
.padding(.leading, 4) 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) } else { expandedMessageIDs.insert(message.id) }
} }
.bitchatFont(size: 11, weight: .medium) .bitchatFont(size: 11, weight: .medium)
.foregroundColor(Color.blue) .foregroundColor(palette.accentBlue)
.padding(.top, 4) .padding(.top, 4)
} }
@@ -78,6 +120,12 @@ struct TextMessageView: View {
.padding(.leading, 2) .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 { struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette @ThemedPalette private var palette
@@ -43,7 +44,6 @@ struct ContentComposerView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
} }
} }
.themedOverlayPanel() .themedOverlayPanel()
@@ -60,10 +60,8 @@ struct ContentComposerView: View {
TextField( TextField(
"", "",
text: $messageText, text: $messageText,
prompt: Text( prompt: Text(placeholderText)
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") .foregroundColor(palette.secondary.opacity(0.6))
)
.foregroundColor(palette.secondary.opacity(0.6))
) )
.textFieldStyle(.plain) .textFieldStyle(.plain)
.bitchatFont(size: 15) .bitchatFont(size: 15)
@@ -110,6 +108,33 @@ struct ContentComposerView: View {
} }
private extension ContentComposerView { 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 { var recordingIndicator: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "waveform.circle.fill") Image(systemName: "waveform.circle.fill")
@@ -158,7 +183,19 @@ private extension ContentComposerView {
imagePickerSourceType = .camera imagePickerSourceType = .camera
showImagePicker = true 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 #else
Button(action: { showMacImagePicker = true }) { Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill") Image(systemName: "photo.circle.fill")
@@ -167,7 +204,9 @@ private extension ContentComposerView {
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Choose photo") .accessibilityLabel(
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
)
#endif #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 { func sendButtonView(enabled: Bool) -> some View {
+61 -11
View File
@@ -22,6 +22,9 @@ struct ContentHeaderView: View {
let headerPeerIconSize: CGFloat let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat let headerPeerCountFontSize: CGFloat
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text(verbatim: "bitchat/") Text(verbatim: "bitchat/")
@@ -33,6 +36,16 @@ struct ContentHeaderView: View {
.onTapGesture(count: 1) { .onTapGesture(count: 1) {
appChromeModel.presentAppInfo() 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) { HStack(spacing: 0) {
Text(verbatim: "@") Text(verbatim: "@")
@@ -78,6 +91,23 @@ struct ContentHeaderView: View {
}() }()
HStack(spacing: 2) { 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 { if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
@@ -183,6 +213,13 @@ struct ContentHeaderView: View {
headerOtherPeersCount 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) .layoutPriority(3)
.sheet(isPresented: $showVerifySheet) { .sheet(isPresented: $showVerifySheet) {
@@ -190,8 +227,16 @@ struct ContentHeaderView: View {
.environmentObject(verificationModel) .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) .frame(height: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel) .environmentObject(locationChannelsModel)
@@ -266,14 +311,25 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1 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) { func channelPeopleCountAndColor() -> (Int, Color) {
switch locationChannelsModel.selectedChannel { switch locationChannelsModel.selectedChannel {
case .location: case .location:
let count = peerListModel.visibleGeohashPeerCount let count = peerListModel.visibleGeohashPeerCount
return (count, count > 0 ? palette.locationAccent : Color.secondary) return (count, count > 0 ? palette.locationAccent : palette.secondary)
case .mesh: case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) 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) return (peerListModel.reachableMeshPeerCount, color)
} }
} }
@@ -293,16 +349,10 @@ private struct ContentLocationNotesUnavailableView: View {
Text("content.notes.title") Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold) .bitchatFont(size: 16, weight: .bold)
Spacer() Spacer()
Button(action: { showLocationNotes = false }) { SheetCloseButton { showLocationNotes = false }
Image(systemName: "xmark") .foregroundColor(palette.primary)
.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"))
} }
.frame(height: headerHeight) .frame(minHeight: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.themedChromePanel(edge: .top) .themedChromePanel(edge: .top)
Text("content.notes.location_unavailable") 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 appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -159,24 +160,23 @@ private struct ContentPeopleListView: View {
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
} }
.buttonStyle(.plain) .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( .help(
String(localized: "content.help.verification", comment: "Help text for verification button") String(localized: "content.help.verification", comment: "Help text for verification button")
) )
} }
Button(action: { SheetCloseButton {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
dismiss() dismiss()
showSidebar = false showSidebar = false
showVerifySheet = false showVerifySheet = false
privateConversationModel.endConversation() privateConversationModel.endConversation()
} }
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
} }
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
let activeText = String.localizedStringWithFormat( let activeText = String.localizedStringWithFormat(
@@ -198,13 +198,13 @@ private struct ContentPeopleListView: View {
Text(subtitle) Text(subtitle)
.foregroundColor(subtitleColor) .foregroundColor(subtitleColor)
Text(activeText) Text(activeText)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
.bitchatFont(size: 12) .bitchatFont(size: 12)
} else { } else {
Text(activeText) Text(activeText)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -231,6 +231,13 @@ private struct ContentPeopleListView: View {
}, },
onShowFingerprint: { peerID in onShowFingerprint: { peerID in
appChromeModel.showFingerprint(for: peerID) 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") Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary) .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) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
@@ -346,24 +356,20 @@ private struct ContentPrivateChatSheetView: View {
Spacer(minLength: 0) Spacer(minLength: 0)
Button(action: { SheetCloseButton {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation() privateConversationModel.endConversation()
showSidebar = true 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(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .padding(.bottom, 12)
.themedSurface() .modifier(PrivateHeaderChrome())
} }
MessageListView( MessageListView(
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
Divider() Divider()
} }
privacyCaption
#if os(iOS) #if os(iOS)
ContentComposerView( ContentComposerView(
messageText: $messageText, 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 { private struct ContentPrivateHeaderInfoButton: View {
@@ -452,17 +523,30 @@ private struct ContentPrivateHeaderInfoButton: View {
.foregroundColor(.purple) .foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline: 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) Text(headerState.displayName)
.bitchatFont(size: 16, weight: .medium) .bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary) .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, if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon { let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 14)) .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( .foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? palette.primary ? palette.primary
@@ -489,6 +573,6 @@ private struct ContentPrivateHeaderInfoButton: View {
.accessibilityHint( .accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") 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() Spacer()
Button(action: { dismiss() }) { SheetCloseButton { dismiss() }
Image(systemName: "xmark") .foregroundColor(textColor)
.font(.bitchatSystem(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
} }
.padding() .padding()
@@ -83,7 +80,7 @@ struct FingerprintView: View {
Spacer() Spacer()
} }
.padding() .padding()
.background(Color.gray.opacity(0.1)) .background(palette.secondary.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
// Their fingerprint // Their fingerprint
@@ -101,7 +98,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1)) .background(palette.secondary.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
Button(Strings.copy) { Button(Strings.copy) {
@@ -135,7 +132,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1)) .background(palette.secondary.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
Button(Strings.copy) { 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 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 unblock: LocalizedStringKey = "geohash_people.action.unblock"
static let block: LocalizedStringKey = "geohash_people.action.block" 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 { var body: some View {
@@ -46,7 +53,12 @@ struct GeohashPeopleList: View {
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse" let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark) let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
let rowColor: Color = person.isMe ? .orange : assignedColor 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() let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -54,6 +66,8 @@ struct GeohashPeopleList: View {
.bitchatFont(size: 14) .bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular) .fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor) .foregroundColor(rowColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix) 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 // 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 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 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 { static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") 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) Text(Strings.description)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
Group { Group {
switch locationChannelsModel.permissionState { switch locationChannelsModel.permissionState {
@@ -122,7 +125,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied) Text(Strings.permissionDenied)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open) Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -169,13 +172,7 @@ struct LocationChannelsSheet: View {
} }
private var closeButton: some View { private var closeButton: some View {
Button(action: { isPresented = false }) { SheetCloseButton { isPresented = false }
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
private var channelList: some View { private var channelList: some View {
@@ -210,7 +207,10 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .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.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel)) locationChannelsModel.select(ChannelID.location(channel))
@@ -277,7 +277,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 2) { HStack(spacing: 2) {
Text(verbatim: "#") Text(verbatim: "#")
.bitchatFont(size: 14) .bitchatFont(size: 14)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
TextField("geohash", text: $customGeohash) TextField("geohash", text: $customGeohash)
#if os(iOS) #if os(iOS)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
@@ -319,7 +319,7 @@ struct LocationChannelsSheet: View {
.bitchatFont(size: 14) .bitchatFont(size: 14)
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12)) .background(palette.secondary.opacity(0.12))
.cornerRadius(6) .cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4) .opacity(isValid ? 1.0 : 0.4)
.disabled(!isValid) .disabled(!isValid)
@@ -336,7 +336,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked) Text(Strings.bookmarked)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
@@ -357,7 +357,10 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .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 } let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty { if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
@@ -399,6 +402,8 @@ struct LocationChannelsSheet: View {
titleColor: Color? = nil, titleColor: Color? = nil,
titleBold: Bool = false, titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, @ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
accessoryActionTitle: String? = nil,
accessoryAction: (() -> Void)? = nil,
action: @escaping () -> Void action: @escaping () -> Void
) -> some View { ) -> some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
@@ -409,17 +414,17 @@ struct LocationChannelsSheet: View {
Text(parts.base) Text(parts.base)
.bitchatFont(size: 14) .bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular) .fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary) .foregroundColor(titleColor ?? palette.primary)
if let count = parts.countSuffix, !count.isEmpty { if let count = parts.countSuffix, !count.isEmpty {
Text(count) Text(count)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
} }
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull) Text(subtitleFull)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
@@ -434,6 +439,19 @@ struct LocationChannelsSheet: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture(perform: action) .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]" // Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
@@ -477,16 +495,16 @@ extension LocationChannelsSheet {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle) Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold) .bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary) .foregroundColor(palette.primary)
Text(Strings.torSubtitle) Text(Strings.torSubtitle)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
} }
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
} }
.padding(12) .padding(12)
.background(Color.secondary.opacity(0.12)) .background(palette.secondary.opacity(0.12))
.cornerRadius(8) .cornerRadius(8)
} }
+7 -14
View File
@@ -30,7 +30,6 @@ struct LocationNotesView: View {
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings { 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 description: LocalizedStringKey = "location_notes.description"
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
@@ -97,13 +96,7 @@ struct LocationNotesView: View {
} }
private var closeButton: some View { private var closeButton: some View {
Button(action: { dismiss() }) { SheetCloseButton { dismiss() }
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.closeAccessibility)
} }
private var headerSection: some View { private var headerSection: some View {
@@ -126,12 +119,12 @@ struct LocationNotesView: View {
} }
Text(Strings.description) Text(Strings.description)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays { if manager.state == .noRelays {
Text(Strings.relaysPaused) Text(Strings.relaysPaused)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -180,7 +173,7 @@ struct LocationNotesView: View {
if !ts.isEmpty { if !ts.isEmpty {
Text(ts) Text(ts)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
Spacer() Spacer()
} }
@@ -197,7 +190,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold) .bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint) Text(Strings.relaysRetryHint)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
Button(Strings.retry) { manager.refresh() } Button(Strings.retry) { manager.refresh() }
.bitchatFont(size: 12) .bitchatFont(size: 12)
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -210,7 +203,7 @@ struct LocationNotesView: View {
ProgressView() ProgressView()
Text(Strings.loadingNotes) Text(Strings.loadingNotes)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
Spacer() Spacer()
} }
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -222,7 +215,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold) .bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle) Text(Strings.emptySubtitle)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(palette.secondary)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
} }
+136 -28
View File
@@ -9,6 +9,7 @@ private typealias PlatformImage = NSImage
#endif #endif
struct BlockRevealImageView: View { struct BlockRevealImageView: View {
@ThemedPalette private var palette
private let url: URL private let url: URL
private let revealProgress: Double? private let revealProgress: Double?
private let isSending: Bool private let isSending: Bool
@@ -20,6 +21,25 @@ struct BlockRevealImageView: View {
@State private var platformImage: PlatformImage? @State private var platformImage: PlatformImage?
@State private var aspectRatio: CGFloat = 1 @State private var aspectRatio: CGFloat = 1
@State private var isBlurred: Bool = false @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( init(
url: URL, url: URL,
@@ -69,20 +89,32 @@ struct BlockRevealImageView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35)) .fill(Color.black.opacity(0.35))
.overlay( .overlay(
Image(systemName: "eye.slash.fill") VStack(spacing: 6) {
.font(.bitchatSystem(size: 24, weight: .semibold)) Image(systemName: "eye.slash.fill")
.foregroundColor(.white.opacity(0.85)) .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 { } else {
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.gray.opacity(0.2)) .fill(palette.secondary.opacity(0.2))
.frame(height: 200) .frame(height: 200)
.overlay( .overlay {
ProgressView() if loadFailed {
.progressViewStyle(.circular) Image(systemName: "photo")
) .font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(palette.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
} }
if let onCancel = onCancel, isSending { if let onCancel = onCancel, isSending {
@@ -95,6 +127,7 @@ struct BlockRevealImageView: View {
.padding(8) .padding(8)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
} }
} }
.onAppear { .onAppear {
@@ -106,30 +139,105 @@ struct BlockRevealImageView: View {
loadImage() loadImage()
} }
.gesture(mainGesture) .gesture(mainGesture)
} .contextMenu {
if isSending {
private func loadImage() { cancelSendAction
DispatchQueue.global(qos: .userInitiated).async { } else {
#if os(iOS) imageActions
guard let image = UIImage(contentsOfFile: url.path) else { return } }
#else }
guard let image = NSImage(contentsOf: url) else { return } .confirmationDialog(
#endif Strings.deleteConfirmTitle,
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1 isPresented: $showDeleteConfirmation,
DispatchQueue.main.async { titleVisibility: .visible
self.platformImage = image ) {
self.aspectRatio = ratio 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 { @ViewBuilder
let doubleTap = TapGesture(count: 2).onEnded { private var cancelSendAction: some View {
guard !isSending else { return } if let onCancel {
onDelete?() 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 { let singleTap = TapGesture().onEnded {
guard !isSending else { return } guard !isSending, !loadFailed else { return }
if isBlurred { if isBlurred {
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: 0.2)) {
isBlurred = false isBlurred = false
@@ -139,7 +247,7 @@ struct BlockRevealImageView: View {
} }
} }
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in 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 horizontal = value.translation.width
let vertical = value.translation.height let vertical = value.translation.height
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return } 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 { struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage let message: BitchatMessage
let media: BitchatMessage.Media let media: BitchatMessage.Media
@@ -20,6 +21,7 @@ struct MediaMessageView: View {
/// fields by identity, so without the snapshot a status-only change /// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row. /// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus? private let deliveryStatus: DeliveryStatus?
@State private var showDeliveryDetail = false
@Binding var imagePreviewURL: URL? @Binding var imagePreviewURL: URL?
@@ -35,46 +37,93 @@ struct MediaMessageView: View {
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) { // Baseline alignment (via the header text inside the VStack) keeps the
HStack(alignment: .center, spacing: 4) { // lock on the header line; a fixed top padding left its solid body
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) // hanging below the line's visual center.
.fixedSize(horizontal: false, vertical: true) HStack(alignment: .firstTextBaseline, spacing: 0) {
.frame(maxWidth: .infinity, alignment: .leading) 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), if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus { let status = deliveryStatus {
DeliveryStatusView(status: status) if case .failed = status {
.padding(.leading, 4) 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 { Group {
switch media { switch media {
case .voice(let url): case .voice(let url):
VoiceNoteView( VoiceNoteView(
url: url, url: url,
isSending: state.isSending, isSending: state.isSending,
sendProgress: state.progress, sendProgress: state.progress,
onCancel: cancelAction onCancel: cancelAction
) )
case .image(let url): case .image(let url):
BlockRevealImageView( BlockRevealImageView(
url: url, url: url,
revealProgress: state.progress, revealProgress: state.progress,
isSending: state.isSending, isSending: state.isSending,
onCancel: cancelAction, onCancel: cancelAction,
initiallyBlurred: !isFromMe, initiallyBlurred: !isFromMe,
onOpen: { onOpen: {
if !state.isSending { if !state.isSending {
imagePreviewURL = url imagePreviewURL = url
} }
}, },
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
) )
.frame(maxWidth: 280) .frame(maxWidth: 280)
}
} }
} }
} }
.padding(.vertical, 4) .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) { private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
@@ -90,7 +139,7 @@ struct MediaMessageView: View {
isSending = true isSending = true
progress = Double(reached) / Double(total) progress = Double(reached) / Double(total)
} }
case .sent, .read, .delivered, .failed: case .sent, .carried, .read, .delivered, .failed:
break break
} }
} }
+13 -2
View File
@@ -28,7 +28,9 @@ struct VoiceNoteView: View {
} }
private var backgroundColor: Color { 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 { private var borderColor: Color {
@@ -50,6 +52,12 @@ struct VoiceNoteView: View {
.background(Circle().fill(palette.accent)) .background(Circle().fill(palette.accent))
} }
.buttonStyle(.plain) .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( WaveformView(
samples: samples, samples: samples,
@@ -63,7 +71,7 @@ struct VoiceNoteView: View {
Text(playbackLabel) Text(playbackLabel)
.bitchatFont(size: 13) .bitchatFont(size: 13)
.foregroundColor(Color.secondary) .foregroundColor(palette.secondary)
if let onCancel = onCancel, isSending { if let onCancel = onCancel, isSending {
Button(action: onCancel) { Button(action: onCancel) {
@@ -74,6 +82,9 @@ struct VoiceNoteView: View {
.foregroundColor(.white) .foregroundColor(.white)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
)
} }
} }
.padding(12) .padding(12)
+1 -1
View File
@@ -42,7 +42,7 @@ struct WaveformView: View {
} else if let send = clampedSend, binPosition <= send { } else if let send = clampedSend, binPosition <= send {
color = palette.accentBlue color = palette.accentBlue
} else { } else {
color = Color.gray.opacity(0.35) color = palette.secondary.opacity(0.35)
} }
context.fill(Path(rect), with: .color(color)) context.fill(Path(rect), with: .color(color))
} }
+100 -1
View File
@@ -7,6 +7,9 @@ struct MeshPeerList: View {
let onTapPeer: (PeerID) -> Void let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (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 @Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = [] @State private var orderedIDs: [String] = []
@@ -15,6 +18,20 @@ struct MeshPeerList: View {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" 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 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 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 { var body: some View {
@@ -49,21 +66,25 @@ struct MeshPeerList: View {
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
.help(Strings.connected)
} else if peer.isReachable { } else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon // Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
.help(Strings.reachable)
} else if peer.isMutualFavorite { } else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple) // Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe") Image(systemName: "globe")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.purple) .foregroundColor(.purple)
.help(Strings.nostr)
} else { } else {
// Fallback icon for others (dimmed) // Fallback icon for others (dimmed)
Image(systemName: "person") Image(systemName: "person")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary) .foregroundColor(palette.secondary)
.help(Strings.offline)
} }
let (base, suffix) = peer.displayName.splitSuffix() let (base, suffix) = peer.displayName.splitSuffix()
@@ -71,6 +92,8 @@ struct MeshPeerList: View {
Text(base) Text(base)
.bitchatFont(size: 14) .bitchatFont(size: 14)
.foregroundColor(baseColor) .foregroundColor(baseColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix) Text(suffix)
@@ -91,6 +114,10 @@ struct MeshPeerList: View {
if let icon = peer.encryptionStatus.icon { if let icon = peer.encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 10)) .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) .foregroundColor(baseColor)
} }
} else { } else {
@@ -103,6 +130,7 @@ struct MeshPeerList: View {
// Fallback to whatever status says (likely lock if we had a past session) // Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor) .foregroundColor(baseColor)
} }
} }
@@ -123,6 +151,11 @@ struct MeshPeerList: View {
Image(systemName: peer.isFavorite ? "star.fill" : "star") Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary) .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) .buttonStyle(.plain)
} }
@@ -131,8 +164,53 @@ struct MeshPeerList: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0) .padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle()) .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(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 // 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 var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false @State private var showMessageActions = false
@State private var showClearConfirmation = false
@State private var lastScrollTime: Date = .distantPast @State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer? @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 { var body: some View {
let currentWindowCount: Int = { let currentWindowCount: Int = {
@@ -65,6 +74,9 @@ struct MessageListView: View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
if messageItems.isEmpty && privatePeer == nil {
publicEmptyState
}
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in ForEach(messageItems) { item in
let message = item.message let message = item.message
@@ -72,6 +84,7 @@ struct MessageListView: View {
.onAppear { .onAppear {
if message.id == windowedMessages.last?.id { if message.id == windowedMessages.last?.id {
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
if message.id == windowedMessages.first?.id, if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count { messages.count > windowedMessages.count {
@@ -89,13 +102,32 @@ struct MessageListView: View {
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture {
if message.sender != "system" {
messageText = "@\(message.sender) "
isTextFieldFocused.wrappedValue = true
}
}
.contextMenu { .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") { Button("content.message.copy") {
#if os(iOS) #if os(iOS)
UIPasteboard.general.string = message.content UIPasteboard.general.string = message.content
@@ -105,6 +137,16 @@ struct MessageListView: View {
pb.setString(message.content, forType: .string) pb.setString(message.content, forType: .string)
#endif #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(.horizontal, 12)
.padding(.vertical, 1) .padding(.vertical, 1)
@@ -113,9 +155,24 @@ struct MessageListView: View {
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } } .transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2) .padding(.vertical, 2)
} }
.overlay(alignment: .bottomTrailing) {
if !isAtBottom && !messageItems.isEmpty {
jumpToLatestPill(proxy: proxy)
}
}
.onOpenURL(perform: handleOpenURL) .onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) { .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 { .onAppear {
scrollToBottom(on: proxy) scrollToBottom(on: proxy)
@@ -139,9 +196,7 @@ struct MessageListView: View {
) { ) {
Button("content.actions.mention") { Button("content.actions.mention") {
if let sender = selectedMessageSender { if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field insertMention(sender)
messageText = "@\(sender) "
isTextFieldFocused.wrappedValue = true
} }
} }
@@ -208,6 +263,142 @@ struct MessageListView: View {
} }
private extension MessageListView { 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 @ViewBuilder
func messageRow(for message: BitchatMessage) -> some View { func messageRow(for message: BitchatMessage) -> some View {
Group { Group {
@@ -294,6 +485,9 @@ private extension MessageListView {
func scrollToBottom(on proxy: ScrollViewProxy) { func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true isAtBottom = true
unseenCount = 0
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
unseenBaselineKey = currentContextKey
if let targetPeerID { if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom) proxy.scrollTo(targetPeerID, anchor: .bottom)
} }
@@ -316,15 +510,23 @@ private extension MessageListView {
} }
func onMessagesChange(proxy: ScrollViewProxy) { func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil else { return }
let messages = publicChatModel.messages 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 // If the newest message is from me, always scroll to bottom
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return return
} else { // Ensure we consider ourselves at bottom for subsequent messages } else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
func scrollIfNeeded(date: Date) { func scrollIfNeeded(date: Date) {
@@ -352,18 +554,23 @@ private extension MessageListView {
} }
func onPrivateChatsChange(proxy: ScrollViewProxy) { func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer, guard let peerID = privatePeer else { return }
let lastMsg = privateInboxModel.messages(for: peerID).last else { 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 return
} }
let messages = privateInboxModel.messages(for: peerID)
// If the newest private message is from me, always scroll // If the newest private message is from me, always scroll
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return return
} else { } else {
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
func scrollIfNeeded(date: Date) { func scrollIfNeeded(date: Date) {
@@ -391,17 +598,27 @@ private extension MessageListView {
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) { func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom // When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return } 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 { switch channel {
case .mesh: case .mesh:
break contextKey = "mesh"
case .location(let ch): case .location(let ch):
// Reset window size contextKey = "geo:\(ch.geohash)"
isAtBottom = true }
windowCountPublic = TransportConfig.uiWindowInitialCountPublic if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
let contextKey = "geo:\(ch.geohash)" proxy.scrollTo(target, anchor: .bottom)
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() // MessageListView()
//} // }
+13 -12
View File
@@ -11,7 +11,10 @@ import AppKit
struct MyQRView: View { struct MyQRView: View {
let qrString: String let qrString: String
@Environment(\.colorScheme) var colorScheme @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 { private enum Strings {
static let title: LocalizedStringKey = "verification.my_qr.title" static let title: LocalizedStringKey = "verification.my_qr.title"
@@ -50,6 +53,7 @@ struct MyQRView: View {
struct QRCodeImage: View { struct QRCodeImage: View {
let data: String let data: String
let size: CGFloat let size: CGFloat
@ThemedPalette private var palette
private let context = CIContext() private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator() private let filter = CIFilter.qrCodeGenerator()
@@ -65,12 +69,12 @@ struct QRCodeImage: View {
.frame(width: size, height: size) .frame(width: size, height: size)
} else { } else {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 1) .stroke(palette.secondary.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
Text(Strings.unavailable) Text(Strings.unavailable)
.bitchatFont(size: 12) .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. /// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View { struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = "" @State private var input = ""
@@ -153,7 +158,7 @@ struct QRScanView: View {
.bitchatFont(size: 14, weight: .medium) .bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input) TextEditor(text: $input)
.frame(height: 100) .frame(height: 100)
.border(Color.gray.opacity(0.4)) .border(palette.secondary.opacity(0.4))
Button(Strings.validate) { Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR // Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else { guard input != lastValid else {
@@ -265,7 +270,7 @@ struct CameraScannerView: UIViewRepresentable {
} }
final class PreviewView: UIView { final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@@ -285,7 +290,7 @@ struct VerificationSheetView: View {
private var backgroundColor: Color { palette.background } private var backgroundColor: Color { palette.background }
private var accentColor: Color { palette.accent } 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -295,15 +300,11 @@ struct VerificationSheetView: View {
.bitchatFont(size: 14, weight: .bold) .bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor) .foregroundColor(accentColor)
Spacer() Spacer()
Button(action: { SheetCloseButton {
showingScanner = false showingScanner = false
isPresented = false isPresented = false
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
.foregroundColor(accentColor)
} }
.buttonStyle(.plain) .foregroundColor(accentColor)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 12) .padding(.top, 12)
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
self.loadFirstPlainText(from: providers) { text in self.loadFirstPlainText(from: providers) { text in
if let t = text, !t.isEmpty { if let t = text, !t.isEmpty {
// Treat as URL if parseable http(s), else plain text // 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) self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else { } else {
self.saveAndFinish(text: t) self.saveAndFinish(text: t)
+10 -4
View File
@@ -14,23 +14,29 @@ import BitFoundation
struct BLEServiceCoreTests { struct BLEServiceCoreTests {
@Test @Test
func duplicatePacket_isDeduped() async { func duplicatePacket_isDeduped() async throws {
let ble = makeService() let ble = makeService()
let delegate = PublicCaptureDelegate() let delegate = PublicCaptureDelegate()
ble.delegate = delegate ble.delegate = delegate
// Public messages must carry a valid signature from the claimed sender;
// sign the packet and preseed the sender's signing key so the receiver
// can verify it (production `sendMessage` signs public broadcasts too).
let signer = NoiseEncryptionService(keychain: MockKeychain())
let sender = PeerID(str: "1122334455667788") let sender = PeerID(str: "1122334455667788")
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp) let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
let signingKey = signer.getSigningPublicKeyData()
ble._test_handlePacket(packet, fromPeerID: sender) ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
let receivedFirst = await TestHelpers.waitUntil( let receivedFirst = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count == 1 }, { delegate.publicMessagesSnapshot().count == 1 },
timeout: TestConstants.defaultTimeout timeout: TestConstants.defaultTimeout
) )
#expect(receivedFirst) #expect(receivedFirst)
ble._test_handlePacket(packet, fromPeerID: sender) ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
let receivedDuplicate = await TestHelpers.waitUntil( let receivedDuplicate = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count > 1 }, { delegate.publicMessagesSnapshot().count > 1 },
timeout: TestConstants.shortTimeout timeout: TestConstants.shortTimeout
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
context.meshNicknamesByPeerID = [ context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice", PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "bob", PeerID(str: "2222222222222222"): "bob",
PeerID(str: "3333333333333333"): "me", PeerID(str: "3333333333333333"): "me"
] ]
// Matching query: suggestions and range are published, index resets. // Matching query: suggestions and range are published, index resets.
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
"aaaabbbbccccdddd": "carol", "aaaabbbbccccdddd": "carol",
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy // Own token (nickname#last-4-of-pubkey) must be removed; the dummy
// identity's public key hex ends in "2222". // identity's public key hex ends in "2222".
"ffffeeeeddddcccc2222": "me", "ffffeeeeddddcccc2222": "me"
] ]
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3) coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
// Same message under both keys: the read copy must win over sent. // Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent), makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2), makePrivateMessage(id: "m2", timestamp: t2)
] ]
context.privateChats[stablePeerID] = [ 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) let merged = coordinator.getPrivateChatMessages(for: peerID)
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
makePrivateMessage(id: "m1", senderPeerID: convKey), makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey), makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true), 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) coordinator.markPrivateMessagesAsRead(from: convKey)
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
) )
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: 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) coordinator.markPrivateMessagesAsRead(from: peerID)
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
peerID: currentPeer, peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32), noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice" nickname: "alice"
), )
] ]
context.unreadPrivateMessages = [ context.unreadPrivateMessages = [
currentPeer, currentPeer,
staleShortPeer, staleShortPeer,
geoDMWithMessages, geoDMWithMessages,
geoDMWithoutMessages, geoDMWithoutMessages,
noiseKeyWithMessages, noiseKeyWithMessages
] ]
context.privateChats = [ context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")], geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")], noiseKeyWithMessages: [makeMessage(id: "noise-1")]
] ]
coordinator.didUpdatePeerList([currentPeer]) coordinator.didUpdatePeerList([currentPeer])
@@ -352,7 +352,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
context.displayNamesByPubkey[senderPubkey] = "alice#1234" context.displayNamesByPubkey[senderPubkey] = "alice#1234"
context.privateChats[convKey] = [ context.privateChats[convKey] = [
makeIncomingMessage(id: "mine-1", sender: "me"), makeIncomingMessage(id: "mine-1", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me"), makeIncomingMessage(id: "mine-2", sender: "me")
] ]
coordinator.handleDelivered( coordinator.handleDelivered(
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID), makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), 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) coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks() await drainMainActorTasks()
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.unreadPrivateMessages = [peerID] context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: 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) coordinator.didDisconnectFromPeer(peerID)
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor @Test @MainActor
func statusRank_orderingIsCorrect() async { func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages // This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < partiallyDelivered < delivered < read // failed < sending < sent < carried < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [ let statuses: [DeliveryStatus] = [
.failed(reason: "test"), .failed(reason: "test"),
.sending, .sending,
.sent, .sent,
.carried,
.partiallyDelivered(reached: 1, total: 3), .partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()), .delivered(to: "B", at: Date()),
.read(by: "C", at: Date()) .read(by: "C", at: Date())
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
case .failed: #expect(index == 0) case .failed: #expect(index == 0)
case .sending: #expect(index == 1) case .sending: #expect(index == 1)
case .sent: #expect(index == 2) case .sent: #expect(index == 2)
case .partiallyDelivered: #expect(index == 3) case .carried: #expect(index == 3)
case .delivered: #expect(index == 4) case .partiallyDelivered: #expect(index == 4)
case .read: #expect(index == 5) case .delivered: #expect(index == 5)
case .read: #expect(index == 6)
} }
} }
} }
@@ -297,8 +297,23 @@ struct ChatViewModelNostrExtensionTests {
let didAppend = await TestHelpers.waitUntil({ let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded() 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) #expect(didAppend)
} }
@@ -1000,7 +1015,11 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url) 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(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
@@ -1020,7 +1039,7 @@ struct ChatViewModelMediaTransferTests {
let didFail = await TestHelpers.waitUntil({ let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: 5.0) }, timeout: TestConstants.longTimeout)
#expect(didFail) #expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path)) #expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty) #expect(transport.sentPrivateFiles.isEmpty)
@@ -1036,7 +1055,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL) 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(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@@ -1057,7 +1076,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({ let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: 5.0) }, timeout: TestConstants.longTimeout)
#expect(didNotify) #expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty) #expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false) #expect(viewModel.privateChats[peerID]?.isEmpty != false)
+85
View File
@@ -263,6 +263,59 @@ struct ChatViewModelCommandTests {
#expect(transport.sentPrivateMessages.isEmpty) #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 // MARK: - Composer Tests
@@ -1042,6 +1095,38 @@ struct ChatViewModelPanicTests {
#expect(viewModel.unreadPrivateMessages.isEmpty) #expect(viewModel.unreadPrivateMessages.isEmpty)
#expect(viewModel.selectedPrivateChatPeer == nil) #expect(viewModel.selectedPrivateChatPeer == nil)
} }
@Test @MainActor
func panicClearAllData_resetsLiveGeohashAndNostrState() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruy"
let channel = GeohashChannel(level: .city, geohash: geohash)
let identity = try NostrIdentity.generate()
let pubkey = String(repeating: "ab", count: 32)
let peerID = PeerID(nostr: pubkey)
viewModel.activeChannel = .location(channel)
viewModel.setGeoChatSubscriptionID("geo-\(geohash)")
viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)")
viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash)
viewModel.cachedGeohashIdentity = (geohash, identity)
viewModel.registerNostrKeyMapping(pubkey, for: peerID)
viewModel.currentGeohash = geohash
viewModel.geoNicknames = [pubkey: "alice"]
viewModel.teleportedGeo = [pubkey]
viewModel.panicClearAllData()
#expect(viewModel.activeChannel == .mesh)
#expect(viewModel.geoSubscriptionID == nil)
#expect(viewModel.geoDmSubscriptionID == nil)
#expect(viewModel.geoSamplingSubs.isEmpty)
#expect(viewModel.cachedGeohashIdentity == nil)
#expect(viewModel.nostrKeyMapping.isEmpty)
#expect(viewModel.currentGeohash == nil)
#expect(viewModel.geoNicknames.isEmpty)
#expect(viewModel.teleportedGeo.isEmpty)
}
} }
// MARK: - Service Lifecycle Tests // MARK: - Service Lifecycle 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)
}
}
@@ -21,9 +21,16 @@ struct FragmentationTests {
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
// Construct a big packet (3KB) from a remote sender (not our own ID) // Construct a big SIGNED public packet (3KB) from a remote sender. Public
// messages must carry a valid signature, so the reassembled packet is
// signed and the sender's signing key is preseeded into the registry.
let signer = NoiseEncryptionService(keychain: MockKeychain())
let signingKey = signer.getSigningPublicKeyData()
let remoteShortID = PeerID(str: "1122334455667788") let remoteShortID = PeerID(str: "1122334455667788")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000) let original = try #require(
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)),
"Failed to sign public packet"
)
// Use a small fragment size to ensure multiple pieces // Use a small fragment size to ensure multiple pieces
let fragments = fragmentPacket(original, fragmentSize: 400) let fragments = fragmentPacket(original, fragmentSize: 400)
@@ -36,7 +43,7 @@ struct FragmentationTests {
if i > 0 { if i > 0 {
try await Task.sleep(for: .milliseconds(5)) try await Task.sleep(for: .milliseconds(5))
} }
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
} }
// Wait for delegate callback with proper timeout // Wait for delegate callback with proper timeout
@@ -52,8 +59,13 @@ struct FragmentationTests {
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
let signer = NoiseEncryptionService(keychain: MockKeychain())
let signingKey = signer.getSigningPublicKeyData()
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708") let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048) let original = try #require(
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)),
"Failed to sign public packet"
)
var frags = fragmentPacket(original, fragmentSize: 300) var frags = fragmentPacket(original, fragmentSize: 300)
// Duplicate one fragment // Duplicate one fragment
@@ -66,7 +78,7 @@ struct FragmentationTests {
if i > 0 { if i > 0 {
try await Task.sleep(for: .milliseconds(5)) try await Task.sleep(for: .milliseconds(5))
} }
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
} }
// Wait for delegate callback with proper timeout // Wait for delegate callback with proper timeout
@@ -174,7 +174,7 @@ struct IntegrationTests {
} }
// Encrypted path: use NoiseSessionManager explicitly // 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) let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
@@ -206,7 +206,7 @@ struct IntegrationTests {
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
// David tracks received messages // David tracks received messages
helper.nodes["David"]!.messageDeliveryHandler = { message in helper.nodes["David"]!.messageDeliveryHandler = { _ in
completion() completion()
} }
@@ -288,7 +288,7 @@ struct IntegrationTests {
} }
do { 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 ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
@@ -121,4 +121,3 @@ final class TestNetworkHelper {
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
} }
} }
+6 -6
View File
@@ -52,19 +52,19 @@ struct MimeTypeTests {
@Test(arguments: [ @Test(arguments: [
// === Image types === // === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]), (MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, (MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP" 0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types === // === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE" 0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types === // === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
]) ])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws { func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes) let data = Data(bytes)
+1 -1
View File
@@ -172,7 +172,7 @@ struct NoiseCoverageTests {
Data(), Data(),
Data(repeating: 0x00, count: 32), Data(repeating: 0x00, count: 32),
Data([0x01] + Array(repeating: 0x00, count: 31)), Data([0x01] + Array(repeating: 0x00, count: 31)),
Data(repeating: 0xFF, count: 32), Data(repeating: 0xFF, count: 32)
] ]
for invalidKey in invalidKeys { for invalidKey in invalidKeys {
+27 -28
View File
@@ -151,7 +151,7 @@ struct NoiseProtocolTests {
@Test func basicEncryptionDecryption() throws { @Test func basicEncryptionDecryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Hello, Bob!".data(using: .utf8)! let plaintext = Data("Hello, Bob!".utf8)
// Alice encrypts // Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
@@ -167,13 +167,13 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob // Alice -> Bob
let aliceMessage = "Hello from Alice".data(using: .utf8)! let aliceMessage = Data("Hello from Alice".utf8)
let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext) let bobReceived = try bobSession.decrypt(aliceCiphertext)
#expect(bobReceived == aliceMessage) #expect(bobReceived == aliceMessage)
// Bob -> Alice // Bob -> Alice
let bobMessage = "Hello from Bob".data(using: .utf8)! let bobMessage = Data("Hello from Bob".utf8)
let bobCiphertext = try bobSession.encrypt(bobMessage) let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext) let aliceReceived = try aliceSession.decrypt(bobCiphertext)
#expect(aliceReceived == bobMessage) #expect(aliceReceived == bobMessage)
@@ -193,7 +193,7 @@ struct NoiseProtocolTests {
} }
@Test func encryptionBeforeHandshake() { @Test func encryptionBeforeHandshake() {
let plaintext = "test".data(using: .utf8)! let plaintext = Data("test".utf8)
#expect(throws: NoiseSessionError.notEstablished) { #expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.encrypt(plaintext) try aliceSession.encrypt(plaintext)
@@ -270,7 +270,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Encrypt with manager // Encrypt with manager
let plaintext = "Test message".data(using: .utf8)! let plaintext = Data("Test message".utf8)
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID) let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager // Decrypt with manager
@@ -283,7 +283,7 @@ struct NoiseProtocolTests {
@Test func tamperedCiphertextDetection() throws { @Test func tamperedCiphertextDetection() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) 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) var ciphertext = try aliceSession.encrypt(plaintext)
// Tamper with ciphertext // Tamper with ciphertext
@@ -304,7 +304,7 @@ struct NoiseProtocolTests {
@Test func replayPrevention() throws { @Test func replayPrevention() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) 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) let ciphertext = try aliceSession.encrypt(plaintext)
// First decryption should succeed // First decryption should succeed
@@ -337,7 +337,7 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession2, responder: bobSession2) try performHandshake(initiator: aliceSession2, responder: bobSession2)
// Encrypt with session 1 // Encrypt with session 1
let plaintext = "Secret".data(using: .utf8)! let plaintext = Data("Secret".utf8)
let ciphertext1 = try aliceSession1.encrypt(plaintext) let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2 // Should not be able to decrypt with session 2
@@ -366,10 +366,10 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state // 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) _ = 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) _ = try aliceManager.decrypt(message2, from: alicePeerID)
// Simulate Bob restart by creating new manager with same key // Simulate Bob restart by creating new manager with same key
@@ -391,7 +391,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions // 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 encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
#expect(decrypted == testMessage) #expect(decrypted == testMessage)
@@ -409,17 +409,17 @@ struct NoiseProtocolTests {
// Exchange messages to advance nonces // Exchange messages to advance nonces
for i in 0..<5 { 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) _ = try bobSession.decrypt(msg)
} }
// Simulate desynchronization by encrypting but not decrypting // Simulate desynchronization by encrypting but not decrypting
for i in 0..<3 { 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 // 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) { #expect(throws: Never.self) {
try bobSession.decrypt(desyncMessage) try bobSession.decrypt(desyncMessage)
} }
@@ -434,12 +434,11 @@ struct NoiseProtocolTests {
let messageCount = 100 let messageCount = 100
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
{ completion in
var encryptedMessages: [Int: Data] = [:] var encryptedMessages: [Int: Data] = [:]
// Encrypt messages sequentially to avoid nonce races in manager // Encrypt messages sequentially to avoid nonce races in manager
for i in 0..<messageCount { 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) let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
encryptedMessages[i] = encrypted encryptedMessages[i] = encrypted
} }
@@ -452,7 +451,7 @@ struct NoiseProtocolTests {
return return
} }
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID) 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) #expect(decrypted == expected)
completion() completion()
} catch { } catch {
@@ -485,7 +484,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Create a corrupted message // 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 encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail // Decryption should fail
@@ -516,7 +515,7 @@ struct NoiseProtocolTests {
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// Exchange messages to verify sessions work // 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 encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID) let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
#expect(decrypted == testMessage) #expect(decrypted == testMessage)
@@ -539,7 +538,7 @@ struct NoiseProtocolTests {
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!) _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// Verify new sessions work // 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 encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID) let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
#expect(decrypted2 == testMessage2) #expect(decrypted2 == testMessage2)
@@ -555,18 +554,18 @@ struct NoiseProtocolTests {
// Exchange messages normally // Exchange messages normally
for i in 0..<5 { 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) _ = try bobManager.decrypt(msg, from: bobPeerID)
} }
// Simulate desynchronization - Alice sends messages that Bob doesn't receive // Simulate desynchronization - Alice sends messages that Bob doesn't receive
for i in 0..<3 { 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 // With nonce carried in packet, decryption should not throw here
let desyncMessage = try aliceManager.encrypt( let desyncMessage = try aliceManager.encrypt(
"This now succeeds".data(using: .utf8)!, for: alicePeerID) Data("This now succeeds".utf8), for: alicePeerID)
#expect(throws: Never.self) { #expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID) try bobManager.decrypt(desyncMessage, from: bobPeerID)
} }
@@ -587,7 +586,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again // Verify communication works again
let testResynced = "Resynced".data(using: .utf8)! let testResynced = Data("Resynced".utf8)
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID) let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID) let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
#expect(decryptedResync == testResynced) #expect(decryptedResync == testResynced)
@@ -900,20 +899,20 @@ struct NoiseProtocolTests {
#expect(trackingKeychain.secureClearDataCallCount > 0) #expect(trackingKeychain.secureClearDataCallCount > 0)
// Test encryption from Alice to Bob // 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 ciphertext1 = try alice.encrypt(plaintext1)
let decrypted1 = try bob.decrypt(ciphertext1) let decrypted1 = try bob.decrypt(ciphertext1)
#expect(decrypted1 == plaintext1) #expect(decrypted1 == plaintext1)
// Test encryption from Bob to Alice // 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 ciphertext2 = try bob.encrypt(plaintext2)
let decrypted2 = try alice.decrypt(ciphertext2) let decrypted2 = try alice.decrypt(ciphertext2)
#expect(decrypted2 == plaintext2) #expect(decrypted2 == plaintext2)
// Test multiple messages to verify cipher state is correct // Test multiple messages to verify cipher state is correct
for i in 1...10 { 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 cipher = try alice.encrypt(msg)
let dec = try bob.decrypt(cipher) let dec = try bob.decrypt(cipher)
#expect(dec == msg) #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))
}
}
+47
View File
@@ -120,6 +120,42 @@ struct NostrProtocolTests {
} }
} }
@Test func decryptRejectsInvalidSealSignature() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
content: "forged signature",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
}
}
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
let claimedSender = try NostrIdentity.generate()
let sealSigner = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
content: "spoofed sender",
recipientPubkey: recipient.publicKeyHex,
rumorIdentity: claimedSender,
sealSignerIdentity: sealSigner
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
}
}
func testAckRoundTripNIP44V2_Delivered() throws { func testAckRoundTripNIP44V2_Delivered() throws {
// Identities // Identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
@@ -260,4 +296,15 @@ struct NostrProtocolTests {
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
return Data(base64Encoded: str) return Data(base64Encoded: str)
} }
private func expectInvalidEvent(_ operation: () throws -> Void) {
do {
try operation()
Issue.record("Expected NostrError.invalidEvent")
} catch NostrError.invalidEvent {
return
} catch {
Issue.record("Expected NostrError.invalidEvent, got \(error)")
}
}
} }
@@ -301,7 +301,7 @@ final class PerformanceBaselineTests: XCTestCase {
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]), ("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
("checking in from the harbor #bitchat #mesh", nil), ("checking in from the harbor #bitchat #mesh", nil),
("@bob#0042 ping me when you get this", ["bob#0042"]), ("@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 let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
(0..<batchSize).map { i in (0..<batchSize).map { i in
@@ -547,7 +547,7 @@ final class PerformanceBaselineTests: XCTestCase {
"anyone near the station?", "anyone near the station?",
"@bob#0042 are you on mesh too?", "@bob#0042 are you on mesh too?",
"check this out https://example.com/p/123 #bitchat", "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 return try (0..<count).map { i in
try NostrProtocol.createEphemeralGeohashEvent( try NostrProtocol.createEphemeralGeohashEvent(
+10 -5
View File
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
struct ProtocolContractTests { struct ProtocolContractTests {
@Test @Test
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() { func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
#expect(CommandInfo.message.id == "dm") // Aliases must match what CommandProcessor actually accepts
#expect(CommandInfo.message.alias == "/dm") // 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.message.placeholder != nil)
#expect(CommandInfo.clear.placeholder == nil) #expect(CommandInfo.clear.placeholder == nil)
#expect(CommandInfo.favorite.description.isEmpty == false) #expect(CommandInfo.favorite.description.isEmpty == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false) #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite)) // Favorites are rejected by the processor in geohash contexts, so
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite)) // 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 @Test
@@ -98,8 +98,12 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now) 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.count == 1)
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32)) #expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
#expect(recorder.barrierCount == 1) #expect(recorder.barrierCount == 1)
@@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) 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.verifySignatureCalls.isEmpty)
#expect(recorder.barrierCount == 1) #expect(recorder.barrierCount == 1)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
@@ -173,7 +180,10 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false) #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
#expect(recorder.persistedIdentities.count == 1) // Identity persistence MUST NOT occur for unverified announces:
// persisting would let an attacker who replays a victim's noisePublicKey
// overwrite the victim's stored signing key/nickname (identity poisoning).
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.trackedPackets.count == 1) #expect(recorder.trackedPackets.count == 1)
#expect(recorder.announceBacks == 1) #expect(recorder.announceBacks == 1)
} }
@@ -194,8 +204,9 @@ struct BLEAnnounceHandlerTests {
recorder.signatureValid = false recorder.signatureValid = false
let handler = makeHandler(recorder: recorder, now: now) 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.verifySignatureCalls.count == 1)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
@@ -219,8 +230,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder) expectNoSideEffects(recorder)
} }
@@ -239,8 +251,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now) 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) expectNoSideEffects(recorder)
} }
@@ -260,8 +273,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID) let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder) expectNoSideEffects(recorder)
} }
@@ -308,8 +322,10 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now) 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.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false) #expect(recorder.upsertCalls.first?.isConnected == false)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
@@ -381,8 +397,9 @@ struct BLEAnnounceHandlerTests {
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32) recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
let handler = makeHandler(recorder: recorder, now: now) 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.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
@@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests {
#expect(selection.centralIDs == Set(["c2"])) #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 @Test
func directedSendExcludesAllLinksToIngressPeer() { func directedSendExcludesAllLinksToIngressPeer() {
let selection = BLEFanoutSelector.selectLinks( let selection = BLEFanoutSelector.selectLinks(
@@ -8,10 +8,12 @@ struct BLEPublicMessageHandlerTests {
var localNickname = "Me" var localNickname = "Me"
var peers: [PeerID: BLEPeerInfo] = [:] var peers: [PeerID: BLEPeerInfo] = [:]
var signedName: String? var signedName: String?
var verifyPacketSignatureResult = false
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var selfBroadcastMessageID: String? var selfBroadcastMessageID: String?
var peersSnapshotReads = 0 var peersSnapshotReads = 0
var verifyPacketSignatureQueries: [PeerID] = []
var signedNameQueries: [PeerID] = [] var signedNameQueries: [PeerID] = []
var trackedPackets: [BitchatPacket] = [] var trackedPackets: [BitchatPacket] = []
var selfBroadcastTakes: [BitchatPacket] = [] var selfBroadcastTakes: [BitchatPacket] = []
@@ -35,6 +37,10 @@ struct BLEPublicMessageHandlerTests {
recorder.peersSnapshotReads += 1 recorder.peersSnapshotReads += 1
return recorder.peers return recorder.peers
}, },
verifyPacketSignature: { packet, _ in
recorder.verifyPacketSignatureQueries.append(PeerID(hexData: packet.senderID))
return recorder.verifyPacketSignatureResult
},
signedSenderDisplayName: { _, peerID in signedSenderDisplayName: { _, peerID in
recorder.signedNameQueries.append(peerID) recorder.signedNameQueries.append(peerID)
return recorder.signedName return recorder.signedName
@@ -59,13 +65,17 @@ struct BLEPublicMessageHandlerTests {
let now = Date(timeIntervalSince1970: 1_000) let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder() let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
// A valid packet signature is required even for a registry-verified peer:
// senderID is spoofable, so registry membership alone is not authentication.
recorder.signedName = "SignedAlice"
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now)) let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
handler.handle(packet, from: remotePeerID) handler.handle(packet, from: remotePeerID)
#expect(recorder.peersSnapshotReads == 1) #expect(recorder.peersSnapshotReads == 1)
#expect(recorder.signedNameQueries.isEmpty) // Signature is verified, then the registry's collision-resolved name is preferred.
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.trackedPackets.count == 1) #expect(recorder.trackedPackets.count == 1)
#expect(recorder.selfBroadcastTakes.isEmpty) #expect(recorder.selfBroadcastTakes.isEmpty)
#expect(recorder.deliveries.count == 1) #expect(recorder.deliveries.count == 1)
@@ -133,6 +143,63 @@ struct BLEPublicMessageHandlerTests {
#expect(recorder.deliveries.isEmpty) #expect(recorder.deliveries.isEmpty)
} }
@Test
func registryVerifiedPeerDeliveredBeforeIdentityCachePersists() {
// A freshly verified announce updates the peer registry synchronously,
// but identity-cache persistence is async. A message arriving in that
// window has a valid signature and a registry signing key, yet the
// persisted-identity lookup (signedName) would still return nil. It must
// be verified against the registry key and delivered, not dropped.
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true,
signingPublicKey: Data(repeating: 0xAB, count: 32)
)]
recorder.verifyPacketSignatureResult = true
recorder.signedName = nil
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, content: "first msg", timestamp: timestamp(now))
handler.handle(packet, from: remotePeerID)
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
// Verified via the registry key, so no fallback to the persisted lookup.
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.nickname == "Alice")
#expect(recorder.deliveries.first?.content == "first msg")
}
@Test
func registryPeerWithInvalidSignatureFallsBackAndDrops() {
// Spoofed senderID: the peer is in the registry with a signing key, but
// the packet signature does not verify against it. The handler must fall
// back to the persisted lookup and, finding nothing, drop the message.
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true,
signingPublicKey: Data(repeating: 0xAB, count: 32)
)]
recorder.verifyPacketSignatureResult = false
recorder.signedName = nil
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, content: "spoofed", timestamp: timestamp(now))
handler.handle(packet, from: remotePeerID)
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.deliveries.isEmpty)
}
@Test @Test
func signedSenderFallbackDeliversWithSignedName() { func signedSenderFallbackDeliversWithSignedName() {
let now = Date(timeIntervalSince1970: 1_000) let now = Date(timeIntervalSince1970: 1_000)
@@ -154,6 +221,7 @@ struct BLEPublicMessageHandlerTests {
let now = Date(timeIntervalSince1970: 1_000) let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder() let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
recorder.signedName = "SignedAlice"
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now)) let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
@@ -187,6 +255,7 @@ struct BLEPublicMessageHandlerTests {
let now = Date(timeIntervalSince1970: 1_000) let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder() let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
recorder.signedName = "SignedAlice"
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket( let packet = makeMessagePacket(
sender: remotePeerID, sender: remotePeerID,
@@ -213,14 +282,15 @@ struct BLEPublicMessageHandlerTests {
_ peerID: PeerID, _ peerID: PeerID,
nickname: String, nickname: String,
isVerified: Bool, isVerified: Bool,
isConnected: Bool = true isConnected: Bool = true,
signingPublicKey: Data? = nil
) -> BLEPeerInfo { ) -> BLEPeerInfo {
BLEPeerInfo( BLEPeerInfo(
peerID: peerID, peerID: peerID,
nickname: nickname, nickname: nickname,
isConnected: isConnected, isConnected: isConnected,
noisePublicKey: nil, noisePublicKey: nil,
signingPublicKey: nil, signingPublicKey: signingPublicKey,
isVerifiedNickname: isVerified, isVerifiedNickname: isVerified,
lastSeen: Date(timeIntervalSince1970: 999) lastSeen: Date(timeIntervalSince1970: 999)
) )
@@ -14,7 +14,10 @@ struct BLEReceivePipelineTests {
let context = BLEReceivePipeline.context(for: packet, localPeerID: local) let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
#expect(context.senderID == sender) #expect(context.senderID == sender)
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)") // The message ID includes a payload digest so distinct packets sharing a
// sender/timestamp(ms)/type are not collapsed as duplicates.
let digest = packet.payload.sha256Hash().prefix(4).hexEncodedString()
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)-\(digest)")
#expect(context.messageType == .message) #expect(context.messageType == .message)
#expect(context.shouldDeduplicate) #expect(context.shouldDeduplicate)
#expect(context.logsHandlingDetails) #expect(context.logsHandlingDetails)
@@ -1328,6 +1328,68 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
} }
func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws {
let relayURL = "wss://panic-reset.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
let event = try makeSignedEvent(content: "queued before panic")
var handledEvents = 0
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "panic-sub",
relayUrls: [relayURL],
handler: { _ in handledEvents += 1 },
onEOSE: { eoseCount += 1 }
)
context.manager.sendEvent(event, to: [relayURL])
XCTAssertEqual(context.manager.debugMessageHandlerCount, 1)
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1)
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.manager.resetForPanicWipe()
XCTAssertEqual(context.manager.debugMessageHandlerCount, 0)
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0)
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
XCTAssertEqual(handledEvents, 0)
XCTAssertEqual(eoseCount, 0)
// Stale Tor wait and fallback callbacks from the pre-wipe generation
// must not resurrect connections or settle callbacks after reset.
context.torWaiter.resolve(true)
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertEqual(eoseCount, 0)
}
func test_resetForPanicWipe_marksConnectedRelaysDisconnected() async {
let relayURL = "wss://panic-connected.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.manager.isConnected &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
context.manager.resetForPanicWipe()
XCTAssertFalse(context.manager.isConnected)
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected, false)
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts, 0)
XCTAssertNil(context.manager.relays.first(where: { $0.url == relayURL })?.lastError)
}
func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async { func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async {
let relayURL = "wss://jitter-bounds.example" let relayURL = "wss://jitter-bounds.example"
// Pin the jitter source to the extremes and the midpoint of [0, 1). // Pin the jitter source to the extremes and the midpoint of [0, 1).
@@ -41,6 +41,44 @@ struct UnifiedPeerServiceTests {
#expect(service.isBlocked(peerID)) #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 { private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
@@ -12,6 +12,11 @@ import Foundation
struct TestConstants { struct TestConstants {
static let defaultTimeout: TimeInterval = 5.0 static let defaultTimeout: TimeInterval = 5.0
static let shortTimeout: TimeInterval = 1.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 longTimeout: TimeInterval = 10.0
static let testNickname1 = "Alice" static let testNickname1 = "Alice"
+2 -2
View File
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01, type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString), senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil, recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!, payload: Data = Data("test payload".utf8),
signature: Data? = nil, signature: Data? = nil,
ttl: UInt8 = 3 ttl: UInt8 = 3
) -> BitchatPacket { ) -> BitchatPacket {
return BitchatPacket( return BitchatPacket(
type: type, type: type,
senderID: senderID.id.data(using: .utf8)!, senderID: Data(senderID.id.utf8),
recipientID: recipientID?.id.data(using: .utf8), recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
+13 -3
View File
@@ -556,11 +556,19 @@ struct ViewSmokeTests {
@Test @Test
func voiceAndMediaViews_renderAndWarmCaches() async throws { func voiceAndMediaViews_renderAndWarmCaches() async throws {
let audioURL = try makeTemporaryAudioURL() 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() let imageURL = try makeTemporaryImageURL()
defer { defer {
try? FileManager.default.removeItem(at: audioURL) try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: waveformProbeURL)
try? FileManager.default.removeItem(at: imageURL) try? FileManager.default.removeItem(at: imageURL)
WaveformCache.shared.purge(url: audioURL) WaveformCache.shared.purge(url: audioURL)
WaveformCache.shared.purge(url: waveformProbeURL)
} }
let waveformView = WaveformView( let waveformView = WaveformView(
@@ -594,12 +602,14 @@ struct ViewSmokeTests {
_ = mount(voiceNoteView) _ = mount(voiceNoteView)
let bins = await withCheckedContinuation { continuation in 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) continuation.resume(returning: values)
} }
} }
playback.loadDuration() 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.seek(to: 1.25)
playback.stop() playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback) VoiceNotePlaybackCoordinator.shared.activate(playback)
@@ -607,7 +617,7 @@ struct ViewSmokeTests {
await VoiceRecorder.shared.cancelRecording() await VoiceRecorder.shared.cancelRecording()
#expect(bins.count == 16) #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.duration > 0)
#expect(playback.progress == 0) #expect(playback.progress == 0)
} }
+12 -12
View File
@@ -13,7 +13,7 @@ import struct Foundation.Data
struct XChaCha20Poly1305CompatTests { struct XChaCha20Poly1305CompatTests {
@Test func sealAndOpenRoundtrip() throws { @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 key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealAndOpenWithAAD() throws { @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 key = Data(repeating: 0xAB, count: 32)
let nonce = Data(repeating: 0xCD, count: 24) 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 sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
let decrypted = try XChaCha20Poly1305Compat.open( let decrypted = try XChaCha20Poly1305Compat.open(
@@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws { @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 key = Data(repeating: 0x42, count: 32)
let nonce1 = Data(repeating: 0x01, count: 24) let nonce1 = Data(repeating: 0x01, count: 24)
let nonce2 = Data(repeating: 0x02, count: 24) let nonce2 = Data(repeating: 0x02, count: 24)
@@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnShortKey() { @Test func sealThrowsOnShortKey() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let shortKey = Data(repeating: 0x42, count: 16) let shortKey = Data(repeating: 0x42, count: 16)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnLongKey() { @Test func sealThrowsOnLongKey() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let longKey = Data(repeating: 0x42, count: 64) let longKey = Data(repeating: 0x42, count: 64)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnEmptyKey() { @Test func sealThrowsOnEmptyKey() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let emptyKey = Data() let emptyKey = Data()
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnShortNonce() { @Test func sealThrowsOnShortNonce() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let shortNonce = Data(repeating: 0x24, count: 12) let shortNonce = Data(repeating: 0x24, count: 12)
@@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnLongNonce() { @Test func sealThrowsOnLongNonce() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let longNonce = Data(repeating: 0x24, count: 32) let longNonce = Data(repeating: 0x24, count: 32)
@@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnEmptyNonce() { @Test func sealThrowsOnEmptyNonce() {
let plaintext = "Test".data(using: .utf8)! let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let emptyNonce = Data() let emptyNonce = Data()
@@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func openFailsWithWrongKey() throws { @Test func openFailsWithWrongKey() throws {
let plaintext = "Secret".data(using: .utf8)! let plaintext = Data("Secret".utf8)
let correctKey = Data(repeating: 0x42, count: 32) let correctKey = Data(repeating: 0x42, count: 32)
let wrongKey = Data(repeating: 0x43, count: 32) let wrongKey = Data(repeating: 0x43, count: 32)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func openFailsWithTamperedCiphertext() throws { @Test func openFailsWithTamperedCiphertext() throws {
let plaintext = "Secret".data(using: .utf8)! let plaintext = Data("Secret".utf8)
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24) 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 name: "Tor", // Keep name "Tor" for drop-in compatibility
platforms: [ platforms: [
.iOS(.v16), .iOS(.v16),
.macOS(.v13), .macOS(.v13)
], ],
products: [ products: [
.library( .library(
name: "Tor", name: "Tor",
targets: ["Tor"] targets: ["Tor"]
), )
], ],
dependencies: [ dependencies: [
.package(path: "../BitLogger"), .package(path: "../BitLogger")
], ],
targets: [ targets: [
// Main Swift target // Main Swift target
@@ -22,19 +22,19 @@ let package = Package(
name: "Tor", name: "Tor",
dependencies: [ dependencies: [
"arti", "arti",
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger")
], ],
path: "Sources", path: "Sources",
exclude: ["C"], exclude: ["C"],
sources: [ sources: [
"TorManager.swift", "TorManager.swift",
"TorURLSession.swift", "TorURLSession.swift",
"TorNotifications.swift", "TorNotifications.swift"
], ],
linkerSettings: [ linkerSettings: [
.linkedLibrary("resolv"), .linkedLibrary("resolv"),
.linkedLibrary("z"), .linkedLibrary("z"),
.linkedLibrary("sqlite3"), .linkedLibrary("sqlite3")
] ]
), ),
// Binary framework containing the Rust static library. // Binary framework containing the Rust static library.
@@ -42,6 +42,6 @@ let package = Package(
.binaryTarget( .binaryTarget(
name: "arti", name: "arti",
path: "Frameworks/arti.xcframework" path: "Frameworks/arti.xcframework"
), )
] ]
) )
+1 -1
View File
@@ -21,7 +21,7 @@ let package = Package(
.target( .target(
name: "BitFoundation", name: "BitFoundation",
dependencies: [ dependencies: [
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger")
], ],
path: "Sources" 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 { public enum DeliveryStatus: Codable, Equatable, Hashable {
case sending case sending
case sent // Left our device 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 delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient case read(by: String, at: Date) // Seen by recipient
case failed(reason: String) case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms case partiallyDelivered(reached: Int, total: Int) // For rooms
public var displayText: String { public var displayText: String {
switch self { switch self {
case .sending: case .sending:
return "Sending..." return "Sending..."
case .sent: case .sent:
return "Sent" return "Sent"
case .carried:
return "Carried by a friend"
case .delivered(let nickname, _): case .delivered(let nickname, _):
return "Delivered to \(nickname)" return "Delivered to \(nickname)"
case .read(let nickname, _): case .read(let nickname, _):
@@ -12,8 +12,9 @@
public enum MessageType: UInt8 { public enum MessageType: UInt8 {
// Public messages (unencrypted) // Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname 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 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) case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption // Noise encryption
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
case .requestSync: return "requestSync" case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
@@ -46,7 +46,8 @@ struct BinaryProtocolTests {
// Verify recipient // Verify recipient
#expect(decodedPacket.recipientID != nil) #expect(decodedPacket.recipientID != nil)
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes() 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") #expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
} }
@@ -294,7 +295,7 @@ struct BinaryProtocolTests {
@Test("Create a large, compressible payload above current threshold (2048B)") @Test("Create a large, compressible payload above current threshold (2048B)")
func payloadCompression() throws { func payloadCompression() throws {
let repeatedString = String(repeating: "This is a test message. ", count: 200) 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) let packet = TestHelpers.createTestPacket(payload: largePayload)
@@ -314,7 +315,7 @@ struct BinaryProtocolTests {
@Test("Small payloads should not be compressed") @Test("Small payloads should not be compressed")
func smallPayloadNoCompression() throws { func smallPayloadNoCompression() throws {
let smallPayload = "Hi".data(using: .utf8)! let smallPayload = Data("Hi".utf8)
let packet = TestHelpers.createTestPacket(payload: smallPayload) let packet = TestHelpers.createTestPacket(payload: smallPayload)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet") let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
@@ -362,7 +363,7 @@ struct BinaryProtocolTests {
var encodedSizes = Set<Int>() var encodedSizes = Set<Int>()
for payload in payloads { 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") 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) // 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, type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString), senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil, recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!, payload: Data = Data("test payload".utf8),
signature: Data? = nil, signature: Data? = nil,
ttl: UInt8 = 3 ttl: UInt8 = 3
) -> BitchatPacket { ) -> BitchatPacket {
return BitchatPacket( return BitchatPacket(
type: type, type: type,
senderID: senderID.id.data(using: .utf8)!, senderID: Data(senderID.id.utf8),
recipientID: recipientID?.id.data(using: .utf8), recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
+401 -426
View File
@@ -1,441 +1,416 @@
Relay URL,Latitude,Longitude Relay URL,Latitude,Longitude
nostr.bitcoiner.social:443,47.6743,-117.112
relay-dev.satlantis.io:443,40.8302,-74.1299
relay.lightning.pub:443,39.0438,-77.4874
ribo.us.nostria.app:443,43.6532,-79.3832
relay.notoshi.win,13.3622,100.983
openrelay.ziomc.com,50.0755,14.4378
relay.agentry.com:443,42.8864,-78.8784
nostr-relay.xbytez.io,50.6924,3.20113
relay.gulugulu.moe,43.6532,-79.3832
nostr.thebiglake.org:443,32.71,-96.6745
relay.fountain.fm,43.6532,-79.3832
freelay.sovbit.host,60.1699,24.9384
nostr.girino.org:443,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
nas01xanthosnet.synology.me:7778,47.1285,8.74735
relay.ohstr.com:443,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
relay.nostrdice.com,-33.8688,151.209
bbw-nostr.xyz,41.5284,-87.4237
cs-relay.nostrdev.com:443,50.4754,12.3683
top.testrelay.top,43.6532,-79.3832
relay.trotters.cc,43.6532,-79.3832
blossom.gnostr.cloud,43.6532,-79.3832
ribo.eu.nostria.app:443,43.6532,-79.3832
public.crostr.com,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
strfry.bonsai.com:443,39.0438,-77.4874
nostr.carroarmato0.be,50.914,3.21378
relay.endfiat.money,59.3327,18.0656
nostr.overmind.lol:443,43.6532,-79.3832
nostr.myshosholoza.co.za:443,52.3913,4.66545
relay.wellorder.net,45.5201,-122.99
nostr.tagomago.me,42.3601,-71.0589
relay.internationalright-wing.org,-22.5022,-48.7114
relay.fckstate.net,59.3293,18.0686
nostr.azzamo.net,52.2633,21.0283
relay.0xchat.com,43.6532,-79.3832
relayone.geektank.ai,39.1008,-94.5811
relay.homeinhk.xyz,35.694,139.754
nostr.nodesmap.com,59.3327,18.0656
relay.islandbitcoin.com:443,12.8498,77.6545
relay.mrmave.work,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683
fanfares.nostr1.com:443,40.7057,-74.0136
wot.shaving.kiwi,43.6532,-79.3832
relay.nearhood.co.uk,51.5072,-0.127586
relay.fundstr.me,42.3601,-71.0589
syb.lol:443,43.6532,-79.3832
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
no.str.cr,10.6352,-85.4378
relay.jbnco.co,43.6532,-79.3832
ribo.nostria.app:443,43.6532,-79.3832
us-east.nostr.pikachat.org,39.0438,-77.4874
nexus.libernet.app,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
nostr.debate.report,50.1109,8.68213
str-define-contributing-jackets.trycloudflare.com,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
nostr.spicyz.io:443,43.6532,-79.3832
relay.layer.systems:443,49.0291,8.35695
nostr-relay.amethyst.name,39.0067,-77.4291
slick.mjex.me,39.0418,-77.4744
nostr.girino.org,43.6532,-79.3832
nostr.quali.chat:443,60.1699,24.9384
purplerelay.com:443,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
relay.plebeian.market:443,50.1109,8.68213
relay.samt.st,40.8302,-74.1299
relay.mitchelltribe.com:443,39.0438,-77.4874
nostr.0x7e.xyz,47.4949,8.71954
relayone.soundhsa.com:443,39.1008,-94.5811
nostr.thalheim.io:443,60.1699,24.9384
relay.getsafebox.app:443,43.6532,-79.3832
nostr-relay.psfoundation.info,39.0438,-77.4874
bucket.coracle.social,37.7775,-122.397
nostr.carroarmato0.be:443,50.914,3.21378
relay.staging.commonshub.brussels,49.4543,11.0746
relay-dev.satlantis.io,40.8302,-74.1299
relay.lacompagniemaximus.com:443,45.3147,-73.8785
relay-rpi.edufeed.org,49.4521,11.0767
nostr.computingcache.com:443,34.0356,-118.442
relay.lanavault.space:443,60.1699,24.9384
relay.getsafebox.app,43.6532,-79.3832
nrs-02.darkcloudarcade.com:443,39.9526,-75.1652
nostr.hekster.org,37.3986,-121.964
node.kommonzenze.de,49.4521,11.0767
0x-nostr-relay.fly.dev,37.7648,-122.432
speakeasy.cellar.social,49.4543,11.0746
nostr.0x7e.xyz:443,47.4949,8.71954
nostr.vulpem.com,49.4543,11.0746
relay5.bitransfer.org,43.6532,-79.3832
relay.inforsupports.com,43.6532,-79.3832
relay.plebeian.market,50.1109,8.68213
shu02.shugur.net,21.4902,39.2246
nostr.easycryptosend.it,43.6532,-79.3832
nostr.spaceshell.xyz,43.6532,-79.3832
relay.minibolt.info,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
nos.lol:443,50.4754,12.3683
nostr.thebiglake.org,32.71,-96.6745
relay.nostriot.com,41.5695,-83.9786
strfry.shock.network:443,39.0438,-77.4874
relay01.lnfi.network,35.6764,139.65
r.0kb.io:443,32.789,-96.7989
bcast.girino.org,43.6532,-79.3832
nostr-relay.psfoundation.info:443,39.0438,-77.4874
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
testnet.samt.st,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
nrs-02.darkcloudarcade.com,39.9526,-75.1652
relay.binaryrobot.com:443,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
schnorr.me,43.6532,-79.3832
nostr.twinkle.lol,51.902,7.6657
nostr.overmind.lol,43.6532,-79.3832
relay.mmwaves.de:443,48.8575,2.35138
relay2.veganostr.com,60.1699,24.9384
nostr.mom,50.4754,12.3683
nostr2.girino.org:443,43.6532,-79.3832
wot.sudocarlos.com,43.6532,-79.3832
dev.relay.stream,43.6532,-79.3832
nostr.thalheim.io,60.1699,24.9384
relay-dev.gulugulu.moe:443,43.6532,-79.3832
conduitl2.fly.dev,37.7648,-122.432
relay.bullishbounty.com,43.6532,-79.3832
myvoiceourstory.org,37.3598,-121.981
relay.angor.io,48.1046,11.6002
r.0kb.io,32.789,-96.7989
nexus.libernet.app:443,43.6532,-79.3832
us-east.nostr.pikachat.org:443,39.0438,-77.4874
nostr.bitcoiner.social,47.6743,-117.112
nostrelay.circum.space:443,52.6907,4.8181
relayone.soundhsa.com,39.1008,-94.5811
nostr.snowbla.de,60.1699,24.9384
relay1.orangesync.tech,44.7839,-106.941
nostr-2.21crypto.ch:443,47.5356,8.73209
espelho.girino.org,43.6532,-79.3832
nostr.blankfors.se,60.1699,24.9384
relay.sigit.io:443,50.4754,12.3683
relay.vrtmrz.net:443,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
nostrcity-club.fly.dev,37.7648,-122.432
nostrelay.circum.space,52.6907,4.8181
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
nostr.dlcdevkit.com:443,40.0992,-83.1141
x.kojira.io,43.6532,-79.3832
relay.binaryrobot.com,43.6532,-79.3832
relay.nostrhub.fr,48.1045,11.6004
nostr.sathoarder.com,48.5734,7.75211
prl.plus,55.7628,37.5983
relay.cosmicbolt.net:443,37.3986,-121.964
relay.nostu.be:443,40.4167,-3.70329
cs-relay.nostrdev.com,50.4754,12.3683
satsage.xyz,37.3986,-121.964
relay.lab.rytswd.com:443,49.4543,11.0746
rilo.nostria.app:443,43.6532,-79.3832
portal-relay.pareto.space,49.0291,8.35696
relay.wavlake.com:443,41.2619,-95.8608
relay.beginningend.com,35.2227,-97.4786
relay.openresist.com:443,43.6532,-79.3832
bitcoiner.social:443,47.6743,-117.112
relay.notoshi.win:443,13.3622,100.983
dev.relay.edufeed.org:443,49.4521,11.0767
relay.cypherflow.ai:443,48.8575,2.35138
relay.ru.ac.th,13.7607,100.627
shu03.shugur.net,25.2048,55.2708
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.agorist.space:443,52.3734,4.89406
relay02.lnfi.network,35.6764,139.65
relay-testnet.k8s.layer3.news:443,37.3387,-121.885
nostr.notribe.net:443,40.8302,-74.1299
relay.ditto.pub,43.6532,-79.3832
nostr.rikmeijer.nl,51.7111,5.36809
blossom.gnostr.cloud:443,43.6532,-79.3832
nostr.oxtr.dev,50.4754,12.3683
cache.trustr.ing,43.6548,-79.3885
nostr.bitczat.pl,60.1699,24.9384
relay.nostrverse.net,43.6532,-79.3832
shu04.shugur.net,25.2048,55.2708
eu.nostr.pikachat.org:443,49.4543,11.0746
ribo.us.nostria.app,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nostr.bitczat.pl:443,60.1699,24.9384
nostr-relay.corb.net:443,38.8353,-104.822
relay.libernet.app,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
relay.nostu.be,40.4167,-3.70329
rele.speyhard.fi,51.5072,-0.127586
relay.staging.plebeian.market,51.5072,-0.127586
nostr.spicyz.io,43.6532,-79.3832
nostrride.io,37.3986,-121.964
x.kojira.io:443,43.6532,-79.3832
relay.staging.plebeian.market:443,51.5072,-0.127586
relay.sigit.io,50.4754,12.3683
nostr.stakey.net:443,52.3676,4.90414
relay.nostr.blockhenge.com,39.0438,-77.4874
relay.comcomponent.com,43.6532,-79.3832
wot.nostr.place,43.6532,-79.3832
relay.mostr.pub:443,43.6532,-79.3832
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
no.str.cr:443,10.6352,-85.4378
relay.chorus.community:443,48.5333,10.7
relay.aarpia.com,37.3986,-121.964
relay.nostrmap.net,60.1699,24.9384
relay.snotr.nl:49999,52.0195,4.42946
relay.bebond.net,43.6532,-79.3832
relay.illuminodes.com,43.6532,-79.3832
chat-relay.zap-work.com:443,43.6532,-79.3832
testr.nymble.world,40.8054,-74.0241
relay.underorion.se,50.1109,8.68213
fanfares.nostr1.com,40.7057,-74.0136
relay.damus.io,43.6532,-79.3832
relay.nostriches.club,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
relay2.angor.io,48.1046,11.6002
relay.openfarmtools.org,60.1699,24.9384
wot.makenomistakes.ca,43.7064,-79.3986
relay.thecryptosquid.com,50.4754,12.3683
test.thedude.cloud,50.1109,8.68213
nostr.rtvslawenia.com,49.4543,11.0746
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.olas.app:443,60.1699,24.9384
strfry.bonsai.com,39.0438,-77.4874
relayrs.notoshi.win,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885
wot.utxo.one,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
relay.dwadziesciajeden.pl,52.2297,21.0122
soloco.nl,43.6532,-79.3832
armada.sharegap.net,43.6532,-79.3832
dm-test-strfry-generic.samt.st,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
nrs-01.darkcloudarcade.com,39.1008,-94.5811
relay.beginningend.com:443,35.2227,-97.4786
relay.nostr.place,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
adre.su,59.9311,30.3609
relay.0xchat.com:443,43.6532,-79.3832
nostr.infero.net,35.6764,139.65
relay.typedcypher.com:443,51.5072,-0.127586
relay.mostro.network:443,40.8302,-74.1299
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
relay.mitchelltribe.com,39.0438,-77.4874
relay.mostr.pub,43.6532,-79.3832
bitcoiner.social,47.6743,-117.112
nostr.tac.lol:443,47.4748,-122.273
nostr.hekster.org:443,37.3986,-121.964
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
relay.zone667.com:443,60.1699,24.9384
relay.jeffg.fyi:443,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
nostr.spaceshell.xyz:443,43.6532,-79.3832
top.testrelay.top:443,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
relay.nostrian-conquest.com:443,41.223,-111.974
relay.laantungir.net:443,-19.4692,-42.5315
relay.islandbitcoin.com,12.8498,77.6545
purplerelay.com,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
nostr.wecsats.io,43.6532,-79.3832
nostr.2b9t.xyz,34.0549,-118.243
nostr.quali.chat,60.1699,24.9384
relay.dreamith.to,43.6532,-79.3832
nostr.islandarea.net:443,35.4669,-97.6473
relay.primal.net,43.6532,-79.3832
eu.nostr.pikachat.org,49.4543,11.0746
relay.nostr.net,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
relay.mulatta.io,37.5665,126.978
nittom.nostr1.com,40.7057,-74.0136
relay2.orangesync.tech,40.7128,-74.006
relay.artx.market,43.6548,-79.3885
relay.wavefunc.live,41.8781,-87.6298
bitchat.nostr1.com,40.7057,-74.0136
relay.ditto.pub:443,43.6532,-79.3832
relay.wisp.talk,49.4543,11.0746
nostrelites.org,41.8781,-87.6298
relay.goodmorningbitcoin.com,43.6532,-79.3832
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.chorus.community,48.5333,10.7
relay.plebchain.club,43.6532,-79.3832
nostr-relay.amethyst.name:443,39.0067,-77.4291
relay.lanacoin-eternity.com,40.8302,-74.1299
relay.edufeed.org:443,49.4521,11.0767
relay.lacompagniemaximus.com,45.3147,-73.8785
relay.dreamith.to:443,43.6532,-79.3832
nostr.stakey.net,52.3676,4.90414
nostr.n7ekb.net:443,47.4941,-122.294
relay.dyne.org,49.0291,8.35705
nostr.janx.com,43.6532,-79.3832
relay.trustr.ing,43.6548,-79.3885
relay.paulstephenborile.com:443,49.4543,11.0746
relay.wisp.talk:443,49.4543,11.0746
yabu.me,35.6092,139.73
bcast.seutoba.com.br,43.6532,-79.3832
nos.xmark.cc,50.6924,3.20113
nos.lol,50.4754,12.3683
relay.satmaxt.xyz,43.6532,-79.3832
relay.endfiat.money:443,59.3327,18.0656
relay.tapestry.ninja,40.8054,-74.0241
relay.lab.rytswd.com,49.4543,11.0746 relay.lab.rytswd.com,49.4543,11.0746
nostr-kyomu-haskell.onrender.com,37.7775,-122.397 relay.paulstephenborile.com:443,49.4543,11.0746
relay.damus.io:443,43.6532,-79.3832 relay.binaryrobot.com,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138 nostr-2.21crypto.ch,47.5356,8.73209
spookstr2.nostr1.com:443,40.7057,-74.0136
fanfares.nostr1.com:443,40.7057,-74.0136
x.kojira.io,43.6532,-79.3832
freelay.sovbit.host,60.1699,24.9384
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
testnet.samt.st,43.6532,-79.3832
relay.angor.io,48.1046,11.6002
relay-arg.zombi.cloudrodion.com,1.35208,103.82
nostr-01.yakihonne.com,1.32123,103.695 nostr-01.yakihonne.com,1.32123,103.695
relay.nostriot.com:443,41.5695,-83.9786 nostr-relay.cbrx.io,43.6532,-79.3832
nostr-relay.nextblockvending.com,47.2343,-119.853 relay.guggero.org,46.5971,9.59652
nostr.aruku.ovh,1.27994,103.849 nostr.snowbla.de,60.1699,24.9384
nostr.chaima.info,50.1109,8.68213
ynostr.yael.at,60.1699,24.9384
ynostr.yael.at:443,60.1699,24.9384
nostr-01.yakihonne.com:443,1.32123,103.695
spookstr2.nostr1.com,40.7057,-74.0136
relay.trustr.ing:443,43.6548,-79.3885
dev.relay.edufeed.org,49.4521,11.0767
relay2.angor.io:443,48.1046,11.6002
nostr.azzamo.net:443,52.2633,21.0283
offchain.bostr.online,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384 relay.zone667.com,60.1699,24.9384
relay.veganostr.com,60.1699,24.9384 nexus.libernet.app:443,43.6532,-79.3832
vault.iris.to:443,43.6532,-79.3832 relay.islandbitcoin.com,12.8498,77.6545
relay.artx.market:443,43.6548,-79.3885 relay-testnet.k8s.layer3.news,37.3387,-121.885
wot.rejecttheframe.xyz,43.6532,-79.3832 nostr-relay.xbytez.io,50.6924,3.20113
nostr.pbfs.io,50.4754,12.3683 kasztanowa.bieda.it,43.6532,-79.3832
relay.mostro.network,40.8302,-74.1299 nostrcity-club.fly.dev,37.7648,-122.432
strfry.shock.network,39.0438,-77.4874 relay.typedcypher.com,51.5072,-0.127586
relay.klabo.world,47.2343,-119.853 nostr.na.social:443,43.6532,-79.3832
relay.fountain.fm:443,43.6532,-79.3832
nostrja-kari.heguro.com,43.6532,-79.3832
relaisnostr.trivaco.fr,48.5734,7.75211
offchain.pub,39.1585,-94.5728
relay.degmods.com,50.4754,12.3683
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.paulstephenborile.com,49.4543,11.0746
nittom.nostr1.com:443,40.7057,-74.0136
dev-relay.nostreon.com,60.1699,24.9384
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
relay.jeffg.fyi,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315 relay.laantungir.net,-19.4692,-42.5315
nostr.data.haus:443,50.4754,12.3683 relay-dev.satlantis.io:443,40.8302,-74.1299
wot.codingarena.top,50.4754,12.3683 rilo.nostria.app,43.6532,-79.3832
nostr.hekster.org:443,37.3986,-121.964
nostr-relay.amethyst.name:443,39.0067,-77.4291
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
syb.lol:443,43.6532,-79.3832
relay.sigit.io,50.4754,12.3683
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.wavefunc.live,41.8781,-87.6298
nostr.sathoarder.com,48.5734,7.75211
myvoiceourstory.org,37.3598,-121.981
relay.underorion.se,50.1109,8.68213
nostr.data.haus,50.4754,12.3683
relay.erybody.com,41.4513,-81.7021
espelho.girino.org,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
wot.dergigi.com,64.1476,-21.9392
nostr.bitcoiner.social:443,47.6743,-117.112
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
treuzkas.branruz.com,48.8575,2.35138
relay1.nostrchat.io,60.1699,24.9384
kotukonostr.onrender.com,37.7775,-122.397
nostr.plantroon.com,50.1013,8.62643
nostr.davenov.com,50.1109,8.68213
node.kommonzenze.de,49.4521,11.0767
relay2.veganostr.com,60.1699,24.9384
armada.sharegap.net,43.6532,-79.3832
wot.makenomistakes.ca,43.7064,-79.3986
nostr.2b9t.xyz:443,34.0549,-118.243 nostr.2b9t.xyz:443,34.0549,-118.243
relay.libernet.app:443,43.6532,-79.3832 relay.libernet.app:443,43.6532,-79.3832
nostr.ps1829.com,33.8851,130.883 relay.dreamith.to:443,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392 relay.lightning.pub:443,39.0438,-77.4874
relay.olas.app,60.1699,24.9384 nostr.rtvslawenia.com,49.4543,11.0746
relay.lanacoin-eternity.com:443,40.8302,-74.1299
nostr.data.haus,50.4754,12.3683
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.ohstr.com,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
relay.guggero.org,46.5971,9.59652
testnet-relay.samt.st:443,40.8302,-74.1299
thecitadel.nostr1.com,40.7057,-74.0136
nostr.ps1829.com:443,33.8851,130.883
nostr-relay.corb.net,38.8353,-104.822
relay.npubhaus.com,43.6532,-79.3832
nostr.21crypto.ch,47.5356,8.73209 nostr.21crypto.ch,47.5356,8.73209
nostr.tadryanom.me,43.6532,-79.3832 relay.ditto.pub:443,43.6532,-79.3832
nostr.myshosholoza.co.za,52.3913,4.66545 relay.plebchain.club,43.6532,-79.3832
relay.bitmacro.cloud,43.6532,-79.3832 memlay.v0l.io,53.3498,-6.26031
ribo.nostria.app,43.6532,-79.3832
relay.vrtmrz.net,43.6532,-79.3832
syb.lol,43.6532,-79.3832
relay.bebond.net:443,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
nostr-2.21crypto.ch,47.5356,8.73209
nostrcity-club.fly.dev:443,37.7648,-122.432
articles.layer3.news:443,37.3387,-121.885
relay.internationalright-wing.org:443,-22.5022,-48.7114
nostr-relay.zimage.com,34.0549,-118.243
chat-relay.zap-work.com,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
nostr.liberty.fans,36.9104,-89.5875
spookstr2.nostr1.com:443,40.7057,-74.0136
nostr.chaima.info:443,50.1109,8.68213 nostr.chaima.info:443,50.1109,8.68213
schnorr.me:443,43.6532,-79.3832 relay.wavlake.com:443,41.2619,-95.8608
ribo.eu.nostria.app,43.6532,-79.3832 nostr.thalheim.io:443,60.1699,24.9384
nostr.bond,50.1109,8.68213 relay.lightning.pub,39.0438,-77.4874
wot.nostr.party,36.1659,-86.7844 dev.relay.edufeed.org:443,49.4521,11.0767
temp.iris.to,43.6532,-79.3832 nostr.myshosholoza.co.za:443,52.3913,4.66545
relay.lotek-distro.com,43.6532,-79.3832 relay.binaryrobot.com:443,43.6532,-79.3832
relay.minibolt.info:443,43.6532,-79.3832 wot.nostr.place,43.6532,-79.3832
relay.lanavault.space,60.1699,24.9384
relay.mmwaves.de,48.8575,2.35138
social.amanah.eblessing.co,48.1046,11.6002
nostr2.thalheim.io,49.4543,11.0746
relay.typedcypher.com,51.5072,-0.127586
relay.cosmicbolt.net,37.3986,-121.964
relayrs.notoshi.win:443,43.6532,-79.3832
nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
relay.nostrmap.net:443,60.1699,24.9384
relay.bullishbounty.com:443,43.6532,-79.3832
nostr.oxtr.dev:443,50.4754,12.3683
srtrelay.c-stellar.net,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
vault.iris.to,43.6532,-79.3832
relay.mccormick.cx:443,52.3563,4.95714
relay.toastr.net,40.8054,-74.0241
nostr.hifish.org,47.4244,8.57658
speakeasy.cellar.social:443,49.4543,11.0746
relay.wavlake.com,41.2619,-95.8608
testnet-relay.samt.st,40.8302,-74.1299
aeon.libretechsystems.xyz,55.486,9.86577
mostro-p2p.tech,50.1109,8.68213
nostr.wild-vibes.ts.net,48.8566,2.35222
nostr.88mph.life,52.1941,-2.21905
relay.nostrcheck.me,43.6532,-79.3832
rilo.nostria.app,43.6532,-79.3832
relay.bowlafterbowl.com,32.9483,-96.7299
relay.nostr.place:443,43.6532,-79.3832
nostr.mom:443,50.4754,12.3683
relay.nostrian-conquest.com,41.223,-111.974
herbstmeister.com,34.0549,-118.243
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
nostr.red5d.dev,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.sharegap.net,43.6532,-79.3832
reraw.pbla2fish.cc,43.6532,-79.3832
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.sathoarder.com:443,48.5734,7.75211 nostr.sathoarder.com:443,48.5734,7.75211
relay.veganostr.com:443,60.1699,24.9384 thecitadel.nostr1.com,40.7057,-74.0136
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 relay.artx.market,43.6548,-79.3885
nos.lol,50.4754,12.3683
nostr.plantroon.com:443,50.1013,8.62643
premium.primal.net,43.6532,-79.3832 premium.primal.net,43.6532,-79.3832
relay.wavefunc.live:443,41.8781,-87.6298 nas01xanthosnet.synology.me:7778,47.1285,8.74735
nostrja-kari.heguro.com,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
mostro-p2p.tech,50.1109,8.68213
wot.shaving.kiwi,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589
nostrelay.circum.space:443,52.6907,4.8181
relay.nostrdice.com,-33.8688,151.209
relay.getvia.xyz,60.1699,24.9384
strfry.shock.network:443,39.0438,-77.4874
relay.nostrmap.net:443,60.1699,24.9384
relay.nearhood.co.uk,51.5072,-0.127586
no.str.cr,10.6352,-85.4378
relay.getsafebox.app:443,43.6532,-79.3832
relay0.gfcom.info,13.6992,100.694
nostr.ps1829.com,33.8851,130.883
relay2.angor.io,48.1046,11.6002
relay.stickeroo.is-cool.dev,37.3387,-121.885
ricardo-oem.tailb5546.ts.net,40.7128,-74.006
relay.typedcypher.com:443,51.5072,-0.127586
relay.paulstephenborile.com,49.4543,11.0746
nittom.nostr1.com,40.7057,-74.0136
conduitl2.fly.dev,37.7648,-122.432
nostr.rikmeijer.nl,51.7111,5.36809
relay.thecryptosquid.com,50.4754,12.3683
spookstr2.nostr1.com,40.7057,-74.0136
offchain.bostr.online,43.6532,-79.3832
nostr.planix.org,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
0x-nostr-relay.fly.dev,37.7648,-122.432
nostr.wecsats.io,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
relay.nostrhub.fr,48.1045,11.6004
blossom.gnostr.cloud:443,43.6532,-79.3832
nostr-02.yakihonne.com:443,1.32123,103.695
dev.relay.stream,43.6532,-79.3832
ithurtswhenip.ee,51.5072,-0.127586
nostr.myshosholoza.co.za,52.3913,4.66545
relayrs.notoshi.win:443,43.6532,-79.3832
relay-rpi.edufeed.org:443,49.4521,11.0767 relay-rpi.edufeed.org:443,49.4521,11.0767
kotukonostr.onrender.com,37.7775,-122.397 relay.olas.app:443,60.1699,24.9384
bridge.tagomago.me,42.3601,-71.0589 nostr.unkn0wn.world,46.8499,9.53287
nostr.tadryanom.me:443,43.6532,-79.3832 relay.mitchelltribe.com,39.0438,-77.4874
relay.gulugulu.moe:443,43.6532,-79.3832 yabu.me,35.6092,139.73
nostr.nodesmap.com,59.3327,18.0656
dm-test-strfry-generic.samt.st,43.6532,-79.3832
nostr2.girino.org:443,43.6532,-79.3832
wot.brightbolt.net,47.6735,-116.781
strfry.shock.network,39.0438,-77.4874
relay.kilombino.com,43.6532,-79.3832
relay.nostr.blockhenge.com,39.0438,-77.4874
shu04.shugur.net,25.2048,55.2708
relay-rpi.edufeed.org,49.4521,11.0767
relay.bullishbounty.com:443,43.6532,-79.3832
vault.iris.to:443,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
offchain.pub:443,39.1585,-94.5728 offchain.pub:443,39.1585,-94.5728
soloco.nl,43.6532,-79.3832
relay.nostu.be,40.4167,-3.70329
nostr.pbfs.io,50.4754,12.3683
relay.directsponsor.net,42.8864,-78.8784 relay.directsponsor.net,42.8864,-78.8784
relay.decentralia.fr,49.4282,10.9796
relayrs.notoshi.win,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
relay.arx-ccn.com,50.4754,12.3683
nostr.spaceshell.xyz,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
rilo.nostria.app:443,43.6532,-79.3832
relay.trotters.cc:443,43.6532,-79.3832
nostr.overmind.lol:443,43.6532,-79.3832
nostr.girino.org:443,43.6532,-79.3832
bitsat.molonlabe.holdings,51.4012,-1.3147
nostr.azzamo.net,52.2633,21.0283
insta-relay.apps3.slidestr.net,40.4167,-3.70329
bridge.tagomago.me,42.3601,-71.0589
nostr.thalheim.io,60.1699,24.9384
relay.artx.market:443,43.6548,-79.3885
nostr.openhoofd.nl,51.5717,3.70417
nostr.bond,50.1109,8.68213
relay.earthly.city,34.1749,-118.54
nexus.libernet.app,43.6532,-79.3832
relay.plebeian.market,50.1109,8.68213
relay.nostr.net,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
relay.ohstr.com,43.6532,-79.3832
testnet-relay.samt.st:443,40.8302,-74.1299
relay01.lnfi.network,35.6764,139.65
relay.mostr.pub:443,43.6532,-79.3832
wot.nostr.party,36.1659,-86.7844
relayone.soundhsa.com,39.1008,-94.5811
relay.mostro.network,40.8302,-74.1299
ribo.eu.nostria.app,43.6532,-79.3832
chat-relay.zap-work.com,43.6532,-79.3832
relay.nostreon.com,60.1699,24.9384
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
nostr.quali.chat:443,60.1699,24.9384
relay.internationalright-wing.org:443,-22.5022,-48.7114
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.satlantis.io,40.8054,-74.0241
nittom.nostr1.com:443,40.7057,-74.0136
nostr.janx.com,43.6532,-79.3832
nostr.carroarmato0.be:443,50.914,3.21378
relay.mmwaves.de:443,48.8575,2.35138
relay.chorus.community:443,48.5333,10.7
wot.utxo.one,43.6532,-79.3832
relay.plebeian.market:443,50.1109,8.68213
relay.cosmicbolt.net,37.3986,-121.964
x.kojira.io:443,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
dev.relay.edufeed.org,49.4521,11.0767
relayone.geektank.ai:443,39.1008,-94.5811
relay.nostar.org,43.6532,-79.3832
nostr.oxtr.dev:443,50.4754,12.3683
nostr.88mph.life,52.1941,-2.21905
relay.staging.commonshub.brussels,49.4543,11.0746
weboftrust.libretechsystems.xyz,55.4724,9.87335
relay.openfarmtools.org,60.1699,24.9384
cs-relay.nostrdev.com,50.4754,12.3683
relay.inforsupports.com,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
nostr.hekster.org,37.3986,-121.964
relay.gulugulu.moe:443,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
nostrcity-club.fly.dev:443,37.7648,-122.432
relay.vrtmrz.net:443,43.6532,-79.3832
relay.nostr.place,43.6532,-79.3832
relay.wavefunc.live:443,41.8781,-87.6298
nostr.islandarea.net,35.4669,-97.6473
purplerelay.com:443,43.6532,-79.3832
nostr-relay.psfoundation.info:443,39.0438,-77.4874
r.0kb.io,32.789,-96.7989
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.mulatta.io,37.5665,126.978
strfry.bonsai.com:443,39.0438,-77.4874
bendernostur.duckdns.org:8443,50.1109,8.68213
vault.iris.to,43.6532,-79.3832
ec2.f7z.io,60.1699,24.9384
nostr.debate.report,50.1109,8.68213
wot.codingarena.top,50.4754,12.3683
relay.layer.systems:443,49.0291,8.35695
relay.degmods.com,50.4754,12.3683
nostr.mom,50.4754,12.3683
ribo.us.nostria.app:443,43.6532,-79.3832
adre.su,59.9311,30.3609
wot.sudocarlos.com,43.6532,-79.3832
relay.nostrian-conquest.com,41.223,-111.974
nostr-relay.nextblockvending.com,47.2343,-119.853
relay.endfiat.money:443,59.3327,18.0656
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
nostr.carroarmato0.be,50.914,3.21378
relay.cypherflow.ai:443,48.8575,2.35138
nostr.girino.org,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
strfry.ymir.cloud,43.6532,-79.3832
relay.mypathtofire.de,42.8864,-78.8784
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr.snowbla.de:443,60.1699,24.9384 nostr.snowbla.de:443,60.1699,24.9384
relay.ditto.pub,43.6532,-79.3832
relay.damus.io,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
nrs-01.darkcloudarcade.com,39.1008,-94.5811
testnet-relay.samt.st,40.8302,-74.1299
antiprimal.net,43.6532,-79.3832
bitchat.nostr1.com,40.7057,-74.0136
relay.snort.social,53.3498,-6.26031
relay.mccormick.cx:443,52.3563,4.95714
relay02.lnfi.network,35.6764,139.65
srtrelay.c-stellar.net,43.6532,-79.3832
relay.minibolt.info,43.6532,-79.3832
nostrride.io,37.3986,-121.964
articles.layer3.news:443,37.3387,-121.885
rele.speyhard.fi,51.5072,-0.127586
relay.aarpia.com,37.3986,-121.964
nostr.chaima.info,50.1109,8.68213
relay.wisp.talk:443,49.4543,11.0746
relay.agorist.space:443,52.3734,4.89406
strfry.bonsai.com,39.0438,-77.4874
nostr.hifish.org,47.4244,8.57658
offchain.pub,39.1585,-94.5728
nostr.spicyz.io:443,43.6532,-79.3832
relay.beginningend.com,35.2227,-97.4786
relay.sharegap.net,43.6532,-79.3832
nostr.purpura.cloud,43.6532,-79.3832
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
relay.fountain.fm:443,43.6532,-79.3832
relay.olas.app,60.1699,24.9384
relay.mmwaves.de,48.8575,2.35138
relay.openresist.com:443,43.6532,-79.3832
relay.homeinhk.xyz,35.694,139.754
relay.libernet.app,43.6532,-79.3832
relay.comcomponent.com,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.nostriot.com:443,41.5695,-83.9786
bcast.girino.org,43.6532,-79.3832
nostr.azzamo.net:443,52.2633,21.0283
relay.islandbitcoin.com:443,12.8498,77.6545
pool.libernet.app,43.6532,-79.3832
test.thedude.cloud,50.1109,8.68213
nostrelites.org,41.8781,-87.6298
nostr.infero.net,35.6764,139.65
relay.primal.net,43.6532,-79.3832
ribo.nostria.app,43.6532,-79.3832
relay.chorus.community,48.5333,10.7
bitcoiner.social:443,47.6743,-117.112
relay.wisp.talk,49.4543,11.0746
relay.layer.systems,49.0291,8.35695
relay-dev.satlantis.io,40.8302,-74.1299
nostr.bitcoiner.social,47.6743,-117.112
relay.lanavault.space:443,60.1699,24.9384
relay.staging.plebeian.market,51.5072,-0.127586
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
relay.fountain.fm,43.6532,-79.3832
nostr.middling.mydns.jp,35.8099,140.12
relay.dreamith.to,43.6532,-79.3832
relay.satmaxt.xyz:443,43.6532,-79.3832
shu03.shugur.net,25.2048,55.2708
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
ribo.us.nostria.app,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
nostr.hifish.org:443,47.4244,8.57658
nostr.vulpem.com,49.4543,11.0746
relay.cosmicbolt.net:443,37.3986,-121.964
nostr-02.yakihonne.com,1.32123,103.695
r.0kb.io:443,32.789,-96.7989
nostr-relay.corb.net,38.8353,-104.822
ribo.eu.nostria.app:443,43.6532,-79.3832
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.wellorder.net,45.5201,-122.99
relay.novospes.com,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
relay.endfiat.money,59.3327,18.0656
relay.angor.io:443,48.1046,11.6002
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
strfry.openhoofd.nl,51.5717,3.70417
relay.getsafebox.app,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
relay5.bitransfer.org,43.6532,-79.3832
nostr.na.social,43.6532,-79.3832
portal-relay.pareto.space,49.0291,8.35696
nostr.notribe.net:443,40.8302,-74.1299
relay.bitmacro.cloud,43.6532,-79.3832
no.str.cr:443,10.6352,-85.4378
relay.klabo.world,47.2343,-119.853
nostr.notribe.net,40.8302,-74.1299
relay.staging.plebeian.market:443,51.5072,-0.127586
relay.nostrmap.net,60.1699,24.9384
temp.iris.to,43.6532,-79.3832
nostr.sovereignservices.xyz,43.6532,-79.3832
nostr.liberty.fans,36.9104,-89.5875
relay.nostrian-conquest.com:443,41.223,-111.974
relay.nostriot.com,41.5695,-83.9786
nostrbtc.com,43.6532,-79.3832
shu02.shugur.net,21.4902,39.2246
relay.kalcafe.xyz,37.3986,-121.964
relay.illuminodes.com,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
nostr.ps1829.com:443,33.8851,130.883
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
nostr.dlcdevkit.com:443,40.0992,-83.1141
nostr.mom:443,50.4754,12.3683
ribo.nostria.app:443,43.6532,-79.3832
nostr.2b9t.xyz,34.0549,-118.243
nostr.data.haus:443,50.4754,12.3683
staging.yabu.me,35.6092,139.73
relay.sigit.io:443,50.4754,12.3683
relay.edufeed.org:443,49.4521,11.0767
nostr-01.yakihonne.com:443,1.32123,103.695
reraw.pbla2fish.cc,43.6532,-79.3832
cs-relay.nostrdev.com:443,50.4754,12.3683
herbstmeister.com,34.0549,-118.243
relay.minibolt.info:443,43.6532,-79.3832
relay2.angor.io:443,48.1046,11.6002
social.amanah.eblessing.co,48.1046,11.6002
nostr.stakey.net,52.3676,4.90414
nostr.computingcache.com:443,34.0356,-118.442
slick.mjex.me,39.0418,-77.4744
fanfares.nostr1.com,40.7057,-74.0136
bitcoinostr.duckdns.org,43.3434,-3.99532
nostr.oxtr.dev,50.4754,12.3683
cache.trustr.ing,43.6548,-79.3885
purplerelay.com,43.6532,-79.3832
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr-relay.corb.net:443,38.8353,-104.822
relay-dev.gulugulu.moe,43.6532,-79.3832
prl.plus,55.7628,37.5983
nostr.tac.lol:443,47.4748,-122.273
relay.mostr.pub,43.6532,-79.3832
schnorr.me:443,43.6532,-79.3832
dev-relay.nostreon.com,60.1699,24.9384
nostr.islandarea.net:443,35.4669,-97.6473
bucket.coracle.social,37.7775,-122.397
blossom.gnostr.cloud,43.6532,-79.3832
relay.solife.me,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
relay.vrtmrz.net,43.6532,-79.3832
relay-dev.gulugulu.moe:443,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
relay.fckstate.net,59.3293,18.0686
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.nostx.io,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
relay.notoshi.win,13.7829,100.546
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.trotters.cc,43.6532,-79.3832
relay.lanavault.space,60.1699,24.9384
public.crostr.com:443,43.6532,-79.3832
nostr.stakey.net:443,52.3676,4.90414
relay.nostr.place:443,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
nostr.aruku.ovh,1.27994,103.849
satsage.xyz,37.3986,-121.964
strfry.apps3.slidestr.net,40.4167,-3.70329
nostr2.girino.org,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
articles.layer3.news,37.3387,-121.885
aeon.libretechsystems.xyz,55.486,9.86577
relay.routstr.com,59.4016,17.9455
relay.ohstr.com:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
strfry.openhoofd.nl:443,51.5717,3.70417
nostr.blankfors.se,60.1699,24.9384
nostr-2.21crypto.ch:443,47.5356,8.73209
relayone.soundhsa.com:443,39.1008,-94.5811
relay.lab.rytswd.com:443,49.4543,11.0746
nostr.tagomago.me,42.3601,-71.0589
relay.0xchat.com:443,43.6532,-79.3832
1 Relay URL Latitude Longitude
nostr.bitcoiner.social:443 47.6743 -117.112
relay-dev.satlantis.io:443 40.8302 -74.1299
relay.lightning.pub:443 39.0438 -77.4874
ribo.us.nostria.app:443 43.6532 -79.3832
relay.notoshi.win 13.3622 100.983
openrelay.ziomc.com 50.0755 14.4378
relay.agentry.com:443 42.8864 -78.8784
nostr-relay.xbytez.io 50.6924 3.20113
relay.gulugulu.moe 43.6532 -79.3832
nostr.thebiglake.org:443 32.71 -96.6745
relay.fountain.fm 43.6532 -79.3832
freelay.sovbit.host 60.1699 24.9384
nostr.girino.org:443 43.6532 -79.3832
relay.openresist.com 43.6532 -79.3832
nas01xanthosnet.synology.me:7778 47.1285 8.74735
relay.ohstr.com:443 43.6532 -79.3832
nostr-pub.wellorder.net 45.5201 -122.99
relay.nostrdice.com -33.8688 151.209
bbw-nostr.xyz 41.5284 -87.4237
cs-relay.nostrdev.com:443 50.4754 12.3683
top.testrelay.top 43.6532 -79.3832
relay.trotters.cc 43.6532 -79.3832
blossom.gnostr.cloud 43.6532 -79.3832
ribo.eu.nostria.app:443 43.6532 -79.3832
public.crostr.com 43.6532 -79.3832
relay.nostar.org 43.6532 -79.3832
strfry.bonsai.com:443 39.0438 -77.4874
nostr.carroarmato0.be 50.914 3.21378
relay.endfiat.money 59.3327 18.0656
nostr.overmind.lol:443 43.6532 -79.3832
nostr.myshosholoza.co.za:443 52.3913 4.66545
relay.wellorder.net 45.5201 -122.99
nostr.tagomago.me 42.3601 -71.0589
relay.internationalright-wing.org -22.5022 -48.7114
relay.fckstate.net 59.3293 18.0686
nostr.azzamo.net 52.2633 21.0283
relay.0xchat.com 43.6532 -79.3832
relayone.geektank.ai 39.1008 -94.5811
relay.homeinhk.xyz 35.694 139.754
nostr.nodesmap.com 59.3327 18.0656
relay.islandbitcoin.com:443 12.8498 77.6545
relay.mrmave.work 43.6532 -79.3832
relay.arx-ccn.com 50.4754 12.3683
fanfares.nostr1.com:443 40.7057 -74.0136
wot.shaving.kiwi 43.6532 -79.3832
relay.nearhood.co.uk 51.5072 -0.127586
relay.fundstr.me 42.3601 -71.0589
syb.lol:443 43.6532 -79.3832
dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
relay.nostx.io 43.6532 -79.3832
no.str.cr 10.6352 -85.4378
relay.jbnco.co 43.6532 -79.3832
ribo.nostria.app:443 43.6532 -79.3832
us-east.nostr.pikachat.org 39.0438 -77.4874
nexus.libernet.app 43.6532 -79.3832
nostr.dlcdevkit.com 40.0992 -83.1141
nostr.debate.report 50.1109 8.68213
str-define-contributing-jackets.trycloudflare.com 43.6532 -79.3832
nostr2.girino.org 43.6532 -79.3832
nostr.unkn0wn.world 46.8499 9.53287
nostr.spicyz.io:443 43.6532 -79.3832
relay.layer.systems:443 49.0291 8.35695
nostr-relay.amethyst.name 39.0067 -77.4291
slick.mjex.me 39.0418 -77.4744
nostr.girino.org 43.6532 -79.3832
nostr.quali.chat:443 60.1699 24.9384
purplerelay.com:443 43.6532 -79.3832
nostr.notribe.net 40.8302 -74.1299
relay.plebeian.market:443 50.1109 8.68213
relay.samt.st 40.8302 -74.1299
relay.mitchelltribe.com:443 39.0438 -77.4874
nostr.0x7e.xyz 47.4949 8.71954
relayone.soundhsa.com:443 39.1008 -94.5811
nostr.thalheim.io:443 60.1699 24.9384
relay.getsafebox.app:443 43.6532 -79.3832
nostr-relay.psfoundation.info 39.0438 -77.4874
bucket.coracle.social 37.7775 -122.397
nostr.carroarmato0.be:443 50.914 3.21378
relay.staging.commonshub.brussels 49.4543 11.0746
relay-dev.satlantis.io 40.8302 -74.1299
relay.lacompagniemaximus.com:443 45.3147 -73.8785
relay-rpi.edufeed.org 49.4521 11.0767
nostr.computingcache.com:443 34.0356 -118.442
relay.lanavault.space:443 60.1699 24.9384
relay.getsafebox.app 43.6532 -79.3832
nrs-02.darkcloudarcade.com:443 39.9526 -75.1652
nostr.hekster.org 37.3986 -121.964
node.kommonzenze.de 49.4521 11.0767
0x-nostr-relay.fly.dev 37.7648 -122.432
speakeasy.cellar.social 49.4543 11.0746
nostr.0x7e.xyz:443 47.4949 8.71954
nostr.vulpem.com 49.4543 11.0746
relay5.bitransfer.org 43.6532 -79.3832
relay.inforsupports.com 43.6532 -79.3832
relay.plebeian.market 50.1109 8.68213
shu02.shugur.net 21.4902 39.2246
nostr.easycryptosend.it 43.6532 -79.3832
nostr.spaceshell.xyz 43.6532 -79.3832
relay.minibolt.info 43.6532 -79.3832
nostr.pbfs.io:443 50.4754 12.3683
nos.lol:443 50.4754 12.3683
nostr.thebiglake.org 32.71 -96.6745
relay.nostriot.com 41.5695 -83.9786
strfry.shock.network:443 39.0438 -77.4874
relay01.lnfi.network 35.6764 139.65
r.0kb.io:443 32.789 -96.7989
bcast.girino.org 43.6532 -79.3832
nostr-relay.psfoundation.info:443 39.0438 -77.4874
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
testnet.samt.st 43.6532 -79.3832
relay.bornheimer.app 51.5072 -0.127586
nrs-02.darkcloudarcade.com 39.9526 -75.1652
relay.binaryrobot.com:443 43.6532 -79.3832
nostr.computingcache.com 34.0356 -118.442
nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
schnorr.me 43.6532 -79.3832
nostr.twinkle.lol 51.902 7.6657
nostr.overmind.lol 43.6532 -79.3832
relay.mmwaves.de:443 48.8575 2.35138
relay2.veganostr.com 60.1699 24.9384
nostr.mom 50.4754 12.3683
nostr2.girino.org:443 43.6532 -79.3832
wot.sudocarlos.com 43.6532 -79.3832
dev.relay.stream 43.6532 -79.3832
nostr.thalheim.io 60.1699 24.9384
relay-dev.gulugulu.moe:443 43.6532 -79.3832
conduitl2.fly.dev 37.7648 -122.432
relay.bullishbounty.com 43.6532 -79.3832
myvoiceourstory.org 37.3598 -121.981
relay.angor.io 48.1046 11.6002
r.0kb.io 32.789 -96.7989
nexus.libernet.app:443 43.6532 -79.3832
us-east.nostr.pikachat.org:443 39.0438 -77.4874
nostr.bitcoiner.social 47.6743 -117.112
nostrelay.circum.space:443 52.6907 4.8181
relayone.soundhsa.com 39.1008 -94.5811
nostr.snowbla.de 60.1699 24.9384
relay1.orangesync.tech 44.7839 -106.941
nostr-2.21crypto.ch:443 47.5356 8.73209
espelho.girino.org 43.6532 -79.3832
nostr.blankfors.se 60.1699 24.9384
relay.sigit.io:443 50.4754 12.3683
relay.vrtmrz.net:443 43.6532 -79.3832
nostr.wecsats.io:443 43.6532 -79.3832
nostrcity-club.fly.dev 37.7648 -122.432
nostrelay.circum.space 52.6907 4.8181
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
relay.edufeed.org 49.4521 11.0767
nostr.dlcdevkit.com:443 40.0992 -83.1141
x.kojira.io 43.6532 -79.3832
relay.binaryrobot.com 43.6532 -79.3832
relay.nostrhub.fr 48.1045 11.6004
nostr.sathoarder.com 48.5734 7.75211
prl.plus 55.7628 37.5983
relay.cosmicbolt.net:443 37.3986 -121.964
relay.nostu.be:443 40.4167 -3.70329
cs-relay.nostrdev.com 50.4754 12.3683
satsage.xyz 37.3986 -121.964
relay.lab.rytswd.com:443 49.4543 11.0746
rilo.nostria.app:443 43.6532 -79.3832
portal-relay.pareto.space 49.0291 8.35696
relay.wavlake.com:443 41.2619 -95.8608
relay.beginningend.com 35.2227 -97.4786
relay.openresist.com:443 43.6532 -79.3832
bitcoiner.social:443 47.6743 -117.112
relay.notoshi.win:443 13.3622 100.983
dev.relay.edufeed.org:443 49.4521 11.0767
relay.cypherflow.ai:443 48.8575 2.35138
relay.ru.ac.th 13.7607 100.627
shu03.shugur.net 25.2048 55.2708
nostr.rtvslawenia.com:443 49.4543 11.0746
relay.agorist.space:443 52.3734 4.89406
relay02.lnfi.network 35.6764 139.65
relay-testnet.k8s.layer3.news:443 37.3387 -121.885
nostr.notribe.net:443 40.8302 -74.1299
relay.ditto.pub 43.6532 -79.3832
nostr.rikmeijer.nl 51.7111 5.36809
blossom.gnostr.cloud:443 43.6532 -79.3832
nostr.oxtr.dev 50.4754 12.3683
cache.trustr.ing 43.6548 -79.3885
nostr.bitczat.pl 60.1699 24.9384
relay.nostrverse.net 43.6532 -79.3832
shu04.shugur.net 25.2048 55.2708
eu.nostr.pikachat.org:443 49.4543 11.0746
ribo.us.nostria.app 43.6532 -79.3832
nostr-relay.cbrx.io 43.6532 -79.3832
nostr.bitczat.pl:443 60.1699 24.9384
nostr-relay.corb.net:443 38.8353 -104.822
relay.libernet.app 43.6532 -79.3832
nostr-verified.wellorder.net 45.5201 -122.99
relay.nostu.be 40.4167 -3.70329
rele.speyhard.fi 51.5072 -0.127586
relay.staging.plebeian.market 51.5072 -0.127586
nostr.spicyz.io 43.6532 -79.3832
nostrride.io 37.3986 -121.964
x.kojira.io:443 43.6532 -79.3832
relay.staging.plebeian.market:443 51.5072 -0.127586
relay.sigit.io 50.4754 12.3683
nostr.stakey.net:443 52.3676 4.90414
relay.nostr.blockhenge.com 39.0438 -77.4874
relay.comcomponent.com 43.6532 -79.3832
wot.nostr.place 43.6532 -79.3832
relay.mostr.pub:443 43.6532 -79.3832
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
no.str.cr:443 10.6352 -85.4378
relay.chorus.community:443 48.5333 10.7
relay.aarpia.com 37.3986 -121.964
relay.nostrmap.net 60.1699 24.9384
relay.snotr.nl:49999 52.0195 4.42946
relay.bebond.net 43.6532 -79.3832
relay.illuminodes.com 43.6532 -79.3832
chat-relay.zap-work.com:443 43.6532 -79.3832
testr.nymble.world 40.8054 -74.0241
relay.underorion.se 50.1109 8.68213
fanfares.nostr1.com 40.7057 -74.0136
relay.damus.io 43.6532 -79.3832
relay.nostriches.club 43.6532 -79.3832
nostr-dev.wellorder.net 45.5201 -122.99
relay2.angor.io 48.1046 11.6002
relay.openfarmtools.org 60.1699 24.9384
wot.makenomistakes.ca 43.7064 -79.3986
relay.thecryptosquid.com 50.4754 12.3683
test.thedude.cloud 50.1109 8.68213
nostr.rtvslawenia.com 49.4543 11.0746
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay.olas.app:443 60.1699 24.9384
strfry.bonsai.com 39.0438 -77.4874
relayrs.notoshi.win 43.6532 -79.3832
articles.layer3.news 37.3387 -121.885
wot.utxo.one 43.6532 -79.3832
relay.angor.io:443 48.1046 11.6002
relay.dwadziesciajeden.pl 52.2297 21.0122
soloco.nl 43.6532 -79.3832
armada.sharegap.net 43.6532 -79.3832
dm-test-strfry-generic.samt.st 43.6532 -79.3832
nostr.4rs.nl 49.0291 8.35696
nrs-01.darkcloudarcade.com 39.1008 -94.5811
relay.beginningend.com:443 35.2227 -97.4786
relay.nostr.place 43.6532 -79.3832
relay.layer.systems 49.0291 8.35695
adre.su 59.9311 30.3609
relay.0xchat.com:443 43.6532 -79.3832
nostr.infero.net 35.6764 139.65
relay.typedcypher.com:443 51.5072 -0.127586
relay.mostro.network:443 40.8302 -74.1299
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
relay.mitchelltribe.com 39.0438 -77.4874
relay.mostr.pub 43.6532 -79.3832
bitcoiner.social 47.6743 -117.112
nostr.tac.lol:443 47.4748 -122.273
nostr.hekster.org:443 37.3986 -121.964
relay.satmaxt.xyz:443 43.6532 -79.3832
relay.cypherflow.ai 48.8575 2.35138
relay.zone667.com:443 60.1699 24.9384
relay.jeffg.fyi:443 43.6532 -79.3832
relay.agorist.space 52.3734 4.89406
nostr.spaceshell.xyz:443 43.6532 -79.3832
top.testrelay.top:443 43.6532 -79.3832
insta-relay.apps3.slidestr.net 40.4167 -3.70329
relay.nostrian-conquest.com:443 41.223 -111.974
relay.laantungir.net:443 -19.4692 -42.5315
relay.islandbitcoin.com 12.8498 77.6545
purplerelay.com 43.6532 -79.3832
nostr.tac.lol 47.4748 -122.273
nostr.wecsats.io 43.6532 -79.3832
nostr.2b9t.xyz 34.0549 -118.243
nostr.quali.chat 60.1699 24.9384
relay.dreamith.to 43.6532 -79.3832
nostr.islandarea.net:443 35.4669 -97.6473
relay.primal.net 43.6532 -79.3832
eu.nostr.pikachat.org 49.4543 11.0746
relay.nostr.net 43.6532 -79.3832
nostr.n7ekb.net 47.4941 -122.294
relay.mulatta.io 37.5665 126.978
nittom.nostr1.com 40.7057 -74.0136
relay2.orangesync.tech 40.7128 -74.006
relay.artx.market 43.6548 -79.3885
relay.wavefunc.live 41.8781 -87.6298
bitchat.nostr1.com 40.7057 -74.0136
relay.ditto.pub:443 43.6532 -79.3832
relay.wisp.talk 49.4543 11.0746
nostrelites.org 41.8781 -87.6298
relay.goodmorningbitcoin.com 43.6532 -79.3832
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
relay.chorus.community 48.5333 10.7
relay.plebchain.club 43.6532 -79.3832
nostr-relay.amethyst.name:443 39.0067 -77.4291
relay.lanacoin-eternity.com 40.8302 -74.1299
relay.edufeed.org:443 49.4521 11.0767
relay.lacompagniemaximus.com 45.3147 -73.8785
relay.dreamith.to:443 43.6532 -79.3832
nostr.stakey.net 52.3676 4.90414
nostr.n7ekb.net:443 47.4941 -122.294
relay.dyne.org 49.0291 8.35705
nostr.janx.com 43.6532 -79.3832
relay.trustr.ing 43.6548 -79.3885
relay.paulstephenborile.com:443 49.4543 11.0746
relay.wisp.talk:443 49.4543 11.0746
yabu.me 35.6092 139.73
bcast.seutoba.com.br 43.6532 -79.3832
nos.xmark.cc 50.6924 3.20113
nos.lol 50.4754 12.3683
relay.satmaxt.xyz 43.6532 -79.3832
relay.endfiat.money:443 59.3327 18.0656
relay.tapestry.ninja 40.8054 -74.0241
2 relay.lab.rytswd.com 49.4543 11.0746
3 nostr-kyomu-haskell.onrender.com relay.paulstephenborile.com:443 37.7775 49.4543 -122.397 11.0746
4 relay.damus.io:443 relay.binaryrobot.com 43.6532 -79.3832
5 treuzkas.branruz.com nostr-2.21crypto.ch 48.8575 47.5356 2.35138 8.73209
6 spookstr2.nostr1.com:443 40.7057 -74.0136
7 fanfares.nostr1.com:443 40.7057 -74.0136
8 x.kojira.io 43.6532 -79.3832
9 freelay.sovbit.host 60.1699 24.9384
10 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
11 testnet.samt.st 43.6532 -79.3832
12 relay.angor.io 48.1046 11.6002
13 relay-arg.zombi.cloudrodion.com 1.35208 103.82
14 nostr-01.yakihonne.com 1.32123 103.695
15 relay.nostriot.com:443 nostr-relay.cbrx.io 41.5695 43.6532 -83.9786 -79.3832
16 nostr-relay.nextblockvending.com relay.guggero.org 47.2343 46.5971 -119.853 9.59652
17 nostr.aruku.ovh nostr.snowbla.de 1.27994 60.1699 103.849 24.9384
nostr.chaima.info 50.1109 8.68213
ynostr.yael.at 60.1699 24.9384
ynostr.yael.at:443 60.1699 24.9384
nostr-01.yakihonne.com:443 1.32123 103.695
spookstr2.nostr1.com 40.7057 -74.0136
relay.trustr.ing:443 43.6548 -79.3885
dev.relay.edufeed.org 49.4521 11.0767
relay2.angor.io:443 48.1046 11.6002
nostr.azzamo.net:443 52.2633 21.0283
offchain.bostr.online 43.6532 -79.3832
18 relay.zone667.com 60.1699 24.9384
19 relay.veganostr.com nexus.libernet.app:443 60.1699 43.6532 24.9384 -79.3832
20 vault.iris.to:443 relay.islandbitcoin.com 43.6532 12.8498 -79.3832 77.6545
21 relay.artx.market:443 relay-testnet.k8s.layer3.news 43.6548 37.3387 -79.3885 -121.885
22 wot.rejecttheframe.xyz nostr-relay.xbytez.io 43.6532 50.6924 -79.3832 3.20113
23 nostr.pbfs.io kasztanowa.bieda.it 50.4754 43.6532 12.3683 -79.3832
24 relay.mostro.network nostrcity-club.fly.dev 40.8302 37.7648 -74.1299 -122.432
25 strfry.shock.network relay.typedcypher.com 39.0438 51.5072 -77.4874 -0.127586
26 relay.klabo.world nostr.na.social:443 47.2343 43.6532 -119.853 -79.3832
relay.fountain.fm:443 43.6532 -79.3832
nostrja-kari.heguro.com 43.6532 -79.3832
relaisnostr.trivaco.fr 48.5734 7.75211
offchain.pub 39.1585 -94.5728
relay.degmods.com 50.4754 12.3683
nostr-relay.xbytez.io:443 50.6924 3.20113
relay.paulstephenborile.com 49.4543 11.0746
nittom.nostr1.com:443 40.7057 -74.0136
dev-relay.nostreon.com 60.1699 24.9384
nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
relay.jeffg.fyi 43.6532 -79.3832
27 relay.laantungir.net -19.4692 -42.5315
28 nostr.data.haus:443 relay-dev.satlantis.io:443 50.4754 40.8302 12.3683 -74.1299
29 wot.codingarena.top rilo.nostria.app 50.4754 43.6532 12.3683 -79.3832
30 nostr.hekster.org:443 37.3986 -121.964
31 nostr-relay.amethyst.name:443 39.0067 -77.4291
32 chat-relay.zap-work.com:443 43.6532 -79.3832
33 relay.edufeed.org 49.4521 11.0767
34 syb.lol:443 43.6532 -79.3832
35 relay.sigit.io 50.4754 12.3683
36 nostr-relay.xbytez.io:443 50.6924 3.20113
37 relay.wavefunc.live 41.8781 -87.6298
38 nostr.sathoarder.com 48.5734 7.75211
39 myvoiceourstory.org 37.3598 -121.981
40 relay.underorion.se 50.1109 8.68213
41 nostr.data.haus 50.4754 12.3683
42 relay.erybody.com 41.4513 -81.7021
43 espelho.girino.org 43.6532 -79.3832
44 nostr.pbfs.io:443 50.4754 12.3683
45 wot.dergigi.com 64.1476 -21.9392
46 nostr.bitcoiner.social:443 47.6743 -117.112
47 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
48 relay.gulugulu.moe 43.6532 -79.3832
49 nostr.spicyz.io 43.6532 -79.3832
50 relay.cypherflow.ai 48.8575 2.35138
51 treuzkas.branruz.com 48.8575 2.35138
52 relay1.nostrchat.io 60.1699 24.9384
53 kotukonostr.onrender.com 37.7775 -122.397
54 nostr.plantroon.com 50.1013 8.62643
55 nostr.davenov.com 50.1109 8.68213
56 node.kommonzenze.de 49.4521 11.0767
57 relay2.veganostr.com 60.1699 24.9384
58 armada.sharegap.net 43.6532 -79.3832
59 wot.makenomistakes.ca 43.7064 -79.3986
60 nostr.2b9t.xyz:443 34.0549 -118.243
61 relay.libernet.app:443 43.6532 -79.3832
62 nostr.ps1829.com relay.dreamith.to:443 33.8851 43.6532 130.883 -79.3832
63 wot.dergigi.com relay.lightning.pub:443 64.1476 39.0438 -21.9392 -77.4874
64 relay.olas.app nostr.rtvslawenia.com 60.1699 49.4543 24.9384 11.0746
relay.lanacoin-eternity.com:443 40.8302 -74.1299
nostr.data.haus 50.4754 12.3683
relay-dev.gulugulu.moe 43.6532 -79.3832
relay.ohstr.com 43.6532 -79.3832
relay.lightning.pub 39.0438 -77.4874
relay.guggero.org 46.5971 9.59652
testnet-relay.samt.st:443 40.8302 -74.1299
thecitadel.nostr1.com 40.7057 -74.0136
nostr.ps1829.com:443 33.8851 130.883
nostr-relay.corb.net 38.8353 -104.822
relay.npubhaus.com 43.6532 -79.3832
65 nostr.21crypto.ch 47.5356 8.73209
66 nostr.tadryanom.me relay.ditto.pub:443 43.6532 -79.3832
67 nostr.myshosholoza.co.za relay.plebchain.club 52.3913 43.6532 4.66545 -79.3832
68 relay.bitmacro.cloud memlay.v0l.io 43.6532 53.3498 -79.3832 -6.26031
ribo.nostria.app 43.6532 -79.3832
relay.vrtmrz.net 43.6532 -79.3832
syb.lol 43.6532 -79.3832
relay.bebond.net:443 43.6532 -79.3832
relay.agentry.com 42.8864 -78.8784
nostr-2.21crypto.ch 47.5356 8.73209
nostrcity-club.fly.dev:443 37.7648 -122.432
articles.layer3.news:443 37.3387 -121.885
relay.internationalright-wing.org:443 -22.5022 -48.7114
nostr-relay.zimage.com 34.0549 -118.243
chat-relay.zap-work.com 43.6532 -79.3832
relay.mwaters.net 50.9871 2.12554
nostr.liberty.fans 36.9104 -89.5875
spookstr2.nostr1.com:443 40.7057 -74.0136
69 nostr.chaima.info:443 50.1109 8.68213
70 schnorr.me:443 relay.wavlake.com:443 43.6532 41.2619 -79.3832 -95.8608
71 ribo.eu.nostria.app nostr.thalheim.io:443 43.6532 60.1699 -79.3832 24.9384
72 nostr.bond relay.lightning.pub 50.1109 39.0438 8.68213 -77.4874
73 wot.nostr.party dev.relay.edufeed.org:443 36.1659 49.4521 -86.7844 11.0767
74 temp.iris.to nostr.myshosholoza.co.za:443 43.6532 52.3913 -79.3832 4.66545
75 relay.lotek-distro.com relay.binaryrobot.com:443 43.6532 -79.3832
76 relay.minibolt.info:443 wot.nostr.place 43.6532 -79.3832
relay.lanavault.space 60.1699 24.9384
relay.mmwaves.de 48.8575 2.35138
social.amanah.eblessing.co 48.1046 11.6002
nostr2.thalheim.io 49.4543 11.0746
relay.typedcypher.com 51.5072 -0.127586
relay.cosmicbolt.net 37.3986 -121.964
relayrs.notoshi.win:443 43.6532 -79.3832
nostr-relay-1.trustlessenterprise.com:443 43.6532 -79.3832
nostr.islandarea.net 35.4669 -97.6473
relay.nostrmap.net:443 60.1699 24.9384
relay.bullishbounty.com:443 43.6532 -79.3832
nostr.oxtr.dev:443 50.4754 12.3683
srtrelay.c-stellar.net 43.6532 -79.3832
relay.mccormick.cx 52.3563 4.95714
vault.iris.to 43.6532 -79.3832
relay.mccormick.cx:443 52.3563 4.95714
relay.toastr.net 40.8054 -74.0241
nostr.hifish.org 47.4244 8.57658
speakeasy.cellar.social:443 49.4543 11.0746
relay.wavlake.com 41.2619 -95.8608
testnet-relay.samt.st 40.8302 -74.1299
aeon.libretechsystems.xyz 55.486 9.86577
mostro-p2p.tech 50.1109 8.68213
nostr.wild-vibes.ts.net 48.8566 2.35222
nostr.88mph.life 52.1941 -2.21905
relay.nostrcheck.me 43.6532 -79.3832
rilo.nostria.app 43.6532 -79.3832
relay.bowlafterbowl.com 32.9483 -96.7299
relay.nostr.place:443 43.6532 -79.3832
nostr.mom:443 50.4754 12.3683
relay.nostrian-conquest.com 41.223 -111.974
herbstmeister.com 34.0549 -118.243
nrs-01.darkcloudarcade.com:443 39.1008 -94.5811
nostr.red5d.dev 43.6532 -79.3832
nostrbtc.com 43.6532 -79.3832
strfry.apps3.slidestr.net 40.4167 -3.70329
relay.sharegap.net 43.6532 -79.3832
reraw.pbla2fish.cc 43.6532 -79.3832
relay-testnet.k8s.layer3.news 37.3387 -121.885
77 nostr.sathoarder.com:443 48.5734 7.75211
78 relay.veganostr.com:443 thecitadel.nostr1.com 60.1699 40.7057 24.9384 -74.0136
79 relay-fra.zombi.cloudrodion.com:443 relay.artx.market 48.8566 43.6548 2.35222 -79.3885
80 nos.lol 50.4754 12.3683
81 nostr.plantroon.com:443 50.1013 8.62643
82 premium.primal.net 43.6532 -79.3832
83 relay.wavefunc.live:443 nas01xanthosnet.synology.me:7778 41.8781 47.1285 -87.6298 8.74735
84 nostrja-kari.heguro.com 43.6532 -79.3832
85 relay.mrmave.work 43.6532 -79.3832
86 nostrelay.circum.space 52.6907 4.8181
87 mostro-p2p.tech 50.1109 8.68213
88 wot.shaving.kiwi 43.6532 -79.3832
89 relay.fundstr.me 42.3601 -71.0589
90 nostrelay.circum.space:443 52.6907 4.8181
91 relay.nostrdice.com -33.8688 151.209
92 relay.getvia.xyz 60.1699 24.9384
93 strfry.shock.network:443 39.0438 -77.4874
94 relay.nostrmap.net:443 60.1699 24.9384
95 relay.nearhood.co.uk 51.5072 -0.127586
96 no.str.cr 10.6352 -85.4378
97 relay.getsafebox.app:443 43.6532 -79.3832
98 relay0.gfcom.info 13.6992 100.694
99 nostr.ps1829.com 33.8851 130.883
100 relay2.angor.io 48.1046 11.6002
101 relay.stickeroo.is-cool.dev 37.3387 -121.885
102 ricardo-oem.tailb5546.ts.net 40.7128 -74.006
103 relay.typedcypher.com:443 51.5072 -0.127586
104 relay.paulstephenborile.com 49.4543 11.0746
105 nittom.nostr1.com 40.7057 -74.0136
106 conduitl2.fly.dev 37.7648 -122.432
107 nostr.rikmeijer.nl 51.7111 5.36809
108 relay.thecryptosquid.com 50.4754 12.3683
109 spookstr2.nostr1.com 40.7057 -74.0136
110 offchain.bostr.online 43.6532 -79.3832
111 nostr.planix.org 43.6532 -79.3832
112 relay.mccormick.cx 52.3563 4.95714
113 0x-nostr-relay.fly.dev 37.7648 -122.432
114 nostr.wecsats.io 43.6532 -79.3832
115 schnorr.me 43.6532 -79.3832
116 relay.satmaxt.xyz 43.6532 -79.3832
117 relay.bornheimer.app 51.5072 -0.127586
118 relay.nostrhub.fr 48.1045 11.6004
119 blossom.gnostr.cloud:443 43.6532 -79.3832
120 nostr-02.yakihonne.com:443 1.32123 103.695
121 dev.relay.stream 43.6532 -79.3832
122 ithurtswhenip.ee 51.5072 -0.127586
123 nostr.myshosholoza.co.za 52.3913 4.66545
124 relayrs.notoshi.win:443 43.6532 -79.3832
125 relay-rpi.edufeed.org:443 49.4521 11.0767
126 kotukonostr.onrender.com relay.olas.app:443 37.7775 60.1699 -122.397 24.9384
127 bridge.tagomago.me nostr.unkn0wn.world 42.3601 46.8499 -71.0589 9.53287
128 nostr.tadryanom.me:443 relay.mitchelltribe.com 43.6532 39.0438 -79.3832 -77.4874
129 relay.gulugulu.moe:443 yabu.me 43.6532 35.6092 -79.3832 139.73
130 nostr.nodesmap.com 59.3327 18.0656
131 dm-test-strfry-generic.samt.st 43.6532 -79.3832
132 nostr2.girino.org:443 43.6532 -79.3832
133 wot.brightbolt.net 47.6735 -116.781
134 strfry.shock.network 39.0438 -77.4874
135 relay.kilombino.com 43.6532 -79.3832
136 relay.nostr.blockhenge.com 39.0438 -77.4874
137 shu04.shugur.net 25.2048 55.2708
138 relay-rpi.edufeed.org 49.4521 11.0767
139 relay.bullishbounty.com:443 43.6532 -79.3832
140 vault.iris.to:443 43.6532 -79.3832
141 relay.mostro.network:443 40.8302 -74.1299
142 offchain.pub:443 39.1585 -94.5728
143 soloco.nl 43.6532 -79.3832
144 relay.nostu.be 40.4167 -3.70329
145 nostr.pbfs.io 50.4754 12.3683
146 relay.directsponsor.net 42.8864 -78.8784
147 relay.decentralia.fr 49.4282 10.9796
148 relayrs.notoshi.win 43.6532 -79.3832
149 nostr-relay.amethyst.name 39.0067 -77.4291
150 relay.arx-ccn.com 50.4754 12.3683
151 nostr.spaceshell.xyz 43.6532 -79.3832
152 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
153 rilo.nostria.app:443 43.6532 -79.3832
154 relay.trotters.cc:443 43.6532 -79.3832
155 nostr.overmind.lol:443 43.6532 -79.3832
156 nostr.girino.org:443 43.6532 -79.3832
157 bitsat.molonlabe.holdings 51.4012 -1.3147
158 nostr.azzamo.net 52.2633 21.0283
159 insta-relay.apps3.slidestr.net 40.4167 -3.70329
160 bridge.tagomago.me 42.3601 -71.0589
161 nostr.thalheim.io 60.1699 24.9384
162 relay.artx.market:443 43.6548 -79.3885
163 nostr.openhoofd.nl 51.5717 3.70417
164 nostr.bond 50.1109 8.68213
165 relay.earthly.city 34.1749 -118.54
166 nexus.libernet.app 43.6532 -79.3832
167 relay.plebeian.market 50.1109 8.68213
168 relay.nostr.net 43.6532 -79.3832
169 nostr.overmind.lol 43.6532 -79.3832
170 relay.ohstr.com 43.6532 -79.3832
171 testnet-relay.samt.st:443 40.8302 -74.1299
172 relay01.lnfi.network 35.6764 139.65
173 relay.mostr.pub:443 43.6532 -79.3832
174 wot.nostr.party 36.1659 -86.7844
175 relayone.soundhsa.com 39.1008 -94.5811
176 relay.mostro.network 40.8302 -74.1299
177 ribo.eu.nostria.app 43.6532 -79.3832
178 chat-relay.zap-work.com 43.6532 -79.3832
179 relay.nostreon.com 60.1699 24.9384
180 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
181 nostr.quali.chat:443 60.1699 24.9384
182 relay.internationalright-wing.org:443 -22.5022 -48.7114
183 relay.mitchelltribe.com:443 39.0438 -77.4874
184 relay.satlantis.io 40.8054 -74.0241
185 nittom.nostr1.com:443 40.7057 -74.0136
186 nostr.janx.com 43.6532 -79.3832
187 nostr.carroarmato0.be:443 50.914 3.21378
188 relay.mmwaves.de:443 48.8575 2.35138
189 relay.chorus.community:443 48.5333 10.7
190 wot.utxo.one 43.6532 -79.3832
191 relay.plebeian.market:443 50.1109 8.68213
192 relay.cosmicbolt.net 37.3986 -121.964
193 x.kojira.io:443 43.6532 -79.3832
194 top.testrelay.top 43.6532 -79.3832
195 nos.lol:443 50.4754 12.3683
196 dev.relay.edufeed.org 49.4521 11.0767
197 relayone.geektank.ai:443 39.1008 -94.5811
198 relay.nostar.org 43.6532 -79.3832
199 nostr.oxtr.dev:443 50.4754 12.3683
200 nostr.88mph.life 52.1941 -2.21905
201 relay.staging.commonshub.brussels 49.4543 11.0746
202 weboftrust.libretechsystems.xyz 55.4724 9.87335
203 relay.openfarmtools.org 60.1699 24.9384
204 cs-relay.nostrdev.com 50.4754 12.3683
205 relay.inforsupports.com 43.6532 -79.3832
206 nostr-verified.wellorder.net 45.5201 -122.99
207 nostr.hekster.org 37.3986 -121.964
208 relay.gulugulu.moe:443 43.6532 -79.3832
209 relay.mwaters.net 50.9871 2.12554
210 nostrcity-club.fly.dev:443 37.7648 -122.432
211 relay.vrtmrz.net:443 43.6532 -79.3832
212 relay.nostr.place 43.6532 -79.3832
213 relay.wavefunc.live:443 41.8781 -87.6298
214 nostr.islandarea.net 35.4669 -97.6473
215 purplerelay.com:443 43.6532 -79.3832
216 nostr-relay.psfoundation.info:443 39.0438 -77.4874
217 r.0kb.io 32.789 -96.7989
218 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
219 relay.mulatta.io 37.5665 126.978
220 strfry.bonsai.com:443 39.0438 -77.4874
221 bendernostur.duckdns.org:8443 50.1109 8.68213
222 vault.iris.to 43.6532 -79.3832
223 ec2.f7z.io 60.1699 24.9384
224 nostr.debate.report 50.1109 8.68213
225 wot.codingarena.top 50.4754 12.3683
226 relay.layer.systems:443 49.0291 8.35695
227 relay.degmods.com 50.4754 12.3683
228 nostr.mom 50.4754 12.3683
229 ribo.us.nostria.app:443 43.6532 -79.3832
230 adre.su 59.9311 30.3609
231 wot.sudocarlos.com 43.6532 -79.3832
232 relay.nostrian-conquest.com 41.223 -111.974
233 nostr-relay.nextblockvending.com 47.2343 -119.853
234 relay.endfiat.money:443 59.3327 18.0656
235 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
236 nostr.carroarmato0.be 50.914 3.21378
237 relay.cypherflow.ai:443 48.8575 2.35138
238 nostr.girino.org 43.6532 -79.3832
239 nostr.thebiglake.org 32.71 -96.6745
240 strfry.ymir.cloud 43.6532 -79.3832
241 relay.mypathtofire.de 42.8864 -78.8784
242 relay.lanacoin-eternity.com 40.8302 -74.1299
243 nostr.snowbla.de:443 60.1699 24.9384
244 relay.ditto.pub 43.6532 -79.3832
245 relay.damus.io 43.6532 -79.3832
246 relay.ru.ac.th 13.7607 100.627
247 nrs-01.darkcloudarcade.com 39.1008 -94.5811
248 testnet-relay.samt.st 40.8302 -74.1299
249 antiprimal.net 43.6532 -79.3832
250 bitchat.nostr1.com 40.7057 -74.0136
251 relay.snort.social 53.3498 -6.26031
252 relay.mccormick.cx:443 52.3563 4.95714
253 relay02.lnfi.network 35.6764 139.65
254 srtrelay.c-stellar.net 43.6532 -79.3832
255 relay.minibolt.info 43.6532 -79.3832
256 nostrride.io 37.3986 -121.964
257 articles.layer3.news:443 37.3387 -121.885
258 rele.speyhard.fi 51.5072 -0.127586
259 relay.aarpia.com 37.3986 -121.964
260 nostr.chaima.info 50.1109 8.68213
261 relay.wisp.talk:443 49.4543 11.0746
262 relay.agorist.space:443 52.3734 4.89406
263 strfry.bonsai.com 39.0438 -77.4874
264 nostr.hifish.org 47.4244 8.57658
265 offchain.pub 39.1585 -94.5728
266 nostr.spicyz.io:443 43.6532 -79.3832
267 relay.beginningend.com 35.2227 -97.4786
268 relay.sharegap.net 43.6532 -79.3832
269 nostr.purpura.cloud 43.6532 -79.3832
270 nrs-01.darkcloudarcade.com:443 39.1008 -94.5811
271 relay.fountain.fm:443 43.6532 -79.3832
272 relay.olas.app 60.1699 24.9384
273 relay.mmwaves.de 48.8575 2.35138
274 relay.openresist.com:443 43.6532 -79.3832
275 relay.homeinhk.xyz 35.694 139.754
276 relay.libernet.app 43.6532 -79.3832
277 relay.comcomponent.com 43.6532 -79.3832
278 nostr.tac.lol 47.4748 -122.273
279 relay.goodmorningbitcoin.com 43.6532 -79.3832
280 relay.nostriot.com:443 41.5695 -83.9786
281 bcast.girino.org 43.6532 -79.3832
282 nostr.azzamo.net:443 52.2633 21.0283
283 relay.islandbitcoin.com:443 12.8498 77.6545
284 pool.libernet.app 43.6532 -79.3832
285 test.thedude.cloud 50.1109 8.68213
286 nostrelites.org 41.8781 -87.6298
287 nostr.infero.net 35.6764 139.65
288 relay.primal.net 43.6532 -79.3832
289 ribo.nostria.app 43.6532 -79.3832
290 relay.chorus.community 48.5333 10.7
291 bitcoiner.social:443 47.6743 -117.112
292 relay.wisp.talk 49.4543 11.0746
293 relay.layer.systems 49.0291 8.35695
294 relay-dev.satlantis.io 40.8302 -74.1299
295 nostr.bitcoiner.social 47.6743 -117.112
296 relay.lanavault.space:443 60.1699 24.9384
297 relay.staging.plebeian.market 51.5072 -0.127586
298 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
299 relay.fountain.fm 43.6532 -79.3832
300 nostr.middling.mydns.jp 35.8099 140.12
301 relay.dreamith.to 43.6532 -79.3832
302 relay.satmaxt.xyz:443 43.6532 -79.3832
303 shu03.shugur.net 25.2048 55.2708
304 zealand-charts-craig-thru.trycloudflare.com 43.6532 -79.3832
305 nostr.computingcache.com 34.0356 -118.442
306 ribo.us.nostria.app 43.6532 -79.3832
307 relay.agentry.com 42.8864 -78.8784
308 nostr.hifish.org:443 47.4244 8.57658
309 nostr.vulpem.com 49.4543 11.0746
310 relay.cosmicbolt.net:443 37.3986 -121.964
311 nostr-02.yakihonne.com 1.32123 103.695
312 r.0kb.io:443 32.789 -96.7989
313 nostr-relay.corb.net 38.8353 -104.822
314 ribo.eu.nostria.app:443 43.6532 -79.3832
315 nostr-relay.psfoundation.info 39.0438 -77.4874
316 relay.wellorder.net 45.5201 -122.99
317 relay.novospes.com 43.6532 -79.3832
318 nostr-dev.wellorder.net 45.5201 -122.99
319 relay.endfiat.money 59.3327 18.0656
320 relay.angor.io:443 48.1046 11.6002
321 relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
322 strfry.openhoofd.nl 51.5717 3.70417
323 relay.getsafebox.app 43.6532 -79.3832
324 relay.openresist.com 43.6532 -79.3832
325 relay5.bitransfer.org 43.6532 -79.3832
326 nostr.na.social 43.6532 -79.3832
327 portal-relay.pareto.space 49.0291 8.35696
328 nostr.notribe.net:443 40.8302 -74.1299
329 relay.bitmacro.cloud 43.6532 -79.3832
330 no.str.cr:443 10.6352 -85.4378
331 relay.klabo.world 47.2343 -119.853
332 nostr.notribe.net 40.8302 -74.1299
333 relay.staging.plebeian.market:443 51.5072 -0.127586
334 relay.nostrmap.net 60.1699 24.9384
335 temp.iris.to 43.6532 -79.3832
336 nostr.sovereignservices.xyz 43.6532 -79.3832
337 nostr.liberty.fans 36.9104 -89.5875
338 relay.nostrian-conquest.com:443 41.223 -111.974
339 relay.nostriot.com 41.5695 -83.9786
340 nostrbtc.com 43.6532 -79.3832
341 shu02.shugur.net 21.4902 39.2246
342 relay.kalcafe.xyz 37.3986 -121.964
343 relay.illuminodes.com 43.6532 -79.3832
344 relay.wavlake.com 41.2619 -95.8608
345 nostr.ps1829.com:443 33.8851 130.883
346 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
347 nostr.wecsats.io:443 43.6532 -79.3832
348 nostr-pub.wellorder.net 45.5201 -122.99
349 nostr.dlcdevkit.com:443 40.0992 -83.1141
350 nostr.mom:443 50.4754 12.3683
351 ribo.nostria.app:443 43.6532 -79.3832
352 nostr.2b9t.xyz 34.0549 -118.243
353 nostr.data.haus:443 50.4754 12.3683
354 staging.yabu.me 35.6092 139.73
355 relay.sigit.io:443 50.4754 12.3683
356 relay.edufeed.org:443 49.4521 11.0767
357 nostr-01.yakihonne.com:443 1.32123 103.695
358 reraw.pbla2fish.cc 43.6532 -79.3832
359 cs-relay.nostrdev.com:443 50.4754 12.3683
360 herbstmeister.com 34.0549 -118.243
361 relay.minibolt.info:443 43.6532 -79.3832
362 relay2.angor.io:443 48.1046 11.6002
363 social.amanah.eblessing.co 48.1046 11.6002
364 nostr.stakey.net 52.3676 4.90414
365 nostr.computingcache.com:443 34.0356 -118.442
366 slick.mjex.me 39.0418 -77.4744
367 fanfares.nostr1.com 40.7057 -74.0136
368 bitcoinostr.duckdns.org 43.3434 -3.99532
369 nostr.oxtr.dev 50.4754 12.3683
370 cache.trustr.ing 43.6548 -79.3885
371 purplerelay.com 43.6532 -79.3832
372 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
373 nostr-relay.corb.net:443 38.8353 -104.822
374 relay-dev.gulugulu.moe 43.6532 -79.3832
375 prl.plus 55.7628 37.5983
376 nostr.tac.lol:443 47.4748 -122.273
377 relay.mostr.pub 43.6532 -79.3832
378 schnorr.me:443 43.6532 -79.3832
379 dev-relay.nostreon.com 60.1699 24.9384
380 nostr.islandarea.net:443 35.4669 -97.6473
381 bucket.coracle.social 37.7775 -122.397
382 blossom.gnostr.cloud 43.6532 -79.3832
383 relay.solife.me 43.6532 -79.3832
384 nostr.quali.chat 60.1699 24.9384
385 relay.vrtmrz.net 43.6532 -79.3832
386 relay-dev.gulugulu.moe:443 43.6532 -79.3832
387 relay.bullishbounty.com 43.6532 -79.3832
388 relay.fckstate.net 59.3293 18.0686
389 nostr.rtvslawenia.com:443 49.4543 11.0746
390 relay.nostx.io 43.6532 -79.3832
391 relay.agorist.space 52.3734 4.89406
392 relay.notoshi.win 13.7829 100.546
393 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
394 relay.trotters.cc 43.6532 -79.3832
395 relay.lanavault.space 60.1699 24.9384
396 public.crostr.com:443 43.6532 -79.3832
397 nostr.stakey.net:443 52.3676 4.90414
398 relay.nostr.place:443 43.6532 -79.3832
399 nostr.dlcdevkit.com 40.0992 -83.1141
400 nostr.aruku.ovh 1.27994 103.849
401 satsage.xyz 37.3986 -121.964
402 strfry.apps3.slidestr.net 40.4167 -3.70329
403 nostr2.girino.org 43.6532 -79.3832
404 relay.samt.st 40.8302 -74.1299
405 articles.layer3.news 37.3387 -121.885
406 aeon.libretechsystems.xyz 55.486 9.86577
407 relay.routstr.com 59.4016 17.9455
408 relay.ohstr.com:443 43.6532 -79.3832
409 relay.lanacoin-eternity.com:443 40.8302 -74.1299
410 strfry.openhoofd.nl:443 51.5717 3.70417
411 nostr.blankfors.se 60.1699 24.9384
412 nostr-2.21crypto.ch:443 47.5356 8.73209
413 relayone.soundhsa.com:443 39.1008 -94.5811
414 relay.lab.rytswd.com:443 49.4543 11.0746
415 nostr.tagomago.me 42.3601 -71.0589
416 relay.0xchat.com:443 43.6532 -79.3832

Some files were not shown because too many files have changed in this diff Show More