Mesh bridging: stitch nearby mesh islands over the internet, courier drops, unified settings (#1412)

* Mesh bridging: stitch nearby mesh islands over Nostr, courier drops, settings surface

Three features plus a UI consolidation, all opt-in behind a new Bridge toggle:

Channel bridge: while bridging, outgoing public mesh messages are also
signed (with a derived, unlinkable per-cell Nostr identity) as kind-20000
events tagged #r with the local geohash-6 cell and published to the cell's
deterministic geo relays; mesh-only peers deposit via new toBridge/fromBridge
carrier directions through a bridge gateway (bridge + gateway toggles).
Remote islands' events render into the mesh timeline marked with a network
glyph. Events carry the original mesh message ID so the store's insert-by-ID
absorbs radio/bridge duplicates in either order. Loop prevention mirrors
GatewayService (three BoundedIDSet caches + skip-if-seen-locally + budgets).
Nothing crosses a bridge unless its author signed it for the bridge; a
per-message "nearby only" composer toggle keeps a message radio-only.

Courier over the bridge: sealed courier envelopes park on default relays as
kind-1401 drops tagged #x with their day-rotating recipient tag (NIP-40
expiry), signed by per-drop throwaway keys. Recipients subscribe for their
own candidate tags; bridge gateways watch verified local peers' tags and
hand matching drops over as directed courier packets. DM delivery to known
peers stops requiring a physical courier encounter; the Noise-X seal never
opens in transit.

Presence: kind-20001 heartbeats on the rendezvous feed a "people across the
bridge" count in the header (approximate: local participants subtracted by
radio-copy attribution).

Settings/Info: AppInfoView is now a segmented Settings/Info sheet. Settings
hosts appearance, voice (fixes the duplicated Voice section), a Connectivity
section (bridge + gateway + Tor toggles, the latter two moved out of the
location sheet), and a confirmed panic-wipe button. New announce TLV 0x06
advertises the gateway's rendezvous cell; PeerCapabilities gains .bridge.

i18n: 23 new keys across all 29 locales; coverage tests green.
Tests: 50 new app tests + 3 BitFoundation tests; full suite 1445 green.

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

* Settings polish: one toggle style, sticky Info-first tab, location access in Settings

- The live-voice toggle now uses the same settings card + IRC pill as the
  connectivity toggles (settingToggle, renamed from connectivityToggle).
- Segmented control orders Info first; the selected pane persists across
  opens (AppStorage), so first-ever open lands on Info and afterwards the
  sheet reopens where it was left.
- "remove location access" moved from the channels sheet into the Settings
  Connectivity section (same key, still deep-links to system settings).

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

* Location UX + copy: state-aware access control, honest empty state, clearer bridge/gateway text

- Settings' location control now covers all three permission states: grant
  (real prompt, only possible while never-asked), open-system-settings when
  denied, remove-access when granted. The channels sheet keeps its own grant
  path for people who start there.
- The channels list no longer spins forever without permission; it shows
  "grant location access to find nearby channels" instead (new key, 29
  locales).
- Bridge and gateway subtitles rewritten for clarity; the gateway subtitle
  moved to a new key since it now carries bridge traffic, and the old
  geohash-only key is deleted. The word "user" is banned from copy in every
  locale ("this person is blocked").

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

* Field-test fixes: dedupe relay-connectivity triggers, throttle presence, log bridge decisions

First on-device run confirmed publishes accepted and the subscription
delivering, but exposed trigger spam: NostrRelayManager's isConnected
re-emits per relay recompute, so presence published 5x/second and the
courier-drop subscription rebuilt 6x in 300ms. removeDuplicates() on the
sinks + a 30s presence throttle (same-second heartbeats are byte-identical
events anyway). Also: injection/skip/downlink now log under 🌉 so field
verification is observable — the first test looked silent precisely because
dedup correctly suppressed same-island bridged copies.

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

* Fix restart self-echo: recognize own rendezvous events by derived pubkey

Second field run proved bidirectional bridging live (~1-2s Mac<->iPhone via
Tor) but caught a bug: relay backfill after an app relaunch re-delivered the
device's own pre-restart events, and with the in-memory published-ID cache
wiped they rendered as bridged copies of your own messages. The rendezvous
identity is deterministically derived per cell, so self-recognition by
pubkey needs no cache and survives restarts; own events are also marked
never-downlink. Regression test simulates the fresh-launch state.

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

* People sheet: mesh, bridge, and groups in one list

The header's bridged count had no matching faces anywhere — the people
sheet only knew mesh peers. BridgeService now publishes named participants
(nickname from message tags, geohash-style #last4 disambiguation, presence
keeps a known name alive) and the mesh people sheet gains an
"across the bridge" section between mesh peers and groups. Display-only
rows in v1 (bridged identities have no DM route yet). Two new catalog keys
x29 locales; also normalizes one out-of-sort-order entry inherited from a
hand-edited key on main.

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

* Header: one people icon, one count, one sheet

Fold the bridged-people count into the main person.2.fill count instead of
a second network-glyph counter; the merged people sheet (mesh / across the
bridge / groups) is the breakdown. VoiceOver still announces how many of
the total are across the bridge.

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

* People sheet symmetry: #mesh section header, no active count

Every section now gets the same glyph+label header shape (shared
PeopleSectionHeader): #mesh over the peer list, across-the-bridge over
bridged people. The "N active" line is gone (mesh); location channels keep
their geohash subtitle. Dead subtitle/count helpers removed.

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

* One switch: the bridge toggle drives all internet sharing

Field feedback: two toggles (bridge + gateway) with an invisible dependency
was a trap — bridged messages silently never reached mesh-only neighbors
unless a second lever was found and flipped. Collapsed to a single switch
that does the right thing for your situation:

- Bridge ON + internet: your messages cross, you see the bridge, AND your
  device serves its island — accepts toBridge deposits, carries remote
  messages onto the radio, watches courier drops for verified local peers,
  advertises the cell, and runs the geohash-channel gateway.
- Bridge ON, no internet: you ride whoever nearby is serving.
- Bridge OFF: nothing of yours crosses; radio reception of bridged traffic
  stays passive and free.

With every online bridger serving, downlink gets a 0.2-1.5s jittered
holdoff + send-time suppression recheck so co-located gateways don't burn
duplicate airtime (two-gateway test included). The internet-gateway card is
gone from Settings (GatewayService now follows the bridge switch, with
launch-time migration); its orphaned catalog keys deleted and the bridge
subtitle broadened across all 29 locales. Also: MeshPeerList's empty state
("nobody around...") aligned to the section row rhythm.

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

* Bridge pumps its own location; fix centered people sheet; stop drop-subscription churn

Field session 3 found the bridge silently cell-less: it read
availableChannels passively, which only flow while some other feature
(channels sheet, location notes, geo sampling) happens to pump location —
turn those off and the bridge never gets a rendezvous. BridgeService now
requests a one-shot fix whenever it's enabled without a cell (and
piggybacks one on the presence timer so moving devices migrate cells).

Also from the session: the people sheet's scroll content hugged its widest
child and got centered on iPhone when the list was empty — pinned to full
width, leading. And the courier-drop subscription rebuilt every ~60s on
verified announces despite an unchanged tag set — now resubscribes only
when the tags actually change.

(Also merges origin/main: keychain test isolation #1413.)

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

* Fix launch race: bridge reacts to the permission callback, retries cell-less

Field session 4: the launch-time location request ran before the CoreLocation
authorization callback delivered, so refreshChannels() silently no-opped
(it requires .authorized) and nothing ever retried — the bridge stayed
cell-less all session. Three layers now close it:

- a $permissionState sink re-enters refreshRendezvous the moment
  authorization resolves (the fast path),
- the maintenance timer arms even without a cell and retries the full
  rendezvous refresh (the backstop; it previously required a cell, which
  made it useless for exactly this failure),
- flipping the bridge switch while never-asked triggers the location
  prompt — that's the user-initiated moment for it.

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

* People sheet: one header style for every section; bridged people visible while own bridge is off

GroupChatList's header now uses the shared PeopleSectionHeader (glyph +
label, same size/padding as #mesh and across-the-bridge; keeps its key and
header trait). Bridge section and the header count are no longer gated on
this device's own toggle: bridged people arrive over passive radio from a
serving neighbor, and whoever is visible in the timeline belongs in the
sheet and the count.

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

* People sheet: equalize the first section's gap

MeshPeerList's first row kept a legacy 10pt top bump from when nothing sat
above it, and the outer VStack's 6pt inter-child spacing applied between
the #mesh header and the list but not inside the other sections. Both gone:
sections own their rhythm (header 12/4, rows 4), spacing 0 outside.

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

* People sheet: rows drop their leading glyphs — the section header carries the type

Mesh rows lose the per-state transport icon (connected/relayed/nostr/
offline), bridge rows the network glyph, group rows the person.3 icon.
Trailing state badges (star, lock, verified, unread, blocked, crown) stay,
and the row accessibility description still announces connection state, so
VoiceOver loses nothing.

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

* Remove dead code left behind by the Settings|Info consolidation

Periphery (--strict) flagged five leftovers on this branch:
- LocationChannelsModel.setGatewayEnabled: the standalone internet-gateway
  toggle is gone (the bridge switch drives internet sharing), so nothing
  calls it; the gatewayEnabled published property stays for the header dot.
- AppInfoView Strings.Location title/enable/openSettings: the old Location
  section's header and permission buttons no longer exist. Their orphaned
  Localizable.xcstrings entries go with them (the gateway-toggle keys were
  already pruned).
- BridgePeopleList's appTheme environment value was never read.

The sixth CI finding (PrekeyBundleStore.StoredBundle.noiseKey assign-only)
is a Periphery flake: the property is read in loadFromDisk, the finding
didn't reproduce locally or on the next CI run of unchanged code.

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

* People sheet: mesh rows get their transport glyph back

The mesh section is the one heterogeneous list — the leading icon encodes
HOW a peer is reachable (radio / relayed / nostr-only / offline), which the
header can't say. Bridge and group rows stay glyph-free.

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

* Fix courier drop skipped by stale BLE reachability; periodic deposit sweep

Field test: a DM sent seconds after the recipient's radio vanished still
saw them as "reachable" (60s verified retention), so canDeliverPromptly
held, every deposit was skipped, and the message sat spooled with no retry
path. MessageRouter now sweeps its outbox every 2 minutes and publishes
bridge drops for messages whose recipient no transport can promptly reach;
the drop layer's message-ID dedup makes the sweep idempotent. Regression
test reproduces the exact field sequence.

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

* Show "carried" when a message ships as a bridge drop

Field feedback: dropped DMs delivered but the sender saw nothing — the
drop path never fired onMessageCarried, and the recipient's delivery ack
has no radio route back until the peers next share a transport. depositDrop
now reports whether a fresh drop was sealed and the router marks the
message carried (📦) on both the send path and the sweep; the ack still
upgrades it to delivered whenever a route exists.

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

* Fix locale word order: offline tag after name in DM header; localized "ago"

The private-chat header rendered the localized offline word in the
availability-glyph slot, before the name — "sin conexión bob". Offline now
shows the same dimmed person glyph the mesh list uses, with the word as a
small trailing tag after the name and lock, so it reads correctly in every
locale. Full sweep of views found one more composition bug: notice
timestamps glued English "ago" onto a localized duration; now the whole
phrase comes from RelativeDateTimeFormatter (same as the "fades" label).

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

* DM header offline state: icon only

The availability slot now reads uniformly as a glyph (radio / relayed /
globe / dimmed person), matching the mesh list; the text tag is gone.
VoiceOver still announces "offline" via the glyph's accessibility label.

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

* Offline glyph: slashed antenna instead of dimmed person

