Harden PTT audio and the 1.7.1 release (#1423)

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.
This commit is contained in:
jack
2026-07-10 14:04:09 +02:00
committed by GitHub
parent b205a55523
commit 820c933958
91 changed files with 8381 additions and 1083 deletions
+13 -5
View File
@@ -313,21 +313,29 @@ private extension AppRuntime {
}
func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
let sharedContent = userDefaults.string(forKey: "sharedContent"),
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
let clearSharedContent = {
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
// A partial or malformed handoff must not linger in the shared
// app-group container indefinitely.
clearSharedContent()
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
clearSharedContent()
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
clearSharedContent()
switch contentKind {
case .url:
+5 -3
View File
@@ -229,15 +229,17 @@ final class Conversation: ObservableObject, Identifiable {
// sending < sent < carried < delivered < read. A late `.sent` write
// (e.g. the optimistic stamp after routing) must not clobber the
// `.carried` the router already set when it handed a copy to a
// courier/bridge, nor a `.delivered`/`.read` ack. Same for the
// courier/bridge, nor a `.delivered`/`.read` ack. A late asynchronous
// failure is weaker than a confirmed recipient receipt too, so it may
// not replace `.delivered`/`.read`. Same for the
// `.sending` stamp a pre-handshake resend emits asynchronously: it
// can land after the message already reached `.sent`, and "Sent" was
// already truthful. (`.failed` `.sending` stays allowed so a real
// failure retry is visible.)
switch (current, new) {
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending):
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed):
return true
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending):
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed):
return true
case (.carried, .sent), (.carried, .sending):
return true
+9
View File
@@ -27,6 +27,7 @@ final class NearbyNotesCounter: ObservableObject {
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable?
private var permissionCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
@@ -73,6 +74,13 @@ final class NearbyNotesCounter: ObservableObject {
channelsCancellable = locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// CoreLocation can revoke authorization while the view remains
// mounted. `availableChannels` deliberately retains its last value,
// so permission must be an independent invalidation signal or the
// building REQ survives on stale coordinates.
permissionCancellable = locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// The app-info kill switch must take effect immediately, not on the
// next location change or remount.
settingCancellable = NotificationCenter.default
@@ -86,6 +94,7 @@ final class NearbyNotesCounter: ObservableObject {
activeHolders = max(0, activeHolders - 1)
guard activeHolders == 0 else { return }
channelsCancellable = nil
permissionCancellable = nil
settingCancellable = nil
managerCancellable = nil
releaseManager(manager)
@@ -0,0 +1,472 @@
//
// AudioSessionCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// The raw audio-session calls the coordinator makes, abstracted so the
/// state machine is unit-testable with a mock (and compiles on the macOS
/// test host, where `AVAudioSession` doesn't exist).
///
/// Calls arrive on the coordinator's private serial queue never the main
/// thread. `setCategory`/`setActive` block on IPC to the audio server
/// (observed >1 s under contention on device, tripping the system gesture
/// gate), and Apple explicitly recommends activating the session off the
/// main thread.
protocol SessionApplying: Sendable {
func setCategory(_ category: AudioSessionCoordinator.Category) throws
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws
}
/// Sole owner of `AVAudioSession` category/activation for voice features.
///
/// Talk-over means capture (push-to-talk) and playback (inbound bursts,
/// voice notes) can be live simultaneously; letting each engine configure
/// the shared session directly made them stomp each other's category and
/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every
/// client acquires a `Token` and the coordinator:
///
/// - reference-counts activation: `setActive(true)` only on the first
/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the
/// last one releases no client can deactivate another's session;
/// - keeps one escalating category: playback-only holders get `.playback`,
/// any capture holder escalates to `.playAndRecord`, and the category is
/// never downgraded while anyone still holds a token (capture ending must
/// not yank the route out from under live playback);
/// - fans out `onInterrupted` on system interruptions and when the active
/// route's device disappears (no auto-resume: bursts are transient, the
/// next press or burst simply re-acquires). The escalating category change
/// fans out separately as `onCategoryEscalated` the session stays live,
/// so holders that can rebuild their engine against the new configuration
/// keep playing (talk-over is bidirectional); holders that don't provide
/// it fall back to `onInterrupted`.
///
/// Threading: all state lives on a private serial queue, which both
/// serializes rapid acquire/release pairs and keeps the blocking session IPC
/// off the main thread (`acquire` is `async` for exactly that hop; `release`
/// is fire-and-forget onto the queue). Holder callbacks always run on the
/// main actor.
///
/// Microphone *permission* queries stay with their callers; this type owns
/// only category and activation.
///
/// `@unchecked Sendable`: every mutable property is confined to `queue`.
final class AudioSessionCoordinator: @unchecked Sendable {
enum Use {
case playback
case capture
}
/// The session category the coordinator has applied (the `SessionApplying`
/// adapter maps these to concrete `AVAudioSession` category/mode/options).
enum Category {
case playback
case playAndRecord
}
/// Opaque handle for one client's hold on the session. Release exactly
/// once when done (extra releases are ignored).
///
/// `@unchecked` because the stored callbacks are `@MainActor`-isolated
/// closures (non-Sendable as stored types). Lifecycle state is protected
/// by `stateLock`, and callbacks are only ever invoked on the main actor.
final class Token: @unchecked Sendable {
fileprivate enum CallbackKind: Sendable {
case interrupted
case categoryEscalated
}
/// A callback snapshot is only valid for the lifecycle epoch in which
/// it was captured. `release` advances the epoch synchronously before
/// its queue work, so a callback already headed to the main actor can't
/// reach a client that has since released this token and reacquired a
/// different one.
fileprivate struct CallbackTicket: Sendable {
let token: Token
let kind: CallbackKind
let lifecycleEpoch: UInt64
}
private enum Lifecycle {
/// Registered on the session queue, but `acquire` has not yet
/// returned into the client's main-actor call frame.
case acquiring
case ready
case released
}
fileprivate let onInterrupted: @MainActor () -> Void
fileprivate let onCategoryEscalated: (@MainActor () -> Void)?
private let stateLock = NSLock()
private var lifecycle = Lifecycle.acquiring
private var lifecycleEpoch: UInt64 = 0
/// A terminal event that lands while the token is registered but not
/// yet handed off invalidates the acquire before its caller can start.
private var terminalEventPendingHandoff = false
fileprivate init(
onInterrupted: @escaping @MainActor () -> Void,
onCategoryEscalated: (@MainActor () -> Void)?
) {
self.onInterrupted = onInterrupted
self.onCategoryEscalated = onCategoryEscalated
}
/// Records an event at the same linearization point at which the
/// coordinator snapshots its holders. An acquiring token cannot safely
/// receive a callback yet: terminal events invalidate the acquire,
/// while category escalation needs no callback because its engine will
/// start against the already-escalated configuration.
fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? {
stateLock.withLock {
switch lifecycle {
case .acquiring:
switch kind {
case .interrupted:
terminalEventPendingHandoff = true
case .categoryEscalated:
break
}
return nil
case .ready:
return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch)
case .released:
return nil
}
}
}
/// Completes the main-actor ownership handoff if no terminal event
/// invalidated it. Because `acquire` itself is main-actor isolated, a
/// successful handoff returns directly into the caller without another
/// actor hop; no callback can interleave before the caller stores the
/// returned token.
fileprivate func completeHandoff() -> Bool {
stateLock.withLock {
guard lifecycle == .acquiring,
!terminalEventPendingHandoff
else { return false }
lifecycle = .ready
return true
}
}
/// Marks the token dead synchronously, before the asynchronous holder
/// removal. Returns false for an already-released token.
fileprivate func markReleased() -> Bool {
stateLock.withLock {
guard lifecycle != .released else { return false }
lifecycle = .released
lifecycleEpoch &+= 1
terminalEventPendingHandoff = false
return true
}
}
/// Revalidates a queue snapshot at the main-actor delivery boundary.
/// The lock is deliberately released before invoking client code: real
/// callbacks commonly call `release` on this same token.
@MainActor
fileprivate func deliver(_ ticket: CallbackTicket) {
let isLive = stateLock.withLock {
lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch
}
guard isLive else { return }
switch ticket.kind {
case .interrupted:
onInterrupted()
case .categoryEscalated:
(onCategoryEscalated ?? onInterrupted)()
}
}
}
/// Deterministic suspension points for lifecycle race tests. Production
/// instances use the nil defaults; the hooks never move session calls off
/// the coordinator queue or callback execution off the main actor.
struct TestingHooks: Sendable {
let beforeAcquireHandoff: (@Sendable () async -> Void)?
let beforeCallbackDelivery: (@Sendable () async -> Void)?
init(
beforeAcquireHandoff: (@Sendable () async -> Void)? = nil,
beforeCallbackDelivery: (@Sendable () async -> Void)? = nil
) {
self.beforeAcquireHandoff = beforeAcquireHandoff
self.beforeCallbackDelivery = beforeCallbackDelivery
}
}
static let shared = AudioSessionCoordinator(session: SystemAudioSession())
private let session: SessionApplying
private let testingHooks: TestingHooks
/// Confines all mutable state, serializes whole acquire/release
/// operations (two rapid presses can't interleave their category and
/// activation calls), and hosts the blocking session IPC off main.
private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated)
// Queue-confined state.
private var holders: [ObjectIdentifier: Token] = [:]
private var currentCategory: Category?
private var sessionActive = false
/// Written once in init, read in deinit never touched concurrently.
private var observers: [NSObjectProtocol] = []
init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) {
self.session = session
self.testingHooks = testingHooks
observeSystemNotifications()
}
deinit {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}
/// Configures + activates the session for `use` and registers the caller
/// as a holder. The blocking `AVAudioSession` calls run on the session
/// queue the caller suspends instead of stalling its thread (a PTT
/// press used to block main >1 s in `setActive`, tripping the system
/// gesture gate). `onInterrupted` fires (on the main actor) when the
/// client must stop using the session: a system interruption began or
/// its route's device went away. The client should stop its engine,
/// finalize any artifacts, and release resuming means acquiring again.
///
/// `onCategoryEscalated` fires instead when the session category
/// escalated underneath the holder (a capture client joined): the session
/// stays active, so a holder that can rebuild its engine against the new
/// configuration should restart and keep going. Holders that pass `nil`
/// get `onInterrupted` for escalation too. Escalation is delivered before
/// `acquire` returns, so the new holder starts its engine strictly after
/// existing ones were told to rebuild. Main-actor isolation is also the
/// ownership handoff boundary: if interruption or route loss lands after
/// queue registration but before that boundary, the provisional holder is
/// removed and `acquire` throws `CancellationError` instead of returning a
/// token whose callback already fired.
@MainActor
func acquire(
_ use: Use,
onInterrupted: @escaping @MainActor () -> Void,
onCategoryEscalated: (@MainActor () -> Void)? = nil
) async throws -> Token {
let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated)
let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
continuation.resume(returning: try self.activateOnQueue(use, registering: token))
} catch {
continuation.resume(throwing: error)
}
}
}
// Escalating playback -> playAndRecord reconfigures the hardware
// route; engines started against the old configuration must restart.
if !reconfigured.isEmpty {
SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session)
await deliver(reconfigured)
}
if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff {
await beforeAcquireHandoff()
}
guard token.completeHandoff() else {
// A call/Siri interruption or route loss landed after registration
// but before ownership handoff. Remove the provisional holder and
// fail instead of starting a client engine after the stop event.
release(token)
throw CancellationError()
}
return token
}
/// Drops one holder. Deactivates the session (notifying other apps) only
/// when the last holder releases. Safe to call more than once, from any
/// thread (including `deinit` paths): the work is fire-and-forget onto
/// the session queue, so the blocking deactivation IPC never runs on the
/// caller.
func release(_ token: Token) {
guard token.markReleased() else { return }
queue.async {
self.releaseOnQueue(token)
}
}
// MARK: - Queue-confined core
/// Returns callback tickets for pre-existing live holders whose engines
/// must restart because this acquire escalated the category.
private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] {
let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback
let categoryChanged = target != currentCategory
let previousCategory = currentCategory
if categoryChanged {
try session.setCategory(target)
currentCategory = target
}
if !sessionActive {
do {
try session.setActive(true, notifyOthersOnDeactivation: false)
} catch {
// Activation failed (e.g. a phone call owns the hardware):
// with no holder registered, an escalated category recorded
// here would stick and pin later playback-only acquires to
// .playAndRecord. Existing holders keep the category the
// hardware really has.
if categoryChanged, holders.isEmpty {
currentCategory = previousCategory
}
throw error
}
sessionActive = true
}
let reconfigured = categoryChanged
? holders.values.compactMap { $0.record(.categoryEscalated) }
: []
holders[ObjectIdentifier(token)] = token
return reconfigured
}
private func releaseOnQueue(_ token: Token) {
guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return }
guard holders.isEmpty else { return }
currentCategory = nil
guard sessionActive else { return }
sessionActive = false
do {
try session.setActive(false, notifyOthersOnDeactivation: true)
} catch {
SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session)
}
}
private func onQueue<T: Sendable>(_ body: @escaping @Sendable () -> T) async -> T {
await withCheckedContinuation { continuation in
queue.async {
continuation.resume(returning: body())
}
}
}
@MainActor
private func deliver(_ tickets: [Token.CallbackTicket]) async {
guard !tickets.isEmpty else { return }
if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery {
await beforeCallbackDelivery()
}
for ticket in tickets {
ticket.token.deliver(ticket)
}
}
// MARK: - System events (internal so tests can drive them directly)
/// A system interruption began: the session is already deactivated by the
/// OS, so just mark it inactive and tell every ready holder (on the main
/// actor) to stop. A provisional acquiring holder is invalidated instead.
/// No auto-resume the next acquire re-activates.
func handleInterruptionBegan() async {
let tickets = await onQueue { () -> [Token.CallbackTicket] in
self.sessionActive = false
return self.holders.values.compactMap { $0.record(.interrupted) }
}
await deliver(tickets)
}
/// The active route's input/output device disappeared (e.g. BT headset
/// off): ready holders' engines are wedged against a dead route stop
/// them; invalidate a holder whose acquire has not returned yet.
func handleRouteDeviceUnavailable() async {
let tickets = await onQueue {
self.holders.values.compactMap { $0.record(.interrupted) }
}
await deliver(tickets)
}
/// Test hook: suspends until every session operation enqueued before this
/// call including fire-and-forget `release`s has completed.
func drain() async {
await onQueue {}
}
private func observeSystemNotifications() {
#if os(iOS)
let center = NotificationCenter.default
observers.append(center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .began,
let self
else { return }
SecureLogger.info("AudioSession: interruption began", category: .session)
Task { await self.handleInterruptionBegan() }
})
observers.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable,
let self
else { return }
SecureLogger.info("AudioSession: route device became unavailable", category: .session)
Task { await self.handleRouteDeviceUnavailable() }
})
#endif
}
}
// MARK: - Production adapter
#if os(iOS)
private struct SystemAudioSession: SessionApplying {
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
let session = AVAudioSession.sharedInstance()
switch category {
case .playback:
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
case .playAndRecord:
// allowBluetoothHFP is not available on iOS Simulator
#if targetEnvironment(simulator)
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers]
)
#endif
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
try AVAudioSession.sharedInstance().setActive(
active,
options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : []
)
}
}
#else
/// macOS has no app-level audio session; the coordinator still runs its
/// bookkeeping so client code is identical across platforms.
private struct SystemAudioSession: SessionApplying {
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
}
#endif
+397 -32
View File
@@ -6,10 +6,142 @@
// For more information, see <https://unlicense.org>
//
import AVFoundation
@preconcurrency import AVFoundation
import BitLogger
import Foundation
/// The engine operations behind live-burst playback, abstracted so the
/// player's lifecycle (jitter start, category-escalation restart, stop) is
/// unit-testable without real audio hardware.
@MainActor
protocol PTTPlaybackEngine: AnyObject {
/// The object `AVAudioEngineConfigurationChange` notifications are posted
/// for (nil for mocks no observer is registered).
var configChangeObject: AnyObject? { get }
func start() throws
func play()
func stop()
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
)
}
/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled
/// buffer. `dataConsumed` only means the node no longer needs the bytes; it
/// may arrive before the render pipeline has made the audio audible.
enum PTTPlaybackCompletionType: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
}
enum PTTPlaybackCompletionEvent: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
/// AVAudioPlayerNode invokes the requested callback when the node is
/// stopped too. That is not audible completion and must remain replayable.
case playbackStopped
}
/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start:
/// an engine instantiated against an earlier audio-session configuration keeps
/// rendering to the stale route (same class of failure as the capture side's
/// fresh-engine-per-press rule).
@MainActor
private final class SystemPTTPlaybackEngine: PTTPlaybackEngine {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
init(format: AVAudioFormat) {
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
}
var configChangeObject: AnyObject? { engine }
func start() throws {
engine.prepare()
try engine.start()
}
func play() {
node.play()
}
func stop() {
node.stop()
engine.stop()
}
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
) {
let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType {
case .dataConsumed: .dataConsumed
case .dataPlayedBack: .dataPlayedBack
}
let scheduledEngine = engine
node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in
// The API invokes this callback when the player is stopped as
// well. A configuration change can stop the engine before its
// notification reaches MainActor, so do not misclassify that
// flushed tail as audible playback.
guard scheduledEngine?.isRunning == true else {
completionHandler(.playbackStopped)
return
}
switch callbackType {
case .dataConsumed:
completionHandler(.dataConsumed)
case .dataRendered:
completionHandler(.dataConsumed)
case .dataPlayedBack:
completionHandler(.dataPlayedBack)
@unknown default:
completionHandler(.playbackStopped)
}
}
}
}
/// Completion callbacks arrive off the main actor, while engine rebuilds are
/// serialized on it. This small lock-backed latch lets a rebuild atomically
/// claim only buffers whose completion has not already fired even when the
/// callback's hop back to the main actor is still queued.
private final class PTTPlaybackCompletionState: @unchecked Sendable {
private enum State {
case scheduled
case completed
case retired
}
private let lock = NSLock()
private var state: State = .scheduled
/// Returns true exactly once when playback completion wins the race with
/// an engine rebuild or stop.
func complete() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .completed
return true
}
}
/// Returns true exactly once when a rebuild or stop claims this
/// still-pending schedule. Later callbacks from that engine are stale.
func retireIfPending() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .retired
return true
}
}
}
/// Plays one inbound live voice burst with a small jitter buffer.
///
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
@@ -17,27 +149,77 @@ import Foundation
/// buffer arrives, which self-heals timing without explicit silence
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
///
/// Talk-over is bidirectional: when push-to-talk capture starts while this
/// burst plays, the session category escalates underneath the engine the
/// player rebuilds a fresh engine against the new configuration and keeps
/// streaming instead of dying. Real interruptions (phone call, route device
/// gone) still stop it; the burst keeps assembling to file either way.
@MainActor
final class PTTBurstPlayer {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
/// handful of category/route changes is plenty beyond it something is
/// thrashing and stopping cleanly beats an engine-rebuild loop.
private static let maxEngineRestarts = 8
private let makeEngine: @MainActor () -> PTTPlaybackEngine
private var engine: PTTPlaybackEngine
private let decoder: PTTFrameDecoder
private let coordinator: AudioSessionCoordinator
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would stop this player mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var queuedBuffers: [AVAudioPCMBuffer] = []
private var queuedDuration: TimeInterval = 0
private var scheduledCount = 0
private struct ScheduledBuffer {
let id: UInt64
let buffer: AVAudioPCMBuffer
let completionState: PTTPlaybackCompletionState
}
/// Buffers handed to the current engine whose completion has not yet
/// been processed on the main actor. Keeping the buffers themselves lets
/// a category-escalation rebuild replay the unfinished tail in order.
private var scheduledBuffers: [ScheduledBuffer] = []
private var nextScheduledBufferID: UInt64 = 0
/// Bumped on every engine rebuild or stop so completion tasks from a
/// torn-down engine cannot mutate the current generation's pending list.
private var engineGeneration = 0
private var engineRestarts = 0
private var engineStarted = false
private var finished = false
private var stopped = false
/// Latched off (internal read so tests can await the async failure path).
private(set) var stopped = false
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates `startIfReady` against double acquisition.
private var acquiringSession = false
private var deadlineTask: Task<Void, Never>?
private var sessionToken: AudioSessionCoordinator.Token?
/// Reserved before the session acquire suspends. Activation succeeds only
/// if no newer playback request claimed the floor in the meantime.
private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation?
private var configChangeObserver: NSObjectProtocol?
private(set) var isPlaying = false
init?() {
/// Fires exactly once when the player stops for good (drain-out, cancel,
/// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark
/// the draining player it keeps alive after the assembly the player's
/// only long-lived owner is discarded on burst END.
var onStopped: (() -> Void)?
init?(
coordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil,
makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil
) {
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
self.decoder = decoder
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
self.coordinator = coordinator ?? .shared
self.exclusivity = exclusivity ?? .shared
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
self.makeEngine = factory
self.engine = factory()
deadlineTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
@@ -45,6 +227,21 @@ final class PTTBurstPlayer {
}
}
deinit {
// Backstop for an owner dropping the player before it stopped: the
// session coordinator retains registered tokens strongly, so a token
// leaked here would keep the session active (and pin any escalated
// category) for the app's lifetime. `release` is fire-and-forget
// onto the coordinator's queue, so it is deinit-safe.
if let token = sessionToken {
coordinator.release(token)
}
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
deadlineTask?.cancel()
}
/// Decodes and queues frames (in burst order). Starts playback when the
/// jitter buffer fills.
func enqueue(_ frames: [Data]) {
@@ -64,49 +261,119 @@ final class PTTBurstPlayer {
/// The burst ended: stop once everything scheduled has played out.
func finishAfterDrain() {
finished = true
// The complete burst is queued no jitter left to wait for. This
// also matters when END lands while the async session acquire is
// still in flight: the queued audio must play out, not be treated
// as already drained.
startIfReady(force: true)
stopIfDrained()
}
/// Immediate stop (cancel, another playback taking over, teardown).
/// Immediate stop (cancel, another playback taking over, interruption,
/// teardown).
func stop() {
guard !stopped else { return }
stopped = true
deadlineTask?.cancel()
removeConfigObserver()
queuedBuffers = []
retireScheduledBuffers()
if engineStarted {
node.stop()
engine.stop()
}
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
releaseSessionToken()
exclusivity.deactivate(self)
onStopped?()
}
private func startIfReady(force: Bool) {
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
#if os(iOS)
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
// Acquiring the session suspends for its blocking IPC (off the main
// actor); frames arriving meanwhile keep queueing and are flushed
// onto the engine once it starts.
acquiringSession = true
playbackReservation = exclusivity.reserve(self)
Task { [weak self] in
await self?.acquireSessionAndStart()
}
#endif
}
engine.prepare()
private func acquireSessionAndStart() async {
let token: AudioSessionCoordinator.Token
do {
token = try await coordinator.acquire(
.playback,
onInterrupted: { [weak self] in self?.stop() },
onCategoryEscalated: { [weak self] in self?.restartEngine() }
)
} catch {
acquiringSession = false
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
// Playing unregistered would leave the engine exposed: another
// holder's last release deactivates the session mid-play, and no
// interruption/escalation fan-out ever reaches us. Bail like the
// engine-start failure below; the burst still assembles to file.
// (stop() also fires onStopped so a parked draining player is
// unparked instead of leaking.)
stop()
return
}
acquiringSession = false
// stop() (cancel, exclusivity, teardown) may have landed while the
// session was activating: hand the token straight back.
guard !stopped else {
coordinator.release(token)
return
}
sessionToken = token
guard let playbackReservation,
exclusivity.isCurrent(playbackReservation, for: self)
else {
// The request was superseded while audio-session activation was
// suspended. Do not even start the retired engine.
stop()
return
}
// Observe reconfiguration before starting so nothing lands between.
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
stopped = true
return
// A capture racing this start can reconfigure the session while
// the engine spins up (its escalation fan-out no-ops on a player
// that never started): rebuild once against the settled
// configuration counted against the restart cap before
// giving up.
SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session)
removeConfigObserver()
engineRestarts += 1
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
// stop() removes the observer, hands the token back, and
// fires onStopped for any parked draining owner.
stop()
return
}
}
engineStarted = true
guard exclusivity.activate(self, reservation: playbackReservation)
else {
// A newer user-initiated playback reserved the floor while this
// older PTT request was suspended in audio-session activation.
// Never let the late completion steal playback back.
stop()
return
}
isPlaying = true
VoiceNotePlaybackCoordinator.shared.activate(self)
node.play()
engine.play()
let buffered = queuedBuffers
queuedBuffers = []
@@ -116,21 +383,119 @@ final class PTTBurstPlayer {
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
scheduledCount += 1
node.scheduleBuffer(buffer) { [weak self] in
/// The audio session was reconfigured underneath the running engine
/// (category escalation for talk-over, or an engine configuration
/// change): rebuild a fresh engine against the new configuration and
/// keep streaming. Buffers already completed stay completed; the
/// unfinished scheduled tail is replayed in order on the fresh engine,
/// and frames still arriving continue scheduling after it.
private func restartEngine() {
guard engineStarted, !stopped else { return }
engineRestarts += 1
guard engineRestarts <= Self.maxEngineRestarts else {
SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session)
stop()
return
}
removeConfigObserver()
// Claim the unfinished tail before stopping the old engine. Stopping
// a player node may itself invoke its completion handlers; retiring
// the claimed entries first makes those callbacks unambiguously stale.
// A completion that fired just before this rebuild wins the latch and
// is excluded even if its MainActor task has not run yet.
let buffersToReplay = scheduledBuffers.compactMap { scheduled in
scheduled.completionState.retireIfPending() ? scheduled.buffer : nil
}
scheduledBuffers = []
engineGeneration += 1
engine.stop()
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session)
stop()
return
}
engine.play()
for buffer in buffersToReplay {
schedule(buffer)
}
SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session)
// If every old buffer completed before the rebuild, a finished burst
// can stop now. Otherwise the replayed tail keeps it alive until its
// new-generation completions arrive.
stopIfDrained()
}
private func registerConfigObserver() {
guard let object = engine.configChangeObject else { return }
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: object,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
self.scheduledCount -= 1
self?.restartEngine()
}
}
}
private func removeConfigObserver() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
let id = nextScheduledBufferID
nextScheduledBufferID &+= 1
let completionState = PTTPlaybackCompletionState()
scheduledBuffers.append(ScheduledBuffer(
id: id,
buffer: buffer,
completionState: completionState
))
let generation = engineGeneration
engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in
guard event == .dataPlayedBack else { return }
// Mark completion before hopping to MainActor. A rebuild can then
// distinguish already-completed audio from an unfinished tail
// even when this task has not run yet.
guard completionState.complete() else { return }
Task { @MainActor [weak self] in
guard let self, self.engineGeneration == generation else { return }
self.scheduledBuffers.removeAll { $0.id == id }
self.stopIfDrained()
}
}
}
private func retireScheduledBuffers() {
engineGeneration += 1
for scheduled in scheduledBuffers {
_ = scheduled.completionState.retireIfPending()
}
scheduledBuffers = []
}
private func stopIfDrained() {
guard finished, scheduledCount <= 0 else { return }
guard finished, scheduledBuffers.isEmpty else { return }
// Started: everything scheduled has played out. Never started with
// nothing queued or in flight (e.g. no decodable frames): nothing
// will ever play. Otherwise the engine start is still pending (the
// async session acquire) and the queued audio must play out first.
guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return }
stop()
}
private func releaseSessionToken() {
sessionToken.map(coordinator.release)
sessionToken = nil
}
}
extension PTTBurstPlayer: ExclusivePlayback {
+179 -36
View File
@@ -10,12 +10,85 @@ import AVFoundation
import BitLogger
import Foundation
/// Owns one capture token and returns it even when the capture engine's owner
/// disappears without reaching its normal stop/cancel path. The coordinator
/// retains registered tokens strongly, so relying on `Token.deinit` cannot
/// reclaim an abandoned hold.
final class PTTCaptureSessionLease: @unchecked Sendable {
private let coordinator: AudioSessionCoordinator
private let lock = NSLock()
private var token: AudioSessionCoordinator.Token?
init(coordinator: AudioSessionCoordinator) {
self.coordinator = coordinator
}
func install(_ token: AudioSessionCoordinator.Token) {
let previous = lock.withLock {
let previous = self.token
self.token = token
return previous
}
previous.map(coordinator.release)
}
func release() {
let token = lock.withLock {
let token = self.token
self.token = nil
return token
}
token.map(coordinator.release)
}
deinit {
release()
}
}
/// Monotonic capture identity shared by main-actor lifecycle code and queued
/// engine callbacks. Removing a notification observer does not cancel a block
/// already enqueued on the main queue, so every callback must also prove it
/// still belongs to the current hold before mutating capture state.
final class PTTCaptureGeneration: @unchecked Sendable {
private let lock = NSLock()
private var value: UInt = 0
func begin() -> UInt {
lock.withLock {
value &+= 1
return value
}
}
func invalidate() {
lock.withLock { value &+= 1 }
}
func invalidate(ifCurrent generation: UInt) -> Bool {
lock.withLock {
guard value == generation else { return false }
value &+= 1
return true
}
}
func isCurrent(_ generation: UInt) -> Bool {
lock.withLock { value == generation }
}
}
/// Captures microphone audio for a live push-to-talk burst, producing both:
/// - live AAC frames via `onFrames` (called on the capture queue), and
/// - a finalized `.m4a` voice note on `stop()` the same artifact
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
/// handles delivery to receivers that missed the live stream.
final class PTTCaptureEngine {
/// `@unchecked Sendable`: every mutable property is confined to one executor
/// the capture `queue` (resampler/encoder/file/counters) or the main actor
/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) so
/// weak references may cross the `@Sendable` tap/notification closures, which
/// immediately hop back to the owning executor.
final class PTTCaptureEngine: @unchecked Sendable {
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
/// engine keeps running (the UI owns the gesture) but stops encoding.
private static let maxCaptureDuration: TimeInterval = 120
@@ -26,6 +99,9 @@ final class PTTCaptureEngine {
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
private var engine = AVAudioEngine()
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
private let coordinator: AudioSessionCoordinator
private let sessionLease: PTTCaptureSessionLease
private let captureGeneration = PTTCaptureGeneration()
// Capture-queue-confined state.
private var resampler: PTTInputResampler?
@@ -35,10 +111,10 @@ final class PTTCaptureEngine {
private var encodedFrameCount = 0
private var running = false
private var captureStart = Date()
/// Whether `engine.start()` succeeded for the current capture (main-actor
/// callers only; see `stopEngineIfStarted`).
private var engineStarted = false
/// Whether `engine.start()` succeeded for the current capture
/// (see `stopEngineIfStarted`).
@MainActor private var engineStarted = false
@MainActor private var configChangeObserver: NSObjectProtocol?
/// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)?
@@ -47,11 +123,41 @@ final class PTTCaptureEngine {
case audioSetupFailed
}
func start(outputURL: URL) throws {
#if os(iOS)
try Self.configureAudioSession()
#endif
init(coordinator: AudioSessionCoordinator = .shared) {
self.coordinator = coordinator
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
}
deinit {
sessionLease.release()
}
/// Async because acquiring the session hops its blocking IPC off the main
/// actor (a PTT press used to stall main >1 s in `setActive`); the engine
/// itself still starts back on main once the session is configured.
@MainActor
func start(outputURL: URL) async throws {
let generation = captureGeneration.begin()
let token = try await coordinator.acquire(.capture) { [weak self] in
self?.handleInterruption(for: generation)
}
// The hold ended (stop/cancel) while the session was activating:
// starting the engine now would leave a hot mic after release.
guard captureGeneration.isCurrent(generation) else {
coordinator.release(token)
throw CancellationError()
}
sessionLease.install(token)
do {
try beginCapture(outputURL: outputURL, generation: generation)
} catch {
releaseSessionToken()
throw error
}
}
@MainActor
private func beginCapture(outputURL: URL, generation: UInt) throws {
// Fresh engine per capture so its input unit binds to the session
// that is active *now* (see `engine` doc comment).
engine = AVAudioEngine()
@@ -83,13 +189,30 @@ final class PTTCaptureEngine {
}
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.queue.async { self?.process(buffer) }
self?.queue.async { self?.process(buffer, generation: generation) }
}
// Route/category changes reconfigure the engine underneath the tap;
// stop and finalize cleanly the .m4a captured so far still sends.
// Registered before start() so no reconfigure lands unobserved
// (handleInterruption also validates this capture generation).
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: engine,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleInterruption(for: generation)
}
}
engine.prepare()
do {
try engine.start()
} catch {
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
engine.inputNode.removeTap(onBus: 0)
queue.sync { self.teardown(deleteFile: true) }
throw error
@@ -100,7 +223,9 @@ final class PTTCaptureEngine {
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
@MainActor
func stop() -> (url: URL?, encodedFrames: Int) {
captureGeneration.invalidate()
stopEngineIfStarted()
let result: (URL?, Int) = queue.sync {
let url = fileURL
@@ -108,34 +233,70 @@ final class PTTCaptureEngine {
teardown(deleteFile: false)
return (url, frames)
}
#if os(iOS)
Self.deactivateAudioSession()
#endif
releaseSessionToken()
return result
}
@MainActor
func cancel() {
captureGeneration.invalidate()
stopEngineIfStarted()
queue.sync { teardown(deleteFile: true) }
#if os(iOS)
Self.deactivateAudioSession()
#endif
releaseSessionToken()
}
/// Audio session interrupted (call, Siri) or the engine was reconfigured
/// mid-capture: behave like `stop()` finalize the `.m4a` container but
/// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()`
/// still returns the note for delivery.
@MainActor
private func handleInterruption(for generation: UInt) {
// Also invalidate a start whose acquire has registered its token but
// has not returned to this actor yet. Without this bump the callback
// is lost while `engineStarted == false`, and the resumed start can
// open the mic after the stop signal.
guard captureGeneration.invalidate(ifCurrent: generation) else { return }
guard engineStarted else {
releaseSessionToken()
return
}
stopEngineIfStarted()
queue.sync {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
}
releaseSessionToken()
SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session)
}
/// Touching `inputNode` on an engine that never started instantiates its
/// input unit against whatever session is active and spams AURemoteIO
/// errors a canceled-before-start hold must not touch the engine.
@MainActor
private func stopEngineIfStarted() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
guard engineStarted else { return }
engineStarted = false
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
@MainActor
private func releaseSessionToken() {
sessionLease.release()
}
// MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer) {
guard running,
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
guard captureGeneration.isCurrent(generation),
running,
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
let resampled = resampler?.resample(buffer)
else { return }
@@ -162,22 +323,4 @@ final class PTTCaptureEngine {
}
fileURL = nil
}
// MARK: - Audio session (iOS)
#if os(iOS)
private static func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
#if targetEnvironment(simulator)
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
#else
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP])
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
}
private static func deactivateAudioSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
#endif
}
@@ -31,25 +31,45 @@ protocol VoiceCaptureSession: AnyObject {
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@MainActor
final class VoiceNoteCaptureSession: VoiceCaptureSession {
private let recorder: VoiceRecorder
private let owner = VoiceRecorder.RecordingOwner()
var isLive: Bool { false }
init(recorder: VoiceRecorder = .shared) {
self.recorder = recorder
}
func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission()
await recorder.requestPermission()
}
func start() async throws {
try await VoiceRecorder.shared.startRecording()
try await recorder.startRecording(owner: owner)
}
func finish() async -> URL? {
await VoiceRecorder.shared.stopRecording()
await recorder.stopRecording(owner: owner)
}
func cancel() async {
await VoiceRecorder.shared.cancelRecording()
await recorder.cancelRecording(owner: owner)
}
}
/// Testable surface of the live capture engine. Production uses
/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening
/// real audio hardware.
@MainActor
protocol PTTCapturing: AnyObject {
var onFrames: (([Data]) -> Void)? { get set }
func start(outputURL: URL) async throws
func stop() -> (url: URL?, encodedFrames: Int)
func cancel()
}
extension PTTCaptureEngine: PTTCapturing {}
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
/// recording, then finalizes the same audio as a standard voice note whose
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
@@ -60,7 +80,8 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
let burstID: Data
private let sendPacket: (Data) -> Void
private let capture = PTTCaptureEngine()
private let capture: any PTTCapturing
private let now: () -> Date
/// Capture-queue-confined stream state: packetizes frames and lazily
/// emits START so packet order is guaranteed by queue serialization.
private final class StreamState {
@@ -79,10 +100,17 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
/// target peer; must be safe to call from any queue (BLEService hops to
/// its own message queue internally).
init(sendPacket: @escaping (Data) -> Void) {
self.burstID = VoiceBurstPacket.makeBurstID()
init(
sendPacket: @escaping (Data) -> Void,
capture: (any PTTCapturing)? = nil,
now: @escaping () -> Date = Date.init,
burstID: Data? = nil
) {
self.burstID = burstID ?? VoiceBurstPacket.makeBurstID()
self.sendPacket = sendPacket
self.stream = StreamState(burstID: burstID)
self.capture = capture ?? PTTCaptureEngine()
self.now = now
self.stream = StreamState(burstID: self.burstID)
}
func requestPermission() async -> Bool {
@@ -117,7 +145,15 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
}
}
do {
try capture.start(outputURL: outputURL)
try await capture.start(outputURL: outputURL)
} catch is CancellationError {
// The hold was released/canceled while the session acquire was
// in flight: the engine never started and the capture already
// handed its token back nothing to retry. A coordinator-side
// interruption during handoff also cancels acquire, but that is
// not a successful start and must propagate to the view model.
guard completed else { throw CancellationError() }
return
} catch {
// The HAL can briefly report a dead input right after the audio
// session (re)activates while the route settles; one retry after
@@ -131,9 +167,9 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
capture.cancel()
return
}
try capture.start(outputURL: outputURL)
try await capture.start(outputURL: outputURL)
}
startDate = Date()
startDate = now()
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
}
@@ -141,11 +177,16 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
guard !completed else { return nil }
completed = true
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
let (url, encodedFrames) = capture.stop()
// stop() drained the capture queue, so touching `stream` is safe now.
guard elapsed >= VoiceRecorder.minRecordingDuration, let url else {
let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration
guard elapsed >= VoiceRecorder.minRecordingDuration,
capturedDuration >= VoiceRecorder.minRecordingDuration,
let url
else {
sendControlPacket(.canceled)
if let url {
try? FileManager.default.removeItem(at: url)
@@ -156,7 +197,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
let durationMs = UInt32((Double(encodedFrames) * PTTAudioFormat.frameDuration * 1000).rounded())
let durationMs = UInt32((capturedDuration * 1000).rounded())
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
return url
@@ -9,6 +9,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0
/// Internal lifecycle visibility for deterministic acquisition tests.
var isPlaybackStartPending: Bool { sessionAcquireInFlight }
/// rounded so 4.9s shows "00:05"
var roundedDuration: Int {
guard duration.isFinite else { return 0 }
@@ -24,9 +27,24 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
private var player: AVAudioPlayer?
private var timer: Timer?
private var url: URL
/// Test seam; `AudioSessionCoordinator.shared` when nil.
private let sessionCoordinatorOverride: AudioSessionCoordinator?
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would pause this controller mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var sessionToken: AudioSessionCoordinator.Token?
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates against double acquisition on rapid play taps.
private var sessionAcquireInFlight = false
init(url: URL) {
init(
url: URL,
sessionCoordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil
) {
self.url = url
self.sessionCoordinatorOverride = sessionCoordinator
self.exclusivity = exclusivity ?? .shared
super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed
}
@@ -51,6 +69,16 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
deinit {
timer?.invalidate()
player?.stop()
// A per-row @StateObject can be discarded mid-playback (navigating
// away). Leaking the token here would hold the session forever
// never deactivating it, and pinning any escalated category for the
// app's lifetime. `release` is fire-and-forget onto the coordinator's
// queue, so it is deinit-safe: only the Sendable token crosses.
if let token = sessionToken {
sessionToken = nil
(sessionCoordinatorOverride ?? .shared).release(token)
}
}
func replaceURL(_ url: URL) {
@@ -68,11 +96,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
func play() {
guard ensurePlayerReady() else { return }
VoiceNotePlaybackCoordinator.shared.activate(self)
player?.play()
exclusivity.activate(self)
isPlaying = true
startTimer()
updateProgress()
isPlaying = true
// Acquired here (not in ensurePlayerReady): scrubbing a paused note
// must not hold the session while nothing is audible. The session
// calls block on audio-server IPC, so they run off the main thread;
// the player starts once the session is configured.
startPlayerAfterAcquiringSession()
}
func pause() {
@@ -80,6 +112,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer()
updateProgress()
isPlaying = false
releaseSession()
}
func stop() {
@@ -88,7 +121,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer()
updateProgress()
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
releaseSession()
exclusivity.deactivate(self)
}
func seek(to fraction: Double) {
@@ -96,8 +130,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
let clamped = max(0, min(1, fraction))
if let player = player {
player.currentTime = clamped * player.duration
if isPlaying {
player.play()
// While the session acquire is still in flight, don't start
// audio pre-activation the pending acquire's completion starts
// playback (from the new position) once the session resolves.
if isPlaying, !sessionAcquireInFlight {
startPreparedPlayer()
}
updateProgress()
}
@@ -112,18 +149,20 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
self.stopTimer()
self.updateProgress()
self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
self.releaseSession()
self.exclusivity.deactivate(self)
}
}
// MARK: - Private Helpers
private func preparePlayer(for url: URL) {
// Prepare player synchronously (only called when playback is requested)
// Load metadata synchronously, but do not call prepareToPlay here:
// paused scrubbing reaches this path and must not acquire playback
// hardware outside the AudioSessionCoordinator token lifetime.
do {
let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self
player.prepareToPlay()
self.player = player
duration = player.duration
currentTime = player.currentTime
@@ -141,18 +180,81 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
if player == nil {
preparePlayer(for: url)
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
#endif
return player != nil
}
/// All entry points (SwiftUI actions, `pauseForExclusivity`, the
/// delegate's main-queue hop) run on the main thread; the acquire itself
/// suspends while the blocking session IPC runs on the coordinator's
/// queue, and the player starts when it resolves. An acquire failure
/// leaves playback stopped: starting without a registered token would
/// bypass interruption fan-out and the coordinator's refcount. A
/// pause/stop landing mid-acquire hands the token straight back.
private func startPlayerAfterAcquiringSession() {
if sessionToken != nil {
startPreparedPlayer()
return
}
guard !sessionAcquireInFlight else { return }
sessionAcquireInFlight = true
let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared
Task { @MainActor [weak self] in
var token: AudioSessionCoordinator.Token?
do {
token = try await coordinator.acquire(.playback) { [weak self] in
self?.pause()
}
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
guard let self else {
// The row was discarded while acquiring; deinit had no token
// to release yet.
token.map(coordinator.release)
return
}
self.sessionAcquireInFlight = false
guard self.isPlaying else {
// Paused/stopped while the session was activating.
token.map(coordinator.release)
return
}
guard let token else {
self.failPlaybackStart()
return
}
self.sessionToken = token
self.startPreparedPlayer()
}
}
@discardableResult
private func startPreparedPlayer() -> Bool {
guard let player,
player.prepareToPlay(),
player.play()
else {
SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session)
failPlaybackStart()
return false
}
return true
}
private func failPlaybackStart() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
releaseSession()
exclusivity.deactivate(self)
}
private func releaseSession() {
sessionToken.map((sessionCoordinatorOverride ?? .shared).release)
sessionToken = nil
}
private func startTimer() {
if timer != nil { return }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
@@ -197,21 +299,59 @@ extension VoiceNotePlaybackController: ExclusivePlayback {
final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator()
struct Reservation: Equatable {
fileprivate let generation: UInt64
}
private weak var activeController: (any ExclusivePlayback)?
private weak var latestReservedController: (any ExclusivePlayback)?
private var latestReservation = Reservation(generation: 0)
private init() {}
/// Internal so tests can isolate their own exclusivity slot; the app
/// uses `shared`.
init() {}
func activate(_ controller: any ExclusivePlayback) {
/// Records playback intent without interrupting audio that is already
/// audible. Async starters reserve before suspension, then activate only
/// after their audio resource is ready.
func reserve(_ controller: any ExclusivePlayback) -> Reservation {
latestReservation = Reservation(generation: latestReservation.generation &+ 1)
latestReservedController = controller
return latestReservation
}
/// Immediate activation for synchronous/user-initiated playback.
@discardableResult
func activate(_ controller: any ExclusivePlayback) -> Reservation {
let reservation = reserve(controller)
_ = activate(controller, reservation: reservation)
return reservation
}
/// Commits an earlier reservation only when it is still the newest
/// playback request. This prevents an older async acquire from stealing
/// the floor after a newer play gesture.
@discardableResult
func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool {
guard isCurrent(reservation, for: controller) else { return false }
if activeController === controller {
return
return true
}
activeController?.pauseForExclusivity()
activeController = controller
return true
}
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
latestReservation == reservation && latestReservedController === controller
}
func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller {
activeController = nil
}
if latestReservedController === controller {
latestReservedController = nil
}
}
}
+218 -63
View File
@@ -1,21 +1,95 @@
import Foundation
import AVFoundation
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
/// it behind a protocol lets lifecycle races be tested without opening the
/// microphone on the test host.
protocol VoiceAudioRecording: AnyObject {
var isRecording: Bool { get }
var isMeteringEnabled: Bool { get set }
func prepareToRecord() -> Bool
func record(forDuration duration: TimeInterval) -> Bool
func stop()
}
extension AVAudioRecorder: VoiceAudioRecording {}
protocol VoiceAudioRecorderCreating {
func makeRecorder(url: URL) throws -> any VoiceAudioRecording
}
private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
return try AVAudioRecorder(url: url, settings: settings)
}
}
/// Manages audio capture for mesh voice notes with predictable encoding settings.
actor VoiceRecorder {
enum RecorderError: Error {
enum RecorderError: Error, Equatable {
case microphoneAccessDenied
case recordingInProgress
case failedToStartRecording
}
static let shared = VoiceRecorder()
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1
private var recorder: AVAudioRecorder?
/// Identity of one press/hold. Every lifecycle mutation must present the
/// same owner that started the recorder, so a stale finish or cancel from
/// another hold cannot stop or delete the current recording.
final class RecordingOwner: @unchecked Sendable {}
/// Test-only scheduling seams for lifecycle boundaries that otherwise rely
/// on wall-clock sleeps. Production uses the real padding delay.
struct TestingHooks: Sendable {
let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)?
init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) {
self.waitForStopPadding = waitForStopPadding
}
}
private let sessionCoordinator: AudioSessionCoordinator
private let recorderFactory: any VoiceAudioRecorderCreating
private let permissionGranted: () -> Bool
private let paddingInterval: TimeInterval
private let maxRecordingDuration: TimeInterval
private let outputDirectory: URL?
private let testingHooks: TestingHooks
private var recorder: (any VoiceAudioRecording)?
private var currentURL: URL?
private var sessionToken: AudioSessionCoordinator.Token?
private var activeOwner: RecordingOwner?
/// True only while `startRecording()` is suspended in session acquire.
/// A second start is rejected instead of superseding the first one.
private var startInFlight = false
init(
sessionCoordinator: AudioSessionCoordinator = .shared,
recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(),
permissionGranted: (() -> Bool)? = nil,
paddingInterval: TimeInterval = 0.5,
maxRecordingDuration: TimeInterval = 120,
outputDirectory: URL? = nil,
testingHooks: TestingHooks = TestingHooks()
) {
self.sessionCoordinator = sessionCoordinator
self.recorderFactory = recorderFactory
self.permissionGranted = permissionGranted ?? Self.hasSystemPermission
self.paddingInterval = paddingInterval
self.maxRecordingDuration = maxRecordingDuration
self.outputDirectory = outputDirectory
self.testingHooks = testingHooks
}
// MARK: - Permissions
@@ -41,82 +115,130 @@ actor VoiceRecorder {
// MARK: - Recording Lifecycle
@discardableResult
func startRecording() throws -> URL {
if recorder?.isRecording == true {
func startRecording(owner: RecordingOwner) async throws -> URL {
if activeOwner != nil {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
guard permissionGranted() else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
activeOwner = owner
startInFlight = true
// The acquire suspends while the blocking session IPC runs on the
// coordinator's queue (never this actor's thread or main).
let token: AudioSessionCoordinator.Token
do {
token = try await sessionCoordinator.acquire(.capture) { [weak self] in
Task { await self?.handleSessionInterruption(for: owner) }
}
} catch {
guard activeOwner === owner else {
throw CancellationError()
}
startInFlight = false
activeOwner = nil
throw error
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
// Actor reentrancy: release/cancel may have ended this hold while the
// blocking session activation was still in progress.
guard activeOwner === owner, startInFlight else {
sessionCoordinator.release(token)
throw CancellationError()
}
startInFlight = false
sessionToken = token
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
var outputURL: URL?
do {
let newURL = try makeOutputURL()
outputURL = newURL
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
audioRecorder.isMeteringEnabled = true
guard audioRecorder.prepareToRecord() else {
throw RecorderError.failedToStartRecording
}
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
throw RecorderError.failedToStartRecording
}
recorder = audioRecorder
currentURL = outputURL
return outputURL
recorder = audioRecorder
currentURL = newURL
return newURL
} catch {
releaseSessionToken()
recorder = nil
currentURL = nil
activeOwner = nil
if let outputURL {
try? FileManager.default.removeItem(at: outputURL)
}
throw error
}
}
func stopRecording() async -> URL? {
guard let recorder, recorder.isRecording else {
return currentURL
func stopRecording(owner: RecordingOwner) async -> URL? {
guard activeOwner === owner else { return nil }
// `finish()` can race a still-suspended start on a direct caller even
// though the UI normally routes quick releases through cancel().
if startInFlight {
activeOwner = nil
startInFlight = false
return nil
}
guard let activeRecorder = recorder else {
let sessionURL = currentURL
releaseSessionToken()
currentURL = nil
activeOwner = nil
return sessionURL
}
let sessionURL = currentURL
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
recorder.stop()
// A new session may have started during the sleep don't touch its state
if self.recorder === recorder {
cleanupSession()
self.recorder = nil
currentURL = nil
if activeRecorder.isRecording, paddingInterval > 0 {
if let waitForStopPadding = testingHooks.waitForStopPadding {
await waitForStopPadding(paddingInterval)
} else {
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
}
}
// Cancellation or interruption may have run during the padding sleep.
// Only the recorder whose stop began here may be finalized by it.
guard activeOwner === owner,
let recorder = self.recorder,
recorder === activeRecorder
else { return nil }
if activeRecorder.isRecording {
activeRecorder.stop()
}
releaseSessionToken()
self.recorder = nil
currentURL = nil
activeOwner = nil
return sessionURL
}
func cancelRecording() {
func cancelRecording(owner: RecordingOwner) async {
guard activeOwner === owner else { return }
// Invalidate ownership before cleanup. An actor-reentrant start whose
// session acquire resumes later will observe the mismatch and release
// its token without opening the microphone.
activeOwner = nil
startInFlight = false
if let recorder, recorder.isRecording {
recorder.stop()
}
cleanupSession()
releaseSessionToken()
if let currentURL {
try? FileManager.default.removeItem(at: currentURL)
}
@@ -124,14 +246,45 @@ actor VoiceRecorder {
currentURL = nil
}
/// The audio session was interrupted (call, Siri) or reconfigured: stop
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
/// `stopRecording()` still returns the partial note.
private func handleSessionInterruption(for owner: RecordingOwner) async {
// A callback captured for a released token must never stop a newer
// recording. Conversely, an interruption delivered while acquire is
// still suspended invalidates that acquire before it can open the mic.
guard activeOwner === owner else { return }
if startInFlight {
activeOwner = nil
startInFlight = false
return
}
startInFlight = false
if let recorder, recorder.isRecording {
recorder.stop()
}
releaseSessionToken()
}
// MARK: - Helpers
private static func hasSystemPermission() -> Bool {
#if os(iOS)
AVAudioSession.sharedInstance().recordPermission == .granted
#elseif os(macOS)
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
#else
true
#endif
}
private func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "voice_\(formatter.string(from: Date())).m4a"
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
let baseDirectory = try outputDirectory
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName)
}
@@ -146,9 +299,11 @@ actor VoiceRecorder {
#endif
}
private func cleanupSession() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC
/// onto its own queue.
private func releaseSessionToken() {
guard let token = sessionToken else { return }
sessionToken = nil
sessionCoordinator.release(token)
}
}
+4 -2
View File
@@ -31,6 +31,8 @@
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
@@ -40,9 +42,9 @@
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<string>bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<string>bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key>
+3 -4
View File
@@ -268,10 +268,9 @@ struct NostrProtocol {
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
/// parsers, which key their dedup on `m[1]` unconditionally and need it
/// per-message-unique; current parsers ignore it and recompute the ID
/// from the origin coordinates (elements 2-3) plus the event's own
/// content to dedup the bridged copy against the radio copy by
/// timeline ID.
/// per-message-unique. Current parsers key bridge rows by the authenticated
/// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh
/// coordinates are public and cannot authenticate the Nostr signer.
static func createBridgeMeshEvent(
content: String,
cell: String,
+135 -4
View File
@@ -216,6 +216,15 @@ final class NostrRelayManager: ObservableObject {
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
/// Non-queued sends whose callers require relay durability. A WebSocket
/// write only proves bytes left this process; NIP-20 OK is the relay's
/// accept/reject acknowledgment.
private struct ConfirmedSendState {
let token: UUID
var awaitingRelays: Set<String>
let completion: (Bool) -> Void
}
private var confirmedSends: [String: ConfirmedSendState] = [:]
// Total pending sends dropped at the queue cap; drives the sampled
// overflow warning (first + every Nth drop).
private var pendingSendDropCount = 0
@@ -312,6 +321,9 @@ final class NostrRelayManager: ObservableObject {
for (_, tracker) in trackers {
tracker.callback()
}
let confirmed = confirmedSends.values.map(\.completion)
confirmedSends.removeAll()
confirmed.forEach { $0(false) }
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
torReadyWaitAttempts = 0
@@ -345,6 +357,7 @@ final class NostrRelayManager: ObservableObject {
duplicateInboundEventDropCountBySubscription.removeAll()
inboundEventLogCount = 0
Self.pendingGiftWrapIDs.removeAll()
confirmedSends.removeAll()
messageQueueLock.lock()
messageQueue.removeAll()
@@ -419,6 +432,97 @@ final class NostrRelayManager: ObservableObject {
}
}
/// Attempts an event only on currently connected target relays and
/// reports whether at least one relay explicitly accepted it via NIP-20
/// OK. A successful WebSocket write alone is not durable acceptance.
/// Unlike `sendEvent`, this never enters the process-local pending queue;
/// callers use it when success unlocks durable state or user-visible
/// delivery progress.
func sendEventImmediately(
_ event: NostrEvent,
to relayUrls: [String]? = nil,
completion: @escaping (Bool) -> Void
) {
guard dependencies.activationAllowed() else {
completion(false)
return
}
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
completion(false)
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in
guard let connection = connectedConnection(for: relayUrl) else { return nil }
return (relayUrl, connection)
}
guard !connectedTargets.isEmpty else {
completion(false)
return
}
let token = UUID()
let eventID = event.id
if let replaced = confirmedSends.removeValue(forKey: eventID) {
replaced.completion(false)
}
confirmedSends[eventID] = ConfirmedSendState(
token: token,
awaitingRelays: Set(connectedTargets.map(\.0)),
completion: completion
)
dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in
Task { @MainActor [weak self] in
self?.timeoutConfirmedSend(eventID: eventID, token: token)
}
}
for (relayUrl, connection) in connectedTargets {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in
guard let self else { return }
// Success only means the bytes reached the socket; wait for
// the matching relay OK. A failed write settles this target
// as rejected because no OK can arrive for it.
if !succeeded {
self.resolveConfirmedSend(
eventID: eventID,
relayURL: relayUrl,
accepted: false,
token: token
)
}
}
}
}
private func resolveConfirmedSend(
eventID: String,
relayURL: String,
accepted: Bool,
token: UUID? = nil
) {
guard var state = confirmedSends[eventID],
token == nil || state.token == token,
state.awaitingRelays.remove(relayURL) != nil else { return }
if accepted {
confirmedSends.removeValue(forKey: eventID)
state.completion(true)
} else if state.awaitingRelays.isEmpty {
confirmedSends.removeValue(forKey: eventID)
state.completion(false)
} else {
confirmedSends[eventID] = state
}
}
private func timeoutConfirmedSend(eventID: String, token: UUID) {
guard let state = confirmedSends[eventID], state.token == token else { return }
confirmedSends.removeValue(forKey: eventID)
state.completion(false)
}
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
@@ -923,6 +1027,7 @@ final class NostrRelayManager: ObservableObject {
// Send initial ping to verify connection
task.sendPing { [weak self] error in
DispatchQueue.main.async {
guard self?.connections[urlString] === task else { return }
if error == nil {
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true)
@@ -932,7 +1037,11 @@ final class NostrRelayManager: ObservableObject {
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
self?.handleDisconnection(
relayUrl: urlString,
error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil),
connection: task
)
}
}
}
@@ -990,18 +1099,20 @@ final class NostrRelayManager: ObservableObject {
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
guard self.connections[relayUrl] === task else { return }
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Continue receiving
Task { @MainActor in
guard self.connections[relayUrl] === task else { return }
self.receiveMessage(from: task, relayUrl: relayUrl)
}
case .failure(let error):
DispatchQueue.main.async {
self.handleDisconnection(relayUrl: relayUrl, error: error)
self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task)
}
}
}
@@ -1055,6 +1166,7 @@ final class NostrRelayManager: ObservableObject {
}
}
case .ok(let eventId, let success, let reason):
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
@@ -1071,7 +1183,12 @@ final class NostrRelayManager: ObservableObject {
}
}
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
private func sendToRelay(
event: NostrEvent,
connection: NostrRelayConnectionProtocol,
relayUrl: String,
completion: ((Bool) -> Void)? = nil
) {
let req = NostrRequest.event(event)
do {
@@ -1084,17 +1201,20 @@ final class NostrRelayManager: ObservableObject {
DispatchQueue.main.async {
if let error = error {
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
completion?(false)
} else {
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1
}
completion?(true)
}
}
}
} catch {
SecureLogger.error("Failed to encode event: \(error)", category: .session)
completion?(false)
}
}
@@ -1158,9 +1278,20 @@ final class NostrRelayManager: ObservableObject {
eoseTrackers[id] = tracker
}
private func handleDisconnection(relayUrl: String, error: Error) {
private func handleDisconnection(
relayUrl: String,
error: Error,
connection: NostrRelayConnectionProtocol? = nil
) {
if let connection, connections[relayUrl] !== connection { return }
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
state.awaitingRelays.contains(relayUrl) ? eventID : nil
}
for eventID in awaitingConfirmation {
resolveConfirmedSend(eventID: eventID, relayURL: relayUrl, accepted: false)
}
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
// If networking is disallowed, do not schedule reconnection
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+5 -8
View File
@@ -14,14 +14,11 @@ import Foundation
/// The BLE wire carries no message ID for public broadcasts, so every device
/// recomputes the same stable ID from the signed wire fields (sender ID,
/// millisecond timestamp, content). That gives the mesh bridge a
/// cross-device-consistent dedup/attribution key with zero wire change and
/// because receivers derive the key themselves instead of trusting a claimed
/// ID, forging a bridge tag that binds a chosen ID to *different* content is
/// infeasible. The inputs are cleartext on the radio, though: an attacker in
/// radio range can re-sign the identical sender/timestamp/content under
/// their own Nostr key and win first-wins injection on remote islands
/// duplicate-content spoofing the unbridged mesh already permits, so no
/// worse than before.
/// cross-device-consistent radio identity with zero wire change. Bridge events
/// carry this value only as a hint for detecting a radio copy that is already
/// present: sender/timestamp/content are public, so a different Nostr signer
/// can copy them and must never be allowed to reserve the genuine event's
/// authenticated dedup slot.
enum MeshMessageIdentity {
/// Matches the wire truncation in `BLEService.sendMessage`.
static func millisecondTimestamp(_ date: Date) -> UInt64 {
@@ -18,6 +18,7 @@ enum BLEFanoutSelector {
preferredPeripheralPerPeer: [PeerID: String] = [:],
collapseDuplicatePeerLinks: Bool = true,
directedPeerHint: PeerID?,
requireDirectPeerLink: Bool = false,
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
@@ -38,6 +39,9 @@ enum BLEFanoutSelector {
) {
return directedSelection
}
if directedPeerHint != nil, requireDirectPeerLink {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
@@ -7,6 +7,26 @@ struct BLEOutboundFragmentTransferRequest {
let maxChunk: Int?
let directedPeer: PeerID?
let transferId: String?
let requireDirectPeerLink: Bool
let requireNoiseAuthenticatedPeerLink: Bool
init(
packet: BitchatPacket,
pad: Bool,
maxChunk: Int?,
directedPeer: PeerID?,
transferId: String?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) {
self.packet = packet
self.pad = pad
self.maxChunk = maxChunk
self.directedPeer = directedPeer
self.transferId = transferId
self.requireDirectPeerLink = requireDirectPeerLink
self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink
}
var resolvedTransferId: String? {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
@@ -22,6 +42,21 @@ struct BLEOutboundFragmentTransferRequest {
}
}
/// Transactional admission for strict fragment trains. Durable callers may
/// commit only when every fragment was accepted; the first rejection stops
/// the train and reports failure so the original remains retryable.
enum BLEStrictFragmentAdmission {
static func admitAll<Fragment>(
_ fragments: [Fragment],
accepting: (Fragment) -> Bool
) -> Bool {
for fragment in fragments where !accepting(fragment) {
return false
}
return true
}
}
struct BLEOutboundFragmentTransferScheduler {
enum QueuePosition {
case front
@@ -31,6 +66,11 @@ struct BLEOutboundFragmentTransferScheduler {
enum SubmitResult {
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
/// Strict direct-link requests are transactional: returning false to
/// their durable owner must mean no process-local copy remains that
/// can transmit later. They are therefore start-or-reject, never
/// admitted to `pendingTransfers`.
case rejectedStrict(request: BLEOutboundFragmentTransferRequest, transferId: String?)
/// The same file is already being (or waiting to be) fragmented out
/// to an audience covering this request; sending it again would just
/// double the airtime (field-verified: one 41KB voice file went out
@@ -113,11 +153,17 @@ struct BLEOutboundFragmentTransferScheduler {
}
guard activeTransfers.count < maxConcurrentTransfers else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.append(request)
return .queued(request: request, transferId: transferId, position: .back)
}
guard activeTransfers[transferId] == nil else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.insert(request, at: 0)
return .queued(request: request, transferId: transferId, position: .front)
}
@@ -22,21 +22,9 @@ enum BLEOutboundLinkPlanner {
centralPeerBindings: [String: PeerID] = [:],
preferredPeripheralPerPeer: [PeerID: String] = [:],
directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault,
directedOnlyPeer: PeerID?
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false
) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: peripheralWriteLimits,
centralNotifyLimits: centralNotifyLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
shouldSpoolDirectedPacket: false
)
}
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
// Direct announces bypass the per-peer duplicate-link collapse so
// every live link gets bound (see BLEFanoutSelector.selectLinks).
@@ -51,10 +39,34 @@ enum BLEOutboundLinkPlanner {
preferredPeripheralPerPeer: preferredPeripheralPerPeer,
collapseDuplicatePeerLinks: !isDirectAnnounce,
directedPeerHint: directedPeerHint,
requireDirectPeerLink: requireDirectPeerLink,
packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
)
// Fragment only for links that this packet can actually use. Looking
// at every connected link before directed-peer selection lets an
// unrelated peer's MTU make an oversized directed send look routable,
// even though every resulting fragment will select zero target links.
let selectedPeripheralLimits = zip(peripheralIDs, peripheralWriteLimits).compactMap { id, limit in
selectedLinks.peripheralIDs.contains(id) ? limit : nil
}
let selectedCentralLimits = zip(centralIDs, centralNotifyLimits).compactMap { id, limit in
selectedLinks.centralIDs.contains(id) ? limit : nil
}
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: selectedPeripheralLimits,
centralNotifyLimits: selectedCentralLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: selectedLinks,
shouldSpoolDirectedPacket: false
)
}
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: nil,
@@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer<Target> {
guard !pending.isEmpty else { return }
notifications.insert(contentsOf: pending, at: 0)
}
/// Removes a disconnected target from target-specific retries. Broadcast
/// entries (`targets == nil`) remain valid for the surviving subscriber
/// set; an entry with no targets left is discarded entirely.
mutating func removeTarget(where matches: (Target) -> Bool) {
notifications = notifications.compactMap { notification in
guard let targets = notification.targets else { return notification }
let remaining = targets.filter { !matches($0) }
guard !remaining.isEmpty else { return nil }
return BLEPendingNotification(data: notification.data, targets: remaining)
}
}
}
@@ -46,8 +46,25 @@ struct BLEOutboundWriteBuffer {
priority: BLEOutboundWritePriority,
capBytes: Int
) -> EnqueueResult {
enqueueReportingAcceptance(
data: data,
for: peripheralID,
priority: priority,
capBytes: capBytes
).result
}
/// Enqueues while also reporting whether the newly offered write survived
/// priority trimming. `EnqueueResult.enqueued` alone cannot express that:
/// a full queue may immediately trim the new lowest-priority item.
mutating func enqueueReportingAcceptance(
data: Data,
for peripheralID: String,
priority: BLEOutboundWritePriority,
capBytes: Int
) -> (result: EnqueueResult, accepted: Bool) {
guard data.count <= capBytes else {
return .oversized(bytes: data.count)
return (.oversized(bytes: data.count), false)
}
var queue = writesByPeripheralID[peripheralID] ?? []
@@ -65,7 +82,8 @@ struct BLEOutboundWriteBuffer {
}
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
let accepted = insertIndex < queue.count
return (.enqueued(trimmedBytes: trimmedBytes, remainingBytes: total), accepted)
}
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
@@ -74,6 +92,13 @@ struct BLEOutboundWriteBuffer {
return items
}
/// Drops link-specific ciphertext when that physical link is gone. Keeping
/// the dictionary entry would let rotating peripheral UUIDs accumulate a
/// fresh per-link byte cap indefinitely.
mutating func discardAll(for peripheralID: String) {
writesByPeripheralID[peripheralID] = nil
}
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
guard !items.isEmpty else { return }
var existing = writesByPeripheralID[peripheralID] ?? []
+454 -54
View File
@@ -37,6 +37,12 @@ final class BLEService: NSObject {
// 1. Consolidated BLE link tracking for both central and peripheral roles.
private var linkStateStore = BLELinkStateStore()
// A peer ID can retain an established Noise session after its physical
// link disappears. Courier handover therefore needs the stronger fact
// that the session was established *on this current ingress link*, not
// merely that some session exists for the claimed ID. bleQueue-owned.
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert.
private var lastLinkRebindAt: [String: Date] = [:]
@@ -680,6 +686,7 @@ final class BLEService: NSObject {
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
bleQueue.sync {
linkStateStore.clearAll()
noiseAuthenticatedLinkOwners.removeAll()
connectionScheduler.reset()
subscriptionAnnounceLimiter.removeAll()
}
@@ -1188,9 +1195,91 @@ final class BLEService: NSObject {
}
}
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) {
/// Synchronously admits a notification to the link-specific retry queue.
/// Destructive courier handoff uses this result as its commit point, so a
/// full process-local queue must be reported as rejection, not success.
private func enqueuePendingNotificationIfAccepted(
data: Data,
centrals: [CBCentral],
context: String
) -> Bool {
let result = collectionsQueue.sync(flags: .barrier) {
pendingNotifications.enqueue(
data: data,
targets: centrals,
capCount: TransportConfig.blePendingNotificationsCapCount
)
}
switch result {
case let .enqueued(count):
SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session)
return true
case let .full(count):
SecureLogger.warning("⚠️ Rejecting \(context) packet: notification queue full (pending=\(count))", category: .session)
return false
}
}
/// Serializes the final authenticated-link check with CoreBluetooth's
/// notification admission on `bleQueue`, closing the rebind/disconnect
/// race between fanout planning and the actual handoff.
private func notifyOrEnqueueIfAccepted(
data: Data,
centrals: [CBCentral],
characteristic: CBMutableCharacteristic,
context: String,
requiredAuthenticatedPeer: PeerID?
) -> Bool {
let accept = { [self] in
let eligible: [CBCentral]
if let peerID = requiredAuthenticatedPeer {
eligible = centrals.filter { central in
let link = BLEIngressLinkID.central(central.identifier.uuidString)
return noiseAuthenticatedLinkOwners[link] == peerID
&& linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID
}
} else {
eligible = centrals
}
guard !eligible.isEmpty else { return false }
if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true {
return true
}
return enqueuePendingNotificationIfAccepted(
data: data,
centrals: eligible,
context: context
)
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return accept()
}
return bleQueue.sync(execute: accept)
}
/// Returns true only when the packet was accepted by at least one current
/// physical link (including its link-specific backpressure queue). A
/// process-local directed spool is deliberately not success: callers
/// that own a durable upstream copy must keep it retryable.
@discardableResult
private func sendOnAllLinks(
packet: BitchatPacket,
data: Data,
pad: Bool,
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
let excludedPeerLinks = links(to: ingressRecord?.peerID)
var excludedPeerLinks = links(to: ingressRecord?.peerID)
if requireNoiseAuthenticatedPeerLink {
guard let directedOnlyPeer else { return false }
let boundLinks = links(to: directedOnlyPeer)
let authenticatedLinks = currentNoiseAuthenticatedLinks(to: directedOnlyPeer)
guard !authenticatedLinks.isEmpty else { return false }
excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks))
}
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
let states = snapshotPeripheralStates()
@@ -1222,12 +1311,22 @@ final class BLEService: NSObject {
// as a combined snapshot.
preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings },
directAnnounceTTL: messageTTL,
directedOnlyPeer: directedOnlyPeer
directedOnlyPeer: directedOnlyPeer,
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink
)
if let chunk = plan.fragmentChunkSize {
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
return
guard !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty else {
return false
}
return sendFragmentedPacket(
packet,
pad: pad,
maxChunk: chunk,
directedOnlyPeer: directedOnlyPeer,
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
}
// If directed and we currently have no links to forward on, spool for a short window
@@ -1236,36 +1335,73 @@ final class BLEService: NSObject {
spoolDirectedPacket(packet, recipientPeerID: only)
}
var acceptedByPhysicalLink = false
// Writes to selected connected peripherals
for s in connectedStates {
let pid = s.peripheral.identifier.uuidString
guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue }
if let ch = s.characteristic {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
acceptedByPhysicalLink = writeOrEnqueueIfAccepted(
data,
to: s.peripheral,
characteristic: ch,
priority: outboundPriority,
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
) || acceptedByPhysicalLink
} else {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
}
}
}
// Notify selected subscribed centrals
if let ch = characteristic {
let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) }
if !targets.isEmpty {
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
if !success {
// Notification queue full - queue for retry to prevent silent packet loss
// This is critical for fragment delivery reliability
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
enqueuePendingNotification(data: data, centrals: targets, context: context)
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
acceptedByPhysicalLink = notifyOrEnqueueIfAccepted(
data: data,
centrals: targets,
characteristic: ch,
context: "directed",
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
) || acceptedByPhysicalLink
} else {
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
if !success {
// Notification queue full - queue for retry to prevent silent packet loss
// This is critical for fragment delivery reliability
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
enqueuePendingNotification(data: data, centrals: targets, context: context)
}
}
}
}
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { return acceptedByPhysicalLink }
return !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty
}
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
@discardableResult
private func sendPacketDirected(
_ packet: BitchatPacket,
to peerID: PeerID,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
guard let data = packet.toBinaryData(padding: false) else { return false }
return sendOnAllLinks(
packet: packet,
data: data,
pad: false,
directedOnlyPeer: peerID,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
}
// MARK: - Directed store-and-forward
@@ -1938,6 +2074,10 @@ extension BLEService: CBCentralManagerDelegate {
#endif
// Clean up references and peer mappings
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
// A duplicate link can drop while the peer stays live on another
// (the dual-role central link, or a second bound link after a
@@ -1988,6 +2128,10 @@ extension BLEService: CBCentralManagerDelegate {
let peripheralID = peripheral.identifier.uuidString
// Clean up the references
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
@@ -2088,6 +2232,10 @@ extension BLEService {
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
central.cancelPeripheralConnection(peripheral)
self.collectionsQueue.sync(flags: .barrier) {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
@@ -2134,6 +2282,23 @@ extension BLEService {
handleReceivedPacket(packet, from: fromPeerID)
}
/// Waits until fragment ingress already submitted by a test has finished
/// reassembly/reinjection and any resulting transport event has crossed
/// the MainActor delivery hop. This is a deterministic pipeline fence,
/// avoiding wall-clock sleeps that become flaky under a parallel suite.
func _test_drainFragmentPipeline() async {
await withCheckedContinuation { continuation in
messageQueue.async(flags: .barrier) {
// Reassembled packets are reinjected synchronously on
// `messageQueue`; their UI delivery task is therefore already
// enqueued before this later MainActor marker.
Task { @MainActor in
continuation.resume()
}
}
}
}
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
}
@@ -2164,6 +2329,13 @@ extension BLEService {
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
}
func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) {
bleQueue.sync {
guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return }
noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID
}
}
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
@@ -2578,7 +2750,12 @@ extension BLEService: CBPeripheralManagerDelegate {
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString.prefix(8))", category: .session)
let centralID = central.identifier.uuidString
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))", category: .session)
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
@@ -3118,6 +3295,45 @@ extension BLEService {
private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
readLinkState { $0.links(to: peerID) }
}
private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? {
switch link {
case .peripheral(let peripheralUUID):
store.peerID(forPeripheralID: peripheralUUID)
case .central(let centralUUID):
store.peerID(forCentralUUID: centralUUID)
}
}
/// Marks the exact physical ingress link that completed a fresh Noise
/// handshake. An old session keyed only by peer ID is insufficient: a
/// replayed announce can rebind an attacker's link to that ID.
private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return }
readLinkState { store in
guard boundPeerID(for: link, in: store) == peerID else { return }
noiseAuthenticatedLinkOwners[link] = peerID
}
}
private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false }
return readLinkState { store in
noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID
}
}
private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool {
!currentNoiseAuthenticatedLinks(to: peerID).isEmpty
}
private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set<BLEIngressLinkID> {
readLinkState { store in
Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in
owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil
})
}
}
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
@@ -3299,22 +3515,32 @@ extension BLEService {
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
return false
}
openCourierEnvelope(envelope)
return true
return openCourierEnvelope(envelope)
}
/// 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.
/// Returns true only if a current Noise-authenticated physical link
/// accepted the packet; a stale peer-level session, reachability record,
/// replay-rebound link, or process-local spool is not delivery.
@discardableResult
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
guard isPeerConnected(peerID) || isPeerReachable(peerID) else { return false }
guard hasCurrentNoiseAuthenticatedLink(to: 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)
let send = { [weak self] in
self?.sendPacketDirected(
packet,
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) ?? false
}
return true
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
return send()
}
return messageQueue.sync(execute: send)
}
/// Our own Noise static public key (for computing our courier tags).
@@ -3385,7 +3611,8 @@ extension BLEService {
}
}
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
@discardableResult
private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
do {
let typedPayload: Data
let senderStaticKey: Data
@@ -3410,12 +3637,12 @@ extension BLEService {
let payloadType = NoisePayloadType(rawValue: typeRaw),
payloadType == .privateMessage else {
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
return
return true // decrypted but deterministically unsupported
}
let payload = Data(typedPayload.dropFirst())
guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else {
SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session)
return
return true // decrypted but deterministically malformed
}
// Redundant copies of one message arrive as distinct envelopes
// (fresh seal each: mesh couriers, bridge drops across relays),
@@ -3427,14 +3654,14 @@ extension BLEService {
}
guard firstOpen else {
SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))", category: .session)
return
return true
}
// Couriered mail arrives while the sender is absent, so the UI's
// block check can't resolve their fingerprint from a live session.
// Gate here, where the full static key is in hand.
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
return
return true
}
// A present sender resolves to their live mesh thread via the
// derived short ID. An absent sender the usual courier case
@@ -3454,9 +3681,11 @@ extension BLEService {
timestamp: Date()
))
}
return true
} catch {
// Tag collision or stale key: not addressed to us after all.
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
return false
}
}
@@ -3498,13 +3727,23 @@ extension BLEService {
/// Hand over any carried envelopes addressed to a peer we just heard from.
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
guard !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
sfMetrics?.record(.courierHandedOver)
let metrics = sfMetrics
let accepted = courierStore.handoverEnvelopes(for: noiseKey) { [weak self] envelope in
guard let self,
let payload = envelope.encode(),
self.sendPacketDirected(
self.makeCourierPacket(payload, to: peerID),
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) else {
return false
}
metrics?.record(.courierHandedOver)
return true
}
if accepted > 0 {
SecureLogger.debug("📦 Handed over \(accepted) courier envelope(s) to \(peerID.id.prefix(8))", category: .session)
}
}
@@ -3534,13 +3773,23 @@ extension BLEService {
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
let store = courierStore
let metrics = sfMetrics
let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in
guard let self, !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID)
let sendSpray: () -> Void = { [weak self] in
guard let self else { return }
let accepted = store.transferSprayCopies(to: noiseKey) { envelope in
guard let payload = envelope.encode(),
self.sendPacketDirected(
self.makeCourierPacket(payload, to: peerID),
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) else {
return false
}
metrics?.record(.courierSprayed)
return true
}
if accepted > 0 {
SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))", category: .session)
}
}
let policy = courierDepositPolicy
@@ -3548,7 +3797,7 @@ extension BLEService {
// Same trust gate as deposits: don't hand mail to a peer who
// would reject it from us.
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
sendSpray(store.takeSprayCopies(for: noiseKey))
sendSpray()
}
}
@@ -3829,6 +4078,62 @@ extension BLEService {
}
}
/// Writes immediately or synchronously admits the packet to this
/// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return
/// value distinguishes a retained queue item from one rejected or trimmed
/// immediately, which lets durable courier state commit truthfully.
private func writeOrEnqueueIfAccepted(
_ data: Data,
to peripheral: CBPeripheral,
characteristic: CBCharacteristic,
priority: BLEOutboundWritePriority,
requiredAuthenticatedPeer: PeerID?
) -> Bool {
let accept = { [self] in
let uuid = peripheral.identifier.uuidString
guard let state = linkStateStore.state(forPeripheralID: uuid),
state.isConnected,
state.characteristic?.uuid == characteristic.uuid else {
return false
}
if let peerID = requiredAuthenticatedPeer {
let link = BLEIngressLinkID.peripheral(uuid)
guard state.peerID == peerID,
noiseAuthenticatedLinkOwners[link] == peerID else {
return false
}
}
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
return true
}
let attempt = collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.enqueueReportingAcceptance(
data: data,
for: uuid,
priority: priority,
capBytes: TransportConfig.blePendingWriteBufferCapBytes
)
}
switch attempt.result {
case .oversized(let bytes):
SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
case .enqueued:
break
}
return attempt.accepted
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return accept()
}
return bleQueue.sync(execute: accept)
}
private func drainPendingWrites(for peripheral: CBPeripheral) {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
@@ -3976,6 +4281,10 @@ extension BLEService {
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
let peripheralID = state.peripheral.identifier.uuidString
central.cancelPeripheralConnection(state.peripheral)
self.collectionsQueue.sync(flags: .barrier) {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
@@ -4119,25 +4428,37 @@ extension BLEService {
// MARK: Fragmentation (Required for messages > BLE MTU)
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil, transferId: String? = nil) {
@discardableResult
private func sendFragmentedPacket(
_ packet: BitchatPacket,
pad: Bool,
maxChunk: Int? = nil,
directedOnlyPeer: PeerID? = nil,
transferId: String? = nil,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: pad,
maxChunk: maxChunk,
directedPeer: directedOnlyPeer,
transferId: transferId
transferId: transferId,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
let result = collectionsQueue.sync(flags: .barrier) {
outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
}
handleFragmentTransferSubmitResult(result)
return handleFragmentTransferSubmitResult(result)
}
private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) {
@discardableResult
private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) -> Bool {
switch result {
case let .start(request, reservedTransferId):
startFragmentedPacket(request, reservedTransferId: reservedTransferId)
return startFragmentedPacket(request, reservedTransferId: reservedTransferId)
case let .queued(_, transferId, _):
if let transferId {
@@ -4145,16 +4466,29 @@ extension BLEService {
} else {
SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session)
}
return false
case let .rejectedStrict(_, transferId):
SecureLogger.debug(
"🚫 Strict directed fragment transfer \(transferId?.prefix(8) ?? "?")… rejected while scheduler busy",
category: .session
)
return false
case let .droppedDuplicate(_, activeTransferId):
SecureLogger.debug(
"🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")",
category: .session
)
return false
}
}
private func startFragmentedPacket(_ request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?) {
@discardableResult
private func startFragmentedPacket(
_ request: BLEOutboundFragmentTransferRequest,
reservedTransferId: String?
) -> Bool {
let releaseReservedSlot: (String) -> Void = { [weak self] id in
guard let self = self else { return }
TransferProgressManager.shared.cancel(id: id)
@@ -4174,7 +4508,7 @@ extension BLEService {
if let id = reservedTransferId {
releaseReservedSlot(id)
}
return
return false
}
// Lightweight pacing to reduce floods and allow BLE buffers to drain
@@ -4200,6 +4534,42 @@ extension BLEService {
return id
}()
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
guard let self else { return false }
if request.requireDirectPeerLink, let directedPeer = request.directedPeer {
return self.sendPacketDirected(
fragmentPacket,
to: directedPeer,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink
)
}
self.broadcastPacket(fragmentPacket)
return true
}
// Strict courier handoff is transactional at the fragment-admission
// boundary: every fragment must enter the intended authenticated
// link or its bounded retry queue before the durable owner may commit.
// A partial train is harmlessly abandoned and the envelope stays
// retryable with a fresh fragment ID on the next encounter.
if request.requireDirectPeerLink {
let admitted = BLEStrictFragmentAdmission.admitAll(plan.fragmentPackets) { fragmentPacket in
guard sendFragment(fragmentPacket) else { return false }
if let transferId = transferIdentifier {
markFragmentSent(transferId: transferId)
}
return true
}
guard admitted else {
if let id = reservedTransferId {
releaseReservedSlot(id)
}
return false
}
return true
}
var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []
for (index, fragmentPacket) in plan.fragmentPackets.enumerated() {
@@ -4212,7 +4582,7 @@ extension BLEService {
if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true {
self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket)
}
self.broadcastPacket(fragmentPacket)
_ = sendFragment(fragmentPacket)
if let transferId = transferIdentifier {
self.markFragmentSent(transferId: transferId)
}
@@ -4232,6 +4602,7 @@ extension BLEService {
let delayMs = index * plan.spacingMs
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
}
return true
}
// MARK: - Fragmentation (Required for messages > BLE MTU)
@@ -4503,15 +4874,32 @@ extension BLEService {
let result,
result.isVerified else { return }
let noiseKey = result.announcement.noisePublicKey
if result.isDirectAnnounce {
// Established link: destructive handover is safe, and the peer is
// close enough to become a courier for other carried mail.
let authenticatedIngress = result.isDirectAnnounce
&& canDeliverSecurely(to: result.peerID)
&& isNoiseAuthenticatedIngressLink(for: packet, peerID: result.peerID)
if authenticatedIngress {
// The session was established on this still-bound ingress link.
// A peer-level Noise session alone is not enough: it can outlive
// its physical link while a replay rebinds an attacker's link to
// the victim's ID.
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
} else {
// Relayed announce: recipient is multi-hop away. Push a copy
// toward them speculatively; the carried copy stays put.
// Relayed announce, or a direct-looking announce that has not yet
// proved link ownership with Noise: push a speculative copy while
// retaining the durable carried original.
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
if result.isDirectAnnounce,
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
if noiseService.hasEstablishedSession(with: result.peerID) {
// A session with no surviving authenticated link is stale;
// force the current link to prove possession again.
noiseService.clearSession(for: result.peerID)
}
if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
}
}
}
}
@@ -4559,6 +4947,10 @@ extension BLEService {
}
self.lastLinkRebindAt[linkUUID] = now
// A Noise proof belongs to the old physical binding. Never carry
// it across an announce-driven rebind, whose direct TTL is
// replayable; the new owner must complete a fresh handshake.
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
switch link {
case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
@@ -4652,6 +5044,10 @@ extension BLEService {
)
for uuid in retiring {
guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue }
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: uuid)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
_ = linkStateStore.removePeripheral(uuid)
SecureLogger.info(
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
@@ -5029,7 +5425,11 @@ extension BLEService {
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
noisePacketHandler.handleHandshake(packet, from: peerID)
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
}
}
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
+291 -41
View File
@@ -10,6 +10,9 @@ import BitFoundation
import BitLogger
import Combine
import Foundation
#if os(iOS)
import UIKit
#endif
/// Trust level of a courier deposit, decided by the caller's policy.
/// Favorites get the larger quota and are never evicted to make room for
@@ -120,13 +123,45 @@ final class CourierStore {
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
private let readData: (URL) throws -> Data
/// A protected file can be present but unreadable during an iOS
/// background restoration before first unlock. Keep that distinct from
/// an absent file: mutations may proceed in memory, but must not replace
/// the unreadable durable snapshot until it can be merged.
private var diskLoadDeferred = false
#if os(iOS)
private var protectedDataObserver: NSObjectProtocol?
#endif
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
init(
persistsToDisk: Bool = true,
fileURL: URL? = nil,
now: @escaping () -> Date = Date.init,
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) }
) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
self.readData = readData
loadFromDisk()
#if os(iOS)
protectedDataObserver = NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.retryDeferredPersistence()
}
#endif
}
deinit {
#if os(iOS)
if let protectedDataObserver {
NotificationCenter.default.removeObserver(protectedDataObserver)
}
#endif
}
// MARK: - Depositing (courier side)
@@ -154,10 +189,16 @@ final class CourierStore {
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently,
// keeping the larger spray budget (bounded by maxCopies either way).
// Identical ciphertext is the same envelope. Before any spray,
// a carry-only copy may legitimately arrive ahead of the original
// higher-budget copy, so keep the larger initial budget. Once a
// branch has sprayed, however, replaying the depositor's original
// packet must never replenish spent copies: that would defeat
// spray-and-wait and let `sprayedTo` grow without bound.
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
if envelopes[existing].sprayedTo.isEmpty {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
}
persistLocked()
return true
}
@@ -207,20 +248,52 @@ final class CourierStore {
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
/// the rotating recipient tag across adjacent days. This compatibility
/// helper accepts every offer; transport callers should use
/// `handoverEnvelopes(for:accepting:)` so failed sends remain durable.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
var handedOver: [CourierEnvelope] = []
handoverEnvelopes(for: noiseStaticKey) { envelope in
handedOver.append(envelope)
return true
}
return handedOver
}
/// Attempts direct handover without retiring the durable carried copy
/// until the transport accepts it onto the intended peer's physical link.
/// A failed encode, stale binding, or backpressure rejection leaves the
/// envelope unchanged for the next authenticated encounter.
@discardableResult
func handoverEnvelopes(
for noiseStaticKey: Data,
accepting: (CourierEnvelope) -> Bool
) -> Int {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
let offered = queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
return envelopes
.filter { candidates.contains($0.recipientTag) }
.map(\.envelope)
}
var acceptedCount = 0
for envelope in offered where accepting(envelope) {
// Do not hold the store queue while the acceptance closure enters
// BLE/collections queues. Commit in a second short critical
// section, rechecking that another handover did not win first.
let committed = queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
return false
}
envelopes.remove(at: index)
persistLocked()
return true
}
if committed { acceptedCount += 1 }
}
return acceptedCount
}
/// Envelopes addressed to a recipient we heard from via a *relayed*
@@ -247,27 +320,33 @@ 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.
/// Envelopes eligible to park on relays as bridge courier drops. Merely
/// offering one does not start its cooldown: the caller commits that only
/// after a relay explicitly accepts the event via NIP-20 OK.
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,
return envelopes.compactMap { stored in
if let last = stored.lastBridgePublishAt,
date.timeIntervalSince(last) < cooldown {
continue
return nil
}
envelopes[index].lastBridgePublishAt = date
// The relay copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
return stored.envelope.withCopies(1)
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
/// Starts the bridge-publish cooldown only for a relay-confirmed copy.
func markBridgePublished(_ envelope: CourierEnvelope) {
let date = now()
queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
return
}
envelopes[index].lastBridgePublishAt = date
persistLocked()
}
}
@@ -278,25 +357,60 @@ final class CourierStore {
/// deposited, envelopes addressed to them (those ride the handover path),
/// carry-only envelopes, and couriers already sprayed.
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
var sprayed: [CourierEnvelope] = []
transferSprayCopies(to: courierNoiseKey) { envelope in
sprayed.append(envelope)
return true
}
return sprayed
}
/// Offers binary-spray copies one at a time and commits the reduced local
/// budget plus `sprayedTo` marker only after the directed transport accepts
/// that copy. A false result is a rollback: retrying the same courier sees
/// the original budget and eligibility.
@discardableResult
func transferSprayCopies(
to courierNoiseKey: Data,
accepting: (CourierEnvelope) -> Bool
) -> Int {
let date = now()
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
return queue.sync {
let offered = queue.sync {
pruneExpiredLocked(at: date)
var sprayed: [CourierEnvelope] = []
for index in envelopes.indices {
let stored = envelopes[index]
return envelopes.compactMap { stored -> CourierEnvelope? in
guard stored.copies > 1,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else { continue }
let given = stored.copies / 2
envelopes[index].copies = stored.copies - given
envelopes[index].sprayedTo.insert(courierNoiseKey)
sprayed.append(stored.envelope.withCopies(given))
!courierTags.contains(stored.recipientTag) else { return nil }
return stored.envelope.withCopies(stored.copies / 2)
}
if !sprayed.isEmpty { persistLocked() }
return sprayed
}
var acceptedCount = 0
for copy in offered where accepting(copy) {
// As with direct handover, BLE acceptance runs outside the store
// queue. Revalidate and commit the exact budget that left this
// device; a competing successful transfer makes this a no-op.
let committed = queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == copy.ciphertext }) else {
return false
}
let stored = envelopes[index]
guard stored.copies > copy.copies,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else {
return false
}
envelopes[index].copies = stored.copies - copy.copies
envelopes[index].sprayedTo.insert(courierNoiseKey)
persistLocked()
return true
}
if committed { acceptedCount += 1 }
}
return acceptedCount
}
// MARK: - Maintenance
@@ -305,6 +419,7 @@ final class CourierStore {
func wipe() {
queue.sync {
envelopes.removeAll()
diskLoadDeferred = false
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
@@ -312,6 +427,16 @@ final class CourierStore {
}
}
/// Retries a protected-data read and merges any envelopes accepted while
/// the file was unavailable. Internal so persistence tests can drive the
/// same transition as iOS's protected-data notification.
func retryDeferredPersistence() {
queue.sync {
guard diskLoadDeferred, resolveDeferredLoadLocked() else { return }
persistLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
@@ -332,6 +457,12 @@ final class CourierStore {
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
// Never turn a transient protected-data read failure into an
// authoritative empty/new file. Once readable, resolve first by
// merging the durable and in-memory snapshots.
if diskLoadDeferred, !resolveDeferredLoadLocked() {
return
}
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
@@ -344,7 +475,7 @@ final class CourierStore {
let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: fileURL, options: options)
} catch {
@@ -355,16 +486,135 @@ final class CourierStore {
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
diskLoadDeferred = false
return
}
envelopes = stored
do {
let data = try readData(fileURL)
do {
envelopes = try JSONDecoder().decode([StoredEnvelope].self, from: data)
diskLoadDeferred = false
pruneExpiredLocked(at: now())
publishCountLocked()
Self.migrateFileProtectionIfNeeded(at: fileURL)
} catch {
// The bytes were readable, so this is corruption/schema
// failure rather than protected-data unavailability.
diskLoadDeferred = false
SecureLogger.error("Failed to decode courier store: \(error)", category: .session)
}
} catch {
diskLoadDeferred = true
SecureLogger.warning("Courier store unavailable; deferring load until protected data is available: \(error)", category: .session)
}
}
}
/// Must be called on `queue`.
private func resolveDeferredLoadLocked() -> Bool {
guard diskLoadDeferred, let fileURL else { return true }
guard FileManager.default.fileExists(atPath: fileURL.path) else {
diskLoadDeferred = false
return true
}
do {
let data = try readData(fileURL)
let durable = try JSONDecoder().decode([StoredEnvelope].self, from: data)
envelopes = Self.merge(durable: durable, inMemory: envelopes)
diskLoadDeferred = false
pruneExpiredLocked(at: now())
publishCountLocked()
Self.migrateFileProtectionIfNeeded(at: fileURL)
return true
} catch let error as DecodingError {
// Readability returned but the snapshot is corrupt. Do not pin
// persistence forever; retain the valid in-memory snapshot.
diskLoadDeferred = false
SecureLogger.error("Failed to decode deferred courier store: \(error)", category: .session)
return true
} catch {
SecureLogger.warning("Courier store still unavailable: \(error)", category: .session)
return false
}
}
private static func merge(durable: [StoredEnvelope], inMemory: [StoredEnvelope]) -> [StoredEnvelope] {
var merged = durable
for candidate in inMemory {
if let index = merged.firstIndex(where: { $0.ciphertext == candidate.ciphertext }) {
// Union progress before deciding the budget. Once either copy
// has sprayed, the lower remaining budget is authoritative;
// an older durable/original snapshot must not replenish it.
let durableHasProgress = !merged[index].sprayedTo.isEmpty
let memoryHasProgress = !candidate.sprayedTo.isEmpty
let combinedSprayedTo = merged[index].sprayedTo.union(candidate.sprayedTo)
switch (durableHasProgress, memoryHasProgress) {
case (false, false):
merged[index].copies = max(merged[index].copies, candidate.copies)
case (true, false):
break // durable progress owns its remaining budget
case (false, true):
merged[index].copies = candidate.copies
case (true, true):
// Concurrent progress can only spend budget; choosing the
// lower branch prevents a merge from minting copies.
merged[index].copies = min(merged[index].copies, candidate.copies)
}
merged[index].sprayedTo = combinedSprayedTo
if candidate.tier == .favorite { merged[index].tier = .favorite }
merged[index].lastRemoteHandoverAt = [merged[index].lastRemoteHandoverAt, candidate.lastRemoteHandoverAt]
.compactMap { $0 }
.max()
merged[index].lastBridgePublishAt = [merged[index].lastBridgePublishAt, candidate.lastBridgePublishAt]
.compactMap { $0 }
.max()
} else {
merged.append(candidate)
}
}
// A long locked wake can accept new mail alongside a full durable
// store. Re-apply deposit quotas: existing per-depositor mail keeps
// its slot, while total-cap eviction sheds oldest verified mail first.
merged.sort { $0.storedAt < $1.storedAt }
var perDepositorCounts: [Data: Int] = [:]
merged = merged.filter { envelope in
let limit = envelope.tier == .favorite
? Limits.maxPerFavoriteDepositor
: Limits.maxPerVerifiedDepositor
let count = perDepositorCounts[envelope.depositorNoiseKey, default: 0]
guard count < limit else { return false }
perDepositorCounts[envelope.depositorNoiseKey] = count + 1
return true
}
while merged.filter({ $0.tier == .verified }).count > Limits.maxVerifiedEnvelopes {
guard let victim = merged.firstIndex(where: { $0.tier == .verified }) else { break }
merged.remove(at: victim)
}
while merged.count > Limits.maxEnvelopes {
if let victim = merged.firstIndex(where: { $0.tier == .verified }) {
merged.remove(at: victim)
} else {
merged.removeFirst()
}
}
return merged
}
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
#if os(iOS)
do {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
} catch {
SecureLogger.warning("Failed to migrate courier store file protection: \(error)", category: .session)
}
#endif
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
+729 -39
View File
@@ -11,6 +11,9 @@ import BitLogger
import CryptoKit
import Foundation
import Security
#if os(iOS)
import UIKit
#endif
/// Disk persistence for the MessageRouter outbox, so private messages queued
/// for an offline peer survive an app kill instead of silently evaporating.
@@ -60,76 +63,550 @@ final class MessageOutboxStore {
private static let keychainService = "chat.bitchat.outbox"
private static let keychainKey = "outbox-encryption-key"
typealias Snapshot = [PeerID: [QueuedMessage]]
private enum DiskState {
case unknown
case loaded
case deferred
}
private enum DiskReadResult {
case missing
case loaded(Snapshot)
case deferred(Error?)
case corrupt(Error)
}
private enum EncryptionKeyReadResult {
case available(SymmetricKey)
case missing
case invalid
case unavailable(Error?)
}
private struct RecoveredSnapshot {
let snapshot: Snapshot
let generation: UInt64
let unseenDurable: Snapshot
}
private let fileURL: URL?
private let keychain: KeychainManagerProtocol
private let readData: (URL) throws -> Data
private let writeData: (Data, URL, Data.WritingOptions) throws -> Void
private let beforeRecoveryNotification: () -> Void
private let lock = NSLock()
private var diskState: DiskState = .unknown
private var cachedSnapshot: Snapshot = [:]
/// Mutations made after a protected-data load failed. They are merged
/// with the durable snapshot once it becomes readable, never written on
/// top of an unreadable file.
private var pendingSnapshot: Snapshot?
/// True after a write failed after the durable baseline was already
/// loaded. That full-router snapshot includes removals and must replace,
/// rather than union with, the older disk contents on retry.
private var pendingSnapshotIsAuthoritative = false
/// Delivery/read acknowledgments received before a deferred cold-load
/// reveals the durable queue. Applied to every merge before persistence.
private var pendingRemovalMessageIDs = Set<String>()
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
/// Recovery loaded durable state that MessageRouter has not merged yet.
/// While true, router saves must union with `cachedSnapshot` instead of
/// replacing unseen durable messages.
private var recoveryDeliveryPending = false
/// Recovery read durable state and classified the unseen subset, but the
/// merged snapshot could not yet be persisted. Preserve that classification
/// across retries instead of treating the cached union as router-known.
private var unseenRecoveryPendingPersistence = false
/// MessageRouter's latest authoritative in-memory snapshot while a
/// recovery classification is awaiting persistence or delivery. This is
/// deliberately separate from `pendingSnapshot`, which may contain the
/// union of router-known and unseen durable work after a failed write.
private var recoveryRouterSnapshot: Snapshot = [:]
/// The durable messages absent from MessageRouter's locked-wake snapshot
/// when recovery completed. Only this subset is unioned into authoritative
/// router saves before the recovery callback is claimed.
private var unseenRecoveredSnapshot: Snapshot = [:]
/// Covers the narrow launch race where protected data becomes available
/// after `load()` returned but before MessageRouter installs its handler.
private var unreportedRecoveredSnapshot: RecoveredSnapshot?
/// Invalidates recovery callbacks already queued onto the main actor when
/// panic wipe begins.
private var lifecycleGeneration: UInt64 = 0
#if os(iOS)
private var protectedDataObserver: NSObjectProtocol?
#endif
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
init(
keychain: KeychainManagerProtocol,
fileURL: URL? = nil,
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) },
writeData: @escaping (Data, URL, Data.WritingOptions) throws -> Void = {
try $0.write(to: $1, options: $2)
},
beforeRecoveryNotification: @escaping () -> Void = {}
) {
self.keychain = keychain
self.fileURL = fileURL ?? Self.defaultFileURL()
self.readData = readData
self.writeData = writeData
self.beforeRecoveryNotification = beforeRecoveryNotification
#if os(iOS)
protectedDataObserver = NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.retryDeferredLoad()
}
#endif
}
deinit {
#if os(iOS)
if let protectedDataObserver {
NotificationCenter.default.removeObserver(protectedDataObserver)
}
#endif
}
// MARK: - API (call from the router's actor; IO is small and atomic)
func load() -> [PeerID: [QueuedMessage]] {
guard let fileURL,
let sealed = try? Data(contentsOf: fileURL),
let key = encryptionKey(createIfMissing: false),
let box = try? ChaChaPoly.SealedBox(combined: sealed),
let plaintext = try? ChaChaPoly.open(box, using: key),
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
return [:]
func load() -> Snapshot {
lock.lock()
defer { lock.unlock() }
if case .loaded = diskState {
return cachedSnapshot
}
var outbox: [PeerID: [QueuedMessage]] = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
// Once a deferred recovery has classified the durable baseline,
// `cachedSnapshot` is the only safe synchronous view. Re-reading via
// the generic load path would confuse its durable+router union with a
// fully router-known authoritative snapshot.
if unseenRecoveryPendingPersistence || recoveryDeliveryPending {
return cachedSnapshot
}
let wasDeferred = diskState == .deferred
switch readSnapshotLocked() {
case .loaded(let durable):
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshotIsAuthoritative
? (pendingSnapshot ?? [:])
: Self.merge(durable, pendingSnapshot ?? [:]))
diskState = .loaded
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
// The recovery callback is driven by `retryDeferredLoad`; `load`
// itself returns the recovered value synchronously to its caller.
return cachedSnapshot
case .missing:
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
diskState = .loaded
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
return cachedSnapshot
case .deferred(let error):
diskState = .deferred
if !wasDeferred {
SecureLogger.warning("Outbox unavailable; deferring load until protected data is available: \(String(describing: error))", category: .session)
}
return pendingSnapshot ?? [:]
case .corrupt(let error):
diskState = .loaded
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
return cachedSnapshot
}
return outbox
}
func save(_ outbox: [PeerID: [QueuedMessage]]) {
guard let fileURL else { return }
let flattened = outbox.filter { !$0.value.isEmpty }
guard !flattened.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
func save(_ outbox: Snapshot) {
var recovered: RecoveredSnapshot?
lock.lock()
let recoveryWasPending = recoveryDeliveryPending
let unseenClassificationWasPending = unseenRecoveryPendingPersistence
let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending
let flattened = applyingPendingRemovalsLocked(outbox.filter { !$0.value.isEmpty })
if recoveryStateWasPending {
// `save` is the router's complete current view. Keep it separate
// from the durable union retained for disk retry.
recoveryRouterSnapshot = flattened
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return
}
do {
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
switch diskState {
case .loaded:
cachedSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try sealed.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
if !persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
// Retain the latest complete router snapshot. Because its
// durable baseline was already loaded, it replaces the older
// disk file after protected data returns (preserving removals).
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .unknown, .deferred:
let wasDeferred = diskState == .deferred
// A non-authoritative deferred snapshot means the initial cold
// load never completed. An authoritative deferred snapshot with
// no recovery state is merely an ordinary post-load write retry.
let isRecoveryAttempt = recoveryStateWasPending ||
(wasDeferred && !pendingSnapshotIsAuthoritative)
switch readSnapshotLocked() {
case .loaded(let durable):
// `save` before a successful `load` is not authoritative over
// an unreadable snapshot. Union by message ID so neither the
// durable queue nor work accepted during the locked wake is
// lost.
let newlyUnseen = recoveryStateWasPending
? unseenRecoveredSnapshot
: (isRecoveryAttempt
? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: flattened))
: [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = newlyUnseen
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: (pendingSnapshotIsAuthoritative ? flattened : Self.merge(durable, flattened))
cachedSnapshot = applyingPendingRemovalsLocked(merged)
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: cachedSnapshot,
generation: lifecycleGeneration,
unseenDurable: newlyUnseen
)
}
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .missing:
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(
preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
cachedSnapshot = merged
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .deferred:
// `save` receives MessageRouter's complete current in-memory
// snapshot. Replace prior locked-wake state so delivery acks
// and expiry removals become tombstones for that state; only
// the still-unknown durable snapshot is unioned on recovery.
pendingSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
diskState = .deferred
case .corrupt(let error):
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(
preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
cachedSnapshot = merged
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
}
if let recovered {
unseenRecoveryPendingPersistence = false
recoveryDeliveryPending = true
unseenRecoveredSnapshot = recovered.unseenDurable
}
lock.unlock()
if let recovered {
beforeRecoveryNotification()
notifyRecovered(recovered.snapshot, generation: recovered.generation)
}
}
/// Installs the router-side merge hook used when a cold, locked launch
/// initially received an empty snapshot and protected data later becomes
/// readable.
func setRecoveryHandler(_ handler: @escaping @MainActor (Snapshot) -> Void) {
lock.lock()
recoveryHandler = handler
let unreported = unreportedRecoveredSnapshot
unreportedRecoveredSnapshot = nil
lock.unlock()
if let unreported {
Task { @MainActor [weak self] in
guard let latest = self?.claimPendingRecovery(generation: unreported.generation) else { return }
handler(latest)
}
}
}
/// Records an ack even when a locked cold-load has not revealed the
/// matching durable message yet. The next `save`/recovery applies this
/// tombstone before writing or notifying MessageRouter.
func recordRemoval(messageID: String) {
lock.lock()
pendingRemovalMessageIDs.insert(messageID)
cachedSnapshot = Self.removing([messageID], from: cachedSnapshot)
unseenRecoveredSnapshot = Self.removing([messageID], from: unseenRecoveredSnapshot)
recoveryRouterSnapshot = Self.removing([messageID], from: recoveryRouterSnapshot)
if let pendingSnapshot {
self.pendingSnapshot = Self.removing([messageID], from: pendingSnapshot)
}
lock.unlock()
}
/// Retries a deferred protected-data load. The returned snapshot includes
/// both durable messages and any messages queued during the locked wake.
@discardableResult
func retryDeferredLoad() -> Snapshot? {
var recovered: RecoveredSnapshot?
lock.lock()
guard diskState == .deferred else {
lock.unlock()
return nil
}
let recoveryWasPending = recoveryDeliveryPending
let unseenClassificationWasPending = unseenRecoveryPendingPersistence
let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending
// `pendingSnapshotIsAuthoritative` alone means a normal write failed
// after the router had already loaded its baseline. It must retry, but
// must not masquerade as cold-load recovery or schedule a callback.
let isRecoveryAttempt = recoveryStateWasPending || !pendingSnapshotIsAuthoritative
switch readSnapshotLocked() {
case .loaded(let durable):
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
let newlyUnseen = recoveryStateWasPending
? unseenRecoveredSnapshot
: (isRecoveryAttempt
? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: known))
: [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = newlyUnseen
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: newlyUnseen
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .missing:
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: known)
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .deferred:
break
case .corrupt(let error):
SecureLogger.error("Failed to decode encrypted outbox after protected-data recovery: \(error)", category: .session)
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: known)
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
if let recovered {
unseenRecoveryPendingPersistence = false
recoveryDeliveryPending = true
unseenRecoveredSnapshot = recovered.unseenDurable
}
lock.unlock()
if let recovered {
beforeRecoveryNotification()
notifyRecovered(recovered.snapshot, generation: recovered.generation)
}
return recovered?.snapshot
}
/// Panic wipe: drop the queued mail and the key that could ever read it.
func wipe() {
lock.lock()
diskState = .loaded
cachedSnapshot = [:]
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
pendingRemovalMessageIDs.removeAll()
recoveryDeliveryPending = false
unseenRecoveryPendingPersistence = false
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = [:]
unreportedRecoveredSnapshot = nil
lifecycleGeneration &+= 1
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
lock.unlock()
}
// MARK: - Internals
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
return SymmetricKey(data: data)
switch readEncryptionKey() {
case .available(let key):
return key
case .missing:
break
case .invalid:
guard createIfMissing else { return nil }
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
case .unavailable:
return nil
}
guard createIfMissing else { return nil }
let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data($0) }
// After-first-unlock so queued mail can flush from background BLE wakes.
@@ -139,9 +616,222 @@ final class MessageOutboxStore {
service: Self.keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
// The protocol's generic save predates result-bearing writes. Verify
// the item before sealing a file: otherwise a locked/full Keychain
// could drop the key while we successfully write unrecoverable mail.
guard case .success(let stored) = keychain.loadWithResult(
key: Self.keychainKey,
service: Self.keychainService
), stored == data else {
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
SecureLogger.error("Outbox encryption key was not retained by Keychain", category: .session)
return nil
}
return key
}
private func readEncryptionKey() -> EncryptionKeyReadResult {
switch keychain.loadWithResult(key: Self.keychainKey, service: Self.keychainService) {
case .success(let data):
guard data.count == 32 else { return .invalid }
return .available(SymmetricKey(data: data))
case .itemNotFound:
return .missing
case .deviceLocked, .authenticationFailed:
return .unavailable(nil)
case .accessDenied:
return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(errSecNotAvailable)))
case .otherError(let status):
return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(status)))
}
}
/// Must be called with `lock` held.
private func readSnapshotLocked() -> DiskReadResult {
guard let fileURL,
FileManager.default.fileExists(atPath: fileURL.path) else {
return .missing
}
let sealed: Data
do {
sealed = try readData(fileURL)
} catch {
return .deferred(error)
}
// A locked Keychain is transient; a genuine item-not-found next to an
// existing sealed file is permanent (notably after restoring onto a
// new device, because the key is ThisDeviceOnly). Remove that
// unrecoverable ciphertext before allowing a replacement key/file.
let key: SymmetricKey
switch readEncryptionKey() {
case .available(let availableKey):
key = availableKey
case .missing:
return discardOrphanedSnapshotLocked(reason: "encryption key is missing")
case .invalid:
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
return discardOrphanedSnapshotLocked(reason: "encryption key has an invalid length")
case .unavailable(let error):
return .deferred(error)
}
do {
let box = try ChaChaPoly.SealedBox(combined: sealed)
let plaintext = try ChaChaPoly.open(box, using: key)
let decoded = try JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext)
var outbox: Snapshot = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
}
Self.migrateFileProtectionIfNeeded(at: fileURL)
return .loaded(outbox)
} catch {
return .corrupt(error)
}
}
/// Must be called with `lock` held. A ciphertext whose ThisDeviceOnly key
/// is definitively absent can never become readable; removing it is safer
/// than deferring forever or overwriting it while pretending it loaded.
private func discardOrphanedSnapshotLocked(reason: String) -> DiskReadResult {
guard let fileURL else { return .missing }
do {
try FileManager.default.removeItem(at: fileURL)
SecureLogger.warning("Removed unrecoverable encrypted outbox because its \(reason)", category: .session)
return .missing
} catch {
SecureLogger.error("Could not remove unrecoverable encrypted outbox: \(error)", category: .session)
return .deferred(error)
}
}
/// Must be called with `lock` held.
@discardableResult
private func persistSnapshotLocked(_ snapshot: Snapshot) -> Bool {
guard let fileURL else { return true }
do {
if snapshot.isEmpty {
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
return true
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return false
}
let keyed = Dictionary(uniqueKeysWithValues: snapshot.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try writeData(sealed, fileURL, options)
return true
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
return false
}
}
/// Must be called with `lock` held.
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
guard persistSnapshotLocked(snapshot) else { return false }
pendingRemovalMessageIDs.removeAll()
return true
}
/// Must be called with `lock` held.
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
Self.removing(pendingRemovalMessageIDs, from: snapshot)
}
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
guard !messageIDs.isEmpty else { return snapshot }
var filtered: Snapshot = [:]
for (peerID, queue) in snapshot {
let remaining = queue.filter { !messageIDs.contains($0.messageID) }
if !remaining.isEmpty { filtered[peerID] = remaining }
}
return filtered
}
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) })
return removing(knownIDs, from: durable)
}
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
var merged = durable
for (peerID, pendingQueue) in pending {
var queue = merged[peerID] ?? []
for var candidate in pendingQueue {
if let index = queue.firstIndex(where: { $0.messageID == candidate.messageID }) {
candidate.sendAttempts = max(candidate.sendAttempts, queue[index].sendAttempts)
candidate.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys)
queue[index] = candidate
} else {
queue.append(candidate)
}
}
queue.sort { $0.timestamp < $1.timestamp }
if !queue.isEmpty { merged[peerID] = queue }
}
return merged.filter { !$0.value.isEmpty }
}
private func notifyRecovered(_ snapshot: Snapshot, generation: UInt64) {
lock.lock()
guard lifecycleGeneration == generation else {
lock.unlock()
return
}
let handler = recoveryHandler
if handler == nil {
unreportedRecoveredSnapshot = RecoveredSnapshot(
snapshot: snapshot,
generation: generation,
unseenDurable: unseenRecoveredSnapshot
)
}
lock.unlock()
guard let handler else { return }
Task { @MainActor [weak self] in
guard let latest = self?.claimPendingRecovery(generation: generation) else { return }
handler(latest)
}
}
private func claimPendingRecovery(generation: UInt64) -> Snapshot? {
lock.lock()
defer { lock.unlock() }
guard lifecycleGeneration == generation, recoveryDeliveryPending else { return nil }
let latest = cachedSnapshot
recoveryDeliveryPending = false
unseenRecoveryPendingPersistence = false
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = [:]
unreportedRecoveredSnapshot = nil
return latest
}
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
#if os(iOS)
do {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
} catch {
SecureLogger.warning("Failed to migrate outbox file protection: \(error)", category: .session)
}
#endif
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
@@ -65,8 +65,11 @@ final class BridgeCourierService: ObservableObject {
var bridgeEnabled: (@MainActor () -> Bool)?
var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event to the default (DM) relays.
var publishEvent: (@MainActor (NostrEvent) -> Void)?
/// 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)?
@@ -76,14 +79,21 @@ final class BridgeCourierService: ObservableObject {
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)?
/// 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 false when the handoff could not even be attempted (peer no
/// longer reachable), so the drop event stays retryable.
/// 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)?
@@ -91,7 +101,11 @@ final class BridgeCourierService: ObservableObject {
private(set) var myTagsHex: Set<String> = []
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: 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
@@ -104,7 +118,27 @@ final class BridgeCourierService: ObservableObject {
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
@@ -156,23 +190,23 @@ final class BridgeCourierService: ObservableObject {
}
}
/// Writes the dedup record now. Sender keys still sitting in the
/// in-memory `pendingDrops` queue are excluded: their drop is not durable
/// until it actually reaches a relay, and persisting the key early would
/// turn "app killed before relays connected" into a silent 24h blackhole
/// (the relaunch loses the queued drop but the persisted key blocks every
/// re-deposit). `flushPendingDrops` re-persists once they publish.
/// 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() {
let pendingKeys = Set(pendingDrops.compactMap(\.dedupKey))
dedupStore.save(BridgeDropDedupStore.Snapshot(
publishedDropKeys: publishedDropKeys.entries.filter { !pendingKeys.contains($0.key) },
publishedDropKeys: publishedDropKeys.entries,
seenDropEventIDs: seenDropEventIDs.entries
))
}
/// Panic wipe: forget queued drops and the persisted dedup record.
func wipe() {
pendingDrops.removeAll()
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()
@@ -182,32 +216,41 @@ final class BridgeCourierService: ObservableObject {
/// 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, now: now()) else { return false }
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
/// 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); consume the dedup slot so the retry sweep stops
// re-running Noise sealing on a drop that can never ship.
// 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 {
publishedDropKeys.insert(messageID, now: now())
persistDedup()
return false
rejectedDropKeys.insert(messageID, now: now())
completion(false)
return
}
// Only consume the sender-side dedup slot once the drop is durably
// accepted (published, or safely queued for the next relay
// connection). If the compose fails, leave the slot open so the
// router's retry sweep can attempt a fresh deposit rather than
// marking the message "carried" and blocking retries forever.
guard publishDrop(envelope, messageID: messageID) else { return false }
publishedDropKeys.insert(messageID, now: now())
persistDedup()
return true
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,
@@ -215,32 +258,59 @@ final class BridgeCourierService: ObservableObject {
func publishHeldEnvelopes() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
publishDrop(envelope)
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 drop can release its slot.
/// Returns false only when the drop could not be made durable (bad
/// encode/expired/compose failure) so callers can keep it retryable.
@discardableResult
private func publishDrop(_ envelope: CourierEnvelope, messageID: String? = nil) -> Bool {
/// 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 { return false }
!envelope.isExpired else {
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
guard relaysConnected?() ?? false else {
pendingDrops.append((envelope, messageID))
// 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()
// The oldest queued drop is being dropped before it ever
// published; release its dedup slot so it stays retryable.
if let key = evicted.dedupKey {
publishedDropKeys.remove(key)
persistDedup()
}
finishPublish(
messageID: evicted.dedupKey,
operationID: evicted.operationID,
succeeded: false
)
}
return true
return
}
guard let identity = try? NostrIdentity.generate(),
let event = try? NostrProtocol.createCourierDropEvent(
@@ -250,10 +320,62 @@ final class BridgeCourierService: ObservableObject {
senderIdentity: identity
) else {
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
return false
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
publishEvent?(event)
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
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
}
@@ -262,16 +384,13 @@ final class BridgeCourierService: ObservableObject {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
let queued = pendingDrops
pendingDrops.removeAll()
for item in queued where !publishDrop(item.envelope, messageID: item.dedupKey) {
// Compose failed with relays up: release the slot so the router's
// retry sweep can attempt a fresh deposit.
if let key = item.dedupKey {
publishedDropKeys.remove(key)
}
for item in queued {
publishDrop(
item.envelope,
messageID: item.dedupKey,
operationID: item.operationID
)
}
// Flushed keys just became durable (published, so no longer excluded
// as pending) or were released above; either way the record changed.
persistDedup()
}
// MARK: - Subscription (recipient + gateway watch)
@@ -281,7 +400,15 @@ final class BridgeCourierService: ObservableObject {
/// (tags rotate daily); idempotent.
func refresh() {
armRefreshTimerIfNeeded()
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
guard bridgeEnabled?() ?? false else {
cancelActivePublishes()
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
}
return
}
guard relaysConnected?() ?? false else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
@@ -323,11 +450,37 @@ final class BridgeCourierService: ObservableObject {
/// 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 }
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
lastAnnounceRefresh = now()
refresh()
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() {
@@ -355,8 +508,10 @@ final class BridgeCourierService: ObservableObject {
func handleDropEvent(_ event: NostrEvent) {
guard bridgeEnabled?() ?? false else { return }
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
guard seenDropEventIDs.insert(event.id, now: now()) else { return }
persistDedup()
// 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),
@@ -371,17 +526,16 @@ final class BridgeCourierService: ObservableObject {
if myTagsHex.contains(tagHex) {
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
openEnvelope?(envelope)
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 {
// The best-effort handoff never left this device (the peer
// walked away between the relay fetch and the mesh send).
// Release the seen slot so a relaunch or backlog redelivery
// retries a single-gateway island has no other carrier.
seenDropEventIDs.remove(event.id)
if deliverToPeer?(envelope, match.peerID) == true {
seenDropEventIDs.insert(event.id, now: now())
persistDedup()
}
}
@@ -389,6 +543,19 @@ final class BridgeCourierService: ObservableObject {
// 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.
+241 -53
View File
@@ -47,11 +47,15 @@ import Foundation
/// 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: the wire carries no
/// public message ID, so every device derives the same content-stable one
/// (`MeshMessageIdentity`) from the origin coordinates in the event's `m`
/// tag recomputed locally, never trusted and the store's insert-by-ID
/// absorbs radio/bridge duplicates in either arrival order.
/// Receivers key bridge rows by the signed Nostr event ID. The event's `m` tag
/// is only a radio-copy hint: its mesh sender/timestamp fields are public and
/// cannot authenticate the event signer, so letting them own the timeline ID
/// would allow a different signer to front-run the genuine event's dedup slot.
/// When the radio copy is already present the hint avoids duplicate rendering
/// and downlink airtime. If the bridge copy wins the race, a later
/// authenticated radio copy replaces every bridge row that claimed the same
/// hint; the untrusted hint can therefore merge a duplicate but can never
/// suppress the radio-authenticated origin.
///
/// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays, radios, or CoreLocation.
@@ -71,11 +75,25 @@ final class BridgeService: ObservableObject {
static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512
/// Keep radio-replacement aliases for every bridge row that can still
/// be visible in the bounded mesh timeline. Retiring an alias earlier
/// would let valid high-volume ingress delete otherwise visible
/// history merely to preserve the radio-wins invariant.
static let maxTrackedRadioAliases = TransportConfig.meshTimelineCap
/// 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
/// Relay ingress is adversarial: bound both accepted work and the
/// people-sheet state even when an attacker rotates signing keys.
static let inboundEventsPerMinute = 600
static let inboundEventsPerMinutePerSigner = 120
/// Cheap pre-crypto gate for both valid and invalid ingress. Without
/// it, invalid carrier events could force unbounded Schnorr work while
/// never reaching the accepted-event limiter below.
static let signatureVerificationAttemptsPerMinute = 720
static let maxParticipants = 128
/// 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).
@@ -91,7 +109,11 @@ final class BridgeService: ObservableObject {
/// A validated rendezvous message ready for the timeline.
struct InboundBridgeMessage {
let messageID: String
/// Unauthenticated mesh coordinates can only be used to notice that a
/// verified radio copy is already present; they never own bridge dedup.
let radioMessageIDHint: String?
let senderNickname: String
let participantNickname: String?
let senderPubkey: String
let content: String
let timestamp: Date
@@ -116,10 +138,9 @@ final class BridgeService: ObservableObject {
/// 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.
/// Distinct rendezvous participants seen within the freshness window.
/// Radio-copy hints never alter signer locality: their public coordinates
/// can suppress a duplicate row but cannot authenticate a Nostr signer.
@Published private(set) var bridgedPeerCount: Int = 0
/// The people behind the count, newest activity first.
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
@@ -159,6 +180,13 @@ final class BridgeService: ObservableObject {
var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Delivers a validated inbound bridge message to the mesh timeline.
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
/// Removes a previously injected bridge row when an authenticated radio
/// copy arrives later. The UI hook also discards a not-yet-flushed row.
var removeInjectedInbound: (@MainActor (String) -> Void)?
/// Exact liveness check for a bridge row in either the pending UI pipeline
/// or bounded conversation store. Alias pruning may discard proof only
/// after the corresponding row is already gone.
var isInjectedInboundPresent: (@MainActor (String) -> Bool)?
/// True when the mesh timeline already holds this message ID (the radio
/// copy) used to skip pointless downlink airtime.
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
@@ -185,32 +213,56 @@ final class BridgeService: ObservableObject {
private var rebroadcastEventIDs: BoundedIDSet
/// Timeline message IDs already injected (either arrival path).
private var injectedMessageIDs: BoundedIDSet
/// Signed relay/carrier events already accepted. Kept separate from the
/// loop caches so a mesh arrival can still mark loop suppression even when
/// the relay copy won the race.
private var receivedEventIDs: BoundedIDSet
/// Authenticated radio IDs observed this session. These close the
/// bridge-first race even before the UI pipeline flushes its radio row.
private var observedRadioMessageIDs: BoundedIDSet
/// event ID -> untrusted radio hint. Event IDs own bridge
/// dedup; this bounded reverse index is used only to replace bridge rows
/// after the genuine radio packet has authenticated successfully.
private var injectedRadioAliases: [String: String] = [:]
private var injectedRadioAliasOrder: [String] = []
/// 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 inboundEventTimes: [Date] = []
private var inboundEventTimesBySigner: [String: [Date]] = [:]
private var signatureVerificationTokens = Double(Limits.signatureVerificationAttemptsPerMinute)
private var signatureVerificationLastRefillAt: 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?)] = [:]
/// pubkey -> (lastSeen, last known nickname).
private var participants: [String: (lastSeen: Date, nickname: String?)] = [:]
private let defaults: UserDefaults
private let now: () -> Date
private let verifyEventSignature: (NostrEvent) -> Bool
private static let enabledKey = "bridge.userEnabled"
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
init(
defaults: UserDefaults = .standard,
now: @escaping () -> Date = Date.init,
verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() }
) {
self.defaults = defaults
self.now = now
self.verifyEventSignature = verifyEventSignature
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)
self.receivedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.observedRadioMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
}
// MARK: - Toggle & lifecycle
@@ -223,6 +275,8 @@ final class BridgeService: ObservableObject {
queuedUplinks.removeAll()
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
inboundEventTimes.removeAll()
inboundEventTimesBySigner.removeAll()
participants.removeAll()
bridgedPeerCount = 0
bridgedParticipants = []
@@ -301,11 +355,6 @@ final class BridgeService: ObservableObject {
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
guard content.utf8.count <= Limits.maxContentBytes else { return }
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
let stableID = MeshMessageIdentity.stableID(
senderIDHex: senderPeerID.id,
timestampMs: timestampMs,
content: content
)
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgeMeshEvent(
content: content,
@@ -319,7 +368,7 @@ final class BridgeService: ObservableObject {
return
}
publishedEventIDs.insert(event.id)
injectedMessageIDs.insert(stableID) // our own timeline already has it
injectedMessageIDs.insert(event.id) // our own timeline already has it
if relaysConnected?() ?? false {
publishToRelays?(event, cell)
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
@@ -388,7 +437,6 @@ final class BridgeService: ObservableObject {
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)
@@ -401,18 +449,23 @@ final class BridgeService: ObservableObject {
publishedEventIDs.insert(event.id) // never downlink it either
return
}
guard event.isValidSignature() else { return }
guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else { return }
guard receivedEventIDs.insert(event.id), allowInboundEvent(from: event.pubkey) else { return }
guard let kind = classify(event, cell: cell) else { return }
switch kind {
case .presence:
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
recordParticipant(event.pubkey, nickname: nil)
case .message(let message):
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
let isLocalRadioCopy = message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false
if isLocalRadioCopy {
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
// The public m-tag proves only that identical radio content is
// already present, not that this Nostr signer authored it.
// Skip the duplicate row without changing signer locality.
SecureLogger.debug("🌉 Bridge: authenticated radio copy already present; bridge alias skipped", category: .session)
} else if inject(message) {
recordParticipant(event.pubkey, nickname: message.participantNickname)
}
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:
@@ -431,6 +484,33 @@ final class BridgeService: ObservableObject {
}
}
/// Called only after the BLE public-message signature has authenticated.
/// A bridge hint is not trusted enough to drop this radio row. Instead,
/// the radio row wins and any earlier bridge aliases are removed, which
/// gives both arrival orders one timeline row without restoring the
/// origin-spoof vulnerability.
func handleAuthenticatedRadioMessage(messageID: String) {
guard !messageID.isEmpty else { return }
observedRadioMessageIDs.insert(messageID)
let matching = injectedRadioAliases.filter { $0.value == messageID }
guard !matching.isEmpty else { return }
let eventIDs = Set(matching.keys)
for eventID in matching.keys {
removeInjectedInbound?(eventID)
injectedRadioAliases.removeValue(forKey: eventID)
}
injectedRadioAliasOrder.removeAll { eventIDs.contains($0) }
// A relay event can already be waiting inside the multi-gateway jitter
// window. Remove it now so it cannot consume BLE airtime after the
// authenticated radio packet proved this island already has a copy.
pendingDownlinks.removeAll { item in
guard case .message(let message)? = classify(item.event, cell: item.cell) else { return false }
return message.radioMessageIDHint == messageID
}
}
// MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets with bridge
@@ -467,7 +547,7 @@ final class BridgeService: ObservableObject {
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))", category: .session)
return
}
guard event.isValidSignature() else {
guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
return
}
@@ -523,6 +603,52 @@ final class BridgeService: ObservableObject {
return true
}
/// Bounds relay/carrier work independently of the downlink airtime budget.
/// A valid signature proves control of one key, not that the sender is
/// entitled to unbounded main-actor state or CPU.
private func allowInboundEvent(from signer: String) -> Bool {
let date = now()
let cutoff = date.addingTimeInterval(-60)
inboundEventTimes.removeAll { $0 < cutoff }
guard inboundEventTimes.count < Limits.inboundEventsPerMinute else { return false }
var signerTimes = inboundEventTimesBySigner[signer, default: []]
signerTimes.removeAll { $0 < cutoff }
guard signerTimes.count < Limits.inboundEventsPerMinutePerSigner else {
inboundEventTimesBySigner[signer] = signerTimes
return false
}
inboundEventTimes.append(date)
signerTimes.append(date)
inboundEventTimesBySigner[signer] = signerTimes
if inboundEventTimesBySigner.count > Limits.maxTrackedEventIDs {
inboundEventTimesBySigner = inboundEventTimesBySigner.filter { entry in
entry.value.contains { $0 >= cutoff }
}
}
return true
}
/// Bounds expensive signature checks before signer identity is trusted.
/// Kept independent from accepted-event accounting so invalid events do
/// not create signer state or poison any dedup cache.
private func allowSignatureVerificationAttempt() -> Bool {
let date = now()
if let lastRefillAt = signatureVerificationLastRefillAt {
let elapsed = max(0, date.timeIntervalSince(lastRefillAt))
let refillPerSecond = Double(Limits.signatureVerificationAttemptsPerMinute) / 60
signatureVerificationTokens = min(
Double(Limits.signatureVerificationAttemptsPerMinute),
signatureVerificationTokens + elapsed * refillPerSecond
)
}
signatureVerificationLastRefillAt = date
guard signatureVerificationTokens >= 1 else { return false }
signatureVerificationTokens -= 1
return true
}
// MARK: - Downlink (gateway role: internet -> mesh)
private func drainPendingDownlinks() {
@@ -533,9 +659,15 @@ final class BridgeService: ObservableObject {
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.
// broadcast this event, or the authenticated radio copy may have
// arrived, during our jitter holdoff.
guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id) else { continue }
if case .message(let message)? = classify(event, cell: cell),
let radioMessageID = message.radioMessageIDHint,
radioCopyAlreadyPresent(radioMessageID) {
continue
}
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
let payload = carrier.encode() else { continue }
broadcastToMesh?(payload)
@@ -584,36 +716,90 @@ final class BridgeService: ObservableObject {
guard let event = structurallyValidEvent(from: carrier),
!publishedEventIDs.contains(event.id),
!isOwnRendezvousEvent(event, cell: carrier.geohash),
event.isValidSignature() else {
allowSignatureVerificationAttempt(),
verifyEventSignature(event) 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 }
// even when the relay copy won the injection race: pending gateway
// downlinks consult this cache and must stand down.
let firstMeshArrival = meshBroadcastEventIDs.insert(event.id)
guard firstMeshArrival,
receivedEventIDs.insert(event.id),
allowInboundEvent(from: event.pubkey) else { return }
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
return
}
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
inject(message)
if inject(message) {
recordParticipant(event.pubkey, nickname: message.participantNickname)
}
}
// MARK: - Injection & participants
private func inject(_ message: InboundBridgeMessage) {
guard injectedMessageIDs.insert(message.messageID) else { return }
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
@discardableResult
private func inject(_ message: InboundBridgeMessage) -> Bool {
guard injectedMessageIDs.insert(message.messageID) else { return false }
guard !(message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false) else {
return false
}
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
injectInbound?(message)
if let radioMessageID = message.radioMessageIDHint {
recordRadioAlias(
eventID: message.messageID,
radioMessageID: radioMessageID
)
}
return true
}
private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
private func radioCopyAlreadyPresent(_ messageID: String) -> Bool {
observedRadioMessageIDs.contains(messageID) || (isMessageSeenLocally?(messageID) ?? false)
}
private func recordRadioAlias(
eventID: String,
radioMessageID: String
) {
guard injectedRadioAliases[eventID] == nil else { return }
injectedRadioAliases[eventID] = radioMessageID
injectedRadioAliasOrder.append(eventID)
guard injectedRadioAliasOrder.count > Limits.maxTrackedRadioAliases,
let isInjectedInboundPresent else { return }
// Arrival order and signed event timestamps are independent. The
// conversation cap trims by timestamp, so blindly evicting the oldest
// alias could discard the proof for a still-visible row and then
// actively delete that history. Prune only aliases whose exact row is
// already absent; temporary overflow is safer than losing radio-wins
// reconciliation for any visible bridge message.
var overflow = injectedRadioAliasOrder.count - Limits.maxTrackedRadioAliases
var retained: [String] = []
retained.reserveCapacity(injectedRadioAliasOrder.count)
for candidate in injectedRadioAliasOrder {
if overflow > 0, !isInjectedInboundPresent(candidate) {
injectedRadioAliases.removeValue(forKey: candidate)
overflow -= 1
} else {
retained.append(candidate)
}
}
injectedRadioAliasOrder = retained
}
private func recordParticipant(_ pubkey: String, nickname: String?) {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
participants = participants.filter { $0.value.lastSeen >= cutoff }
if participants[pubkey] == nil, participants.count >= Limits.maxParticipants,
let oldest = participants.min(by: { $0.value.lastSeen < $1.value.lastSeen })?.key {
participants.removeValue(forKey: oldest)
}
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.
// Presence events carry no nickname, so a known name is never
// forgotten. Radio hints deliberately do not mutate this record.
participants[pubkey] = (
lastSeen: now(),
isLocal: (previous?.isLocal ?? false) || isLocal,
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
)
recomputeBridgedCount()
@@ -628,7 +814,7 @@ final class BridgeService: ObservableObject {
private func recomputeBridgedCount() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
let visible = participants
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
.filter { $0.value.lastSeen >= cutoff }
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
.sorted { $0.lastSeen > $1.lastSeen }
if visible.count != bridgedPeerCount {
@@ -660,28 +846,30 @@ final class BridgeService: ObservableObject {
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]
// Recompute never trust the mesh message ID: the `m` tag is
// `[stable ID, sender ID, wire timestamp ms]`. Element 1 exists
// for v1.7.0 parsers; we ignore it and derive the ID from the
// origin coordinates (elements 2-3) plus the event's own content,
// so a forged tag cannot bind a chosen ID to different content
// (see `MeshMessageIdentity` for the exact property and its
// limits).
// The `m` tag is `[stable ID, sender ID, wire timestamp ms]` for
// radio/bridge duplicate detection. Those coordinates are public
// and not cryptographically bound to this Nostr signer, so they
// are never allowed to own bridge dedup. A copied tag can at most
// make the attacker's own event look like a radio duplicate; it
// cannot reserve the genuine signed event's timeline ID.
let m = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })
let messageID: String
let radioMessageIDHint: String?
if let m, m.count >= 4, m[2].count == 16, m[2].allSatisfy(\.isHexDigit),
let timestampMs = UInt64(m[3]) {
messageID = MeshMessageIdentity.stableID(
radioMessageIDHint = MeshMessageIdentity.stableID(
senderIDHex: m[2],
timestampMs: timestampMs,
content: content
)
} else {
messageID = event.id // old-format or absent tag
radioMessageIDHint = nil
}
let baseNickname = nickname?.trimmedOrNilIfEmpty ?? "anon"
return .message(InboundBridgeMessage(
messageID: messageID,
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.suffix(4))",
messageID: event.id,
radioMessageIDHint: radioMessageIDHint,
senderNickname: baseNickname + "#" + String(event.pubkey.suffix(4)),
participantNickname: nickname?.trimmedOrNilIfEmpty,
senderPubkey: event.pubkey,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
+10 -3
View File
@@ -542,6 +542,15 @@ final class KeychainManager: KeychainManagerProtocol {
/// Load data from a custom service
func load(key: String, service customService: String) -> Data? {
guard case .success(let data) = loadWithResult(key: key, service: customService) else {
return nil
}
return data
}
/// Load custom-service data without collapsing `itemNotFound` and
/// protected-data/keychain failures into the same nil result.
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -551,9 +560,7 @@ final class KeychainManager: KeychainManagerProtocol {
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
return classifyReadStatus(status, data: result as? Data)
}
/// Delete data from a custom service
+14 -5
View File
@@ -367,16 +367,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
updatePermissionState(from: status)
if case .authorized = permissionState {
let newState = updatePermissionState(from: status)
if newState == .authorized {
requestOneShotLocation()
}
}
@available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState {
let newState = updatePermissionState(from: manager.authorizationStatus)
if newState == .authorized {
requestOneShotLocation()
}
}
@@ -393,7 +393,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Private Helpers (Permission)
private func updatePermissionState(from status: CLAuthorizationStatus) {
@discardableResult
private func updatePermissionState(from status: CLAuthorizationStatus) -> PermissionState {
let newState: PermissionState
switch status {
case .notDetermined: newState = .notDetermined
@@ -402,7 +403,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted
}
// Do not rely on a mounted SwiftUI consumer to stop high-accuracy
// updates. Authorization can change while the app is backgrounded,
// and stopping here also closes the gap before the published state is
// delivered on the main actor.
if newState != .authorized {
endLiveRefresh()
}
Task { @MainActor in self.permissionState = newState }
return newState
}
// MARK: - Private Helpers (Channel Computation)
@@ -246,6 +246,16 @@ final class MessageDeduplicationService {
ContentNormalizer.normalizedKey(content)
}
/// Removes the near-duplicate marker for a row that is being replaced,
/// not merely deleted. Bridge-first/radio-second reconciliation needs the
/// authenticated radio copy to pass the next pipeline flush after its
/// unauthenticated bridge alias is removed.
func forgetContent(_ content: String, ifRecordedAt timestamp: Date) {
let key = ContentNormalizer.normalizedKey(content)
guard contentCache.value(for: key) == timestamp else { return }
contentCache.remove(key)
}
// MARK: - Nostr Event Deduplication
/// Checks if a Nostr event has already been processed.
+69 -12
View File
@@ -56,11 +56,15 @@ final class MessageRouter {
/// 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)?
/// Completion is true only after at least one default relay explicitly
/// accepts the event, so a socket write followed by rejection cannot
/// falsely show the sender's message as "carried".
var bridgeCourierDeposit: ((
_ content: String,
_ messageID: String,
_ recipientNoiseKey: Data,
_ completion: @escaping @MainActor (Bool) -> Void
) -> Void)?
/// Re-attempts bridge drops for retained messages whose recipient no
/// transport can promptly reach anymore. Covers sends that raced the BLE
@@ -77,9 +81,7 @@ final class MessageRouter {
}
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)
}
requestBridgeCourierDeposit(message, for: peerID, recipientKey: recipientKey)
}
}
}
@@ -102,6 +104,7 @@ final class MessageRouter {
}
private var bridgeSweepTask: Task<Void, Never>?
private var bridgeDepositsInFlight = Set<String>()
private var outbox: [PeerID: [QueuedMessage]] = [:]
@@ -127,6 +130,9 @@ final class MessageRouter {
self.outboxStore = outboxStore
self.metrics = metrics
self.outbox = outboxStore?.load() ?? [:]
outboxStore?.setRecoveryHandler { [weak self] recovered in
self?.mergeRecoveredOutbox(recovered)
}
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -237,9 +243,7 @@ final class MessageRouter {
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)
}
requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey)
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return }
@@ -324,6 +328,23 @@ final class MessageRouter {
outbox[peerID]?.first { $0.messageID == messageID }
}
private func requestBridgeCourierDeposit(
_ message: QueuedMessage,
for peerID: PeerID,
recipientKey: Data
) {
guard let bridgeCourierDeposit,
bridgeDepositsInFlight.insert(message.messageID).inserted else { return }
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
guard let self else { return }
self.bridgeDepositsInFlight.remove(message.messageID)
// A direct delivery may have cleared the outbox while the relay
// relay confirmation was in flight; do not regress its UI state.
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
self.onMessageCarried?(message.messageID, peerID)
}
}
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
metrics?.record(.courierDeposited)
guard var queue = outbox[peerID],
@@ -344,10 +365,14 @@ final class MessageRouter {
outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true
}
// The durable snapshot may still be hidden by protected data. Record
// the ack even when this cold-load view cannot find the message, then
// persist the current view so the store retains a removal tombstone.
outboxStore?.recordRemoval(messageID: messageID)
if cleared {
metrics?.record(.outboxDelivered)
persistOutbox()
}
persistOutbox()
}
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
@@ -382,6 +407,38 @@ final class MessageRouter {
outboxStore?.save(outbox)
}
/// A cold BLE restoration can launch before protected files are readable.
/// The store initially returns an empty snapshot in that case, then calls
/// back after first unlock. Merge by message ID instead of replacing work
/// accepted during the locked wake, persist the union, and immediately
/// resume normal delivery attempts.
private func mergeRecoveredOutbox(_ recovered: MessageOutboxStore.Snapshot) {
for (peerID, recoveredQueue) in recovered {
var queue = outbox[peerID] ?? []
for var recoveredMessage in recoveredQueue {
if let index = queue.firstIndex(where: { $0.messageID == recoveredMessage.messageID }) {
recoveredMessage.sendAttempts = max(recoveredMessage.sendAttempts, queue[index].sendAttempts)
recoveredMessage.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys)
queue[index] = recoveredMessage
} else {
queue.append(recoveredMessage)
}
}
queue.sort { $0.timestamp < $1.timestamp }
if queue.count > Self.maxMessagesPerPeer {
let overflow = queue.count - Self.maxMessagesPerPeer
for dropped in queue.prefix(overflow) {
dropMessage(dropped.messageID, for: peerID)
}
queue.removeFirst(overflow)
}
outbox[peerID] = queue
}
persistOutbox()
flushAllOutbox()
retryBridgeCourierDeposits()
}
/// Panic wipe: forget queued mail on disk and in memory.
func wipeOutbox() {
outbox.removeAll()
+3
View File
@@ -215,6 +215,9 @@ enum TransportConfig {
// Fallback deadline for treating a subscription's initial fetch as complete
// when a relay never sends EOSE (generous to cover Tor circuit setup).
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
// A bridge drop is durable only after NIP-20 OK. Relays that omit OK must
// not pin the router's in-flight state indefinitely.
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
// After this long, a relay marked permanently failed gets another chance.
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0
@@ -141,6 +141,12 @@ final class ChatLiveVoiceCoordinator {
private var fileManager: FileManager { fileStore.fileManager }
private var assemblies: [AssemblyKey: Assembly] = [:]
private var finishedBursts: [AssemblyKey: FinishedBurst] = [:]
/// Players still draining after their assembly the sole strong owner
/// was discarded on burst END. Without this hold the player deallocates
/// mid-tail (or mid session-acquire, killing the whole burst's audio)
/// and its registered session token would only be reclaimed by the
/// deinit backstop. Entries remove themselves via `onStopped`.
private var drainingPlayers: [ObjectIdentifier: PTTBurstPlayer] = [:]
/// `sweepsOnInit` exists for tests whose coordinator shares the real
/// application-support directory: they pass `false` so parallel test
@@ -465,7 +471,18 @@ final class ChatLiveVoiceCoordinator {
return
}
assembly.player?.finishAfterDrain()
if let player = assembly.player, !player.stopped {
// Park the draining player: this method just dropped the
// assembly, and nothing else holds the player strongly. It may
// still be playing out its tail or still acquiring the audio
// session off-main so it must stay alive until it stops.
let id = ObjectIdentifier(player)
drainingPlayers[id] = player
player.onStopped = { [weak self] in
self?.drainingPlayers.removeValue(forKey: id)
}
player.finishAfterDrain()
}
// The bubble's waveform may have been computed from a partial file.
WaveformCache.shared.purge(url: assembly.fileURL)
// The capture is the bubble's replayable audio from here on (unless a
+27 -1
View File
@@ -698,12 +698,30 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false
}
@MainActor
func bridgeInjectedPublicMessageIsPresent(withID messageID: String) -> Bool {
publicMessagePipeline.containsMessage(withID: messageID) ||
publicConversationContainsMessage(withID: messageID, in: .mesh)
}
/// Removes a message by ID from whichever public conversation contains
/// it. Returns the removed message, if any.
@MainActor
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
conversations.removePublicMessage(withID: messageID)
publicMessagePipeline.removeMessage(withID: messageID)
return conversations.removePublicMessage(withID: messageID)
}
/// Replaces an unauthenticated bridge alias with a later authenticated
/// radio row. In addition to both storage layers, clear the content-window
/// marker written by an already-flushed alias or it would suppress the
/// genuine row during the next public-message batch.
@MainActor
func removeBridgeInjectedPublicMessage(withID messageID: String) {
publicMessagePipeline.removeMessage(withID: messageID)
guard let removed = conversations.removePublicMessage(withID: messageID) else { return }
deduplicationService.forgetContent(removed.content, ifRecordedAt: removed.timestamp)
}
/// Removes every message matching `predicate` from a geohash
@@ -1755,6 +1773,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Handle incoming public message
@MainActor
func handlePublicMessage(_ message: BitchatMessage) {
// Bridge hints are unauthenticated and may never suppress a genuine
// BLE sender. Once the radio packet has passed BLE signature checks,
// replace any earlier bridge alias before this row is enqueued.
if !message.isBridged,
let senderPeerID = message.senderPeerID,
!senderPeerID.isGeoChat {
BridgeService.shared.handleAuthenticatedRadioMessage(messageID: message.id)
}
// A finalized voice note whose burst already streamed in live swaps
// into the existing bubble instead of appearing twice.
if liveVoiceCoordinator.absorbFinalizedVoiceNote(message) { return }
@@ -464,6 +464,12 @@ private extension ChatViewModelBootstrapper {
isBridged: true
))
}
bridge.removeInjectedInbound = { [weak viewModel] messageID in
viewModel?.removeBridgeInjectedPublicMessage(withID: messageID)
}
bridge.isInjectedInboundPresent = { [weak viewModel] messageID in
viewModel?.bridgeInjectedPublicMessageIsPresent(withID: messageID) ?? false
}
bridge.isMessageSeenLocally = { [weak viewModel] messageID in
viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false
}
@@ -532,11 +538,17 @@ private extension ChatViewModelBootstrapper {
let courier = BridgeCourierService.shared
courier.bridgeEnabled = { BridgeService.shared.isEnabled }
courier.relaysConnected = { NostrRelayManager.shared.isConnected }
courier.publishEvent = { event in
// A geo/custom relay does not make a global courier drop durable.
// Require an actually connected default (DM) relay so `sendEvent`
// writes to at least one intended relay instead of only entering its
// process-local pending queue.
courier.relaysConnected = { NostrRelayManager.shared.isDMRelayConnected }
courier.publishEvent = { event, completion in
// Default (DM) relays: drops need the standing global relay set,
// not geo relays sender and recipient share no cell.
NostrRelayManager.shared.sendEvent(event)
// This confirmed path never falls back to the volatile relay
// queue; bridge dedup is committed only after NIP-20 OK.
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
}
courier.openSubscription = { tagsHex in
NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID)
@@ -564,7 +576,7 @@ private extension ChatViewModelBootstrapper {
bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey)
}
courier.openEnvelope = { [weak bleService] envelope in
bleService?.openBridgedCourierEnvelope(envelope)
bleService?.openBridgedCourierEnvelope(envelope) ?? false
}
courier.deliverToPeer = { [weak bleService] envelope, peerID in
bleService?.deliverBridgedEnvelope(envelope, to: peerID) ?? false
@@ -572,12 +584,20 @@ private extension ChatViewModelBootstrapper {
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)
courier.markHeldEnvelopePublished = { envelope in
CourierStore.shared.markBridgePublished(envelope)
}
// (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.bridgeCourierDeposit = { content, messageID, recipientKey, completion in
BridgeCourierService.shared.depositDrop(
content: content,
messageID: messageID,
recipientNoiseKey: recipientKey,
completion: completion
)
}
// The completion flows back only after a default relay accepts the
// event, so a rejected or unacknowledged write never becomes carried.
viewModel.messageRouter.startBridgeDepositSweep()
bleService.onVerifiedPeerAnnounce = { _ in
Task { @MainActor in
@@ -588,7 +608,7 @@ private extension ChatViewModelBootstrapper {
// 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
NostrRelayManager.shared.$isDMRelayConnected
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { _ in BridgeCourierService.shared.refresh() }
+45 -16
View File
@@ -39,25 +39,38 @@ final class GeoChannelCoordinator {
private var cancellables = Set<AnyCancellable>()
private var regionalChannels: [GeohashChannel] = []
private var bookmarkedGeohashes: [String] = []
private var permissionState: LocationChannelManager.PermissionState
private var locationNotesEnabled: Bool
/// Mirrors `NearbyNotesCounter.revealed` (injectable for tests): the
/// session's one explicit notes act. Until it happens, background
/// sampling must not include the building-precision cell see
/// `sampledRegionalGeohashes`.
private var notesRevealed = false
private let notesRevealedPublisher: AnyPublisher<Bool, Never>
private let locationNotesSettingsPublisher: AnyPublisher<Bool, Never>
init(
locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil,
notesRevealed: AnyPublisher<Bool, Never>? = nil,
locationNotesEnabled: Bool? = nil,
locationNotesSettings: AnyPublisher<Bool, Never>? = nil,
context: any GeoChannelContext
) {
self.locationManager = locationManager ?? Self.defaultLocationManager()
let resolvedLocationManager = locationManager ?? Self.defaultLocationManager()
self.locationManager = resolvedLocationManager
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? Self.defaultTorManager()
self.permissionState = resolvedLocationManager.permissionState
self.locationNotesEnabled = locationNotesEnabled ?? LocationNotesSettings.enabled
self.notesRevealedPublisher = notesRevealed
?? NearbyNotesCounter.shared.$revealed.eraseToAnyPublisher()
self.locationNotesSettingsPublisher = locationNotesSettings
?? NotificationCenter.default
.publisher(for: LocationNotesSettings.didChangeNotification)
.map { _ in LocationNotesSettings.enabled }
.eraseToAnyPublisher()
self.context = context
start()
@@ -97,6 +110,18 @@ final class GeoChannelCoordinator {
}
.store(in: &cancellables)
// The location-notes preference is a live privacy kill switch. It
// removes the device-derived building cell even if the session was
// previously revealed. Explicit bookmarks remain eligible below.
locationNotesSettingsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] enabled in
guard let self, self.locationNotesEnabled != enabled else { return }
self.locationNotesEnabled = enabled
self.updateSampling()
}
.store(in: &cancellables)
bookmarksStore.$bookmarks
.receive(on: DispatchQueue.main)
.sink { [weak self] bookmarks in
@@ -109,10 +134,15 @@ final class GeoChannelCoordinator {
locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self, state == .authorized else { return }
Task { @MainActor [weak self] in
self?.locationManager.refreshChannels()
guard let self else { return }
self.permissionState = state
if state == .authorized {
self.locationManager.refreshChannels()
}
// Cached channels outlive authorization by design. Recompute
// regardless of direction so revocation tears down regional
// sampling instead of continuing from stale coordinates.
self.updateSampling()
}
.store(in: &cancellables)
@@ -130,23 +160,22 @@ final class GeoChannelCoordinator {
/// the nearby-conversation hint and channel participant counts working.
/// Bookmarks are exempt: bookmarking a geohash is itself explicit.
private var sampledRegionalGeohashes: [String] {
regionalChannels
.filter { notesRevealed || $0.level != .building }
guard permissionState == .authorized else { return [] }
return regionalChannels
.filter { (notesRevealed && locationNotesEnabled) || $0.level != .building }
.map { $0.geohash }
}
private func updateSampling() {
let union = Array(Set(sampledRegionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else {
context?.endGeohashSampling()
return
}
if torManager.isForeground() {
context?.beginGeohashSampling(for: union)
} else {
context?.endGeohashSampling()
}
guard !union.isEmpty else {
context?.endGeohashSampling()
return
}
if torManager.isForeground() {
context?.beginGeohashSampling(for: union)
} else {
context?.endGeohashSampling()
}
}
@@ -60,6 +60,21 @@ final class PublicMessagePipeline {
scheduleFlush()
}
/// Discards an uncommitted row by ID. Bridge-first/radio-second dedup uses
/// this before inserting the authenticated radio copy, so the ~80 ms UI
/// batch cannot resurrect the replaced bridge alias after store removal.
func removeMessage(withID messageID: String) {
buffer.removeAll { $0.message.id == messageID }
if buffer.isEmpty {
timer?.invalidate()
timer = nil
}
}
func containsMessage(withID messageID: String) -> Bool {
buffer.contains { $0.message.id == messageID }
}
func flushIfNeeded() {
flushBuffer()
}
@@ -63,6 +63,10 @@ final class VoiceRecordingViewModel: ObservableObject {
/// live push-to-talk session when the current DM peer can hear it now.
var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() }
private var activeSession: VoiceCaptureSession?
/// Monotonic press identity. A slow permission/start/finalize task from an
/// older hold may still deliver its file, but it must never mutate the UI
/// state of a newer hold.
private var holdGeneration: UInt64 = 0
func formattedDuration(for date: Date) -> String {
let clamped = max(0, state.duration(for: date))
@@ -75,13 +79,18 @@ final class VoiceRecordingViewModel: ObservableObject {
func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return }
holdGeneration &+= 1
let generation = holdGeneration
let session = sessionProvider()
SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session)
activeSession = session
state = .requestingPermission
Task {
let granted = await session.requestPermission()
guard state == .requestingPermission, activeSession === session else { return }
guard generation == holdGeneration,
state == .requestingPermission,
activeSession === session
else { return }
guard granted else {
state = .permissionDenied
activeSession = nil
@@ -90,16 +99,36 @@ final class VoiceRecordingViewModel: ObservableObject {
state = .preparing
do {
try await session.start()
guard state == .preparing, activeSession === session else {
guard generation == holdGeneration,
state == .preparing,
activeSession === session
else {
await session.cancel()
return
}
state = .recording(startDate: Date())
isLiveStreaming = session.isLive
} catch VoiceRecorder.RecorderError.recordingInProgress {
// The previous classic hold may still be in its intentional
// finalize-padding window. This press owns no recorder, so
// its owner-scoped cancel is harmless; return to idle instead
// of surfacing a false capture failure while the prior note
// finishes and delivers normally.
SecureLogger.info("Voice recording start deferred while the previous hold finalizes", category: .session)
await session.cancel()
guard generation == holdGeneration,
state == .preparing,
activeSession === session
else { return }
activeSession = nil
state = .idle
} catch {
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
await session.cancel()
guard state == .preparing, activeSession === session else { return }
guard generation == holdGeneration,
state == .preparing,
activeSession === session
else { return }
// The live engine and the classic recorder are separate
// capture stacks: when the live one hits an audio-route
// glitch, fall back within the same hold so the user still
@@ -109,7 +138,10 @@ final class VoiceRecordingViewModel: ObservableObject {
activeSession = fallback
do {
try await fallback.start()
guard state == .preparing, activeSession === fallback else {
guard generation == holdGeneration,
state == .preparing,
activeSession === fallback
else {
await fallback.cancel()
return
}
@@ -120,7 +152,7 @@ final class VoiceRecordingViewModel: ObservableObject {
} catch {
SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session)
await fallback.cancel()
guard state == .preparing else { return }
guard generation == holdGeneration, state == .preparing else { return }
}
}
activeSession = nil
@@ -142,6 +174,7 @@ final class VoiceRecordingViewModel: ObservableObject {
state = .idle
isLiveStreaming = false
let session = activeSession
let generation = holdGeneration
activeSession = nil
guard case .recording(let startDate) = previousState, let completion, let session else {
@@ -159,7 +192,7 @@ final class VoiceRecordingViewModel: ObservableObject {
isValidRecording(at: url, duration: finalDuration) {
completion(url)
} else {
guard state == .idle else { return }
guard generation == holdGeneration, state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
+193 -49
View File
@@ -34,6 +34,9 @@ struct NoticesView: View {
/// Days until the notice fades; `permanentExpiry` (geo default) means no
/// NIP-40 tag the note stays until its relay drops it.
@State private var expiryDays: Int
/// Mirrors the app-info kill switch so its notification produces an
/// immediate presentation update as well as tearing down subscriptions.
@State private var locationNotesEnabled = LocationNotesSettings.enabled
/// Sentinel picker tag for the option (geo tab only).
private static let permanentExpiry = 0
@@ -45,6 +48,10 @@ struct NoticesView: View {
/// Acquired from `LocationNotesPool` (shared with the nearby-notes
/// counter, one REQ per geohash) and released on dismissal.
@State private var liveGeoManager: LocationNotesManager?
/// Tracks only this sheet's high-accuracy refresh ownership, so repeated
/// Combine/SwiftUI invalidations do not restart CoreLocation and a
/// revocation or kill-switch transition balances the begin call once.
@State private var ownsLiveGeoRefresh = false
init(
senderNickname: String,
@@ -73,24 +80,127 @@ struct NoticesView: View {
tab == .geo && geoGeohash != nil
}
/// Acquires (or retargets/revives) the pooled notes manager for the
/// current geo scope.
private func ensureGeoNotesManager() {
guard tab == .geo else { return }
guard notesManager == nil, let geohash = geoGeohash else { return }
if let manager = liveGeoManager {
struct GeoSessionState {
let manager: LocationNotesManager?
let ownsLiveRefresh: Bool
}
enum GeoPresentationState: Equatable {
case disabled
case locationUnavailable
case available(String)
}
static func geoPresentationState(notesEnabled: Bool, geohash: String?) -> GeoPresentationState {
guard notesEnabled else { return .disabled }
guard let geohash else { return .locationUnavailable }
return .available(geohash)
}
static func composerGeohash(tab: Tab, notesEnabled: Bool, geoGeohash: String?) -> String? {
switch tab {
case .geo:
guard case .available(let geohash) = geoPresentationState(
notesEnabled: notesEnabled,
geohash: geoGeohash
) else {
return nil
}
return geohash
case .mesh:
return ""
}
}
/// Reconciles both privacy-sensitive resources owned by the sheet: the
/// high-accuracy CoreLocation refresh and the precise notes REQ. Kept as
/// a callback-driven function so permission and kill-switch transitions
/// can be regression tested without presenting SwiftUI.
@MainActor
static func reconcileGeoSession(
tab: Tab,
needsDeviceLocation: Bool,
permissionState: LocationChannelManager.PermissionState,
notesEnabled: Bool,
geohash: String?,
manager: LocationNotesManager?,
ownsLiveRefresh: Bool,
beginLiveRefresh: () -> Void,
endLiveRefresh: () -> Void,
acquire: (String) -> LocationNotesManager,
release: (LocationNotesManager?) -> Void
) -> GeoSessionState {
let geoTabActive = tab == .geo && notesEnabled
let wantsLiveRefresh = geoTabActive && needsDeviceLocation && permissionState == .authorized
if wantsLiveRefresh != ownsLiveRefresh {
if wantsLiveRefresh {
beginLiveRefresh()
} else {
endLiveRefresh()
}
}
// A selected location channel is an explicit remote/teleported scope
// and remains usable without device permission. Only device-derived
// scope requires current authorization.
let mayUseNotes = geoTabActive &&
(!needsDeviceLocation || permissionState == .authorized)
guard mayUseNotes, let geohash else {
if manager != nil {
release(manager)
}
return GeoSessionState(manager: nil, ownsLiveRefresh: wantsLiveRefresh)
}
if let manager {
if manager.geohash != geohash.lowercased() {
// Pooled managers are shared; never retarget one in place
// release the old cell and acquire the new one.
LocationNotesPool.shared.release(manager)
liveGeoManager = LocationNotesPool.shared.acquire(geohash)
} else if manager.state == .idle {
// Revived after a cancel elsewhere; returning re-subscribes.
// Pooled managers are shared; never retarget one in place.
release(manager)
return GeoSessionState(
manager: acquire(geohash),
ownsLiveRefresh: wantsLiveRefresh
)
}
if manager.state == .idle {
manager.refresh()
}
} else {
liveGeoManager = LocationNotesPool.shared.acquire(geohash)
return GeoSessionState(manager: manager, ownsLiveRefresh: wantsLiveRefresh)
}
return GeoSessionState(
manager: acquire(geohash),
ownsLiveRefresh: wantsLiveRefresh
)
}
private func reconcileGeoSession(notesEnabled: Bool? = nil) {
let notesEnabled = notesEnabled ?? locationNotesEnabled
let next = Self.reconcileGeoSession(
tab: tab,
needsDeviceLocation: geoTabNeedsDeviceLocation,
permissionState: locationChannelsModel.permissionState,
notesEnabled: notesEnabled,
geohash: geoGeohash,
manager: liveGeoManager,
ownsLiveRefresh: ownsLiveGeoRefresh,
beginLiveRefresh: {
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
},
endLiveRefresh: { locationChannelsModel.endLiveRefresh() },
acquire: { geohash in
notesManager ?? LocationNotesPool.shared.acquire(geohash)
},
release: { manager in
guard notesManager == nil else { return }
LocationNotesPool.shared.release(manager)
}
)
// A test-injected manager is owned by the caller, not by the pool or
// this view's lifecycle state.
liveGeoManager = notesManager == nil ? next.manager : nil
ownsLiveGeoRefresh = next.ownsLiveRefresh
}
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
@@ -101,6 +211,9 @@ struct NoticesView: View {
if case .location(let channel) = locationChannelsModel.selectedChannel {
return channel.geohash
}
guard locationChannelsModel.permissionState == .authorized else {
return nil
}
return locationChannelsModel.currentBuildingGeohash
}
@@ -112,10 +225,11 @@ struct NoticesView: View {
}
private var activeGeohash: String? {
switch tab {
case .geo: return geoGeohash
case .mesh: return ""
}
Self.composerGeohash(
tab: tab,
notesEnabled: locationNotesEnabled,
geoGeohash: geoGeohash
)
}
enum Strings {
@@ -141,6 +255,8 @@ struct NoticesView: View {
static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays")
static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices")
static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices")
static let locationNotesTitle: LocalizedStringKey = "app_info.location.notes.title"
static let locationNotesDescription: LocalizedStringKey = "app_info.location.notes.description"
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays"
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
@@ -190,55 +306,48 @@ struct NoticesView: View {
#endif
.themedSheetBackground()
.onAppear {
beginGeoLocationIfNeeded()
ensureGeoNotesManager()
reconcileGeoSession()
}
.onChange(of: tab) { newTab in
if newTab == .geo {
beginGeoLocationIfNeeded()
if Self.revealsNearbyNotes(onSwitchingTo: newTab, geoGeohash: geoGeohash) {
NearbyNotesCounter.shared.reveal()
}
ensureGeoNotesManager()
} else {
locationChannelsModel.endLiveRefresh()
// Leaving the geo tab must take its REQ down with it, not
// leave the geohash subscription streaming behind the mesh
// board. The pool makes the return trip cheap (re-acquire
// revives the manager), and the dismissal release below is
// safe: `liveGeoManager` is nil until re-acquired.
LocationNotesPool.shared.release(liveGeoManager)
liveGeoManager = nil
}
reconcileGeoSession()
// Each tab keeps its natural default: geo notes stay until
// deleted (), mesh board posts fade within a week.
expiryDays = newTab == .geo ? Self.permanentExpiry : 7
urgent = false
}
// Catches permission granted from the geo tab's enable button.
// Catches both grant and revocation. Revocation must balance the live
// refresh and release a device-derived building REQ immediately.
.onChange(of: locationChannelsModel.permissionState) { _ in
beginGeoLocationIfNeeded()
reconcileGeoSession()
}
.onChange(of: geoGeohash) { _ in
ensureGeoNotesManager()
reconcileGeoSession()
}
.onChange(of: geoTabNeedsDeviceLocation) { _ in
reconcileGeoSession()
}
.onReceive(NotificationCenter.default.publisher(for: LocationNotesSettings.didChangeNotification)) { _ in
let enabled = LocationNotesSettings.enabled
locationNotesEnabled = enabled
reconcileGeoSession(notesEnabled: enabled)
}
.onDisappear {
locationChannelsModel.endLiveRefresh()
LocationNotesPool.shared.release(liveGeoManager)
if ownsLiveGeoRefresh {
locationChannelsModel.endLiveRefresh()
ownsLiveGeoRefresh = false
}
if notesManager == nil {
LocationNotesPool.shared.release(liveGeoManager)
}
liveGeoManager = nil
}
}
/// The geo tab tracks the device's building geohash while on mesh; keep
/// location fresh only in that case (a selected location channel already
/// fixes the scope).
private func beginGeoLocationIfNeeded() {
guard tab == .geo, geoTabNeedsDeviceLocation,
locationChannelsModel.permissionState == .authorized else { return }
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
@@ -288,21 +397,56 @@ struct NoticesView: View {
notesManager: nil
)
case .geo:
if let geohash = geoGeohash {
switch Self.geoPresentationState(
notesEnabled: locationNotesEnabled,
geohash: geoGeohash
) {
case .disabled:
locationNotesDisabledSection
case .available(let geohash):
if let manager = activeNotesManager {
GeoNoticesList(geohash: geohash, board: board, manager: manager)
} else {
// Manager is created on appear; visible for one frame.
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { ensureGeoNotesManager() }
.onAppear { reconcileGeoSession() }
}
} else {
case .locationUnavailable:
locationUnavailableSection
}
}
}
private var locationNotesDisabledSection: some View {
ScrollView {
Toggle(
isOn: Binding(
get: { locationNotesEnabled },
set: { enabled in
locationNotesEnabled = enabled
LocationNotesSettings.enabled = enabled
}
)
) {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.locationNotesTitle)
.bitchatFont(size: 14)
Text(Strings.locationNotesDescription)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
private var locationUnavailableSection: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {