mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:05:19 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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] ?? []
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user