mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
d499c3e415
commit
020de96519
@@ -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,
|
||||
|
||||
+4491
-913
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
// 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
@@ -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
|
||||
@@ -27,6 +46,41 @@ struct AppInfoView: View {
|
||||
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 {
|
||||
@@ -165,8 +220,12 @@ struct AppInfoView: View {
|
||||
}
|
||||
.themedSurface(opacity: 0.95)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
panePicker
|
||||
|
||||
ScrollView {
|
||||
infoContent
|
||||
paneContent
|
||||
}
|
||||
}
|
||||
.themedSheetBackground()
|
||||
}
|
||||
@@ -178,8 +237,12 @@ struct AppInfoView: View {
|
||||
}
|
||||
#else
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
panePicker
|
||||
|
||||
ScrollView {
|
||||
infoContent
|
||||
paneContent
|
||||
}
|
||||
}
|
||||
.themedSheetBackground()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -198,6 +261,244 @@ 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 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) {
|
||||
// Appearance — single row: label left, theme chips right
|
||||
HStack(spacing: 12) {
|
||||
SectionHeader(Strings.appearanceTitle)
|
||||
Spacer()
|
||||
ForEach(AppTheme.allCases) { theme in
|
||||
Button {
|
||||
appThemeRawValue = theme.rawValue
|
||||
} label: {
|
||||
Text(theme.displayNameKey)
|
||||
.bitchatFont(size: 13, weight: selectedTheme == theme ? .semibold : .regular)
|
||||
.foregroundColor(selectedTheme == theme ? palette.accent : secondaryTextColor)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(selectedTheme == theme ? palette.accent.opacity(0.15) : Color.clear)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
// Voice — same card + IRC pill as every other toggle setting.
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(Strings.Voice.title)
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Connectivity: mesh bridge, internet gateway, tor routing
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(verbatim: Strings.Settings.connectivityTitle)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -224,65 +525,6 @@ struct AppInfoView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Appearance — single row: label left, theme chips right
|
||||
HStack(spacing: 12) {
|
||||
SectionHeader(Strings.appearanceTitle)
|
||||
Spacer()
|
||||
ForEach(AppTheme.allCases) { theme in
|
||||
Button {
|
||||
appThemeRawValue = theme.rawValue
|
||||
} label: {
|
||||
Text(theme.displayNameKey)
|
||||
.bitchatFont(size: 13, weight: selectedTheme == theme ? .semibold : .regular)
|
||||
.foregroundColor(selectedTheme == theme ? palette.accent : secondaryTextColor)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(selectedTheme == theme ? palette.accent.opacity(0.15) : Color.clear)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
// Voice
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Location (notes / dead drops)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Location.title)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locationPermissionRow
|
||||
}
|
||||
|
||||
// Network diagnostics
|
||||
if topologyProvider != nil {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -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: LocalizedStringKey
|
||||
let description: LocalizedStringKey
|
||||
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 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)
|
||||
@@ -426,11 +662,11 @@ struct FeatureRow: View {
|
||||
.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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
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")
|
||||
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable"))
|
||||
)
|
||||
}
|
||||
.layoutPriority(3)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
// 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)
|
||||
} else {
|
||||
Text(activeText)
|
||||
.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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -44,13 +44,14 @@ struct MeshPeerList: View {
|
||||
}
|
||||
|
||||
if peerListModel.meshRows.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// 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(.top, 12)
|
||||
}
|
||||
.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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,6 +49,24 @@ 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(
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user