Offline is now the visual negation of connected (same antenna glyph,
slashed) in both the DM header and mesh list rows; a generic person icon
didn't say "unreachable".

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>
This commit is contained in:
jack
2026-07-08 21:45:38 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent d499c3e415
commit 020de96519
39 changed files with 7925 additions and 1277 deletions
-4
View File
@@ -100,10 +100,6 @@ final class LocationChannelsModel: ObservableObject {
network.setUserTorEnabled(enabled)
}
func setGatewayEnabled(_ enabled: Bool) {
gateway.setEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel,
permissionState == .authorized,
File diff suppressed because it is too large Load Diff
+7
View File
@@ -86,6 +86,13 @@ final class NostrIdentityBridge {
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
/// geohash-chat identity for the same cell string.
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
try deriveIdentity(forGeohash: "bridge|" + cell)
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
+82
View File
@@ -20,6 +20,10 @@ struct NostrProtocol {
case ephemeralEvent = 20000
case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
/// Sealed courier envelope parked on relays under its rotating
/// recipient tag (`#x`). Regular (stored) kind so it survives until
/// its NIP-40 expiration the whole point is store-and-forward.
case courierDrop = 1401
}
/// Create a NIP-17 private message
@@ -256,6 +260,84 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey)
}
// MARK: - Mesh bridge (rendezvous) events
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
/// channel subscriptions (which filter on `#g`); `m` carries the original
/// mesh message ID so receivers dedup the bridged copy against the radio
/// copy by timeline ID.
static func createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
meshMessageID: String? = nil
) throws -> NostrEvent {
var tags = [["r", cell]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
tags.append(["m", meshMessageID])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
/// cell: empty content, `r` tag only the bridge analogue of geohash
/// presence, counted into "people across the bridge".
static func createBridgePresenceEvent(
cell: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["r", cell]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
/// parked on relays. `x` is the hex recipient tag the recipient (or a
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
/// the envelope expiry so honoring relays garbage-collect the drop. The
/// signing identity should be a throwaway the envelope authenticates
/// its sender internally via Noise-X, and linking drops to a stable
/// publisher key would leak courier traffic patterns.
static func createCourierDropEvent(
envelope: Data,
recipientTagHex: String,
expiresAt: Date,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let tags = [
["x", recipientTagHex],
["expiration", String(Int(expiresAt.timeIntervalSince1970))],
]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .courierDrop,
tags: tags,
content: envelope.base64EncodedString()
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
/// drop the note in step with a bridged board post's expiry.
+23
View File
@@ -1510,6 +1510,29 @@ struct NostrFilter: Encodable {
filter.limit = limit
return filter
}
// For the mesh bridge: rendezvous messages (kind 20000) and presence
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [20000, 20001]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["r": cells]
filter.limit = limit
return filter
}
// For courier drops: sealed envelopes (kind 1401) parked under rotating
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
// UTC days x recipients) in one filter.
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["x": recipientTagsHex]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters
@@ -32,6 +32,14 @@ struct NostrCarrierPacket: Equatable {
enum Direction: UInt8 {
case toGateway = 0x01
case fromGateway = 0x02
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
/// publish its signed rendezvous event. Directed, like `toGateway`.
case toBridge = 0x03
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
/// event from a remote island. Broadcast, like `fromGateway`.
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
/// carrier quietly bridge traffic degrades to invisible, not junk.
case fromBridge = 0x04
}
let direction: Direction
+24 -2
View File
@@ -9,19 +9,25 @@ struct AnnouncementPacket {
let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
/// tagged rendezvous events without their own location fix.
let bridgeGeohash: String?
init(
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data,
directNeighbors: [Data]?,
capabilities: PeerCapabilities? = nil
capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
) {
self.nickname = nickname
self.noisePublicKey = noisePublicKey
self.signingPublicKey = signingPublicKey
self.directNeighbors = directNeighbors
self.capabilities = capabilities
self.bridgeGeohash = bridgeGeohash
}
private enum TLVType: UInt8 {
@@ -30,6 +36,7 @@ struct AnnouncementPacket {
case signingPublicKey = 0x03
case directNeighbors = 0x04
case capabilities = 0x05
case bridgeGeohash = 0x06
}
func encode() -> Data? {
@@ -74,6 +81,15 @@ struct AnnouncementPacket {
data.append(capabilityBytes)
}
// TLV for bridge rendezvous cell (optional; old clients skip it)
if let bridgeGeohash = bridgeGeohash,
let cellData = bridgeGeohash.data(using: .utf8),
!cellData.isEmpty, cellData.count <= 12 {
data.append(TLVType.bridgeGeohash.rawValue)
data.append(UInt8(cellData.count))
data.append(cellData)
}
return data
}
@@ -84,6 +100,7 @@ struct AnnouncementPacket {
var signingPublicKey: Data?
var directNeighbors: [Data]?
var capabilities: PeerCapabilities?
var bridgeGeohash: String?
while offset + 2 <= data.count {
let typeRaw = data[offset]
@@ -116,6 +133,10 @@ struct AnnouncementPacket {
}
case .capabilities:
capabilities = PeerCapabilities(encoded: Data(value))
case .bridgeGeohash:
if length <= 12 {
bridgeGeohash = String(data: value, encoding: .utf8)
}
}
} else {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -129,7 +150,8 @@ struct AnnouncementPacket {
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors,
capabilities: capabilities
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
}
}
+15 -2
View File
@@ -10,6 +10,8 @@ struct BLEPeerInfo: Equatable {
var isVerifiedNickname: Bool
var lastSeen: Date
var capabilities: PeerCapabilities = []
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
var bridgeGeohash: String?
}
struct BLEPeerAnnounceUpdate: Equatable {
@@ -117,6 +119,15 @@ struct BLEPeerRegistry {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
}
/// A rendezvous cell advertised by any bridge-capable peer, if one is
/// known lets location-less devices join the island's rendezvous.
func advertisedBridgeGeohash() -> String? {
peers.values
.filter { $0.capabilities.contains(.bridge) }
.compactMap(\.bridgeGeohash)
.first
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
@@ -160,7 +171,8 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date,
capabilities: PeerCapabilities = []
capabilities: PeerCapabilities = [],
bridgeGeohash: String? = nil
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate(
@@ -177,7 +189,8 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now,
capabilities: capabilities
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
return update
+133 -4
View File
@@ -84,7 +84,11 @@ final class BLEService: NSObject {
// for every announce. `directedToUs` distinguishes an uplink deposit
// addressed to this device from a downlink broadcast.
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
/// Fired (off-main) when a signature-verified announce is processed
/// the bridge courier watch refreshes its tag set on new arrivals.
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)?
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
private var localBridgeGeohash: String? // collectionsQueue
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
@@ -708,6 +712,33 @@ final class BLEService: NSObject {
}
}
/// Reachable peers currently advertising the `.bridge` capability.
func reachableBridgePeers() -> [PeerID] {
let now = Date()
return collectionsQueue.sync {
peerRegistry.peers(advertising: .bridge)
.filter { peerRegistry.isReachable($0, now: now) }
}
}
/// A rendezvous cell advertised by a bridge-capable peer's announce.
func advertisedBridgeGeohash() -> String? {
collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() }
}
/// The rendezvous cell this device advertises in its own announces while
/// bridging with the gateway toggle on. Set from the main actor; the
/// value rides the next (forced) announce.
func setLocalBridgeGeohash(_ cell: String?) {
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
guard localBridgeGeohash != cell else { return false }
localBridgeGeohash = cell
return true
}
guard changed else { return }
sendAnnounce(forceSend: true)
}
func getPeerNicknames() -> [PeerID: String] {
return collectionsQueue.sync {
peerRegistry.displayNicknames(selfNickname: myNickname)
@@ -1395,8 +1426,12 @@ final class BLEService: NSObject {
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync {
(peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities))
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
(
peerRegistry.connectedRoutingData,
PeerCapabilities.localSupported.union(runtimeCapabilities),
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
)
}
let announcement = AnnouncementPacket(
@@ -1404,7 +1439,8 @@ final class BLEService: NSObject {
noisePublicKey: noisePub,
signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs,
capabilities: advertisedCapabilities
capabilities: advertisedCapabilities,
bridgeGeohash: advertisedBridgeCell
)
guard let payload = announcement.encode() else {
@@ -3066,6 +3102,92 @@ extension BLEService {
return true
}
// MARK: Courier over the bridge
/// Seals `content` into a courier envelope for relay parking (a bridge
/// courier drop). Same sealing rules as `sendCourierMessage` prekey
/// (v2) when a verified bundle is cached, static Noise X (v1) otherwise
/// but carry-only: a relay copy never sprays.
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? {
guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
return nil
}
do {
let now = Date()
let sealed: Data
let prekeyID: UInt32?
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
prekeyID = prekey.id
} else {
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
prekeyID = nil
}
return CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed,
copies: 1,
prekeyID: prekeyID
)
} catch {
SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption)
return nil
}
}
/// Opens a courier envelope that arrived as a bridge drop (relay fetch,
/// not a directed mesh packet). Returns false when the rotating tag does
/// not match our static key a drop for someone else, or a stale tag.
/// The inner Noise X seal authenticates the sender; there is no packet
/// signature to check on this path.
@discardableResult
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
guard !envelope.isExpired else { return false }
let myKey = noiseService.getStaticPublicKeyData()
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
return false
}
openCourierEnvelope(envelope)
return true
}
/// Hands a bridge-fetched envelope directly to the matching local peer
/// as a directed courier packet. Delivery-only by design: the recipient's
/// tag matched, so this never lands in a stranger's carry quota.
@discardableResult
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
guard isPeerConnected(peerID) || isPeerReachable(peerID) else { return false }
guard let payload = envelope.encode() else { return false }
let packet = makeCourierPacket(payload, to: peerID)
messageQueue.async { [weak self] in
self?.sendPacketDirected(packet, to: peerID)
}
return true
}
/// Our own Noise static public key (for computing our courier tags).
func myNoiseStaticPublicKey() -> Data {
noiseService.getStaticPublicKeyData()
}
/// Verified reachable peers with known Noise keys the set a bridge
/// gateway watches courier drops for.
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] {
let now = Date()
return collectionsQueue.sync {
peerRegistry.snapshotByID.values.compactMap { info in
guard info.isVerifiedNickname,
let key = info.noisePublicKey,
peerRegistry.isReachable(info.peerID, now: now) else { return nil }
return (info.peerID, key)
}
}
}
/// The prekey to seal a courier message with, or nil to fall back to
/// static sealing. The real signal is a verified, unexpired bundle with a
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
@@ -4185,6 +4307,12 @@ extension BLEService {
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
}
// Bridge courier watch: a verified announce may add a peer whose
// relay-parked drops we should start watching for.
if let result, result.isVerified {
onVerifiedPeerAnnounce?(result.peerID)
}
// Courier work: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them
// (or spray-able mail they could carry). Verified announces only.
@@ -4314,7 +4442,8 @@ extension BLEService {
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now,
capabilities: announcement.capabilities ?? []
capabilities: announcement.capabilities ?? [],
bridgeGeohash: announcement.bridgeGeohash
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
},
shouldEmitReconnectLog: { [weak self] peerID, now in
@@ -42,6 +42,8 @@ final class CourierStore {
var sprayedTo: Set<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
/// Last publish of this envelope as a bridge courier drop on relays.
var lastBridgePublishAt: Date?
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
let prekeyID: UInt32?
@@ -59,6 +61,7 @@ final class CourierStore {
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil,
lastBridgePublishAt: Date? = nil,
prekeyID: UInt32? = nil
) {
self.recipientTag = recipientTag
@@ -70,6 +73,7 @@ final class CourierStore {
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
self.lastBridgePublishAt = lastBridgePublishAt
self.prekeyID = prekeyID
}
@@ -86,6 +90,7 @@ final class CourierStore {
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt)
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
}
}
@@ -242,6 +247,30 @@ final class CourierStore {
}
}
/// Envelopes to park on relays as bridge courier drops. Non-destructive
/// like remote handover the relay copy is speculative, so the carried
/// copy stays until direct handover or expiry. The per-envelope cooldown
/// keeps relay churn from republishing the same mail; publishing relays
/// dedup identical events by ID anyway.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
return queue.sync {
pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = []
for index in envelopes.indices {
if let last = envelopes[index].lastBridgePublishAt,
date.timeIntervalSince(last) < cooldown {
continue
}
envelopes[index].lastBridgePublishAt = date
// The relay copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
// MARK: - Spray-and-wait (on encountering another courier)
/// Envelopes to re-deposit with a courier we just encountered, each with
@@ -0,0 +1,35 @@
//
// BoundedIDSet.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
/// evicted when full. Shared by the gateway and bridge loop-prevention
/// caches.
struct BoundedIDSet {
private var members: Set<String> = []
private var order: [String] = []
let capacity: Int
init(capacity: Int) {
self.capacity = capacity
}
func contains(_ id: String) -> Bool {
members.contains(id)
}
/// Returns false when the ID was already present.
@discardableResult
mutating func insert(_ id: String) -> Bool {
guard members.insert(id).inserted else { return false }
order.append(id)
if order.count > capacity {
members.remove(order.removeFirst())
}
return true
}
}
@@ -0,0 +1,284 @@
//
// BridgeCourierService.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 Foundation
import Security
/// Courier delivery over the internet bridge: sealed courier envelopes are
/// parked on relays as kind-1401 "drops" tagged with their rotating
/// recipient tag, so delivery stops requiring a physical courier to bump
/// into the recipient.
///
/// Three duties, all gated on the bridge toggle:
/// - Sender: when the message router seals mail for an unreachable peer, a
/// copy is published as a drop (queued until relays connect). The drop is
/// signed with a fresh throwaway key per publish the envelope
/// authenticates its sender internally via Noise-X, and a stable publisher
/// key would leak courier traffic patterns to relays.
/// - Recipient: subscribes for its own candidate tags (adjacent UTC days)
/// and opens matching drops directly.
/// - Gateway (bridge + gateway toggles): additionally watches the tags of
/// verified local mesh peers and hands matching drops to them as directed
/// courier packets, so mesh-only recipients are served too.
///
/// Privacy: a drop reveals to relays only that "someone" is messaging "some
/// 16-byte day-rotating tag". Only parties who already know the recipient's
/// Noise static key can compute the tag; the payload is an opaque Noise-X
/// seal. Duplicate deliveries (drop + physical courier + direct link) are
/// absorbed downstream by message-ID dedup.
@MainActor
final class BridgeCourierService: ObservableObject {
enum Limits {
/// Drops waiting for relay connectivity (bounded, drop-oldest).
static let maxPendingDrops = 20
/// Republish cooldown for gateway-held envelopes.
static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60
/// Local peers a gateway watches drops for (x3 candidate tags each).
static let maxWatchedPeers = 16
/// Tag-set refresh cadence (also covers UTC day rollover).
static let refreshIntervalSeconds: TimeInterval = 30 * 60
/// Minimum spacing for announce-driven refreshes.
static let announceRefreshDebounceSeconds: TimeInterval = 60
/// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack).
static let maxDropEnvelopeBytes = 20 * 1024
static let maxTrackedIDs = 512
}
static let shared = BridgeCourierService()
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
var bridgeEnabled: (@MainActor () -> Bool)?
var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event to the default (DM) relays.
var publishEvent: (@MainActor (NostrEvent) -> Void)?
/// (Re)opens the drop subscription for the given hex tags.
var openSubscription: (@MainActor ([String]) -> Void)?
var closeSubscription: (@MainActor () -> Void)?
/// Our own Noise static public key.
var myNoiseKey: (@MainActor () -> Data?)?
/// Verified reachable local peers with known Noise keys.
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
/// Seals content into a carry-only envelope for a recipient key.
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
/// Opens a drop addressed to us (tag verified inside).
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
/// Hands a drop to a matching local peer as a directed courier packet.
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)?
/// Held envelopes eligible for (re)publish, honoring the cooldown.
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
/// Timer injection for tests; nil arms a real `Task`.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
// MARK: State
private(set) var myTagsHex: Set<String> = []
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
/// Message IDs already published as drops (sender-side dedup).
private var publishedDropKeys: BoundedIDSet
/// Drop event IDs already handled (multi-relay dedup).
private var seenDropEventIDs: BoundedIDSet
private var subscriptionOpen = false
private var lastSubscribedTags: Set<String> = []
private var refreshTimerArmed = false
private var lastAnnounceRefresh = Date.distantPast
private let now: () -> Date
init(now: @escaping () -> Date = Date.init) {
self.now = now
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs)
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs)
}
// MARK: - Sender role
/// Parallel-deposit a sealed copy of an outbound private message as a
/// relay drop. Called by the message router alongside physical courier
/// deposits; idempotent per message ID. Returns true when a fresh drop
/// was sealed (published now or queued for the next relay connection)
/// the router marks the message "carried" so the sender sees progress.
@discardableResult
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
guard bridgeEnabled?() ?? false else { return false }
guard !publishedDropKeys.contains(messageID) else { return false }
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
publishedDropKeys.insert(messageID)
publishDrop(envelope)
return true
}
/// Publishes held envelopes (mail we carry for others) as drops,
/// honoring the per-envelope cooldown.
func publishHeldEnvelopes() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
publishDrop(envelope)
}
}
private func publishDrop(_ envelope: CourierEnvelope) {
guard let encoded = envelope.encode(),
encoded.count <= Limits.maxDropEnvelopeBytes,
!envelope.isExpired else { return }
guard relaysConnected?() ?? false else {
pendingDrops.append((envelope, nil))
if pendingDrops.count > Limits.maxPendingDrops {
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops)
}
return
}
guard let identity = Self.makeThrowawayIdentity(),
let event = try? NostrProtocol.createCourierDropEvent(
envelope: encoded,
recipientTagHex: envelope.recipientTag.hexEncodedString(),
expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000),
senderIdentity: identity
) else {
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
return
}
publishEvent?(event)
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
}
/// Drops queued while relays were unreachable publish on reconnect.
func flushPendingDrops() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
let queued = pendingDrops
pendingDrops.removeAll()
for item in queued {
publishDrop(item.envelope)
}
}
// MARK: - Subscription (recipient + gateway watch)
/// Recomputes the watched tag set and (re)opens the subscription.
/// Call on toggle changes, relay connectivity changes, and periodically
/// (tags rotate daily); idempotent.
func refresh() {
armRefreshTimerIfNeeded()
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
}
return
}
let date = now()
if let myKey = myNoiseKey?() {
myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() })
} else {
myTagsHex = []
}
// While bridging with internet, every device watches drops for its
// verified local peers the single-switch analogue of gateway duty.
let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers)
watchedPeerTags = peers.map { peer in
(peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() }))
}
let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex))
guard !allTags.isEmpty else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
lastSubscribedTags = []
}
return
}
// Resubscribe only when the watched set actually changed refresh
// fires on every verified announce (field logs showed the drop
// subscription rebuilt every ~60s for an unchanged tag set).
if !subscriptionOpen || allTags != lastSubscribedTags {
openSubscription?(allTags.sorted())
subscriptionOpen = true
lastSubscribedTags = allTags
}
flushPendingDrops()
publishHeldEnvelopes()
}
/// Announce-driven refresh, debounced a newly verified peer should be
/// watched promptly, but announce storms must not thrash subscriptions.
func refreshAfterVerifiedAnnounce() {
guard bridgeEnabled?() ?? false else { return }
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
lastAnnounceRefresh = now()
refresh()
}
private func armRefreshTimerIfNeeded() {
guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return }
refreshTimerArmed = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.refreshTimerArmed = false
self.refresh()
}
if let scheduleTimer {
scheduleTimer(Limits.refreshIntervalSeconds, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000))
fire()
}
}
}
// MARK: - Inbound drops
/// Entry point for every drop event the subscription delivers (the relay
/// manager has already verified the event signature).
func handleDropEvent(_ event: NostrEvent) {
guard bridgeEnabled?() ?? false else { return }
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
guard seenDropEventIDs.insert(event.id) else { return }
guard let data = Data(base64Encoded: event.content),
data.count <= Limits.maxDropEnvelopeBytes,
let envelope = CourierEnvelope.decode(data),
!envelope.isExpired else {
return
}
let tagHex = envelope.recipientTag.hexEncodedString()
// The envelope's own tag must match the event's filterable tag
// otherwise a mislabeled drop could ride a subscription it doesn't
// belong to.
guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return }
if myTagsHex.contains(tagHex) {
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
openEnvelope?(envelope)
return
}
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))", category: .session)
deliverToPeer?(envelope, match.peerID)
}
}
// MARK: - Helpers
/// A fresh random Nostr identity for signing one drop.
static func makeThrowawayIdentity() -> NostrIdentity? {
for _ in 0..<10 {
var bytes = Data(count: 32)
let status = bytes.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
guard status == errSecSuccess else { return nil }
if let identity = try? NostrIdentity(privateKeyData: bytes) {
return identity
}
}
return nil
}
}
@@ -0,0 +1,689 @@
//
// BridgeService.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
/// Policy engine for the mesh bridge: an opt-in stitcher of disjoint BLE
/// mesh islands that share a place. While the toggle is on, this device's
/// public mesh messages are additionally signed (with a derived, unlinkable
/// per-cell Nostr identity) as rendezvous events for the local geohash cell
/// and published to the cell's deterministic geo relays directly when we
/// have internet, or deposited with a bridge gateway peer over a directed
/// `toBridge` carrier when we are mesh-only. Inbound rendezvous events from
/// other islands render into the mesh timeline marked as bridged.
///
/// A device with BOTH this toggle and the gateway toggle on serves the
/// island: it accepts `toBridge` deposits, publishes them, and rebroadcasts
/// remote rendezvous events onto the mesh as `fromBridge` carriers so
/// mesh-only peers see across the bridge too.
///
/// Consent model:
/// - Nothing crosses a bridge unless its author signed it for the bridge:
/// gateways carry only finished, Schnorr-signed rendezvous events, so a
/// neighbor's gateway cannot exfiltrate radio-only traffic. The per-message
/// "nearby only" flag simply skips composing a rendezvous copy.
/// - Receiving over radio (`fromBridge` carriers) is always on it is
/// passive and leaks nothing. Subscribing over the internet (which reveals
/// the coarse cell to relays) and publishing both require the toggle.
///
/// Loop-prevention rules (adapted from `GatewayService`, unit-tested):
/// 1. An event learned from a `fromBridge` mesh broadcast is never published
/// and never rebroadcast (`meshBroadcastEventIDs`) a second bridge
/// gateway on the same island cannot echo mesh-carried traffic.
/// 2. An event this device published (own messages or uplinked deposits,
/// `publishedEventIDs`) is never downlink-rebroadcast: it originated on
/// this island, so our own relay subscription redelivering it must not
/// double BLE airtime.
/// 3. A subscription event is rebroadcast at most once
/// (`rebroadcastEventIDs`, marked after send), and never when the island
/// already holds the radio copy (`isMessageSeenLocally` on the event's
/// mesh message ID) remote islands' traffic is the only thing worth
/// airtime.
/// Receivers additionally dedup by timeline message ID: a bridged copy
/// carries the original mesh message ID in its `m` tag, so the store's
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order.
///
/// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays, radios, or CoreLocation.
@MainActor
final class BridgeService: ObservableObject {
enum Limits {
/// Uplink deposits held while relays are unreachable.
static let maxQueuedUplinks = 20
static let maxQueuedUplinksPerDepositor = 5
/// Uplink deposits accepted per depositor per minute.
static let uplinkEventsPerMinutePerDepositor = 10
/// Downlink mesh rebroadcasts per minute BLE airtime is precious,
/// and bridge traffic shares the radio with everything else.
static let downlinkEventsPerMinute = 20
static let maxPendingDownlinks = 30
/// Accepted clock skew for a rendezvous event.
static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512
/// Presence heartbeat cadence while the bridge is active.
static let presenceIntervalSeconds: TimeInterval = 4 * 60
/// A rendezvous participant counts toward "via bridge" for this long
/// after their last event.
static let participantFreshnessSeconds: TimeInterval = 10 * 60
/// Content cap, matching the public-message pipeline's own limit.
static let maxContentBytes = 16_000
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
static let cellPrecision = 6
}
struct QueuedUplink {
let depositor: PeerID
let cell: String
let event: NostrEvent
}
/// A validated rendezvous message ready for the timeline.
struct InboundBridgeMessage {
let messageID: String
let senderNickname: String
let senderPubkey: String
let content: String
let timestamp: Date
}
/// A person currently visible across the bridge (fresh, not attributed
/// to the local island), for the people sheet.
struct BridgedParticipant: Identifiable, Equatable {
let pubkey: String
let nickname: String?
let lastSeen: Date
var id: String { pubkey }
/// Geohash-chat convention: nickname#last-4-of-pubkey, so two remote
/// "anon"s stay distinguishable.
var displayName: String {
(nickname?.trimmedOrNilIfEmpty ?? "anon") + "#" + String(pubkey.suffix(4))
}
}
static let shared = BridgeService()
/// The user toggle. While true this device publishes its own public mesh
/// messages to the rendezvous and subscribes to it when online.
@Published private(set) var isEnabled: Bool
/// Distinct remote rendezvous participants seen within the freshness
/// window. Approximate by design: local participants are subtracted by
/// matching their events' mesh message IDs against the local timeline,
/// which cannot attribute silent (presence-only) local peers.
@Published private(set) var bridgedPeerCount: Int = 0
/// The people behind the count, newest activity first.
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
/// The rendezvous cell currently in use, when the bridge is active.
@Published private(set) var activeCell: String?
/// Per-session compose flag: while true, outgoing messages stay on the
/// radio no rendezvous copy is composed, so no gateway can carry them.
@Published var nearbyOnly: Bool = false
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
/// Publishes a signed event to the geo relays for a cell.
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
/// Opens the rendezvous subscription for (cell + neighbors); events are
/// fed back via `handleRendezvousEvent`.
var openSubscription: (@MainActor ([String]) -> Void)?
/// Closes the rendezvous subscription.
var closeSubscription: (@MainActor () -> Void)?
/// Whether any Nostr relay connection is currently working.
var relaysConnected: (@MainActor () -> Bool)?
/// The local neighborhood cell from CoreLocation, if permitted.
var locationCell: (@MainActor () -> String?)?
/// Asks the location layer for a fresh one-shot fix. The bridge must
/// pump location itself: channel data otherwise only flows while some
/// other feature (channels sheet, location notes) happens to be active
/// a field failure mode where the bridge silently never got a cell.
var requestLocationFix: (@MainActor () -> Void)?
/// A rendezvous cell advertised by a reachable mesh bridge peer's
/// announce lets a mesh-only, location-less device still compose
/// correctly tagged events.
var meshAdvertisedCell: (@MainActor () -> String?)?
/// Sends an encoded `toBridge` carrier directed to a bridge peer.
var sendToBridgePeer: (@MainActor (Data, PeerID) -> Bool)?
/// Reachable mesh peers advertising the `.bridge` capability.
var availableBridgePeers: (@MainActor () -> [PeerID])?
/// Broadcasts an encoded `fromBridge` carrier on the mesh.
var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Delivers a validated inbound bridge message to the mesh timeline.
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
/// True when the mesh timeline already holds this message ID (the radio
/// copy) used to skip pointless downlink airtime.
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
/// Derives the unlinkable per-cell rendezvous identity.
var deriveIdentity: (@MainActor (String) throws -> NostrIdentity)?
/// Local nickname for the `n` tag.
var myNickname: (@MainActor () -> String)?
/// Fired on toggle changes (advertise/withdraw `.bridge` + re-announce).
var onEnabledChanged: (@MainActor (Bool) -> Void)?
/// Fired when the active rendezvous cell changes (including to nil) so
/// the announce advertisement stays current.
var onActiveCellChanged: (@MainActor (String?) -> Void)?
/// Schedules a closure after a delay; nil arms a real `Task`. Injected so
/// timers are deterministic in tests.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
// MARK: State
/// Loop rule 1: event IDs seen in `fromBridge` mesh broadcasts.
private var meshBroadcastEventIDs: BoundedIDSet
/// Loop rule 2: event IDs this device published (own or deposited).
private var publishedEventIDs: BoundedIDSet
/// Loop rule 3: event IDs this device already rebroadcast.
private var rebroadcastEventIDs: BoundedIDSet
/// Timeline message IDs already injected (either arrival path).
private var injectedMessageIDs: BoundedIDSet
/// Cells the rendezvous subscription covers (own + neighbor ring).
private(set) var subscribedCells: Set<String> = []
private(set) var queuedUplinks: [QueuedUplink] = []
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
private var downlinkSendTimes: [Date] = []
private var pendingDownlinks: [(event: NostrEvent, cell: String)] = []
private var downlinkDrainScheduled = false
private var presenceTimerArmed = false
private var lastPresenceAt = Date.distantPast
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname).
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
private let defaults: UserDefaults
private let now: () -> Date
private static let enabledKey = "bridge.userEnabled"
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults
self.now = now
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
}
// MARK: - Toggle & lifecycle
func setEnabled(_ enabled: Bool) {
guard enabled != isEnabled else { return }
isEnabled = enabled
defaults.set(enabled, forKey: Self.enabledKey)
if !enabled {
queuedUplinks.removeAll()
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
participants.removeAll()
bridgedPeerCount = 0
bridgedParticipants = []
}
SecureLogger.info("🌉 Bridge mode \(enabled ? "enabled" : "disabled")", category: .session)
refreshRendezvous()
onEnabledChanged?(enabled)
}
/// Recomputes the active cell and (re)opens or closes the subscription.
/// Call on toggle changes, location updates, and relay connectivity
/// changes; idempotent.
func refreshRendezvous() {
let cell = isEnabled ? currentCell() : nil
// No cell yet: ask for a fix the availableChannels change re-enters
// here once it lands.
if isEnabled, cell == nil {
requestLocationFix?()
}
guard cell != activeCell else {
// The maintenance timer must run even cell-less: it is what
// retries the location fix (launch races the permission
// callback, so the first request can silently no-op).
if isEnabled { armPresenceTimerIfNeeded() }
return
}
if activeCell != nil {
closeSubscription?()
subscribedCells = []
}
activeCell = cell
onActiveCellChanged?(cell)
guard let cell else {
if isEnabled { armPresenceTimerIfNeeded() }
return
}
// Own cell + neighbors: islands straddling a cell edge still meet.
// Publishes go to the own cell only; symmetric because both sides
// subscribe to each other's cell via the neighbor ring.
let cells = [cell] + Geohash.neighbors(of: cell)
subscribedCells = Set(cells)
openSubscription?(cells)
SecureLogger.info("🌉 Bridge: rendezvous open for cell \(cell)", category: .session)
publishPresence()
armPresenceTimerIfNeeded()
}
/// The rendezvous cell: our own location when we have it, else the cell
/// a reachable bridge gateway advertises in its announce.
private func currentCell() -> String? {
if let own = locationCell?(), !own.isEmpty {
return String(own.prefix(Limits.cellPrecision))
}
if let advertised = meshAdvertisedCell?(), GatewayService.isValidGeohash(advertised) {
return String(advertised.prefix(Limits.cellPrecision))
}
return nil
}
// One switch does the right thing: while bridging, a device with
// internet automatically serves its island (accepts deposits, carries
// remote messages onto the radio). The marginal cost over bridging
// yourself is small the relay connections and subscription already
// exist for you and a separate "serve others" lever proved to be a
// silent trap for mesh-only neighbors.
// MARK: - Outgoing (sender role)
/// Composes and ships the bridged copy of an outgoing public mesh
/// message. Call after the radio send; no-op when the bridge is off,
/// no cell is known, or the message was flagged nearby-only upstream.
func bridgeOutgoing(content: String, messageID: String) {
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
guard content.utf8.count <= Limits.maxContentBytes else { return }
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgeMeshEvent(
content: content,
cell: cell,
senderIdentity: identity,
nickname: myNickname?(),
meshMessageID: messageID
) else {
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
return
}
publishedEventIDs.insert(event.id)
injectedMessageIDs.insert(messageID) // our own timeline already has it
if relaysConnected?() ?? false {
publishToRelays?(event, cell)
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
let payload = carrier.encode(),
let gateway = availableBridgePeers?().first {
if sendToBridgePeer?(payload, gateway) ?? false {
SecureLogger.debug("🌉 Bridge: uplinked own event via gateway \(gateway.id.prefix(8))", category: .session)
}
}
}
/// Publishes a presence heartbeat so silent participants still register
/// across the bridge. Throttled: several triggers (enable, cell change,
/// relay reconnect) can coincide, and same-second heartbeats are
/// byte-identical events anyway.
func publishPresence() {
guard isEnabled, let cell = activeCell, relaysConnected?() ?? false else { return }
guard now().timeIntervalSince(lastPresenceAt) >= 30 else { return }
lastPresenceAt = now()
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: identity) else { return }
publishedEventIDs.insert(event.id)
publishToRelays?(event, cell)
}
/// Maintenance heartbeat while bridging: presence, participant pruning,
/// and a location retry. Runs with or without a cell the cell-less
/// case is exactly when the location retry matters.
private func armPresenceTimerIfNeeded() {
guard isEnabled, !presenceTimerArmed else { return }
presenceTimerArmed = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.presenceTimerArmed = false
self.publishPresence()
self.pruneParticipants()
// Location refresh: migrates cells on a moving device and
// recovers a launch that raced the permission callback.
if self.activeCell == nil {
self.refreshRendezvous()
} else {
self.requestLocationFix?()
}
self.armPresenceTimerIfNeeded()
}
if let scheduleTimer {
scheduleTimer(Limits.presenceIntervalSeconds, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(Limits.presenceIntervalSeconds * 1_000_000_000))
fire()
}
}
}
// MARK: - Subscription ingress (internet role)
/// Entry point for every event the rendezvous subscription delivers.
/// Handles presence accounting, timeline injection, and when acting as
/// the island's gateway downlink rebroadcast.
func handleRendezvousEvent(_ event: NostrEvent) {
guard isEnabled else { return }
// The subscription spans our cell + neighbors; trust only the
// event's own signed `r` tag, and only within that ring.
guard let cell = event.tags.first(where: { $0.count >= 2 && $0[0] == "r" })?[1],
subscribedCells.contains(cell) else {
return
}
guard let kind = classify(event, cell: cell) else { return }
// Events we published come back from our own subscription; they are
// presence-neutral (we never count ourselves) and never re-injected
// or rebroadcast. Two layers: the published-ID cache (this session)
// and pubkey self-recognition the rendezvous identity is derived
// deterministically, so even after a relaunch wipes the cache our
// own relay-backfilled events are recognized (field bug: own
// pre-restart messages re-rendered as bridged).
guard !publishedEventIDs.contains(event.id) else { return }
if isOwnRendezvousEvent(event, cell: cell) {
publishedEventIDs.insert(event.id) // never downlink it either
return
}
guard event.isValidSignature() else { return }
switch kind {
case .presence:
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
case .message(let message):
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
if isLocalRadioCopy {
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
}
recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname)
inject(message)
// Serving duty: carry remote islands' messages onto the radio for
// mesh-only peers. Local-origin events are skipped the island
// already heard them (loop rule 3). The drain is jitter-delayed:
// with every online bridger serving, the holdoff lets gateways
// hear each other's broadcasts and skip duplicates.
if !isLocalRadioCopy,
!meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id),
!pendingDownlinks.contains(where: { $0.event.id == event.id }) {
pendingDownlinks.append((event, cell))
if pendingDownlinks.count > Limits.maxPendingDownlinks {
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
}
scheduleDownlinkDrainIfNeeded(jitter: true)
}
}
}
// MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets with bridge
/// directions. `directedToUs` is true for `toBridge` deposits addressed
/// to this device; false for `fromBridge` broadcasts.
func handleMeshCarrier(_ carrier: NostrCarrierPacket, from peerID: PeerID, directedToUs: Bool) {
switch carrier.direction {
case .toBridge:
guard directedToUs else { return }
handleUplinkDeposit(carrier, from: peerID)
case .fromBridge:
guard !directedToUs else { return }
handleDownlinkBroadcast(carrier)
case .toGateway, .fromGateway:
return // GatewayService territory; routed there by the caller.
}
}
// MARK: - Uplink (gateway role: mesh peer -> internet)
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
guard isEnabled else { return }
// Cheap structural gates before any crypto, mirroring GatewayService.
guard let event = structurallyValidEvent(from: carrier) else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
return
}
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
return
}
guard allowUplinkDeposit(from: depositor) else {
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))", category: .session)
return
}
guard event.isValidSignature() else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
return
}
if relaysConnected?() ?? false {
publish(event, cell: carrier.geohash)
} else {
enqueueUplink(QueuedUplink(depositor: depositor, cell: carrier.geohash, event: event))
}
// No local injection: the depositor's radio broadcast already carried
// the message to this island, including us.
}
/// Publish everything queued while relays were unreachable.
func flushQueuedUplinks() {
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
let queued = queuedUplinks
queuedUplinks.removeAll()
for item in queued where !publishedEventIDs.contains(item.event.id) {
publish(item.event, cell: item.cell)
}
}
private func publish(_ event: NostrEvent, cell: String) {
publishedEventIDs.insert(event.id)
publishToRelays?(event, cell)
SecureLogger.info("🌉 Bridge: published carried event \(event.id.prefix(8))… for cell \(cell)", category: .session)
}
@discardableResult
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { return false }
if queuedUplinks.count >= Limits.maxQueuedUplinks {
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
}
queuedUplinks.append(item)
return true
}
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
let cutoff = now().addingTimeInterval(-60)
var times = uplinkDepositTimes[depositor, default: []]
times.removeAll { $0 < cutoff }
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
uplinkDepositTimes[depositor] = times
return false
}
times.append(now())
uplinkDepositTimes[depositor] = times
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
uplinkDepositTimes = uplinkDepositTimes.filter { $0.value.contains { $0 >= cutoff } }
}
return true
}
// MARK: - Downlink (gateway role: internet -> mesh)
private func drainPendingDownlinks() {
let cutoff = now().addingTimeInterval(-60)
downlinkSendTimes.removeAll { $0 < cutoff }
while !pendingDownlinks.isEmpty,
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
let (event, cell) = pendingDownlinks.removeFirst()
guard isFresh(event) else { continue }
// Suppression recheck at send time: another gateway may have
// broadcast this event during our jitter holdoff.
guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id) else { continue }
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
let payload = carrier.encode() else { continue }
broadcastToMesh?(payload)
SecureLogger.debug("🌉 Bridge: downlinked remote event \(event.id.prefix(8))… onto the mesh", category: .session)
// Mark-after-send (loop rule 3): a queue-overflow drop stays
// retryable on relay redelivery.
rebroadcastEventIDs.insert(event.id)
downlinkSendTimes.append(now())
}
scheduleDownlinkDrainIfNeeded()
}
private func scheduleDownlinkDrainIfNeeded(jitter: Bool = false) {
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
let delay: TimeInterval
if jitter {
// Multi-gateway suppression window: enough spread for another
// gateway's broadcast to land and mark the event mesh-carried.
delay = Double.random(in: 0.2...1.5)
} else {
let oldest = downlinkSendTimes.min() ?? now()
delay = max(0.05, 60 - now().timeIntervalSince(oldest))
}
downlinkDrainScheduled = true
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.downlinkDrainScheduled = false
self.drainPendingDownlinks()
}
if let scheduleTimer {
scheduleTimer(delay, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
fire()
}
}
}
// MARK: - Downlink (receiver role: carried event arrives over radio)
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
// Reception is deliberately NOT gated on the toggle: it is passive
// radio, and two phones side by side should not disagree about what
// the channel said. Publishing/subscribing remain opt-in.
guard let event = structurallyValidEvent(from: carrier),
!publishedEventIDs.contains(event.id),
!isOwnRendezvousEvent(event, cell: carrier.geohash),
event.isValidSignature() else {
return
}
// Mark after verification (a forged copy must not poison the cache),
// and use the marking as multi-path dedup.
guard meshBroadcastEventIDs.insert(event.id) else { return }
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
return
}
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
inject(message)
}
// MARK: - Injection & participants
private func inject(_ message: InboundBridgeMessage) {
guard injectedMessageIDs.insert(message.messageID) else { return }
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
injectInbound?(message)
}
private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
let previous = participants[pubkey]
// Local attribution is sticky: one radio-confirmed message marks the
// pubkey as an islander for as long as they stay fresh. Presence
// events carry no nickname, so a known name is never forgotten.
participants[pubkey] = (
lastSeen: now(),
isLocal: (previous?.isLocal ?? false) || isLocal,
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
)
recomputeBridgedCount()
}
private func pruneParticipants() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
participants = participants.filter { $0.value.lastSeen >= cutoff }
recomputeBridgedCount()
}
private func recomputeBridgedCount() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
let visible = participants
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
.sorted { $0.lastSeen > $1.lastSeen }
if visible.count != bridgedPeerCount {
bridgedPeerCount = visible.count
}
if visible != bridgedParticipants {
bridgedParticipants = visible
}
}
// MARK: - Validation
private enum RendezvousKind {
case message(InboundBridgeMessage)
case presence
}
/// Classifies a structurally acceptable rendezvous event; nil rejects.
private func classify(_ event: NostrEvent, cell: String) -> RendezvousKind? {
guard isFresh(event),
event.tags.contains(where: { $0.count >= 2 && $0[0] == "r" && $0[1] == cell }),
GatewayService.isValidGeohash(cell) else {
return nil
}
switch event.kind {
case NostrProtocol.EventKind.geohashPresence.rawValue:
return .presence
case NostrProtocol.EventKind.ephemeralEvent.rawValue:
let content = event.content
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1]
let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1]
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id
return .message(InboundBridgeMessage(
messageID: messageID,
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))",
senderPubkey: event.pubkey,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
))
default:
return nil
}
}
/// Parse + size + cell + kind + `r` tag + freshness, with NO signature
/// verification callers dedup/rate-limit first, Schnorr-verify last.
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
GatewayService.isValidGeohash(carrier.geohash),
let event = carrier.event(),
classify(event, cell: carrier.geohash) != nil else {
return nil
}
return event
}
private func isFresh(_ event: NostrEvent) -> Bool {
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
}
/// True when the event was signed by this device's own derived
/// rendezvous identity for the cell. Survives relaunches (unlike the
/// published-ID cache) because the derivation is deterministic; the
/// underlying identity cache makes this cheap.
private func isOwnRendezvousEvent(_ event: NostrEvent, cell: String) -> Bool {
guard let identity = try? deriveIdentity?(cell) else { return false }
return identity.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
}
+4 -27
View File
@@ -178,6 +178,10 @@ final class GatewayService: ObservableObject {
// Downlink rides broadcast only; a directed fromGateway is malformed.
guard !directedToUs else { return }
handleDownlinkBroadcast(carrier)
case .toBridge, .fromBridge:
// Mesh-bridge carriers are BridgeService territory; the ingress
// router dispatches them there.
return
}
}
@@ -462,30 +466,3 @@ final class GatewayService: ObservableObject {
&& geohash.allSatisfy { allowed.contains($0) }
}
}
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
/// evicted when full.
private struct BoundedIDSet {
private var members: Set<String> = []
private var order: [String] = []
let capacity: Int
init(capacity: Int) {
self.capacity = capacity
}
func contains(_ id: String) -> Bool {
members.contains(id)
}
/// Returns false when the ID was already present.
@discardableResult
mutating func insert(_ id: String) -> Bool {
guard members.insert(id).inserted else { return false }
order.append(id)
if order.count > capacity {
members.remove(order.removeFirst())
}
return true
}
}
+52
View File
@@ -52,6 +52,53 @@ final class MessageRouter {
/// message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Parallel deposit into the internet bridge: park a sealed copy on
/// relays as a courier drop, so delivery stops requiring a physical
/// courier encounter. No-op unless the bridge is enabled. Runs alongside
/// (not instead of) mesh couriers; receivers dedup by message ID.
/// Returns true when a fresh drop was sealed, so the sender's message
/// can show the "carried" state instead of sitting on "sending" forever
/// (the delivery ack has no radio route back until the peers next share
/// a transport).
var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)?
/// Re-attempts bridge drops for retained messages whose recipient no
/// transport can promptly reach anymore. Covers sends that raced the BLE
/// reachability retention window: a peer stays "reachable" for a minute
/// after its radio disappears, so the original send trusted the mesh and
/// skipped the deposit and nothing else ever retried (field-found).
/// Safe to call often: the drop layer dedups by message ID.
func retryBridgeCourierDeposits() {
guard bridgeCourierDeposit != nil else { return }
for (peerID, queue) in outbox {
guard let recipientKey = courierDirectory.noiseKey(peerID) else { continue }
let promptlyDeliverable = transports.contains {
$0.isPeerReachable(peerID) && $0.canDeliverPromptly(to: peerID)
}
guard !promptlyDeliverable else { continue }
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true {
onMessageCarried?(message.messageID, peerID)
}
}
}
}
/// Arms the periodic sweep behind `retryBridgeCourierDeposits`. Called
/// once by the bootstrapper after the deposit closure is wired; separate
/// from init so tests drive the retry directly.
func startBridgeDepositSweep(interval: TimeInterval = 120) {
bridgeSweepTask?.cancel()
bridgeSweepTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
self?.retryBridgeCourierDeposits()
}
}
}
private var bridgeSweepTask: Task<Void, Never>?
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
@@ -160,6 +207,11 @@ final class MessageRouter {
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID),
let entry = queuedMessage(messageID, for: peerID) else { return }
// The bridge drop needs no connected courier only the recipient
// key so it runs before the courier-slot bookkeeping.
if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true {
onMessageCarried?(messageID, peerID)
}
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return }
@@ -42,6 +42,9 @@ protocol ChatOutgoingContext: AnyObject {
func recordPublicActivity(forChannelKey key: String)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
/// Ships the bridged (rendezvous) copy of a just-sent public mesh
/// message; no-op when the bridge is off or the send is nearby-only.
func bridgeOutgoingPublicMessage(_ content: String, messageID: String)
// MARK: Geohash identity (shared with the other contexts)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
@@ -62,6 +65,10 @@ extension ChatViewModel: ChatOutgoingContext {
func recordPublicActivity(forChannelKey key: String) {
lastPublicActivityAt[key] = Date()
}
func bridgeOutgoingPublicMessage(_ content: String, messageID: String) {
BridgeService.shared.bridgeOutgoing(content: content, messageID: messageID)
}
}
@MainActor
@@ -140,6 +147,7 @@ private extension ChatOutgoingCoordinator {
messageID: message.id,
timestamp: message.timestamp
)
context.bridgeOutgoingPublicMessage(trimmed, messageID: message.id)
}
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
@@ -77,6 +77,8 @@ final class ChatViewModelBootstrapper {
bindTransferProgress()
configureGeoChannels()
configureGateway()
configureBridge()
configureBridgeCourier()
bindTeleportState()
requestNotifications()
registerObservers()
@@ -350,15 +352,31 @@ private extension ChatViewModelBootstrapper {
bleService?.setLocalCapability(.gateway, enabled: enabled)
}
bleService.onNostrCarrierPacket = { payload, from, directedToUs in
GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs)
// One decode, two policy engines: geohash-channel carriers go to
// the gateway, mesh-bridge carriers to the bridge.
guard let carrier = NostrCarrierPacket.decode(payload) else {
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(from.id.prefix(8))", category: .session)
return
}
switch carrier.direction {
case .toGateway, .fromGateway:
GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs)
case .toBridge, .fromBridge:
BridgeService.shared.handleMeshCarrier(carrier, from: from, directedToUs: directedToUs)
}
}
// Uplinks deposited while relays were unreachable flush on reconnect.
// The publisher re-emits `true` on every relay state recompute, so
// dedupe: field logs showed presence published 5x in one second.
NostrRelayManager.shared.$isConnected
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { connected in
if connected {
GatewayService.shared.flushQueuedUplinks()
BridgeService.shared.flushQueuedUplinks()
BridgeService.shared.publishPresence()
}
}
.store(in: &viewModel.cancellables)
@@ -369,6 +387,217 @@ private extension ChatViewModelBootstrapper {
}
}
/// Wires the mesh-bridge policy layer (`BridgeService`) to the mesh
/// transport, the relay manager, location, and the public timeline. Same
/// closure-injection style as `configureGateway`.
func configureBridge() {
guard let bleService = viewModel.meshService as? BLEService else { return }
let bridge = BridgeService.shared
let idBridge = viewModel.idBridge
bridge.publishToRelays = { event, cell in
let relays = GeoRelayDirectory.shared.closestRelays(
toGeohash: cell,
count: TransportConfig.nostrGeoRelayCount
)
guard !relays.isEmpty else {
SecureLogger.warning("🌉 Bridge: no geo relays for cell \(cell); not publishing", category: .session)
return
}
NostrRelayManager.shared.sendEvent(event, to: relays)
}
bridge.openSubscription = { cells in
guard let cell = cells.first else { return }
let relays = GeoRelayDirectory.shared.closestRelays(
toGeohash: cell,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(
filter: .bridgeRendezvous(cells, since: Date().addingTimeInterval(-BridgeService.Limits.maxEventAgeSeconds)),
id: Self.bridgeSubscriptionID,
relayUrls: relays.isEmpty ? nil : relays,
handler: { event in
BridgeService.shared.handleRendezvousEvent(event)
}
)
}
bridge.closeSubscription = {
NostrRelayManager.shared.unsubscribe(id: Self.bridgeSubscriptionID)
}
bridge.relaysConnected = { NostrRelayManager.shared.isConnected }
bridge.locationCell = { [weak viewModel] in
viewModel?.locationManager.availableChannels
.first { $0.level == .neighborhood }?
.geohash
}
bridge.requestLocationFix = { [weak viewModel] in
viewModel?.locationManager.refreshChannels()
}
bridge.meshAdvertisedCell = { [weak bleService] in
bleService?.advertisedBridgeGeohash()
}
bridge.sendToBridgePeer = { [weak bleService] payload, peer in
bleService?.sendNostrCarrier(payload, to: peer) ?? false
}
bridge.availableBridgePeers = { [weak bleService] in
bleService?.reachableBridgePeers() ?? []
}
bridge.broadcastToMesh = { [weak bleService] payload in
bleService?.broadcastNostrCarrier(payload)
}
bridge.injectInbound = { [weak viewModel] inbound in
viewModel?.handlePublicMessage(BitchatMessage(
id: inbound.messageID,
sender: inbound.senderNickname,
content: inbound.content,
timestamp: inbound.timestamp,
isRelay: false,
senderPeerID: PeerID(bridge: inbound.senderPubkey),
isBridged: true
))
}
bridge.isMessageSeenLocally = { [weak viewModel] messageID in
viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false
}
bridge.deriveIdentity = { cell in
try idBridge.deriveIdentity(forBridgeRendezvous: cell)
}
bridge.myNickname = { [weak viewModel] in viewModel?.nickname ?? "" }
// The `.bridge` capability + cell TLV advertise serving duty: "send
// me deposits, and this is the island's cell". One switch: bridging
// with a known cell is serving (deposits queue through connectivity
// gaps, so the advertisement doesn't flap with the relays).
let updateAdvertisement: @MainActor () -> Void = { [weak bleService] in
let advertise = BridgeService.shared.isEnabled
&& BridgeService.shared.activeCell != nil
bleService?.setLocalBridgeGeohash(advertise ? BridgeService.shared.activeCell : nil)
bleService?.setLocalCapability(.bridge, enabled: advertise)
}
bridge.onEnabledChanged = { [weak viewModel] enabled in
updateAdvertisement()
// One switch collapses further: the bridge toggle also drives
// the geohash-channel gateway bridging with internet means
// sharing it with the mesh around you, full stop.
GatewayService.shared.setEnabled(enabled)
// Flipping the switch is the user-initiated moment to ask for
// location if it was never asked; otherwise the bridge sits
// cell-less with only a settings caption explaining why.
if enabled, viewModel?.locationManager.permissionState == .notDetermined {
viewModel?.locationManager.enableLocationChannels()
}
}
bridge.onActiveCellChanged = { _ in updateAdvertisement() }
// Align a persisted split state (e.g. gateway enabled back when it
// had its own toggle) to the single switch at launch.
if GatewayService.shared.isEnabled != bridge.isEnabled {
GatewayService.shared.setEnabled(bridge.isEnabled)
}
// Location fixes (or losing them) move the rendezvous cell.
viewModel.locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { _ in BridgeService.shared.refreshRendezvous() }
.store(in: &viewModel.cancellables)
// The authorization callback lands asynchronously after launch; the
// bootstrap-time location request races it and silently no-ops, so
// re-enter when the permission state resolves (field bug: bridge
// stayed cell-less for a whole session).
viewModel.locationManager.$permissionState
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { _ in BridgeService.shared.refreshRendezvous() }
.store(in: &viewModel.cancellables)
// Apply the persisted toggle at launch.
if bridge.isEnabled {
bridge.refreshRendezvous()
updateAdvertisement()
}
}
/// Wires courier-over-bridge (`BridgeCourierService`) to the relay
/// manager, the mesh transport's sealing/opening primitives, the courier
/// store, and the message router's deposit path.
func configureBridgeCourier() {
guard let bleService = viewModel.meshService as? BLEService else { return }
let courier = BridgeCourierService.shared
courier.bridgeEnabled = { BridgeService.shared.isEnabled }
courier.relaysConnected = { NostrRelayManager.shared.isConnected }
courier.publishEvent = { event in
// Default (DM) relays: drops need the standing global relay set,
// not geo relays sender and recipient share no cell.
NostrRelayManager.shared.sendEvent(event)
}
courier.openSubscription = { tagsHex in
NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID)
NostrRelayManager.shared.subscribe(
filter: .courierDrops(
recipientTagsHex: tagsHex,
since: Date().addingTimeInterval(-CourierEnvelope.maxLifetimeSeconds)
),
id: Self.courierDropSubscriptionID,
handler: { event in
BridgeCourierService.shared.handleDropEvent(event)
}
)
}
courier.closeSubscription = {
NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID)
}
courier.myNoiseKey = { [weak bleService] in
bleService?.myNoiseStaticPublicKey()
}
courier.localVerifiedPeers = { [weak bleService] in
bleService?.verifiedPeersWithNoiseKeys() ?? []
}
courier.sealEnvelope = { [weak bleService] content, messageID, recipientKey in
bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey)
}
courier.openEnvelope = { [weak bleService] envelope in
bleService?.openBridgedCourierEnvelope(envelope)
}
courier.deliverToPeer = { [weak bleService] envelope, peerID in
bleService?.deliverBridgedEnvelope(envelope, to: peerID)
}
courier.heldEnvelopes = { cooldown in
CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown)
}
viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey in
BridgeCourierService.shared.depositDrop(content: content, messageID: messageID, recipientNoiseKey: recipientKey)
}
// (depositDrop's Bool flows back so a dropped message shows the
// "carried" state a drop's delivery ack can't return over radio.)
viewModel.messageRouter.startBridgeDepositSweep()
bleService.onVerifiedPeerAnnounce = { _ in
Task { @MainActor in
BridgeCourierService.shared.refreshAfterVerifiedAnnounce()
}
}
// Relay connectivity gates everything; refresh (re)opens or closes.
// Deduped: refresh() resubscribes, and the raw publisher re-emits on
// every relay state recompute (6x in 300ms in field logs).
NostrRelayManager.shared.$isConnected
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { _ in BridgeCourierService.shared.refresh() }
.store(in: &viewModel.cancellables)
// Toggle changes re-evaluate the watch set.
BridgeService.shared.$isEnabled
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { _ in BridgeCourierService.shared.refresh() }
.store(in: &viewModel.cancellables)
courier.refresh()
}
private static let bridgeSubscriptionID = "bridge-rendezvous"
private static let courierDropSubscriptionID = "bridge-courier-drops"
func bindTeleportState() {
viewModel.locationManager.$teleported
.receive(on: DispatchQueue.main)
+358 -119
View File
@@ -1,17 +1,36 @@
import SwiftUI
/// The sheet behind the "bitchat/" logo: a segmented Settings/Info surface.
/// Settings gathers every user preference (appearance, voice, connectivity
/// toggles, panic wipe); Info keeps the about content (how-to, features,
/// privacy, symbols legend).
struct AppInfoView: View {
@Environment(\.dismiss) var dismiss
@ThemedPalette private var palette
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ObservedObject private var bridgeService = BridgeService.shared
/// Supplies the mesh topology map data. Nil (previews, missing wiring)
/// hides the topology row entirely.
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
/// Wipes all local data. Nil (previews, missing wiring) hides the danger
/// zone entirely.
var onPanicWipe: (@MainActor () -> Void)?
@State private var showTopology = false
@State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled
@State private var locationNotesEnabled = LocationNotesSettings.enabled
@ObservedObject private var locationManager = LocationChannelManager.shared
/// Sticky across opens: first-ever open lands on Info (the gentler
/// introduction), and afterwards the sheet reopens wherever it was left.
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
@State private var showPanicConfirmation = false
private enum Pane: String {
case settings
case info
}
private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix
@@ -20,13 +39,48 @@ struct AppInfoView: View {
private var textColor: Color { palette.primary }
private var secondaryTextColor: Color { palette.secondary }
// MARK: - Constants
private enum Strings {
static let appName: LocalizedStringKey = "app_info.app_name"
static let tagline: LocalizedStringKey = "app_info.tagline"
static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title"
/// New keys carry their English copy inline (defaultValue) until the
/// i18n pass lands them in the catalog; moved keys keep their homes.
enum Settings {
static let tabPickerLabel = String(localized: "app_info.tab.picker_label", defaultValue: "view", comment: "Accessibility label for the segmented control switching between the settings and info panes of the app info sheet")
static let tabSettings = String(localized: "app_info.tab.settings", defaultValue: "settings", comment: "Segmented control label for the settings pane of the app info sheet")
static let tabInfo = String(localized: "app_info.tab.info", defaultValue: "info", comment: "Segmented control label for the info pane of the app info sheet")
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
static func bridgeCell(_ cell: String) -> String {
String(
format: String(localized: "app_info.settings.bridge.cell", defaultValue: "rendezvous cell: %@", comment: "Caption under the mesh bridge toggle showing the geohash cell the bridge is meeting on"),
locale: .current,
cell
)
}
static let bridgeNoCell = String(localized: "app_info.settings.bridge.no_cell", defaultValue: "no rendezvous cell yet — needs location access or a nearby bridge peer", comment: "Caption under the mesh bridge toggle when the bridge is on but has no geohash cell to meet on")
// Moved from LocationChannelsSheet; keys unchanged. (The former
// internet-gateway toggle is gone: the bridge switch drives all
// internet sharing, including geohash-channel gatewaying.)
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings")
static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation")
static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut")
static let panicConfirmTitle = String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe")
static let panicConfirmAction = String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe")
}
enum Features {
static let title: LocalizedStringKey = "app_info.features.title"
static let offlineComm = AppInfoFeatureInfo(
@@ -59,46 +113,47 @@ struct AppInfoView: View {
title: "app_info.features.geohash.title",
description: "app_info.features.geohash.description"
)
static let bridge = AppInfoFeatureInfo(
icon: "network",
resolvedTitle: String(localized: "app_info.features.bridge.title", defaultValue: "mesh bridging", comment: "Feature row title for the mesh bridge in the app info sheet"),
resolvedDescription: String(localized: "app_info.features.bridge.description", defaultValue: "links nearby mesh islands through the internet so one crowd isn't split by radio range", comment: "Feature row description for the mesh bridge in the app info sheet")
)
}
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")
/// nothing else in the app defines them. A nil color renders in
/// the theme's primary text color.
static let items: [(icon: String, color: Color?, text: String)] = [
("antenna.radiowaves.left.and.right", nil, String(localized: "app_info.legend.mesh_connected")),
("point.3.filled.connected.trianglepath.dotted", nil, String(localized: "app_info.legend.mesh_relayed")),
("globe", nil, String(localized: "app_info.legend.nostr")),
("network", Color.cyan, String(localized: "app_info.legend.bridged", defaultValue: "message arrived across a mesh bridge", comment: "Symbols legend entry for the cyan network glyph shown on messages carried across a mesh bridge")),
("person", nil, String(localized: "app_info.legend.offline")),
("mappin.and.ellipse", nil, String(localized: "app_info.legend.location_nearby")),
("face.dashed", nil, String(localized: "app_info.legend.teleported")),
("lock.fill", nil, String(localized: "app_info.legend.encrypted")),
("lock.slash", nil, String(localized: "app_info.legend.encryption_failed")),
("checkmark.seal.fill", nil, String(localized: "app_info.legend.verified")),
("star.fill", nil, String(localized: "app_info.legend.favorite")),
("envelope.fill", nil, String(localized: "app_info.legend.unread")),
("nosign", nil, String(localized: "app_info.legend.blocked"))
]
}
enum Voice {
static let title: LocalizedStringKey = "app_info.voice.title"
static let live = AppInfoFeatureInfo(
icon: "dot.radiowaves.left.and.right",
title: "app_info.voice.live.title",
description: "app_info.voice.live.description"
)
// The live-voice title/description keys are referenced inline at
// the toggle (they ride the shared settingToggle now).
}
enum Location {
static let title: LocalizedStringKey = "app_info.location.title"
static let notes = AppInfoFeatureInfo(
icon: "mappin.and.ellipse",
title: "app_info.location.notes.title",
description: "app_info.location.notes.description"
)
static let enable: LocalizedStringKey = "app_info.location.enable"
static let openSettings: LocalizedStringKey = "app_info.location.open_settings"
}
enum Network {
@@ -149,7 +204,7 @@ struct AppInfoView: View {
}
}
var body: some View {
#if os(macOS)
VStack(spacing: 0) {
@@ -165,8 +220,12 @@ struct AppInfoView: View {
}
.themedSurface(opacity: 0.95)
ScrollView {
infoContent
VStack(spacing: 0) {
panePicker
ScrollView {
paneContent
}
}
.themedSheetBackground()
}
@@ -178,8 +237,12 @@ struct AppInfoView: View {
}
#else
NavigationView {
ScrollView {
infoContent
VStack(spacing: 0) {
panePicker
ScrollView {
paneContent
}
}
.themedSheetBackground()
.navigationBarTitleDisplayMode(.inline)
@@ -197,33 +260,35 @@ struct AppInfoView: View {
}
#endif
}
// MARK: - Pane switching
private var panePicker: some View {
Picker(Strings.Settings.tabPickerLabel, selection: $selectedPane) {
Text(Strings.Settings.tabInfo).tag(Pane.info)
Text(Strings.Settings.tabSettings).tag(Pane.settings)
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal)
.padding(.top, 12)
}
@ViewBuilder
private var infoContent: some View {
private var paneContent: some View {
switch selectedPane {
case .settings:
settingsContent
case .info:
infoContent
}
}
// MARK: - Settings pane
@ViewBuilder
private var settingsContent: some View {
VStack(alignment: .leading, spacing: 24) {
// Header
VStack(alignment: .center, spacing: 8) {
Text(Strings.appName)
.bitchatFont(size: 32, weight: .bold)
.foregroundColor(textColor)
Text(Strings.tagline)
.bitchatFont(size: 16)
.foregroundColor(secondaryTextColor)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
Text(verbatim: Strings.HowToUse.paragraph)
.bitchatFont(size: 14)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
}
// Appearance single row: label left, theme chips right
HStack(spacing: 12) {
SectionHeader(Strings.appearanceTitle)
@@ -248,39 +313,216 @@ struct AppInfoView: View {
}
}
// Voice
VStack(alignment: .leading, spacing: 16) {
// Voice same card + IRC pill as every other toggle setting.
VStack(alignment: .leading, spacing: 12) {
SectionHeader(Strings.Voice.title)
HStack(spacing: 0) {
FeatureRow(info: Strings.Voice.live)
Toggle(Strings.Voice.live.title, isOn: $liveVoiceEnabled)
.labelsHidden()
.tint(palette.accent)
.onChange(of: liveVoiceEnabled) { newValue in
PTTSettings.liveVoiceEnabled = newValue
}
settingsCard {
settingToggle(
title: Text("app_info.voice.live.title"),
subtitle: Text("app_info.voice.live.description"),
isOn: Binding(
get: { liveVoiceEnabled },
set: { newValue in
liveVoiceEnabled = newValue
PTTSettings.liveVoiceEnabled = newValue
}
)
)
}
}
// Location (notes / dead drops)
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Location.title)
// Connectivity: mesh bridge, internet gateway, tor routing
VStack(alignment: .leading, spacing: 12) {
SectionHeader(verbatim: Strings.Settings.connectivityTitle)
HStack(spacing: 0) {
FeatureRow(info: Strings.Location.notes)
Toggle(Strings.Location.notes.title, isOn: $locationNotesEnabled)
.labelsHidden()
.tint(palette.accent)
.onChange(of: locationNotesEnabled) { newValue in
LocationNotesSettings.enabled = newValue
if newValue {
locationManager.enableLocationChannels()
}
}
settingsCard {
settingToggle(
title: Text(Strings.Settings.bridgeTitle),
subtitle: Text(Strings.Settings.bridgeSubtitle),
isOn: bridgeToggleBinding
)
// Where the bridge meets: the geohash rendezvous cell, or
// a hint about why there isn't one yet (no location and no
// bridge peer advertising a cell).
if bridgeService.isEnabled {
Text(bridgeService.activeCell.map(Strings.Settings.bridgeCell) ?? Strings.Settings.bridgeNoCell)
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
}
}
locationPermissionRow
settingsCard {
settingToggle(
title: Text(Strings.Settings.torTitle),
subtitle: Text(Strings.Settings.torSubtitle),
isOn: torToggleBinding
)
}
// Location notes / dead drops (merged from main's flat
// layout into the shared card + pill style). Turning it on
// may need the location prompt; the permission control below
// covers the denied path.
settingsCard {
settingToggle(
title: Strings.Location.notes.title,
subtitle: Strings.Location.notes.description,
isOn: Binding(
get: { locationNotesEnabled },
set: { newValue in
locationNotesEnabled = newValue
LocationNotesSettings.enabled = newValue
if newValue {
locationManager.enableLocationChannels()
}
}
)
)
}
// Location powers the channels list and the bridge cell, so
// its control lives with the other connectivity settings.
// Platform reality shapes the three states: the app may only
// prompt while never-asked; granted/denied both flip in the
// system permission screen.
switch locationChannelsModel.permissionState {
case .authorized:
Button(action: SystemSettings.location.open) {
Text("location_channels.action.remove_access")
.bitchatFont(size: 12)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
}
.buttonStyle(.plain)
case .notDetermined:
Button(action: { locationChannelsModel.enableLocationChannels() }) {
Text("location_channels.action.request_permissions")
.bitchatFont(size: 12)
.foregroundColor(palette.accent)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(palette.accent.opacity(0.12))
.cornerRadius(6)
}
.buttonStyle(.plain)
case .denied, .restricted:
settingsCard {
Text("location_channels.permission_denied")
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
Button("location_channels.action.open_settings", action: SystemSettings.location.open)
.buttonStyle(.plain)
.bitchatFont(size: 12)
.foregroundColor(palette.accent)
}
}
}
// Danger zone
if onPanicWipe != nil {
VStack(alignment: .leading, spacing: 12) {
SectionHeader(verbatim: Strings.Settings.dangerTitle)
Button(action: { showPanicConfirmation = true }) {
Text(Strings.Settings.panicButton)
.bitchatFont(size: 12)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
}
.buttonStyle(.plain)
.confirmationDialog(
Strings.Settings.panicConfirmTitle,
isPresented: $showPanicConfirmation,
titleVisibility: .visible
) {
Button(Strings.Settings.panicConfirmAction, role: .destructive) {
onPanicWipe?()
}
Button("common.cancel", role: .cancel) {}
}
Text(Strings.Settings.panicNote)
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding()
}
private var bridgeToggleBinding: Binding<Bool> {
Binding(
get: { bridgeService.isEnabled },
set: { bridgeService.setEnabled($0) }
)
}
private var torToggleBinding: Binding<Bool> {
Binding(
get: { locationChannelsModel.userTorEnabled },
set: { locationChannelsModel.setUserTorEnabled($0) }
)
}
/// The padded card every connectivity setting sits in (moved look from
/// LocationChannelsSheet's toggle sections).
private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8, content: content)
.padding(12)
.background(palette.secondary.opacity(0.12))
.cornerRadius(8)
}
/// A title+subtitle row driving an IRC-style on/off pill the one
/// toggle style every setting uses.
private func settingToggle(title: Text, subtitle: Text, isOn: Binding<Bool>) -> some View {
Toggle(isOn: isOn) {
VStack(alignment: .leading, spacing: 2) {
title
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(textColor)
subtitle
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.Settings.toggleOn, offLabel: Strings.Settings.toggleOff))
}
// MARK: - Info pane
@ViewBuilder
private var infoContent: some View {
VStack(alignment: .leading, spacing: 24) {
// Header
VStack(alignment: .center, spacing: 8) {
Text(Strings.appName)
.bitchatFont(size: 32, weight: .bold)
.foregroundColor(textColor)
Text(Strings.tagline)
.bitchatFont(size: 16)
.foregroundColor(secondaryTextColor)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
Text(verbatim: Strings.HowToUse.paragraph)
.bitchatFont(size: 14)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
}
// Network diagnostics
@@ -314,6 +556,8 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Features.extendedRange)
FeatureRow(info: Strings.Features.bridge)
FeatureRow(info: Strings.Features.favorites)
FeatureRow(info: Strings.Features.geohash)
@@ -340,7 +584,7 @@ struct AppInfoView: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: item.icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.foregroundColor(item.color ?? textColor)
.frame(width: 30)
Text(item.text)
@@ -358,52 +602,44 @@ struct AppInfoView: View {
}
}
private extension AppInfoView {
/// One status/action line under the notes toggle so the location
/// requirement is actionable right here instead of only in the channel
/// sheet.
@ViewBuilder
var locationPermissionRow: some View {
if locationNotesEnabled {
switch locationManager.permissionState {
case .notDetermined:
Button(Strings.Location.enable) {
locationManager.enableLocationChannels()
}
.buttonStyle(.plain)
.bitchatFont(size: 13)
.foregroundColor(palette.accent)
case .denied, .restricted:
Button(Strings.Location.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
.bitchatFont(size: 13)
.foregroundColor(palette.accent)
case .authorized:
// Granted needs no status line the toggle being on says it.
EmptyView()
}
}
struct AppInfoFeatureInfo {
let icon: String
let title: Text
let description: Text
/// Catalog-backed strings (existing keys).
init(icon: String, title: LocalizedStringKey, description: LocalizedStringKey) {
self.icon = icon
self.title = Text(title)
self.description = Text(description)
}
/// Pre-resolved strings new keys that carry their English defaultValue
/// inline until the i18n pass adds them to the catalog.
init(icon: String, resolvedTitle: String, resolvedDescription: String) {
self.icon = icon
self.title = Text(resolvedTitle)
self.description = Text(resolvedDescription)
}
}
struct AppInfoFeatureInfo {
let icon: String
let title: LocalizedStringKey
let description: LocalizedStringKey
}
struct SectionHeader: View {
let title: LocalizedStringKey
private let title: Text
@ThemedPalette private var palette
private var textColor: Color { palette.primary }
init(_ title: LocalizedStringKey) {
self.title = title
self.title = Text(title)
}
/// For pre-resolved strings (new keys with inline defaultValue).
init(verbatim title: String) {
self.title = Text(title)
}
var body: some View {
Text(title)
title
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(textColor)
.padding(.top, 8)
@@ -424,18 +660,18 @@ struct FeatureRow: View {
.font(.bitchatSystem(size: 20))
.foregroundColor(textColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(info.title)
info.title
.bitchatFont(size: 14, weight: .semibold)
.foregroundColor(textColor)
Text(info.description)
info.description
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
}
@@ -443,14 +679,17 @@ struct FeatureRow: View {
#Preview("Default") {
AppInfoView()
.environmentObject(LocationChannelsModel())
}
#Preview("Dynamic Type XXL") {
AppInfoView()
.environmentObject(LocationChannelsModel())
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
}
#Preview("Dynamic Type XS") {
AppInfoView()
.environmentObject(LocationChannelsModel())
.environment(\.sizeCategory, .extraSmall)
}
+76
View File
@@ -0,0 +1,76 @@
//
// BridgePeopleList.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// Shared section header for the people sheet: a small glyph + label pair,
/// identical shape for every section (#mesh, across the bridge, ).
struct PeopleSectionHeader: View {
@ThemedPalette private var palette
let icon: String
let iconColor: Color
let title: String
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
.foregroundColor(iconColor)
Text(verbatim: title)
.bitchatFont(size: 11, weight: .semibold)
.foregroundColor(palette.secondary)
}
.padding(.horizontal)
.padding(.top, 12)
.padding(.bottom, 4)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isHeader)
}
}
/// The people-sheet section for participants visible across the mesh bridge:
/// same place, beyond radio range. Display-only in v1 bridged identities
/// are per-cell rendezvous keys with no DM route yet.
struct BridgePeopleList: View {
@ObservedObject private var bridgeService = BridgeService.shared
@ThemedPalette private var palette
private enum Strings {
static let sectionTitle = String(localized: "bridge_people.section_title", defaultValue: "across the bridge", comment: "Section header in the people sheet for participants reachable via the mesh bridge")
static let rowHint = String(localized: "bridge_people.accessibility.row_hint", defaultValue: "In your area, connected through the bridge", comment: "Accessibility hint for a person listed in the bridge section of the people sheet")
}
var body: some View {
// Not gated on the toggle: bridged people arrive over passive radio
// (a serving neighbor's carriers) even while this device's own
// bridge is off whoever is visible in the timeline belongs in the
// sheet.
if !bridgeService.bridgedParticipants.isEmpty {
VStack(alignment: .leading, spacing: 0) {
PeopleSectionHeader(
icon: "network",
iconColor: Color.cyan.opacity(0.9),
title: Strings.sectionTitle
)
ForEach(bridgeService.bridgedParticipants) { person in
HStack(spacing: 4) {
Text(person.displayName)
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
.accessibilityHint(Strings.rowHint)
}
}
}
}
}
@@ -0,0 +1,34 @@
import SwiftUI
/// IRC-flavored toggle: the whole row is one button and the state is spelled
/// out as an on/off pill instead of a system switch. Shared by the settings
/// surfaces (App Info's connectivity toggles and friends).
struct IRCToggleStyle: ToggleStyle {
let accent: Color
let onLabel: LocalizedStringKey
let offLabel: LocalizedStringKey
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
)
}
}
.buttonStyle(.plain)
}
}
@@ -50,6 +50,15 @@ struct TextMessageView: View {
.padding(.trailing, 4)
.accessibilityHidden(true)
}
if message.isBridged {
Image(systemName: "network")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.cyan.opacity(0.75))
.padding(.trailing, 4)
.accessibilityLabel(
String(localized: "content.accessibility.bridged_message", defaultValue: "Arrived across a mesh bridge", comment: "Accessibility label for the glyph marking a message that arrived across a mesh bridge")
)
}
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
+40
View File
@@ -7,6 +7,7 @@ struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ObservedObject private var bridgeService = BridgeService.shared
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@@ -89,6 +90,10 @@ struct ContentComposerView: View {
}
HStack(alignment: .center, spacing: 4) {
if showsNearbyOnlyToggle {
nearbyOnlyToggle
}
if conversationUIModel.canSendMediaInCurrentContext {
attachmentButton
}
@@ -108,6 +113,41 @@ struct ContentComposerView: View {
}
private extension ContentComposerView {
/// The nearby-only scope toggle appears only where it means something:
/// the public mesh channel with the bridge on.
var showsNearbyOnlyToggle: Bool {
guard bridgeService.isEnabled,
privateConversationModel.selectedHeaderState == nil,
case .mesh = locationChannelsModel.selectedChannel else {
return false
}
return true
}
/// Scope control for outgoing messages: bridged (default, crosses to
/// other islands in this area) vs nearby-only (radio range, no internet
/// copy exists for any gateway to carry).
var nearbyOnlyToggle: some View {
Button(action: { bridgeService.nearbyOnly.toggle() }) {
Image(systemName: bridgeService.nearbyOnly ? "antenna.radiowaves.left.and.right" : "network")
.font(.bitchatSystem(size: 16))
.foregroundColor(bridgeService.nearbyOnly ? palette.secondary : Color.cyan.opacity(0.9))
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(
bridgeService.nearbyOnly
? String(localized: "content.accessibility.nearby_only_on", defaultValue: "Nearby only: messages stay within radio range", comment: "Accessibility label for the compose scope toggle when messages stay local")
: String(localized: "content.accessibility.nearby_only_off", defaultValue: "Bridged: messages also reach people across the bridge", comment: "Accessibility label for the compose scope toggle when messages cross the mesh bridge")
)
.help(
bridgeService.nearbyOnly
? String(localized: "content.composer.nearby_only_on", defaultValue: "Nearby only — this message won't cross the bridge", comment: "Tooltip for the compose scope toggle when messages stay local")
: String(localized: "content.composer.nearby_only_off", defaultValue: "Bridged — reaches people beyond radio range in this area", comment: "Tooltip for the compose scope toggle when messages cross the mesh bridge")
)
}
/// 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.
+25 -10
View File
@@ -6,6 +6,7 @@ struct ContentHeaderView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@EnvironmentObject private var boardAlertsModel: BoardAlertsModel
@ObservedObject private var bridgeService = BridgeService.shared
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@@ -21,9 +22,6 @@ struct ContentHeaderView: View {
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
/// Unified notices sheet (board posts + location notes) for the current
/// channel context.
/// Board posts mirrored from the store so the pin icon can show when the
/// current scope has notices.
@State private var boardPosts: [BoardPostPacket] = []
@@ -32,6 +30,12 @@ struct ContentHeaderView: View {
/// timeline is showing) they should light the pin too.
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
/// The bridged-people count belongs to the mesh channel only.
private var showBridgedPeerCount: Bool {
if case .location = locationChannelsModel.selectedChannel { return false }
return bridgeService.bridgedPeerCount > 0
}
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
@@ -101,12 +105,17 @@ struct ContentHeaderView: View {
if case .location = locationChannelsModel.selectedChannel {
return peerListModel.visibleGeohashPeerCount
}
return countAndColor.0
// One number for the whole room: radio-reachable peers plus
// people across the bridge (visible via carriers even while
// this device's own bridge is off). The sheet breaks it down.
return countAndColor.0 + bridgeService.bridgedPeerCount
}()
HStack(spacing: 2) {
if locationChannelsModel.gatewayEnabled {
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
// The gateway toggle lives in the App Info settings pane
// now, so the indicator deep-links there.
Button(action: { appChromeModel.presentAppInfo() }) {
Image(systemName: "globe")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.8))
@@ -117,7 +126,7 @@ struct ContentHeaderView: View {
String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator")
)
.accessibilityHint(
String(localized: "content.accessibility.gateway_hint", defaultValue: "Opens channel settings to turn the gateway on or off", comment: "Accessibility hint for the internet gateway indicator explaining a tap opens the channel sheet")
String(localized: "content.accessibility.gateway_settings_hint", defaultValue: "Opens settings to turn the gateway on or off", comment: "Accessibility hint for the internet gateway indicator explaining a tap opens the settings sheet")
)
.help(
String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator")
@@ -265,11 +274,17 @@ struct ContentHeaderView: View {
)
)
// Connected-vs-nobody is otherwise encoded only in the icon's
// color; say it.
// color; say it. With a live bridge, also say who's across 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")
showBridgedPeerCount
? String(
format: String(localized: "content.accessibility.bridged_count", defaultValue: "%lld more people across the bridge", comment: "Accessibility value announcing the number of people reachable via the mesh bridge"),
locale: .current,
bridgeService.bridgedPeerCount
)
: (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)
+37 -52
View File
@@ -174,32 +174,13 @@ private struct ContentPeopleListView: View {
}
}
let activeText = String.localizedStringWithFormat(
String(localized: "%@ active", comment: "Count of active users in the people sheet"),
"\(peopleSheetActiveCount)"
)
if let subtitle = peopleSheetSubtitle {
let subtitleColor: Color = {
switch locationChannelsModel.selectedChannel {
case .mesh:
return palette.accentBlue
case .location:
return palette.locationAccent
}
}()
HStack(spacing: 6) {
Text(subtitle)
.foregroundColor(subtitleColor)
Text(activeText)
.foregroundColor(palette.secondary)
}
.bitchatFont(size: 12)
} else {
Text(activeText)
// The mesh sheet titles its sections inline (#mesh / across
// the bridge / groups) no subtitle or count up here.
// Location channels keep their geohash subtitle.
if case .location(let channel) = locationChannelsModel.selectedChannel {
Text(verbatim: "#\(channel.geohash.lowercased())")
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(palette.locationAccent)
}
}
.padding(.horizontal, 16)
@@ -208,7 +189,10 @@ private struct ContentPeopleListView: View {
.themedSurface()
ScrollView {
VStack(alignment: .leading, spacing: 6) {
// spacing 0: every section supplies its own rhythm (header
// top 12 / bottom 4, rows vertical 4), so inter-child spacing
// here would make the first section's gap read differently.
VStack(alignment: .leading, spacing: 0) {
if case .location = locationChannelsModel.selectedChannel {
GeohashPeopleList(
onTapPerson: {
@@ -216,12 +200,10 @@ private struct ContentPeopleListView: View {
}
)
} else {
GroupChatList(
groups: peerListModel.groupRows,
onTapGroup: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
}
PeopleSectionHeader(
icon: "antenna.radiowaves.left.and.right",
iconColor: palette.accentBlue,
title: "#mesh"
)
MeshPeerList(
onTapPeer: { peerID in
@@ -242,9 +224,24 @@ private struct ContentPeopleListView: View {
}
}
)
// People in this area but beyond radio range, and
// private groups: one sheet for the whole room.
BridgePeopleList()
GroupChatList(
groups: peerListModel.groupRows,
onTapGroup: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
}
)
}
}
.padding(.top, 4)
// Full width even when every row is narrow (empty mesh, no
// groups): without this the VStack hugs its widest child and
// the ScrollView centers it headers and empty states
// floated mid-screen on iPhone.
.frame(maxWidth: .infinity, alignment: .leading)
.id(peerListModel.renderID)
}
}
@@ -260,23 +257,6 @@ private extension ContentPeopleListView {
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
}
var peopleSheetSubtitle: String? {
switch locationChannelsModel.selectedChannel {
case .mesh:
return "#mesh"
case .location(let channel):
return "#\(channel.geohash.lowercased())"
}
}
var peopleSheetActiveCount: Int {
switch locationChannelsModel.selectedChannel {
case .mesh:
return peerListModel.reachableMeshPeerCount
case .location:
return peerListModel.visibleGeohashPeerCount
}
}
}
private struct ContentPrivateChatSheetView: View {
@@ -542,10 +522,14 @@ private struct ContentPrivateHeaderInfoButton: View {
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
// Absence of a glyph was the only offline signal; say it.
Text("mesh_peers.state.offline")
.bitchatFont(size: 11)
// Slashed variant of the connected glyph offline as
// the negation of connected, no text label (a leading
// one read as part of the name: "sin conexión bob").
// VoiceOver still says it.
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.secondary)
.accessibilityLabel(String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable"))
}
}
@@ -580,6 +564,7 @@ private struct ContentPrivateHeaderInfoButton: View {
)
)
}
}
}
.buttonStyle(.plain)
+6 -1
View File
@@ -35,6 +35,7 @@ struct ContentView: View {
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@State private var messageText = ""
@@ -150,7 +151,11 @@ struct ContentView: View {
#endif
}
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() })
AppInfoView(
topologyProvider: { appChromeModel.meshTopologyDisplayModel() },
onPanicWipe: { appChromeModel.panicClearAllData() }
)
.environmentObject(locationChannelsModel)
}
.sheet(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
+6 -11
View File
@@ -21,20 +21,15 @@ struct GroupChatList: View {
var body: some View {
if !groups.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.header)
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 10)
.padding(.bottom, 2)
.accessibilityAddTraits(.isHeader)
// Same glyph+label header shape as #mesh / across the bridge.
PeopleSectionHeader(
icon: "person.3.fill",
iconColor: palette.primary,
title: Strings.header
)
ForEach(groups) { group in
HStack(spacing: 4) {
Image(systemName: "person.3.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.primary)
Text("#\(group.name)")
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
+14 -109
View File
@@ -20,15 +20,9 @@ struct LocationChannelsSheet: View {
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
static let grantToFind: LocalizedStringKey = "location_channels.grant_to_find"
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title"
static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle"
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
@@ -218,7 +212,7 @@ struct LocationChannelsSheet: View {
}
.padding(.vertical, 6)
}
} else {
} else if locationChannelsModel.permissionState == .authorized {
sectionDivider
HStack(spacing: 8) {
ProgressView()
@@ -227,6 +221,15 @@ struct LocationChannelsSheet: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
} else {
// No permission means no fix is coming: an honest hint
// beats a spinner that would never finish.
sectionDivider
Text(Strings.grantToFind)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
}
sectionDivider
@@ -240,24 +243,6 @@ struct LocationChannelsSheet: View {
.padding(.vertical, 8)
}
if locationChannelsModel.permissionState == .authorized {
sectionDivider
torToggleSection
.padding(.top, 12)
gatewayToggleSection
.padding(.top, 8)
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.bitchatFont(size: 12)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
}
.buttonStyle(.plain)
.padding(.vertical, 8)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
@@ -482,94 +467,14 @@ struct LocationChannelsSheet: View {
}
}
// MARK: - TOR Toggle & Standardized Colors
// MARK: - Standardized Colors
// (The tor and internet-gateway toggles moved to AppInfoView's Settings pane;
// IRCToggleStyle now lives in Views/Components.)
extension LocationChannelsSheet {
private var torToggleBinding: Binding<Bool> {
Binding(
get: { locationChannelsModel.userTorEnabled },
set: { locationChannelsModel.setUserTorEnabled($0) }
)
}
private var torToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(palette.primary)
Text(Strings.torSubtitle)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(palette.secondary.opacity(0.12))
.cornerRadius(8)
}
private var gatewayToggleBinding: Binding<Bool> {
Binding(
get: { locationChannelsModel.gatewayEnabled },
set: { locationChannelsModel.setGatewayEnabled($0) }
)
}
private var gatewayToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: gatewayToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.gatewayTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(palette.primary)
Text(Strings.gatewaySubtitle)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(palette.secondary.opacity(0.12))
.cornerRadius(8)
}
private var standardGreen: Color { palette.primary }
private var standardBlue: Color { palette.accentBlue }
}
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
let onLabel: LocalizedStringKey
let offLabel: LocalizedStringKey
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Coverage helpers
extension LocationChannelsSheet {
private func coverageString(forPrecision len: Int) -> String {
+14 -10
View File
@@ -44,13 +44,14 @@ struct MeshPeerList: View {
}
if peerListModel.meshRows.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 12)
}
// Match the section's row rhythm (same size, indent, and vertical
// padding as a peer row) so the empty state reads as the list's
// only line, not a floating caption.
Text(Strings.noneNearby)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.vertical, 4)
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(0..<peers.count, id: \.self) { idx in
@@ -59,6 +60,9 @@ struct MeshPeerList: View {
HStack(spacing: 4) {
let assigned = peerListModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned
// Mesh rows keep their leading glyph: unlike the
// homogeneous bridge/groups sections, it encodes HOW
// the peer is reachable (radio, relayed, nostr-only).
if isMe {
Image(systemName: "person.fill")
.font(.bitchatSystem(size: 10))
@@ -82,8 +86,9 @@ struct MeshPeerList: View {
.foregroundColor(.purple)
.help(Strings.nostr)
} else {
// Fallback icon for others (dimmed)
Image(systemName: "person")
// Offline: slashed variant of the connected glyph
// (dimmed) clearer than a generic person icon.
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary)
.help(Strings.offline)
@@ -174,7 +179,6 @@ struct MeshPeerList: View {
}
.padding(.horizontal)
.padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle())
// count:2 must attach before count:1 or the single tap
// shadows it (same ordering the header logo relies on).
+6 -7
View File
@@ -629,19 +629,18 @@ private struct NoticesList: View {
private static func timestampText(for date: Date) -> String {
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
// The whole "3 hr ago" phrase must come from the formatter
// gluing an English "ago" onto a localized duration ships the
// wrong word order to most locales ("hace 3 h", "vor 3 Std").
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date)
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
@@ -87,6 +87,11 @@ private final class MockChatOutgoingContext: ChatOutgoingContext {
sentGeohashContexts.append(context)
}
private(set) var bridgedMessages: [(content: String, messageID: String)] = []
func bridgeOutgoingPublicMessage(_ content: String, messageID: String) {
bridgedMessages.append((content, messageID))
}
// Geohash identity
struct IdentityUnavailable: Error {}
var deriveNostrIdentityError: Error?
@@ -672,6 +672,50 @@ struct MessageRouterCourierTests {
#expect(carried == ["m1"])
}
/// Field-found: a DM sent while the recipient sits in the BLE
/// reachability retention window (radio gone, still "reachable" for a
/// minute) trusts the mesh and skips every deposit and nothing
/// retried. The periodic sweep must publish the bridge drop once the
/// window lapses.
@Test @MainActor
func sweepDropsQueuedMessageOnceReachabilityLapses() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let transport = CourierCaptureTransport()
transport.reachablePeers = [bobID] // retention window: stale but "reachable"
transport.promptDelivery = true
let directory = CourierDirectory(
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
isTrustedCourier: { _ in false }
)
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var drops: [(content: String, messageID: String, key: Data)] = []
router.bridgeCourierDeposit = { content, messageID, key in
drops.append((content, messageID, key))
return true
}
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m9")
#expect(drops.isEmpty) // send trusted the (stale) mesh reachability
// Still inside the window: the sweep must not spam relays.
router.retryBridgeCourierDeposits()
#expect(drops.isEmpty)
// Window lapses; the sweep publishes the retained copy as a drop and
// the sender's message shows "carried" (its ack has no radio route).
transport.reachablePeers = []
router.retryBridgeCourierDeposits()
#expect(drops.count == 1)
#expect(drops.first?.messageID == "m9")
#expect(drops.first?.content == "hi bob")
#expect(drops.first?.key == bobKey)
#expect(carried == ["m9"])
}
@Test @MainActor
func noCourierDepositWithoutKnownRecipientKey() {
let transport = CourierCaptureTransport()
@@ -0,0 +1,149 @@
//
// BridgeWireFormatTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("Bridge wire formats")
struct BridgeWireFormatTests {
// MARK: - Announce bridgeGeohash TLV
@Test func announceRoundTripsBridgeGeohash() throws {
let packet = AnnouncementPacket(
nickname: "gw",
noisePublicKey: Data(repeating: 1, count: 32),
signingPublicKey: Data(repeating: 2, count: 32),
directNeighbors: nil,
capabilities: [.bridge, .gateway],
bridgeGeohash: "u4pruy"
)
let encoded = try #require(packet.encode())
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
#expect(decoded.bridgeGeohash == "u4pruy")
#expect(decoded.capabilities?.contains(.bridge) == true)
}
@Test func announceWithoutBridgeCellDecodesNil() throws {
let packet = AnnouncementPacket(
nickname: "plain",
noisePublicKey: Data(repeating: 1, count: 32),
signingPublicKey: Data(repeating: 2, count: 32),
directNeighbors: nil
)
let encoded = try #require(packet.encode())
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
#expect(decoded.bridgeGeohash == nil)
}
@Test func announceRejectsOversizedBridgeCellAtEncode() throws {
let packet = AnnouncementPacket(
nickname: "gw",
noisePublicKey: Data(repeating: 1, count: 32),
signingPublicKey: Data(repeating: 2, count: 32),
directNeighbors: nil,
bridgeGeohash: String(repeating: "u", count: 13)
)
// Oversized cell is silently omitted, not a hard failure.
let encoded = try #require(packet.encode())
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
#expect(decoded.bridgeGeohash == nil)
}
// MARK: - Carrier directions
@Test func bridgeCarrierDirectionsRoundTrip() throws {
for direction in [NostrCarrierPacket.Direction.toBridge, .fromBridge] {
let packet = try #require(NostrCarrierPacket(
direction: direction,
geohash: "u4pruy",
eventJSON: Data("{\"id\":\"x\"}".utf8)
))
let encoded = try #require(packet.encode())
let decoded = try #require(NostrCarrierPacket.decode(encoded))
#expect(decoded.direction == direction)
#expect(decoded.geohash == "u4pruy")
}
}
// MARK: - BitchatMessage bridged flag
// (Binary round-trip lives in BitFoundation's own tests
// `toBinaryPayload` is internal to the package.)
@Test func bridgedFlagSurvivesCodableRoundTrip() throws {
let message = BitchatMessage(
sender: "far-friend",
content: "hi",
timestamp: Date(),
isRelay: false,
isBridged: true
)
let data = try JSONEncoder().encode(message)
let decoded = try JSONDecoder().decode(BitchatMessage.self, from: data)
#expect(decoded.isBridged)
}
@Test func legacyJSONWithoutBridgedFlagDecodes() throws {
let plain = BitchatMessage(
sender: "old-client",
content: "hi",
timestamp: Date(),
isRelay: false
)
let encoded = try JSONEncoder().encode(plain)
var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
json.removeValue(forKey: "isBridged")
let decoded = try JSONDecoder().decode(BitchatMessage.self, from: JSONSerialization.data(withJSONObject: json))
#expect(!decoded.isBridged)
}
@Test func bridgePeerIDParsesAndClassifies() {
let peerID = PeerID(str: "bridge:deadbeefcafe0123")
#expect(peerID.isBridge)
#expect(peerID.bare == "deadbeefcafe0123")
#expect(!peerID.isGeoChat)
}
}
@Suite("Courier store bridge publish")
struct CourierStoreBridgePublishTests {
private func makeStore(now: @escaping () -> Date = Date.init) -> CourierStore {
CourierStore(persistsToDisk: false, now: now)
}
private func makeEnvelope(now: Date = Date()) -> CourierEnvelope {
CourierEnvelope(
recipientTag: Data(repeating: 3, count: 16),
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
ciphertext: Data(repeating: 9, count: 64),
copies: 4
)
}
@Test func bridgePublishIsNonDestructiveAndCooledDown() {
var currentDate = Date()
let store = makeStore(now: { currentDate })
#expect(store.deposit(makeEnvelope(now: currentDate), from: Data(repeating: 5, count: 32)))
let first = store.envelopesForBridgePublish(cooldown: 600)
#expect(first.count == 1)
// The relay copy is carry-only regardless of stored spray budget.
#expect(first.first?.copies == 1)
// (Non-destructiveness is proven below: the same envelope is
// eligible again after the cooldown. `carriedCount` publishes
// asynchronously, so it is not asserted here.)
// Within cooldown: nothing to publish.
#expect(store.envelopesForBridgePublish(cooldown: 600).isEmpty)
// After cooldown: eligible again.
currentDate = currentDate.addingTimeInterval(601)
#expect(store.envelopesForBridgePublish(cooldown: 600).count == 1)
}
}
@@ -0,0 +1,284 @@
//
// BridgeCourierServiceTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import CryptoKit
import Foundation
import Testing
@testable import bitchat
@Suite("Courier over the bridge")
@MainActor
struct BridgeCourierServiceTests {
/// Closure-injected harness around `BridgeCourierService`.
@MainActor
private final class Fixture {
var bridgeOn = true
var relaysConnected = true
var myKey: Data? = Fixture.randomKey()
var localPeers: [(peerID: PeerID, noiseKey: Data)] = []
var held: [CourierEnvelope] = []
var sealResult: CourierEnvelope?
private(set) var publishedEvents: [NostrEvent] = []
private(set) var openedSubscriptions: [[String]] = []
private(set) var closedSubscriptions = 0
private(set) var openedEnvelopes: [CourierEnvelope] = []
private(set) var delivered: [(envelope: CourierEnvelope, peer: PeerID)] = []
private(set) var sealRequests: [(content: String, messageID: String, key: Data)] = []
private(set) var heldCooldowns: [TimeInterval] = []
let service: BridgeCourierService
init() {
service = BridgeCourierService()
service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false }
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
service.publishEvent = { [weak self] event in self?.publishedEvents.append(event) }
service.openSubscription = { [weak self] tags in self?.openedSubscriptions.append(tags) }
service.closeSubscription = { [weak self] in self?.closedSubscriptions += 1 }
service.myNoiseKey = { [weak self] in self?.myKey }
service.localVerifiedPeers = { [weak self] in self?.localPeers ?? [] }
service.sealEnvelope = { [weak self] content, messageID, key in
self?.sealRequests.append((content, messageID, key))
return self?.sealResult
}
service.openEnvelope = { [weak self] envelope in self?.openedEnvelopes.append(envelope) }
service.deliverToPeer = { [weak self] envelope, peer in self?.delivered.append((envelope, peer)) }
service.heldEnvelopes = { [weak self] cooldown in
self?.heldCooldowns.append(cooldown)
return self?.held ?? []
}
service.scheduleTimer = { _, _ in } // timers driven manually
}
static func randomKey() -> Data {
Data((0..<32).map { _ in UInt8.random(in: 0...255) })
}
}
private func makeEnvelope(recipientKey: Data, ciphertext: Data = Data(repeating: 7, count: 64)) -> CourierEnvelope {
CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientKey,
epochDay: CourierEnvelope.epochDay(for: Date())
),
expiry: UInt64((Date().timeIntervalSince1970 + 3600) * 1000),
ciphertext: ciphertext,
copies: 1
)
}
private func makeDropEvent(for envelope: CourierEnvelope) throws -> NostrEvent {
let encoded = try #require(envelope.encode())
let identity = try #require(BridgeCourierService.makeThrowawayIdentity())
return try NostrProtocol.createCourierDropEvent(
envelope: encoded,
recipientTagHex: envelope.recipientTag.hexEncodedString(),
expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000),
senderIdentity: identity
)
}
// MARK: - Sender role
@Test func depositSealsAndPublishesOnce() throws {
let fixture = Fixture()
let recipientKey = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
let messageID = UUID().uuidString
fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey)
fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey)
#expect(fixture.sealRequests.count == 1)
#expect(fixture.publishedEvents.count == 1)
let event = try #require(fixture.publishedEvents.first)
#expect(event.kind == NostrProtocol.EventKind.courierDrop.rawValue)
#expect(event.isValidSignature())
#expect(event.tags.contains { $0.count >= 2 && $0[0] == "x" && $0[1] == fixture.sealResult?.recipientTag.hexEncodedString() })
#expect(event.tags.contains { $0.count >= 2 && $0[0] == "expiration" })
}
@Test func depositRequiresBridgeToggle() {
let fixture = Fixture()
fixture.bridgeOn = false
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: key)
fixture.service.depositDrop(content: "hi", messageID: UUID().uuidString, recipientNoiseKey: key)
#expect(fixture.publishedEvents.isEmpty)
#expect(fixture.sealRequests.isEmpty)
}
@Test func depositQueuesWithoutRelaysAndFlushesOnReconnect() {
let fixture = Fixture()
fixture.relaysConnected = false
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: key)
fixture.service.depositDrop(content: "later", messageID: UUID().uuidString, recipientNoiseKey: key)
#expect(fixture.publishedEvents.isEmpty)
#expect(fixture.service.pendingDrops.count == 1)
fixture.relaysConnected = true
fixture.service.flushPendingDrops()
#expect(fixture.publishedEvents.count == 1)
#expect(fixture.service.pendingDrops.isEmpty)
}
@Test func distinctDropsUseDistinctThrowawayKeys() {
let fixture = Fixture()
let keyA = Fixture.randomKey()
let keyB = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: keyA)
fixture.service.depositDrop(content: "a", messageID: UUID().uuidString, recipientNoiseKey: keyA)
fixture.sealResult = makeEnvelope(recipientKey: keyB)
fixture.service.depositDrop(content: "b", messageID: UUID().uuidString, recipientNoiseKey: keyB)
#expect(fixture.publishedEvents.count == 2)
#expect(fixture.publishedEvents[0].pubkey != fixture.publishedEvents[1].pubkey)
}
@Test func bridgingPublishesHeldEnvelopesWithCooldown() {
let fixture = Fixture()
fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())]
fixture.service.publishHeldEnvelopes()
#expect(fixture.publishedEvents.count == 1)
#expect(fixture.heldCooldowns == [BridgeCourierService.Limits.heldEnvelopePublishCooldown])
}
// MARK: - Subscription management
@Test func refreshSubscribesOwnCandidateTags() throws {
let fixture = Fixture()
fixture.service.refresh()
let tags = try #require(fixture.openedSubscriptions.last)
let myKey = try #require(fixture.myKey)
let expected = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).map { $0.hexEncodedString() })
#expect(Set(tags) == expected)
#expect(tags.count == 3) // adjacent UTC days
}
@Test func refreshAlsoWatchesLocalVerifiedPeers() throws {
let fixture = Fixture()
let peerKey = Fixture.randomKey()
fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), peerKey)]
fixture.service.refresh()
let tags = try #require(fixture.openedSubscriptions.last)
#expect(tags.count == 6) // 3 own + 3 watched
}
@Test func refreshClosesSubscriptionWhenBridgeOff() {
let fixture = Fixture()
fixture.service.refresh()
#expect(fixture.openedSubscriptions.count == 1)
fixture.bridgeOn = false
fixture.service.refresh()
#expect(fixture.closedSubscriptions == 1)
}
// MARK: - Inbound drops
@Test func dropForUsIsOpened() throws {
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let envelope = makeEnvelope(recipientKey: myKey)
fixture.service.handleDropEvent(try makeDropEvent(for: envelope))
#expect(fixture.openedEnvelopes.count == 1)
#expect(fixture.delivered.isEmpty)
}
@Test func duplicateDropEventOpensOnce() throws {
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey))
fixture.service.handleDropEvent(event)
fixture.service.handleDropEvent(event)
#expect(fixture.openedEnvelopes.count == 1)
}
@Test func dropForWatchedLocalPeerIsDelivered() throws {
let fixture = Fixture()
let peerKey = Fixture.randomKey()
let peer = PeerID(str: "aabbccdd00112233")
fixture.localPeers = [(peer, peerKey)]
fixture.service.refresh()
let envelope = makeEnvelope(recipientKey: peerKey)
fixture.service.handleDropEvent(try makeDropEvent(for: envelope))
#expect(fixture.delivered.count == 1)
#expect(fixture.delivered.first?.peer == peer)
#expect(fixture.openedEnvelopes.isEmpty)
}
@Test func dropForStrangerIsIgnored() throws {
let fixture = Fixture()
fixture.service.refresh()
let envelope = makeEnvelope(recipientKey: Fixture.randomKey())
fixture.service.handleDropEvent(try makeDropEvent(for: envelope))
#expect(fixture.openedEnvelopes.isEmpty)
#expect(fixture.delivered.isEmpty)
}
@Test func mislabeledDropTagIsRejected() throws {
// The event's filterable #x tag must match the envelope's own tag.
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let envelope = makeEnvelope(recipientKey: Fixture.randomKey())
let encoded = try #require(envelope.encode())
let identity = try #require(BridgeCourierService.makeThrowawayIdentity())
let mislabeled = try NostrProtocol.createCourierDropEvent(
envelope: encoded,
recipientTagHex: CourierEnvelope.recipientTag(
noiseStaticKey: myKey,
epochDay: CourierEnvelope.epochDay(for: Date())
).hexEncodedString(), // labeled for us, addressed to a stranger
expiresAt: Date().addingTimeInterval(3600),
senderIdentity: identity
)
fixture.service.handleDropEvent(mislabeled)
#expect(fixture.openedEnvelopes.isEmpty)
#expect(fixture.delivered.isEmpty)
}
@Test func expiredDropIsIgnored() throws {
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let expired = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(noiseStaticKey: myKey, epochDay: CourierEnvelope.epochDay(for: Date())),
expiry: UInt64((Date().timeIntervalSince1970 - 60) * 1000),
ciphertext: Data(repeating: 1, count: 32),
copies: 1
)
fixture.service.handleDropEvent(try makeDropEvent(for: expired))
#expect(fixture.openedEnvelopes.isEmpty)
}
}
@@ -0,0 +1,594 @@
//
// BridgeServiceTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("Mesh bridge policy")
@MainActor
struct BridgeServiceTests {
private static let cell = "u4pruy"
/// Closure-injected harness around `BridgeService` recording every side
/// effect, with a controllable clock, location, and connectivity.
@MainActor
private final class Fixture {
private final class ClockBox {
var now = Date()
}
var relaysConnected = true
var locationCell: String? = BridgeServiceTests.cell
var meshAdvertisedCell: String?
var bridgePeers: [PeerID] = []
var sendSucceeds = true
var locallySeenMessageIDs: Set<String> = []
var nickname = "tester"
private(set) var published: [(event: NostrEvent, cell: String)] = []
/// Published chat messages only the fixture's own presence
/// heartbeats (kind 20001, sent on enable) are filtered out.
var publishedMessages: [(event: NostrEvent, cell: String)] {
published.filter { $0.event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue }
}
private(set) var broadcasts: [Data] = []
private(set) var injected: [BridgeService.InboundBridgeMessage] = []
private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
private(set) var openedSubscriptions: [[String]] = []
private(set) var closedSubscriptions = 0
private(set) var enabledChanges: [Bool] = []
private(set) var locationFixRequests = 0
private(set) var cellChanges: [String?] = []
private(set) var scheduledTimers: [(delay: TimeInterval, work: @MainActor () -> Void)] = []
private let clock = ClockBox()
let identity: NostrIdentity
let defaults: UserDefaults
let service: BridgeService
init(enabled: Bool = true) {
let suite = "BridgeServiceTests-\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
identity = try! NostrIdentity.generate()
let clock = clock
service = BridgeService(defaults: defaults) { clock.now }
service.publishToRelays = { [weak self] event, cell in
self?.published.append((event, cell))
}
service.openSubscription = { [weak self] cells in
self?.openedSubscriptions.append(cells)
}
service.closeSubscription = { [weak self] in
self?.closedSubscriptions += 1
}
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
service.locationCell = { [weak self] in self?.locationCell }
service.requestLocationFix = { [weak self] in self?.locationFixRequests += 1 }
service.meshAdvertisedCell = { [weak self] in self?.meshAdvertisedCell }
service.sendToBridgePeer = { [weak self] payload, peer in
guard let self, self.sendSucceeds else { return false }
self.uplinkSends.append((payload, peer))
return true
}
service.availableBridgePeers = { [weak self] in self?.bridgePeers ?? [] }
service.broadcastToMesh = { [weak self] payload in
self?.broadcasts.append(payload)
}
service.injectInbound = { [weak self] message in
self?.injected.append(message)
}
service.isMessageSeenLocally = { [weak self] id in
self?.locallySeenMessageIDs.contains(id) ?? false
}
service.deriveIdentity = { [weak self] _ in
guard let self else { throw NostrError.invalidEvent }
return self.identity
}
service.myNickname = { [weak self] in self?.nickname ?? "" }
service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) }
service.onActiveCellChanged = { [weak self] cell in self?.cellChanges.append(cell) }
service.scheduleTimer = { [weak self] delay, work in
self?.scheduledTimers.append((delay, work))
}
if enabled {
service.setEnabled(true)
}
}
func advance(_ seconds: TimeInterval) {
clock.now = clock.now.addingTimeInterval(seconds)
}
func fireScheduledTimers() {
let due = scheduledTimers
scheduledTimers.removeAll()
for item in due { item.work() }
}
}
// MARK: Event helpers
private func makeRemoteEvent(
cell: String = BridgeServiceTests.cell,
content: String = "hi \(UUID().uuidString.prefix(8))",
meshMessageID: String? = UUID().uuidString
) throws -> NostrEvent {
let identity = try NostrIdentity.generate()
return try NostrProtocol.createBridgeMeshEvent(
content: content,
cell: cell,
senderIdentity: identity,
nickname: "remote",
meshMessageID: meshMessageID
)
}
private func makePresenceEvent(cell: String = BridgeServiceTests.cell) throws -> NostrEvent {
try NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: NostrIdentity.generate())
}
private func carrier(
_ event: NostrEvent,
direction: NostrCarrierPacket.Direction,
cell: String = BridgeServiceTests.cell
) throws -> NostrCarrierPacket {
try #require(NostrCarrierPacket(direction: direction, geohash: cell, event: event))
}
// MARK: - Lifecycle & rendezvous
@Test func enablingOpensSubscriptionForCellAndNeighbors() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
#expect(fixture.service.activeCell == Self.cell)
let cells = try #require(fixture.openedSubscriptions.first)
#expect(cells.first == Self.cell)
#expect(cells.count == 9) // own cell + 8 neighbors
#expect(fixture.service.subscribedCells.count == 9)
}
@Test func missingCellRequestsALocationFix() {
// Field bug: the bridge waited passively for availableChannels,
// which only flow while some other feature pumps location. Bridging
// without a cell must ask for a fix itself.
let fixture = Fixture(enabled: true)
fixture.locationCell = nil
fixture.service.refreshRendezvous()
#expect(fixture.locationFixRequests >= 1)
#expect(fixture.service.activeCell == nil)
// The fix lands, channels flow, and the sink re-enters here:
fixture.locationCell = Self.cell
fixture.service.refreshRendezvous()
#expect(fixture.service.activeCell == Self.cell)
}
@Test func noLocationFallsBackToMeshAdvertisedCell() {
let fixture = Fixture(enabled: true)
fixture.locationCell = nil
fixture.meshAdvertisedCell = "u4prux"
fixture.service.refreshRendezvous()
#expect(fixture.service.activeCell == "u4prux")
}
@Test func disablingClosesSubscriptionAndClearsState() {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
fixture.service.setEnabled(false)
#expect(fixture.closedSubscriptions >= 1)
#expect(fixture.service.activeCell == nil)
#expect(fixture.service.bridgedPeerCount == 0)
}
@Test func togglePersistsAcrossInstances() {
let fixture = Fixture(enabled: true)
let revived = BridgeService(defaults: fixture.defaults)
#expect(revived.isEnabled)
}
// MARK: - Outgoing
@Test func outgoingPublishesSignedRendezvousEventWithMeshMessageID() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let messageID = UUID().uuidString
fixture.service.bridgeOutgoing(content: "hello hill", messageID: messageID)
let published = try #require(fixture.published.last)
#expect(published.cell == Self.cell)
#expect(published.event.isValidSignature())
#expect(published.event.content == "hello hill")
#expect(published.event.tags.contains(["r", Self.cell]))
#expect(published.event.tags.contains(["m", messageID]))
#expect(published.event.tags.contains(["n", "tester"]))
}
@Test func nearbyOnlySuppressesTheBridgedCopy() {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
fixture.service.nearbyOnly = true
fixture.service.bridgeOutgoing(content: "just us", messageID: UUID().uuidString)
#expect(fixture.publishedMessages.isEmpty)
#expect(fixture.uplinkSends.isEmpty)
}
@Test func outgoingWithoutRelaysDepositsWithBridgePeer() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
fixture.relaysConnected = false
fixture.bridgePeers = [PeerID(str: "abcdef0123456789")]
fixture.service.bridgeOutgoing(content: "no internet here", messageID: UUID().uuidString)
#expect(fixture.publishedMessages.isEmpty)
let sent = try #require(fixture.uplinkSends.first)
let carrier = try #require(NostrCarrierPacket.decode(sent.payload))
#expect(carrier.direction == .toBridge)
#expect(carrier.geohash == Self.cell)
}
@Test func ownRelayBackfilledEventIsIgnoredAfterRestart() throws {
// Field bug: a relaunch wipes the published-ID cache, and relay
// backfill then re-delivered the device's own pre-restart events as
// "bridged". Self-recognition by the deterministic rendezvous pubkey
// must catch them with no cache state at all.
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let ownOldEvent = try NostrProtocol.createBridgeMeshEvent(
content: "sent before the restart",
cell: Self.cell,
senderIdentity: fixture.identity, // == deriveIdentity(cell)
nickname: "tester",
meshMessageID: UUID().uuidString
)
fixture.service.handleRendezvousEvent(ownOldEvent)
#expect(fixture.injected.isEmpty)
#expect(fixture.broadcasts.isEmpty)
#expect(fixture.service.bridgedPeerCount == 0)
}
@Test func ownEventComingBackFromSubscriptionIsIgnored() {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
fixture.service.bridgeOutgoing(content: "echo me", messageID: UUID().uuidString)
let ownEvent = fixture.published[0].event
fixture.service.handleRendezvousEvent(ownEvent)
#expect(fixture.injected.isEmpty)
#expect(fixture.broadcasts.isEmpty)
#expect(fixture.service.bridgedPeerCount == 0)
}
// MARK: - Subscription ingress
@Test func remoteMessageInjectsAndDownlinks() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
fixture.service.handleRendezvousEvent(event)
#expect(fixture.injected.count == 1)
#expect(fixture.injected.first?.content == event.content)
#expect(fixture.service.bridgedPeerCount == 1)
// Serving duty: after the jitter holdoff, the remote event rides out
// as a fromBridge broadcast one switch, no gateway toggle.
#expect(fixture.broadcasts.isEmpty)
fixture.fireScheduledTimers()
let broadcast = try #require(fixture.broadcasts.first)
let carrier = try #require(NostrCarrierPacket.decode(broadcast))
#expect(carrier.direction == .fromBridge)
}
@Test func jitterHoldoffSuppressesAlreadyBroadcastEvents() throws {
// Two gateways, one island: while our drain waits out the jitter,
// the other gateway's fromBridge broadcast arrives ours must yield.
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
fixture.service.handleRendezvousEvent(event) // queued behind jitter
fixture.service.handleMeshCarrier(
try carrier(event, direction: .fromBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: false
)
fixture.fireScheduledTimers()
#expect(fixture.broadcasts.isEmpty)
#expect(fixture.injected.count == 1) // rendered once, either path
}
@Test func neighborCellEventIsAccepted() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let neighbor = try #require(Geohash.neighbors(of: Self.cell).first)
let event = try makeRemoteEvent(cell: neighbor)
fixture.service.handleRendezvousEvent(event)
#expect(fixture.injected.count == 1)
}
@Test func outOfRingCellEventIsRejected() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent(cell: "9q8yyk")
fixture.service.handleRendezvousEvent(event)
#expect(fixture.injected.isEmpty)
#expect(fixture.broadcasts.isEmpty)
}
@Test func locallySeenMessageIsNeitherInjectedNorDownlinked() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let messageID = UUID().uuidString
fixture.locallySeenMessageIDs = [messageID]
let event = try makeRemoteEvent(meshMessageID: messageID)
fixture.service.handleRendezvousEvent(event)
// The island already heard this over radio: no duplicate render, no
// wasted airtime but the sender still counts as a (local)
// participant, never a bridged one.
#expect(fixture.injected.isEmpty)
#expect(fixture.broadcasts.isEmpty)
#expect(fixture.service.bridgedPeerCount == 0)
}
@Test func duplicateSubscriptionEventInjectsOnce() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
fixture.service.handleRendezvousEvent(event)
fixture.service.handleRendezvousEvent(event)
fixture.fireScheduledTimers()
#expect(fixture.injected.count == 1)
#expect(fixture.broadcasts.count == 1)
}
@Test func presenceCountsParticipantWithoutInjection() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
fixture.service.handleRendezvousEvent(try makePresenceEvent())
#expect(fixture.injected.isEmpty)
#expect(fixture.service.bridgedPeerCount == 1)
}
@Test func staleParticipantsAgeOutOfTheCount() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
fixture.service.handleRendezvousEvent(try makePresenceEvent())
#expect(fixture.service.bridgedPeerCount == 1)
fixture.advance(BridgeService.Limits.participantFreshnessSeconds + 1)
fixture.service.publishPresence() // any activity recomputes via prune path
fixture.fireScheduledTimers()
#expect(fixture.service.bridgedPeerCount == 0)
}
@Test func staleEventIsRejected() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
fixture.advance(BridgeService.Limits.maxEventAgeSeconds + 60)
fixture.service.handleRendezvousEvent(event)
#expect(fixture.injected.isEmpty)
}
// MARK: - Downlink budget
@Test func downlinkRespectsPerMinuteBudgetAndDrainsLater() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
for _ in 0..<(BridgeService.Limits.downlinkEventsPerMinute + 5) {
fixture.service.handleRendezvousEvent(try makeRemoteEvent())
}
fixture.fireScheduledTimers() // jitter holdoff elapses
#expect(fixture.broadcasts.count == BridgeService.Limits.downlinkEventsPerMinute)
// Window frees: the re-armed timer drains the backlog.
fixture.advance(61)
fixture.fireScheduledTimers()
#expect(fixture.broadcasts.count == BridgeService.Limits.downlinkEventsPerMinute + 5)
}
// MARK: - Mesh carrier ingress (receiver role)
@Test func fromBridgeBroadcastInjectsForMeshOnlyReceiver() throws {
// Reception is not gated on the toggle: passive radio.
let fixture = Fixture(enabled: false)
let event = try makeRemoteEvent()
fixture.service.handleMeshCarrier(
try carrier(event, direction: .fromBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: false
)
#expect(fixture.injected.count == 1)
#expect(fixture.service.bridgedPeerCount == 1)
}
@Test func fromBridgeBroadcastDedupsAcrossMeshPaths() throws {
let fixture = Fixture(enabled: false)
let event = try makeRemoteEvent()
let packet = try carrier(event, direction: .fromBridge)
let peer = PeerID(str: "aabbccdd00112233")
fixture.service.handleMeshCarrier(packet, from: peer, directedToUs: false)
fixture.service.handleMeshCarrier(packet, from: peer, directedToUs: false)
#expect(fixture.injected.count == 1)
}
@Test func meshCarriedEventIsNeverRebroadcast() throws {
// Loop rule 1: a second gateway hearing a fromBridge broadcast must
// not downlink the same event when its own subscription delivers it.
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
fixture.service.handleMeshCarrier(
try carrier(event, direction: .fromBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: false
)
fixture.service.handleRendezvousEvent(event)
fixture.fireScheduledTimers()
#expect(fixture.broadcasts.isEmpty)
#expect(fixture.injected.count == 1)
}
@Test func directedFromBridgeIsMalformedAndDropped() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
fixture.service.handleMeshCarrier(
try carrier(makeRemoteEvent(), direction: .fromBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: true
)
#expect(fixture.injected.isEmpty)
}
@Test func tamperedCarrierEventIsRejected() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
let dict: [String: Any] = [
"id": event.id,
"pubkey": event.pubkey,
"created_at": event.created_at,
"kind": event.kind,
"tags": event.tags,
"content": event.content + " (tampered)",
"sig": event.sig ?? "",
]
let forged = try NostrEvent(from: dict)
fixture.service.handleMeshCarrier(
try carrier(forged, direction: .fromBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: false
)
#expect(fixture.injected.isEmpty)
}
// MARK: - Uplink deposits (gateway role)
@Test func validDepositIsPublishedWhenRelaysUp() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
fixture.service.handleMeshCarrier(
try carrier(event, direction: .toBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: true
)
#expect(fixture.published.contains { $0.event.id == event.id })
// Deposits never inject: the depositor's radio broadcast already
// carried the message to this island.
#expect(fixture.injected.isEmpty)
}
@Test func depositQueuesWhileRelaysDownAndFlushesOnReconnect() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
fixture.relaysConnected = false
let event = try makeRemoteEvent()
fixture.service.handleMeshCarrier(
try carrier(event, direction: .toBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: true
)
#expect(fixture.publishedMessages.isEmpty)
#expect(fixture.service.queuedUplinks.count == 1)
fixture.relaysConnected = true
fixture.service.flushQueuedUplinks()
#expect(fixture.published.contains { $0.event.id == event.id })
}
@Test func depositRequiresBridgeToggle() throws {
let fixture = Fixture(enabled: false)
fixture.service.handleMeshCarrier(
try carrier(makeRemoteEvent(), direction: .toBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: true
)
#expect(fixture.publishedMessages.isEmpty)
#expect(fixture.service.queuedUplinks.isEmpty)
}
@Test func depositRateLimitBoundsPerDepositor() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let depositor = PeerID(str: "aabbccdd00112233")
for _ in 0..<(BridgeService.Limits.uplinkEventsPerMinutePerDepositor + 4) {
fixture.service.handleMeshCarrier(
try carrier(makeRemoteEvent(), direction: .toBridge),
from: depositor,
directedToUs: true
)
}
#expect(fixture.publishedMessages.count == BridgeService.Limits.uplinkEventsPerMinutePerDepositor)
}
@Test func depositedEventIsNeverDownlinkedBack() throws {
// Loop rule 2: our own relay subscription redelivering an event we
// uplinked must not burn airtime broadcasting it back.
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
fixture.service.handleMeshCarrier(
try carrier(event, direction: .toBridge),
from: PeerID(str: "aabbccdd00112233"),
directedToUs: true
)
fixture.service.handleRendezvousEvent(event)
fixture.fireScheduledTimers()
#expect(fixture.broadcasts.isEmpty)
}
}
+3 -1
View File
@@ -498,13 +498,15 @@ struct ViewSmokeTests {
description: "app_info.features.encryption.description"
)
// AppInfoView's settings pane reads LocationChannelsModel from the
// environment, so it can only render mounted with one installed.
let appInfo = AppInfoView()
.environmentObject(LocationChannelsModel(manager: makeSmokeLocationManager()))
let header = SectionHeader("app_info.features.title")
let featureRow = FeatureRow(info: feature)
let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token"))
let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test"))
_ = appInfo.body
_ = header.body
_ = featureRow.body
_ = paymentCashu.body
@@ -30,6 +30,9 @@ public final class BitchatMessage: Codable {
public let senderPeerID: PeerID?
public let mentions: [String]? // Array of mentioned nicknames
public var deliveryStatus: DeliveryStatus? // Delivery tracking
/// True when this message reached us across a mesh bridge (signed by its
/// author for an internet rendezvous) rather than over local radio.
public let isBridged: Bool
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
@@ -46,8 +49,26 @@ public final class BitchatMessage: Codable {
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
case isBridged
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
sender = try container.decode(String.self, forKey: .sender)
content = try container.decode(String.self, forKey: .content)
timestamp = try container.decode(Date.self, forKey: .timestamp)
isRelay = try container.decode(Bool.self, forKey: .isRelay)
originalSender = try container.decodeIfPresent(String.self, forKey: .originalSender)
isPrivate = try container.decode(Bool.self, forKey: .isPrivate)
recipientNickname = try container.decodeIfPresent(String.self, forKey: .recipientNickname)
senderPeerID = try container.decodeIfPresent(PeerID.self, forKey: .senderPeerID)
mentions = try container.decodeIfPresent([String].self, forKey: .mentions)
deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus)
// Absent in archives written before bridging existed.
isBridged = try container.decodeIfPresent(Bool.self, forKey: .isBridged) ?? false
}
public init(
id: String? = nil,
sender: String,
@@ -59,7 +80,8 @@ public final class BitchatMessage: Codable {
recipientNickname: String? = nil,
senderPeerID: PeerID? = nil,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
deliveryStatus: DeliveryStatus? = nil,
isBridged: Bool = false
) {
self.id = id ?? UUID().uuidString
self.sender = sender
@@ -72,6 +94,7 @@ public final class BitchatMessage: Codable {
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
self.isBridged = isBridged
}
}
@@ -89,7 +112,8 @@ extension BitchatMessage: Equatable {
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
lhs.deliveryStatus == rhs.deliveryStatus &&
lhs.isBridged == rhs.isBridged
}
}
@@ -121,6 +145,7 @@ extension BitchatMessage {
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
if isBridged { flags |= 0x40 }
data.append(flags)
@@ -213,6 +238,7 @@ extension BitchatMessage {
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
let isBridged = (flags & 0x40) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
@@ -321,7 +347,8 @@ extension BitchatMessage {
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
mentions: mentions,
isBridged: isBridged
)
}
}
@@ -20,6 +20,10 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
public static let board = PeerCapabilities(rawValue: 1 << 4)
public static let vouch = PeerCapabilities(rawValue: 1 << 5)
public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6)
/// Bridges the local mesh channel to the geohash-cell rendezvous on Nostr
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside
/// a `bridgeGeohash` TLV carrying the rendezvous cell.
public static let bridge = PeerCapabilities(rawValue: 1 << 7)
/// Minimal little-endian byte encoding; always at least one byte so an
/// empty set is distinguishable from an absent TLV.
@@ -37,6 +37,10 @@ public struct PeerID: Equatable, Hashable, Sendable {
/// `"group_"` (+ 32 characters hex) virtual conversation ID for a
/// private group (16-byte group ID). Never routed to a single peer.
case group = "group_"
/// `"bridge:"` (+ 16 characters hex) a sender reached across a mesh
/// bridge, identified by their rendezvous Nostr pubkey. Not a
/// routable mesh peer.
case bridge = "bridge:"
}
public let prefix: Prefix
@@ -67,6 +71,12 @@ public extension PeerID {
self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength))
}
/// Convenience init to create a bridged-sender PeerID by appending
/// `"bridge:"` to the first 16 characters of the rendezvous Nostr pubkey.
init(bridge pubKey: String) {
self.init(prefix: .bridge, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength))
}
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
init(str: any StringProtocol) {
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
@@ -167,6 +177,11 @@ public extension PeerID {
prefix == .group
}
/// Returns true if `id` starts with "`bridge:`"
var isBridge: Bool {
prefix == .bridge
}
func toPercentEncoded() -> String {
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
}
@@ -0,0 +1,57 @@
//
// BitchatMessageBridgedFlagTests.swift
// BitFoundationTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import BitFoundation
@Suite("BitchatMessage bridged flag")
struct BitchatMessageBridgedFlagTests {
@Test func bridgedFlagSurvivesBinaryRoundTrip() throws {
let message = BitchatMessage(
sender: "far-friend",
content: "hello from across the hill",
timestamp: Date(),
isRelay: false,
senderPeerID: PeerID(bridge: "deadbeefcafe0123deadbeefcafe0123"),
isBridged: true
)
let binary = try #require(message.toBinaryPayload())
let decoded = try #require(BitchatMessage(binary))
#expect(decoded.isBridged)
#expect(decoded.senderPeerID?.isBridge == true)
}
@Test func plainMessageStaysUnbridgedThroughBinary() throws {
let plain = BitchatMessage(
sender: "neighbor",
content: "radio only",
timestamp: Date(),
isRelay: false
)
let binary = try #require(plain.toBinaryPayload())
let decoded = try #require(BitchatMessage(binary))
#expect(!decoded.isBridged)
}
@Test func legacyBinaryWithoutBridgedBitDecodesUnbridged() throws {
// A pre-bridge encoder never sets flag bit 0x40; decoding such a
// payload must default to unbridged.
let message = BitchatMessage(
sender: "old",
content: "hi",
timestamp: Date(),
isRelay: false,
isBridged: true
)
var binary = try #require(message.toBinaryPayload())
binary[0] &= ~UInt8(0x40) // strip the bridged bit like an old client
let decoded = try #require(BitchatMessage(binary))
#expect(!decoded.isBridged)
}
}