mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:45:19 +00:00
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage. Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
566 lines
25 KiB
Swift
566 lines
25 KiB
Swift
//
|
|
// 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
|
|
#if os(iOS)
|
|
import UIKit
|
|
#elseif os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// 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
|
|
/// Coalescing window for dedup-record writes: a backlog re-fetch
|
|
/// mutates the seen set once per event, and each snapshot save is a
|
|
/// full JSON encode + atomic write on the main actor.
|
|
static let dedupPersistCoalesceSeconds: TimeInterval = 1.0
|
|
}
|
|
|
|
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 directly to connected default (DM)
|
|
/// relays. Completion is true only after at least one relay explicitly
|
|
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
|
|
/// or merely "written to a socket".
|
|
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> 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. True means the inner envelope was
|
|
/// delivered, deduplicated, or intentionally rejected after decryption;
|
|
/// false leaves the relay event retryable after a transient crypto/key
|
|
/// failure.
|
|
var openEnvelope: (@MainActor (CourierEnvelope) -> Bool)?
|
|
/// Hands a drop to a matching local peer as a directed courier packet.
|
|
/// Returns true only when the transport accepted the packet onto a live
|
|
/// physical link or its link-specific backpressure queue. Stale
|
|
/// reachability and process-local directed spooling return false so the
|
|
/// drop event stays retryable.
|
|
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Bool)?
|
|
/// Held envelopes eligible for (re)publish, honoring the cooldown.
|
|
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
|
|
/// Commits a held envelope's cooldown after confirmed relay acceptance.
|
|
var markHeldEnvelopePublished: (@MainActor (CourierEnvelope) -> Void)?
|
|
/// 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?,
|
|
operationID: UUID?
|
|
)] = []
|
|
/// Message IDs already published as drops (sender-side dedup) and drop
|
|
/// event IDs already handled (multi-relay dedup). Both persist across
|
|
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
|
|
/// persisted outbox keeps re-depositing, so in-memory-only dedup meant
|
|
/// every relaunch republished the same message as a fresh drop and every
|
|
/// gateway relaunch re-delivered the whole backlog (field-verified
|
|
/// amplification storm). Entries age out with the 24h drop window.
|
|
private var publishedDropKeys: ExpiringIDSet
|
|
private var seenDropEventIDs: ExpiringIDSet
|
|
private var subscriptionOpen = false
|
|
private var lastSubscribedTags: Set<String> = []
|
|
private var refreshTimerArmed = false
|
|
private var announceRefreshTimerArmed = false
|
|
private var lastAnnounceRefresh = Date.distantPast
|
|
private struct ActiveDropOperation {
|
|
let id: UUID
|
|
let completion: @MainActor (Bool) -> Void
|
|
}
|
|
/// Sender operations queued locally or awaiting relay confirmation.
|
|
/// The per-attempt ID prevents a stale pre-wipe callback from completing
|
|
/// a newer attempt for the same message.
|
|
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
|
/// Held-envelope publishes have no sender message ID, but still need an
|
|
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
|
|
/// must not mint duplicate relay events for the same opaque envelope.
|
|
private var heldDropOperations: [Data: UUID] = [:]
|
|
/// Deterministically invalid envelopes are suppressed for this process,
|
|
/// but never persisted as if a relay accepted them. Bound and age them so
|
|
/// rotating oversize IDs cannot grow process memory forever.
|
|
private var rejectedDropKeys = ExpiringIDSet(
|
|
capacity: Limits.maxTrackedIDs,
|
|
lifetime: CourierEnvelope.maxLifetimeSeconds
|
|
)
|
|
|
|
private let now: () -> Date
|
|
private let dedupStore: BridgeDropDedupStore
|
|
|
|
private var dedupPersistScheduled = false
|
|
|
|
init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) {
|
|
self.now = now
|
|
self.dedupStore = dedupStore ?? BridgeDropDedupStore(persistsToDisk: !TestEnvironment.isRunningTests)
|
|
let snapshot = self.dedupStore.load()
|
|
let date = now()
|
|
self.publishedDropKeys = ExpiringIDSet(
|
|
capacity: Limits.maxTrackedIDs,
|
|
lifetime: CourierEnvelope.maxLifetimeSeconds,
|
|
entries: snapshot.publishedDropKeys,
|
|
now: date
|
|
)
|
|
self.seenDropEventIDs = ExpiringIDSet(
|
|
capacity: Limits.maxTrackedIDs,
|
|
lifetime: CourierEnvelope.maxLifetimeSeconds,
|
|
entries: snapshot.seenDropEventIDs,
|
|
now: date
|
|
)
|
|
// A coalesced dedup write scheduled just before a background kill
|
|
// would be lost; flush when the app backgrounds or terminates.
|
|
#if os(iOS)
|
|
let flushNotifications = [UIApplication.didEnterBackgroundNotification, UIApplication.willTerminateNotification]
|
|
#else
|
|
let flushNotifications = [NSApplication.willTerminateNotification]
|
|
#endif
|
|
for name in flushNotifications {
|
|
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.flushDedupSnapshot() }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Schedules a coalesced write of the dedup record (see
|
|
/// `Limits.dedupPersistCoalesceSeconds`); lifecycle notifications flush
|
|
/// any scheduled write before a background kill could drop it.
|
|
private func persistDedup() {
|
|
guard !dedupPersistScheduled else { return }
|
|
dedupPersistScheduled = true
|
|
Task { @MainActor [weak self] in
|
|
try? await Task.sleep(nanoseconds: UInt64(Limits.dedupPersistCoalesceSeconds * 1_000_000_000))
|
|
guard let self else { return }
|
|
self.dedupPersistScheduled = false
|
|
self.flushDedupSnapshot()
|
|
}
|
|
}
|
|
|
|
/// Writes the dedup record now. `publishedDropKeys` contains only drops
|
|
/// a relay explicitly accepted; queued and in-flight keys
|
|
/// live in `activeDropOperations` and are intentionally process-local.
|
|
func flushDedupSnapshot() {
|
|
dedupStore.save(BridgeDropDedupStore.Snapshot(
|
|
publishedDropKeys: publishedDropKeys.entries,
|
|
seenDropEventIDs: seenDropEventIDs.entries
|
|
))
|
|
}
|
|
|
|
/// Panic wipe: forget queued drops and the persisted dedup record.
|
|
func wipe() {
|
|
cancelActivePublishes()
|
|
rejectedDropKeys = ExpiringIDSet(
|
|
capacity: Limits.maxTrackedIDs,
|
|
lifetime: CourierEnvelope.maxLifetimeSeconds
|
|
)
|
|
publishedDropKeys = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
|
|
seenDropEventIDs = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
|
|
dedupStore.wipe()
|
|
}
|
|
|
|
// 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. Completion becomes true only
|
|
/// after a real relay acceptance arrives, which is when the router may
|
|
/// show "carried".
|
|
func depositDrop(
|
|
content: String,
|
|
messageID: String,
|
|
recipientNoiseKey: Data,
|
|
completion: @escaping @MainActor (Bool) -> Void = { _ in }
|
|
) {
|
|
guard bridgeEnabled?() ?? false else {
|
|
completion(false)
|
|
return
|
|
}
|
|
guard !publishedDropKeys.contains(messageID, now: now()),
|
|
activeDropOperations[messageID] == nil,
|
|
!rejectedDropKeys.contains(messageID, now: now()) else {
|
|
completion(false)
|
|
return
|
|
}
|
|
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else {
|
|
completion(false)
|
|
return
|
|
}
|
|
// An envelope that can't encode within the drop size caps fails the
|
|
// same way on every attempt (size is a function of the content, not
|
|
// of the sealing); suppress it in-memory so the retry sweep does not
|
|
// churn, but never persist it as a published drop.
|
|
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
|
rejectedDropKeys.insert(messageID, now: now())
|
|
completion(false)
|
|
return
|
|
}
|
|
let operationID = UUID()
|
|
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
|
|
publishDrop(envelope, messageID: messageID, operationID: operationID)
|
|
}
|
|
|
|
/// 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) ?? [] {
|
|
let key = envelope.ciphertext
|
|
guard heldDropOperations[key] == nil else { continue }
|
|
let operationID = UUID()
|
|
heldDropOperations[key] = operationID
|
|
publishDrop(envelope) { [weak self] succeeded in
|
|
guard let self, self.heldDropOperations[key] == operationID else { return }
|
|
self.heldDropOperations.removeValue(forKey: key)
|
|
if succeeded {
|
|
self.markHeldEnvelopePublished?(envelope)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
|
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
|
/// it rides the pending queue so an evicted or failed drop can release its
|
|
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
|
private func publishDrop(
|
|
_ envelope: CourierEnvelope,
|
|
messageID: String? = nil,
|
|
operationID: UUID? = nil,
|
|
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
|
) {
|
|
guard let encoded = envelope.encode(),
|
|
encoded.count <= Limits.maxDropEnvelopeBytes,
|
|
!envelope.isExpired else {
|
|
finishPublish(
|
|
messageID: messageID,
|
|
operationID: operationID,
|
|
succeeded: false,
|
|
untrackedCompletion: untrackedCompletion
|
|
)
|
|
return
|
|
}
|
|
guard relaysConnected?() ?? false else {
|
|
// Held mail remains in CourierStore and has no sender operation to
|
|
// recover after an in-memory queue loss. Leave its cooldown unset
|
|
// and let the next connected refresh offer it again.
|
|
guard messageID != nil else {
|
|
untrackedCompletion?(false)
|
|
return
|
|
}
|
|
pendingDrops.append((envelope, messageID, operationID))
|
|
while pendingDrops.count > Limits.maxPendingDrops {
|
|
let evicted = pendingDrops.removeFirst()
|
|
finishPublish(
|
|
messageID: evicted.dedupKey,
|
|
operationID: evicted.operationID,
|
|
succeeded: false
|
|
)
|
|
}
|
|
return
|
|
}
|
|
guard let identity = try? NostrIdentity.generate(),
|
|
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)
|
|
finishPublish(
|
|
messageID: messageID,
|
|
operationID: operationID,
|
|
succeeded: false,
|
|
untrackedCompletion: untrackedCompletion
|
|
)
|
|
return
|
|
}
|
|
guard let publishEvent else {
|
|
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
|
|
finishPublish(
|
|
messageID: messageID,
|
|
operationID: operationID,
|
|
succeeded: false,
|
|
untrackedCompletion: untrackedCompletion
|
|
)
|
|
return
|
|
}
|
|
publishEvent(event) { [weak self] succeeded in
|
|
guard let self else { return }
|
|
guard self.finishPublish(
|
|
messageID: messageID,
|
|
operationID: operationID,
|
|
succeeded: succeeded,
|
|
untrackedCompletion: untrackedCompletion
|
|
) else { return }
|
|
if succeeded {
|
|
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session)
|
|
} else {
|
|
SecureLogger.warning("📦🌉 No relay accepted courier drop", category: .session)
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func finishPublish(
|
|
messageID: String?,
|
|
operationID: UUID?,
|
|
succeeded: Bool,
|
|
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
|
) -> Bool {
|
|
guard let messageID else {
|
|
untrackedCompletion?(succeeded)
|
|
return true
|
|
}
|
|
// Missing/mismatched means this callback was duplicated, invalidated
|
|
// by panic wipe, or belongs to an older attempt for the same key.
|
|
guard let operationID,
|
|
let operation = activeDropOperations[messageID],
|
|
operation.id == operationID else { return false }
|
|
activeDropOperations.removeValue(forKey: messageID)
|
|
if succeeded {
|
|
publishedDropKeys.insert(messageID, now: now())
|
|
persistDedup()
|
|
}
|
|
operation.completion(succeeded)
|
|
return true
|
|
}
|
|
|
|
/// 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,
|
|
messageID: item.dedupKey,
|
|
operationID: item.operationID
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 else {
|
|
cancelActivePublishes()
|
|
if subscriptionOpen {
|
|
closeSubscription?()
|
|
subscriptionOpen = false
|
|
}
|
|
return
|
|
}
|
|
guard 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.
|
|
/// Calls inside the window coalesce into one trailing refresh so peers
|
|
/// learned after the leading edge are not omitted until the 30-minute
|
|
/// periodic timer.
|
|
func refreshAfterVerifiedAnnounce() {
|
|
guard bridgeEnabled?() ?? false else { return }
|
|
let date = now()
|
|
let elapsed = date.timeIntervalSince(lastAnnounceRefresh)
|
|
if elapsed >= Limits.announceRefreshDebounceSeconds {
|
|
lastAnnounceRefresh = date
|
|
refresh()
|
|
return
|
|
}
|
|
|
|
guard !announceRefreshTimerArmed else { return }
|
|
announceRefreshTimerArmed = true
|
|
let delay = max(0, Limits.announceRefreshDebounceSeconds - elapsed)
|
|
let fire: @MainActor () -> Void = { [weak self] in
|
|
guard let self else { return }
|
|
self.announceRefreshTimerArmed = false
|
|
guard self.bridgeEnabled?() ?? false else { return }
|
|
self.lastAnnounceRefresh = self.now()
|
|
self.refresh()
|
|
}
|
|
if let scheduleTimer {
|
|
scheduleTimer(delay, fire)
|
|
} else {
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
fire()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
// A resubscribe can still deliver an event from the old watch set.
|
|
// Do not durably consume it until it actually belongs to us/current
|
|
// local peer and is opened or accepted for physical delivery.
|
|
guard !seenDropEventIDs.contains(event.id, now: now()) 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)
|
|
if openEnvelope?(envelope) == true {
|
|
seenDropEventIDs.insert(event.id, now: now())
|
|
persistDedup()
|
|
}
|
|
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)
|
|
if deliverToPeer?(envelope, match.peerID) == true {
|
|
seenDropEventIDs.insert(event.id, now: now())
|
|
persistDedup()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Cancels queued and in-flight sender operations after bridge disable or
|
|
/// panic wipe. Invalidate first, then resolve false so callback re-entry
|
|
/// cannot be mistaken for an active operation; late relay callbacks no-op.
|
|
private func cancelActivePublishes() {
|
|
let invalidated = activeDropOperations.values.map(\.completion)
|
|
pendingDrops.removeAll()
|
|
activeDropOperations.removeAll()
|
|
// Untracked held publishes are invalidated too. Their late relay
|
|
// callbacks compare operation IDs and no-op after this reset.
|
|
heldDropOperations.removeAll()
|
|
invalidated.forEach { $0(false) }
|
|
}
|
|
|
|
/// A fresh random Nostr identity for signing one drop. Delegates to the
|
|
/// canonical generator (Schnorr key that can't fail validity) instead of
|
|
/// hand-rolling SecRandom + retry.
|
|
static func makeThrowawayIdentity() -> NostrIdentity? {
|
|
try? NostrIdentity.generate()
|
|
}
|
|
}
|