mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:45:22 +00:00
Harden panic recovery and service shutdown
This commit is contained in:
@@ -21,6 +21,9 @@ final class AppChromeModel: ObservableObject {
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// The composer owns capture state above ChatViewModel. ContentView
|
||||
/// installs this hook so both panic entry points synchronously stop it.
|
||||
private var prepareForPanic: (@MainActor () -> Void)?
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
@@ -97,7 +100,12 @@ final class AppChromeModel: ObservableObject {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
|
||||
prepareForPanic = preparation
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
prepareForPanic?()
|
||||
chatViewModel.panicClearAllData()
|
||||
}
|
||||
|
||||
|
||||
@@ -107,12 +107,15 @@ final class AppRuntime: ObservableObject {
|
||||
)
|
||||
)
|
||||
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
if chatViewModel.networkActivationAllowed {
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
bindRuntimeObservers()
|
||||
NotificationDelegate.shared.runtime = self
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
guard !started else {
|
||||
checkForSharedContent()
|
||||
return
|
||||
@@ -151,12 +154,14 @@ final class AppRuntime: ObservableObject {
|
||||
}
|
||||
|
||||
func handleDidBecomeActiveNotification() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func handleMacDidBecomeActiveNotification() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
@@ -175,6 +180,7 @@ final class AppRuntime: ObservableObject {
|
||||
didEnterBackground = true
|
||||
|
||||
case .active:
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.meshService.startServices()
|
||||
TorManager.shared.setAppForeground(true)
|
||||
@@ -222,6 +228,7 @@ final class AppRuntime: ObservableObject {
|
||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
if actionIdentifier == NotificationService.waveActionID {
|
||||
chatViewModel.sendMeshWave()
|
||||
return
|
||||
@@ -273,6 +280,8 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.willRestart))
|
||||
self?.chatViewModel.handleTorWillRestart()
|
||||
}
|
||||
@@ -281,6 +290,8 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||
self?.chatViewModel.handleTorDidBecomeReady()
|
||||
}
|
||||
@@ -289,6 +300,8 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.willStart))
|
||||
self?.chatViewModel.handleTorWillStart()
|
||||
}
|
||||
@@ -297,6 +310,8 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||
}
|
||||
@@ -313,6 +328,7 @@ private extension AppRuntime {
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||
let clearSharedContent = {
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
@@ -359,7 +375,9 @@ private extension AppRuntime {
|
||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||
lastNostrRelayConnectedState = isConnected
|
||||
|
||||
guard started, becameConnected else { return }
|
||||
guard chatViewModel.networkActivationAllowed,
|
||||
started,
|
||||
becameConnected else { return }
|
||||
|
||||
let isInitialConnection = !didHandleInitialNostrConnection
|
||||
didHandleInitialNostrConnection = true
|
||||
|
||||
@@ -26,6 +26,8 @@ protocol VoiceCaptureSession: AnyObject {
|
||||
/// nothing valid was captured.
|
||||
func finish() async -> URL?
|
||||
func cancel() async
|
||||
/// Stops capture and suppresses every later send before returning.
|
||||
func panicCancelSynchronously()
|
||||
}
|
||||
|
||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||
@@ -55,6 +57,10 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||
func cancel() async {
|
||||
await recorder.cancelRecording(owner: owner)
|
||||
}
|
||||
|
||||
func panicCancelSynchronously() {
|
||||
recorder.panicCancelSynchronously(owner: owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Testable surface of the live capture engine. Production uses
|
||||
@@ -216,6 +222,13 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
}
|
||||
}
|
||||
|
||||
func panicCancelSynchronously() {
|
||||
// Do not emit a canceled packet: it would itself be pre-panic
|
||||
// conversation data racing the emergency transport reset.
|
||||
completed = true
|
||||
capture.cancel()
|
||||
}
|
||||
|
||||
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||
sendPacket(packet.encode())
|
||||
|
||||
@@ -246,6 +246,21 @@ actor VoiceRecorder {
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
/// Panic is a synchronous security boundary: the caller must know the
|
||||
/// microphone, audio-session lease, and partial file are gone before it
|
||||
/// rotates identities or deletes the media tree. VoiceRecorder is an
|
||||
/// independent actor and this cleanup path never hops to MainActor, so a
|
||||
/// short semaphore join is safe even when invoked by the UI actor.
|
||||
nonisolated
|
||||
func panicCancelSynchronously(owner: RecordingOwner) {
|
||||
let finished = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
await cancelRecording(owner: owner)
|
||||
finished.signal()
|
||||
}
|
||||
finished.wait()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -2,8 +2,116 @@ import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct PanicRecoveryIntent {
|
||||
let fileMarkerEstablished: Bool
|
||||
let externalMarkerEstablished: Bool
|
||||
|
||||
var hasDurableMarker: Bool {
|
||||
fileMarkerEstablished || externalMarkerEstablished
|
||||
}
|
||||
}
|
||||
|
||||
/// Small, dependency-injectable transaction surface used by ChatViewModel.
|
||||
/// Production persists the same intent in two independent locations before
|
||||
/// any application state is erased. Tests can inject an ephemeral operation
|
||||
/// set without touching the developer's Application Support directory.
|
||||
struct PanicRecoveryOperations {
|
||||
let isPending: () throws -> Bool
|
||||
let begin: () -> PanicRecoveryIntent
|
||||
let wipeMedia: (PanicRecoveryIntent) throws -> Void
|
||||
let complete: () throws -> Void
|
||||
|
||||
static func ephemeral(
|
||||
wipeMedia: @escaping () throws -> Void = {}
|
||||
) -> PanicRecoveryOperations {
|
||||
PanicRecoveryOperations(
|
||||
isPending: { false },
|
||||
begin: {
|
||||
PanicRecoveryIntent(
|
||||
fileMarkerEstablished: false,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in try wipeMedia() },
|
||||
complete: {}
|
||||
)
|
||||
}
|
||||
|
||||
static func live(
|
||||
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||
defaults: UserDefaults = .standard
|
||||
) -> PanicRecoveryOperations {
|
||||
let defaultsKey = "bitchat.panicResetPending"
|
||||
return PanicRecoveryOperations(
|
||||
isPending: {
|
||||
if defaults.bool(forKey: defaultsKey) {
|
||||
return true
|
||||
}
|
||||
return try fileStore.isPanicRecoveryPending()
|
||||
},
|
||||
begin: {
|
||||
defaults.set(true, forKey: defaultsKey)
|
||||
let externalMarkerEstablished =
|
||||
defaults.synchronize()
|
||||
&& defaults.bool(forKey: defaultsKey)
|
||||
|
||||
let fileMarkerEstablished: Bool
|
||||
do {
|
||||
try fileStore.markPanicRecoveryPending()
|
||||
fileMarkerEstablished = true
|
||||
} catch {
|
||||
fileMarkerEstablished = false
|
||||
SecureLogger.error(
|
||||
"Failed to persist file panic-recovery marker: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: fileMarkerEstablished,
|
||||
externalMarkerEstablished: externalMarkerEstablished
|
||||
)
|
||||
},
|
||||
wipeMedia: { intent in
|
||||
try fileStore.panicWipe(
|
||||
hasDurablePendingMarker: intent.hasDurableMarker
|
||||
)
|
||||
},
|
||||
complete: {
|
||||
// Keep the independent defaults latch until the file marker
|
||||
// has definitely cleared. Any failure therefore remains
|
||||
// visible to the next launch.
|
||||
try fileStore.completePanicRecovery()
|
||||
defaults.removeObject(forKey: defaultsKey)
|
||||
guard defaults.synchronize(),
|
||||
!defaults.bool(forKey: defaultsKey) else {
|
||||
throw BLEIncomingFileStore.PanicRecoveryError
|
||||
.externalMarkerCommitFailed
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
enum PanicRecoveryError: Error {
|
||||
case externalMarkerCommitFailed
|
||||
case markerWriteFailed(Error)
|
||||
case markerWriteAndMediaWipeFailed(
|
||||
markerError: Error,
|
||||
mediaError: Error
|
||||
)
|
||||
}
|
||||
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
/// Kept outside `files/` so deleting the media tree cannot erase the
|
||||
/// fail-closed startup decision before the full panic has committed.
|
||||
private static let panicRecoveryPendingMarkerFileName =
|
||||
".panic-recovery-pending"
|
||||
/// Compatibility with a short-lived development build that used the
|
||||
/// media-specific name for the same full-transaction latch.
|
||||
private static let legacyPanicRecoveryPendingMarkerFileName =
|
||||
".panic-media-wipe-pending"
|
||||
private static let mediaSubdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
@@ -25,28 +133,96 @@ struct BLEIncomingFileStore {
|
||||
let fileManager: FileManager
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||
|
||||
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
baseDirectory: URL? = nil,
|
||||
dateProvider: @escaping () -> Date = Date.init,
|
||||
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||
try $0.write(to: $1, options: .atomic)
|
||||
}
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
self.panicMarkerWriter = panicMarkerWriter
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
/// returning. Recreating the directory tree keeps later capture/receive
|
||||
/// paths usable without allowing a detached cleanup task to race them.
|
||||
func panicWipe() throws {
|
||||
let filesDirectory = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: filesDirectory.path) {
|
||||
try fileManager.removeItem(at: filesDirectory)
|
||||
}
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
try fileManager.createDirectory(
|
||||
at: filesDirectory.appendingPathComponent(subdirectory, isDirectory: true),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
///
|
||||
/// Marker persistence and deletion are deliberately separate error
|
||||
/// domains: even when both durable marker channels fail, deletion is
|
||||
/// still attempted before this method reports the marker failure.
|
||||
func panicWipe(
|
||||
hasDurablePendingMarker: Bool = false
|
||||
) throws {
|
||||
let markerError: Error?
|
||||
do {
|
||||
try markPanicRecoveryPending()
|
||||
markerError = nil
|
||||
} catch {
|
||||
markerError = error
|
||||
SecureLogger.error(
|
||||
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let filesDirectory = try rootDirectory()
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: filesDirectory.path) {
|
||||
try fileManager.removeItem(at: filesDirectory)
|
||||
}
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
try fileManager.createDirectory(
|
||||
at: filesDirectory.appendingPathComponent(
|
||||
subdirectory,
|
||||
isDirectory: true
|
||||
),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if let markerError {
|
||||
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
|
||||
markerError: markerError,
|
||||
mediaError: error
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if let markerError, !hasDurablePendingMarker {
|
||||
throw PanicRecoveryError.markerWriteFailed(markerError)
|
||||
}
|
||||
}
|
||||
|
||||
func markPanicRecoveryPending() throws {
|
||||
let markerURL = try panicRecoveryPendingMarkerURL()
|
||||
try fileManager.createDirectory(
|
||||
at: markerURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
try panicMarkerWriter(Data([1]), markerURL)
|
||||
}
|
||||
|
||||
func isPanicRecoveryPending() throws -> Bool {
|
||||
try panicRecoveryMarkerURLs().contains {
|
||||
fileManager.fileExists(atPath: $0.path)
|
||||
}
|
||||
}
|
||||
|
||||
func completePanicRecovery() throws {
|
||||
for markerURL in try panicRecoveryMarkerURLs()
|
||||
where fileManager.fileExists(atPath: markerURL.path) {
|
||||
try fileManager.removeItem(at: markerURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves (and creates) an incoming-media directory for callers that
|
||||
@@ -152,6 +328,27 @@ struct BLEIncomingFileStore {
|
||||
)
|
||||
}
|
||||
|
||||
private func panicRecoveryPendingMarkerURL() throws -> URL {
|
||||
try rootDirectory().appendingPathComponent(
|
||||
Self.panicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
)
|
||||
}
|
||||
|
||||
private func panicRecoveryMarkerURLs() throws -> [URL] {
|
||||
let root = try rootDirectory()
|
||||
return [
|
||||
root.appendingPathComponent(
|
||||
Self.panicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
),
|
||||
root.appendingPathComponent(
|
||||
Self.legacyPanicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
var candidate = (name ?? "")
|
||||
.replacingOccurrences(of: "\0", with: "")
|
||||
|
||||
@@ -155,6 +155,10 @@ final class BLEService: NSObject {
|
||||
private var centralManager: CBCentralManager?
|
||||
private var peripheralManager: CBPeripheralManager?
|
||||
private var characteristic: CBMutableCharacteristic?
|
||||
private let shouldInitializeBluetoothManagers: Bool
|
||||
private let panicLifecycleLock = NSLock()
|
||||
private var _isPanicSuspended: Bool
|
||||
private var panicLifecycleGeneration: UInt64 = 0
|
||||
|
||||
// MARK: - Identity
|
||||
|
||||
@@ -275,10 +279,13 @@ final class BLEService: NSObject {
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
initializeBluetoothManagers: Bool = true
|
||||
initializeBluetoothManagers: Bool = true,
|
||||
startSuspendedForPanicRecovery: Bool = false
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
|
||||
self._isPanicSuspended = startSuspendedForPanicRecovery
|
||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
self.identityManager = identityManager
|
||||
super.init()
|
||||
@@ -327,37 +334,90 @@ final class BLEService: NSObject {
|
||||
// any access from another queue (cross-queue reads use readLinkState).
|
||||
linkStateStore.assumeOwnership(of: bleQueue)
|
||||
|
||||
if initializeBluetoothManagers {
|
||||
// Initialize BLE on background queue to prevent main thread blocking.
|
||||
#if os(iOS)
|
||||
let centralOptions: [String: Any] = [
|
||||
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
||||
]
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
||||
|
||||
let peripheralOptions: [String: Any] = [
|
||||
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
||||
]
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
|
||||
#else
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||
#endif
|
||||
if !startSuspendedForPanicRecovery {
|
||||
initializeBluetoothManagersIfNeeded()
|
||||
}
|
||||
|
||||
// Single maintenance timer for all periodic tasks (dispatch-based for
|
||||
// determinism). Only run it when real Bluetooth managers exist.
|
||||
meshBackgroundEnabled = initializeBluetoothManagers
|
||||
startMaintenanceTimer()
|
||||
if !startSuspendedForPanicRecovery {
|
||||
startMaintenanceTimer()
|
||||
}
|
||||
|
||||
// Publish initial empty state
|
||||
requestPeerDataPublish()
|
||||
|
||||
// Initialize gossip sync manager
|
||||
restartGossipManager()
|
||||
if !startSuspendedForPanicRecovery {
|
||||
restartGossipManager()
|
||||
}
|
||||
}
|
||||
|
||||
private var isPanicSuspended: Bool {
|
||||
panicLifecycleLock.lock()
|
||||
defer { panicLifecycleLock.unlock() }
|
||||
return _isPanicSuspended
|
||||
}
|
||||
|
||||
private func setPanicSuspended(_ suspended: Bool) {
|
||||
panicLifecycleLock.lock()
|
||||
if suspended {
|
||||
panicLifecycleGeneration &+= 1
|
||||
}
|
||||
_isPanicSuspended = suspended
|
||||
panicLifecycleLock.unlock()
|
||||
}
|
||||
|
||||
private func capturePanicLifecycleGeneration() -> UInt64? {
|
||||
panicLifecycleLock.lock()
|
||||
defer { panicLifecycleLock.unlock() }
|
||||
return _isPanicSuspended ? nil : panicLifecycleGeneration
|
||||
}
|
||||
|
||||
private func isCurrentPanicLifecycleGeneration(_ generation: UInt64) -> Bool {
|
||||
panicLifecycleLock.lock()
|
||||
defer { panicLifecycleLock.unlock() }
|
||||
return !_isPanicSuspended && panicLifecycleGeneration == generation
|
||||
}
|
||||
|
||||
private func initializeBluetoothManagersIfNeeded() {
|
||||
guard shouldInitializeBluetoothManagers,
|
||||
centralManager == nil,
|
||||
peripheralManager == nil,
|
||||
!isPanicSuspended else { return }
|
||||
|
||||
// Initialize BLE on its dedicated delegate queue. On iOS, retain the
|
||||
// restoration identifiers even when construction was deferred by a
|
||||
// pending panic-recovery latch.
|
||||
#if os(iOS)
|
||||
let centralOptions: [String: Any] = [
|
||||
CBCentralManagerOptionRestoreIdentifierKey:
|
||||
BLEService.centralRestorationID
|
||||
]
|
||||
centralManager = CBCentralManager(
|
||||
delegate: self,
|
||||
queue: bleQueue,
|
||||
options: centralOptions
|
||||
)
|
||||
|
||||
let peripheralOptions: [String: Any] = [
|
||||
CBPeripheralManagerOptionRestoreIdentifierKey:
|
||||
BLEService.peripheralRestorationID
|
||||
]
|
||||
peripheralManager = CBPeripheralManager(
|
||||
delegate: self,
|
||||
queue: bleQueue,
|
||||
options: peripheralOptions
|
||||
)
|
||||
#else
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func restartGossipManager() {
|
||||
guard !isPanicSuspended else { return }
|
||||
// Stop existing
|
||||
gossipSyncManager?.stop()
|
||||
|
||||
@@ -416,7 +476,35 @@ final class BLEService: NSObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
func resetIdentityForPanic(currentNickname: String) {
|
||||
/// Close radio admission before application state starts disappearing.
|
||||
/// CoreBluetooth callbacks consult the same gate and cannot restart scan
|
||||
/// or advertising while the full panic transaction is incomplete.
|
||||
func suspendForPanicReset() {
|
||||
setPanicSuspended(true)
|
||||
gossipSyncManager?.stop()
|
||||
gossipSyncManager = nil
|
||||
// Wait out sends that passed admission before the gate closed. Work
|
||||
// queued behind this barrier observes `isPanicSuspended` and exits,
|
||||
// so the radio stop below is a true synchronous boundary.
|
||||
messageQueue.sync(flags: .barrier) {}
|
||||
stopServicesImmediatelyForPanic()
|
||||
clearEmergencySessionState()
|
||||
}
|
||||
|
||||
/// Reopen the radio only after media deletion and recovery-marker commit.
|
||||
func completePanicReset(restartServices: Bool) {
|
||||
setPanicSuspended(false)
|
||||
guard restartServices else { return }
|
||||
startServices()
|
||||
sendAnnounce(forceSend: true)
|
||||
}
|
||||
|
||||
func resetIdentityForPanic(
|
||||
currentNickname: String,
|
||||
restartServices: Bool = true
|
||||
) {
|
||||
gossipSyncManager?.stop()
|
||||
gossipSyncManager = nil
|
||||
messageQueue.sync(flags: .barrier) {
|
||||
pendingNoiseSessionQueues.removeAll()
|
||||
}
|
||||
@@ -460,16 +548,19 @@ final class BLEService: NSObject {
|
||||
configureNoiseServiceCallbacks(for: newNoise)
|
||||
refreshPeerIdentity()
|
||||
}
|
||||
restartGossipManager()
|
||||
|
||||
setNickname(currentNickname)
|
||||
|
||||
// Keep the transport silent until the application-level transaction
|
||||
// has also removed its media and committed both recovery markers.
|
||||
myNickname = currentNickname
|
||||
messageDeduplicator.reset()
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.selfBroadcastTracker.removeAll()
|
||||
}
|
||||
requestPeerDataPublish()
|
||||
startServices()
|
||||
if restartServices {
|
||||
restartGossipManager()
|
||||
startServices()
|
||||
sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure this runs on message queue to avoid main thread blocking
|
||||
@@ -481,6 +572,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
guard !isPanicSuspended else { return }
|
||||
|
||||
guard content.count <= maxMessageLength else {
|
||||
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
||||
@@ -562,7 +654,9 @@ final class BLEService: NSObject {
|
||||
/// `startServices()` — the latter matters after a panic reset, where
|
||||
/// `stopServices()` cancels and nils the timer.
|
||||
private func startMaintenanceTimer() {
|
||||
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
|
||||
guard !isPanicSuspended,
|
||||
meshBackgroundEnabled,
|
||||
maintenanceTimer == nil else { return }
|
||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||
repeating: TransportConfig.bleMaintenanceInterval,
|
||||
@@ -575,6 +669,12 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func startServices() {
|
||||
guard let lifecycleGeneration =
|
||||
capturePanicLifecycleGeneration() else { return }
|
||||
initializeBluetoothManagersIfNeeded()
|
||||
if gossipSyncManager == nil {
|
||||
restartGossipManager()
|
||||
}
|
||||
// Restart the maintenance timer if a prior stopServices() cancelled it
|
||||
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
||||
// and cache cleanup would never resume until app restart.
|
||||
@@ -591,7 +691,11 @@ final class BLEService: NSObject {
|
||||
// Send initial announce after services are ready
|
||||
// Use longer delay to avoid conflicts with other announces
|
||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
guard let self,
|
||||
self.isCurrentPanicLifecycleGeneration(
|
||||
lifecycleGeneration
|
||||
) else { return }
|
||||
self.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,10 +763,37 @@ final class BLEService: NSObject {
|
||||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic cannot spend its security boundary sending a signed LEAVE or
|
||||
/// pumping the main run loop. Close the radio and timers immediately;
|
||||
/// the identity/session cleanup follows synchronously.
|
||||
private func stopServicesImmediatelyForPanic() {
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingNotifications.removeAll()
|
||||
}
|
||||
|
||||
maintenanceTimer?.cancel()
|
||||
maintenanceTimer = nil
|
||||
scanDutyTimer?.cancel()
|
||||
scanDutyTimer = nil
|
||||
|
||||
centralManager?.stopScan()
|
||||
peripheralManager?.stopAdvertising()
|
||||
|
||||
let peripheralsToDisconnect = bleQueue.sync {
|
||||
linkStateStore.peripheralStates
|
||||
}
|
||||
for state in peripheralsToDisconnect {
|
||||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyDisconnectAll() {
|
||||
stopServices()
|
||||
clearEmergencySessionState()
|
||||
}
|
||||
|
||||
private func clearEmergencySessionState() {
|
||||
// Clear all sessions and peers
|
||||
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
|
||||
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
|
||||
@@ -899,6 +1030,7 @@ final class BLEService: NSObject {
|
||||
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard !self.isPanicSuspended else { return }
|
||||
guard let payload = filePacket.encode() else {
|
||||
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
|
||||
return
|
||||
@@ -935,6 +1067,7 @@ final class BLEService: NSObject {
|
||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard !self.isPanicSuspended else { return }
|
||||
guard let payload = filePacket.encode() else {
|
||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||
return
|
||||
@@ -1088,6 +1221,7 @@ final class BLEService: NSObject {
|
||||
// MARK: - Packet Broadcasting
|
||||
|
||||
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
|
||||
guard !isPanicSuspended else { return }
|
||||
// Apply route if recipient exists (centralized route application)
|
||||
let packetToSend: BitchatPacket
|
||||
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
|
||||
@@ -1169,8 +1303,10 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
|
||||
guard !isPanicSuspended else { return }
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard !self.isPanicSuspended else { return }
|
||||
let result = self.pendingNotifications.enqueue(
|
||||
data: data,
|
||||
targets: centrals,
|
||||
@@ -1271,6 +1407,7 @@ final class BLEService: NSObject {
|
||||
requireDirectPeerLink: Bool = false,
|
||||
requireNoiseAuthenticatedPeerLink: Bool = false
|
||||
) -> Bool {
|
||||
guard !isPanicSuspended else { return false }
|
||||
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
||||
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
||||
if requireNoiseAuthenticatedPeerLink {
|
||||
@@ -1421,6 +1558,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
private func flushDirectedSpool() {
|
||||
guard !isPanicSuspended else { return }
|
||||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
||||
let toSend = collectionsQueue.sync(flags: .barrier) {
|
||||
pendingDirectedRelays.drainUnexpired(
|
||||
@@ -1637,6 +1775,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
private func sendAnnounce(forceSend: Bool = false) {
|
||||
guard !isPanicSuspended else { return }
|
||||
// Throttle announces to prevent flooding
|
||||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||||
return
|
||||
@@ -1846,6 +1985,13 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
#if os(iOS)
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||||
guard !isPanicSuspended else {
|
||||
central.stopScan()
|
||||
restoredPeripherals.forEach {
|
||||
central.cancelPeripheralConnection($0)
|
||||
}
|
||||
return
|
||||
}
|
||||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||||
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
||||
@@ -1901,6 +2047,10 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
guard !isPanicSuspended else {
|
||||
central.stopScan()
|
||||
return
|
||||
}
|
||||
// Links restored as connected have no characteristic in the new
|
||||
// process; without rediscovery they sit connected-but-unusable
|
||||
// until the peer disconnects. Runs here (not willRestoreState)
|
||||
@@ -1957,7 +2107,8 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
}
|
||||
|
||||
private func startScanning() {
|
||||
guard let central = centralManager,
|
||||
guard !isPanicSuspended,
|
||||
let central = centralManager,
|
||||
central.state == .poweredOn,
|
||||
!central.isScanning else { return }
|
||||
|
||||
@@ -1978,6 +2129,7 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
guard !isPanicSuspended else { return }
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
||||
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
||||
@@ -2019,6 +2171,10 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
guard !isPanicSuspended else {
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
return
|
||||
}
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
#if os(iOS)
|
||||
@@ -2169,7 +2325,9 @@ private extension CBPeripheralState {
|
||||
|
||||
extension BLEService {
|
||||
private func tryConnectFromQueue() {
|
||||
guard let central = centralManager, central.state == .poweredOn else { return }
|
||||
guard !isPanicSuspended,
|
||||
let central = centralManager,
|
||||
central.state == .poweredOn else { return }
|
||||
|
||||
let decision = connectionScheduler.nextCandidate(
|
||||
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||||
@@ -2195,6 +2353,7 @@ extension BLEService {
|
||||
using central: CBCentralManager,
|
||||
logPrefix: String
|
||||
) {
|
||||
guard !isPanicSuspended else { return }
|
||||
let peripheral = candidate.peripheral
|
||||
let peripheralID = candidate.peripheralID
|
||||
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
||||
@@ -2376,6 +2535,7 @@ extension BLEService {
|
||||
|
||||
extension BLEService: CBPeripheralDelegate {
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||
// Retry service discovery after a delay
|
||||
@@ -2402,6 +2562,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
@@ -2449,6 +2610,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
@@ -2566,6 +2728,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
}
|
||||
|
||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||
guard !isPanicSuspended else { return }
|
||||
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
||||
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
|
||||
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
|
||||
@@ -2574,6 +2737,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||
guard !isPanicSuspended else { return }
|
||||
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||
|
||||
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
||||
@@ -2594,6 +2758,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
||||
} else {
|
||||
@@ -2617,6 +2782,12 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
|
||||
switch peripheral.state {
|
||||
case .poweredOn:
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
peripheral.removeAllServices()
|
||||
characteristic = nil
|
||||
return
|
||||
}
|
||||
// Remove all services first to ensure clean state
|
||||
peripheral.removeAllServices()
|
||||
|
||||
@@ -2677,6 +2848,12 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
|
||||
#if os(iOS)
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
peripheral.removeAllServices()
|
||||
characteristic = nil
|
||||
return
|
||||
}
|
||||
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||||
|
||||
@@ -2703,6 +2880,10 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
#endif
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
return
|
||||
}
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
@@ -2718,6 +2899,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||
guard !isPanicSuspended else { return }
|
||||
let centralUUID = central.identifier.uuidString
|
||||
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
||||
linkStateStore.addSubscribedCentral(central)
|
||||
@@ -2759,7 +2941,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||||
|
||||
// Ensure we're still advertising for other devices to find us
|
||||
if peripheral.isAdvertising == false {
|
||||
if !isPanicSuspended, peripheral.isAdvertising == false {
|
||||
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
||||
peripheral.startAdvertising(buildAdvertisementData())
|
||||
}
|
||||
@@ -2796,6 +2978,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
|
||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||||
guard !isPanicSuspended else { return }
|
||||
drainPendingNotifications(logPrefix: "✅ Sent")
|
||||
}
|
||||
|
||||
@@ -2856,6 +3039,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
for request in requests {
|
||||
peripheral.respond(to: request, withResult: .success)
|
||||
}
|
||||
guard !isPanicSuspended else { return }
|
||||
|
||||
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
||||
// Combine per-central request values by offset before decoding.
|
||||
@@ -4138,6 +4322,7 @@ extension BLEService {
|
||||
let uuid = peripheral.identifier.uuidString
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard !self.isPanicSuspended else { return }
|
||||
guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }
|
||||
|
||||
// Atomically take all pending items from the queue to avoid race conditions
|
||||
@@ -4228,7 +4413,10 @@ extension BLEService {
|
||||
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
||||
) {
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
|
||||
guard let self,
|
||||
!self.isPanicSuspended,
|
||||
let central = self.centralManager,
|
||||
central.state == .poweredOn else { return }
|
||||
let budget = TransportConfig.bleMaxCentralLinks
|
||||
- slotReserve
|
||||
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
||||
@@ -5535,6 +5723,7 @@ extension BLEService {
|
||||
// MARK: Consolidated Maintenance
|
||||
|
||||
private func performMaintenance() {
|
||||
guard !isPanicSuspended else { return }
|
||||
maintenanceCounter += 1
|
||||
lastMaintenanceAt = Date()
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject {
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||
private var pendingBroadcastTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var heartbeatGeneration: UInt64 = 0
|
||||
private var started = false
|
||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||
@@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject {
|
||||
|
||||
/// Start the service (safe to call multiple times)
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
heartbeatGeneration &+= 1
|
||||
SecureLogger.info("Presence: service starting...", category: .session)
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
|
||||
/// Stops the timer and every decorrelation task synchronously at the panic
|
||||
/// boundary. Generation checks also protect against custom sleepers that
|
||||
/// ignore task cancellation and return later.
|
||||
func stopForPanic() {
|
||||
started = false
|
||||
heartbeatGeneration &+= 1
|
||||
heartbeatTimer?.invalidate()
|
||||
heartbeatTimer = nil
|
||||
pendingBroadcastTasks.values.forEach { $0.cancel() }
|
||||
pendingBroadcastTasks.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
// Monitor location channel changes
|
||||
locationChanges
|
||||
@@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
|
||||
func handleLocationChange() {
|
||||
guard started else { return }
|
||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||
// to announce presence in the new zone, then reset the loop.
|
||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||
heartbeatTimer?.invalidate()
|
||||
|
||||
// Small delay to allow location state to settle
|
||||
let generation = heartbeatGeneration
|
||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
guard let self,
|
||||
self.started,
|
||||
self.heartbeatGeneration == generation else { return }
|
||||
self.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnectivityChange() {
|
||||
guard started else { return }
|
||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||
// If we were waiting for network, do it now
|
||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||
@@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
|
||||
func scheduleNextHeartbeat() {
|
||||
guard started else { return }
|
||||
heartbeatTimer?.invalidate()
|
||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||
let generation = heartbeatGeneration
|
||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
guard let self,
|
||||
self.started,
|
||||
self.heartbeatGeneration == generation else { return }
|
||||
self.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performHeartbeat() {
|
||||
guard started else { return }
|
||||
let generation = heartbeatGeneration
|
||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||
defer { scheduleNextHeartbeat() }
|
||||
defer {
|
||||
if started, heartbeatGeneration == generation {
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Check preconditions
|
||||
guard torIsReady() else {
|
||||
@@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
|
||||
// Launch independent task for each channel's delay
|
||||
Task { @MainActor in
|
||||
let taskID = UUID()
|
||||
let sleeper = self.sleeper
|
||||
let delay = TimeInterval.random(
|
||||
in: burstMinDelay...burstMaxDelay
|
||||
)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
let task = Task { @MainActor [weak self] in
|
||||
// Random delay for decorrelation
|
||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
await self.sleeper(nanoseconds)
|
||||
|
||||
await sleeper(nanoseconds)
|
||||
|
||||
guard let self else { return }
|
||||
guard !Task.isCancelled,
|
||||
self.started,
|
||||
self.heartbeatGeneration == generation else {
|
||||
self.pendingBroadcastTasks.removeValue(forKey: taskID)
|
||||
return
|
||||
}
|
||||
self.pendingBroadcastTasks.removeValue(forKey: taskID)
|
||||
self.broadcastPresence(for: channel.geohash)
|
||||
}
|
||||
pendingBroadcastTasks[taskID] = task
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,19 @@ final class NetworkActivationService: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Stops all internet-facing work at the synchronous panic boundary.
|
||||
/// `start()` may be called again only after the full wipe commits.
|
||||
func stopForPanic() {
|
||||
cancellables.removeAll()
|
||||
started = false
|
||||
reachabilityMonitor.stop()
|
||||
activationAllowed = false
|
||||
torAutoStartDesired = false
|
||||
relayController.disconnect()
|
||||
torController.setAutoStartAllowed(false)
|
||||
applyTorState(torDesired: false)
|
||||
}
|
||||
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
guard enabled != userTorEnabled else { return }
|
||||
userTorEnabled = enabled
|
||||
@@ -167,6 +180,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
|
||||
private func reevaluate() {
|
||||
guard started else { return }
|
||||
let allowed = effectiveAllowed()
|
||||
let torDesired = allowed && userTorEnabled
|
||||
let statusChanged = allowed != activationAllowed
|
||||
|
||||
@@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject {
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
||||
/// Begin monitoring. Idempotent.
|
||||
func start()
|
||||
/// Stop monitoring and discard pending debounce work. Idempotent.
|
||||
func stop()
|
||||
}
|
||||
|
||||
/// Pure debounce/decision logic for reachability, split out so it can be
|
||||
@@ -98,6 +100,7 @@ final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
|
||||
Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
func start() {}
|
||||
func stop() {}
|
||||
}
|
||||
|
||||
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
||||
@@ -146,6 +149,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard started else { return }
|
||||
started = false
|
||||
flushWorkItem?.cancel()
|
||||
flushWorkItem = nil
|
||||
#if canImport(Network)
|
||||
monitor?.pathUpdateHandler = nil
|
||||
monitor?.cancel()
|
||||
monitor = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Feed an observation into the debounce and publish committed changes.
|
||||
/// Exposed internally so higher layers/tests could drive it if needed.
|
||||
func ingest(reachable: Bool) {
|
||||
|
||||
@@ -89,6 +89,26 @@ import UIKit
|
||||
#endif
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct PanicNetworkLifecycle {
|
||||
let stop: @MainActor () -> Void
|
||||
let restart: @MainActor () -> Void
|
||||
|
||||
static let noop = PanicNetworkLifecycle(stop: {}, restart: {})
|
||||
|
||||
static var live: PanicNetworkLifecycle {
|
||||
PanicNetworkLifecycle(
|
||||
stop: {
|
||||
GeohashPresenceService.shared.stopForPanic()
|
||||
NetworkActivationService.shared.stopForPanic()
|
||||
},
|
||||
restart: {
|
||||
NetworkActivationService.shared.start()
|
||||
GeohashPresenceService.shared.start()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the application state and business logic for BitChat.
|
||||
/// Acts as the primary coordinator between UI components and backend services,
|
||||
/// implementing the BitchatDelegate protocol to handle network events.
|
||||
@@ -142,6 +162,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
@Published var currentColorScheme: ColorScheme = .light
|
||||
@Published var currentTheme: AppTheme = .matrix
|
||||
@Published var isConnected = false
|
||||
@Published private(set) var panicRecoveryBlocked = false
|
||||
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
|
||||
@Published var nickname: String = "" {
|
||||
didSet {
|
||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||
@@ -151,7 +173,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
return
|
||||
}
|
||||
// Update mesh service nickname if it's initialized
|
||||
if !meshService.myPeerID.isEmpty {
|
||||
if !isPanicResetting, !meshService.myPeerID.isEmpty {
|
||||
meshService.setNickname(nickname)
|
||||
}
|
||||
}
|
||||
@@ -295,7 +317,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
var nostrRelayManager: NostrRelayManager?
|
||||
private let userDefaults = UserDefaults.standard
|
||||
let keychain: KeychainManagerProtocol
|
||||
private let panicMediaWipe: () throws -> Void
|
||||
private let panicRecoveryOperations: PanicRecoveryOperations
|
||||
private let panicNetworkLifecycle: PanicNetworkLifecycle
|
||||
private var isPanicResetting = false
|
||||
/// Private group membership: keys in the keychain, metadata on disk.
|
||||
let groupStore: GroupStore
|
||||
private let nicknameKey = "bitchat.nickname"
|
||||
@@ -773,7 +797,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
locationPresenceStore: LocationPresenceStore? = nil,
|
||||
locationManager: LocationChannelManager = .shared
|
||||
) {
|
||||
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||
let livePanicRecoveryOperations = PanicRecoveryOperations.live()
|
||||
let startSuspendedForRecovery: Bool
|
||||
do {
|
||||
startSuspendedForRecovery =
|
||||
try livePanicRecoveryOperations.isPending()
|
||||
} catch {
|
||||
startSuspendedForRecovery = true
|
||||
}
|
||||
// Preserve the preflight decision used to defer CoreBluetooth. A
|
||||
// transiently successful second read must not skip recovery and leave
|
||||
// the service permanently suspended without running the wipe.
|
||||
let panicRecoveryOperations = PanicRecoveryOperations(
|
||||
isPending: {
|
||||
if startSuspendedForRecovery {
|
||||
return true
|
||||
}
|
||||
return try livePanicRecoveryOperations.isPending()
|
||||
},
|
||||
begin: livePanicRecoveryOperations.begin,
|
||||
wipeMedia: livePanicRecoveryOperations.wipeMedia,
|
||||
complete: livePanicRecoveryOperations.complete
|
||||
)
|
||||
let meshService = BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
startSuspendedForPanicRecovery: startSuspendedForRecovery
|
||||
)
|
||||
meshService.sfMetrics = .shared
|
||||
self.init(
|
||||
keychain: keychain,
|
||||
@@ -785,7 +836,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||
locationManager: locationManager,
|
||||
outboxStore: MessageOutboxStore(keychain: keychain),
|
||||
sfMetrics: .shared
|
||||
sfMetrics: .shared,
|
||||
panicRecoveryOperations: panicRecoveryOperations,
|
||||
panicNetworkLifecycle: .live
|
||||
)
|
||||
}
|
||||
|
||||
@@ -804,7 +857,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
readReceiptsDefaults: UserDefaults? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
sfMetrics: StoreAndForwardMetrics? = nil,
|
||||
panicMediaWipe: (() throws -> Void)? = nil
|
||||
panicMediaWipe: (() throws -> Void)? = nil,
|
||||
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||
) {
|
||||
let conversations = conversations ?? ConversationStore()
|
||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||
@@ -819,13 +874,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
|
||||
self.keychain = keychain
|
||||
self.panicMediaWipe = panicMediaWipe ?? {
|
||||
// Unit tests share the developer's real Application Support
|
||||
// directory. Production uses the managed store; tests that need
|
||||
// to exercise the wipe inject a temporary-directory closure.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
try BLEIncomingFileStore().panicWipe()
|
||||
}
|
||||
self.panicRecoveryOperations = panicRecoveryOperations
|
||||
?? .ephemeral(wipeMedia: panicMediaWipe ?? {})
|
||||
self.panicNetworkLifecycle = panicNetworkLifecycle
|
||||
self.groupStore = GroupStore(keychain: keychain)
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
@@ -861,7 +912,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
ChatViewModelBootstrapper(viewModel: self).configure()
|
||||
let recoveryRequired: Bool
|
||||
do {
|
||||
recoveryRequired = try self.panicRecoveryOperations.isPending()
|
||||
} catch {
|
||||
// Failure to read the latch cannot fail open. Re-run the complete
|
||||
// transaction; a persistent storage failure leaves services
|
||||
// blocked below.
|
||||
recoveryRequired = true
|
||||
SecureLogger.error(
|
||||
"Could not read panic-recovery state; retrying the full wipe before startup: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
|
||||
if recoveryRequired {
|
||||
SecureLogger.warning(
|
||||
"Pending panic recovery detected; wiping before runtime services start",
|
||||
category: .security
|
||||
)
|
||||
_ = panicClearAllData(restartServices: false)
|
||||
}
|
||||
|
||||
if networkActivationAllowed {
|
||||
ChatViewModelBootstrapper(viewModel: self).configure()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deinitialization
|
||||
@@ -1165,8 +1240,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
// PANIC: Emergency data clearing for activist safety
|
||||
@MainActor
|
||||
func panicClearAllData() {
|
||||
// Messages are processed immediately - nothing to flush
|
||||
@discardableResult
|
||||
func panicClearAllData(restartServices: Bool = true) -> Bool {
|
||||
panicRecoveryBlocked = true
|
||||
isPanicResetting = true
|
||||
defer { isPanicResetting = false }
|
||||
|
||||
// Stop internet and location-presence work before clearing identity or
|
||||
// state. These services cancel their subscriptions and delayed tasks,
|
||||
// so old callbacks cannot reconnect during the transaction.
|
||||
panicNetworkLifecycle.stop()
|
||||
|
||||
// Establish both independent durable intents before erasing anything.
|
||||
// `wipeMedia` will still attempt deletion if neither write succeeds.
|
||||
let recoveryIntent = panicRecoveryOperations.begin()
|
||||
|
||||
// Quiesce the mesh before clearing stores. Identity replacement below
|
||||
// deliberately stays stopped until media deletion and marker commit.
|
||||
if let bleService = meshService as? BLEService {
|
||||
bleService.suspendForPanicReset()
|
||||
} else {
|
||||
meshService.emergencyDisconnectAll()
|
||||
}
|
||||
|
||||
// Invalidate detached media preparation and close live capture file
|
||||
// handles before clearing state or removing the media directory.
|
||||
@@ -1180,7 +1275,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
pendingGeohashSystemMessages.removeAll()
|
||||
|
||||
// Delete all keychain data (including Noise and Nostr keys)
|
||||
_ = keychain.deleteAllKeychainData()
|
||||
let keychainWipeCompleted = keychain.deleteAllKeychainData()
|
||||
if !keychainWipeCompleted {
|
||||
SecureLogger.error(
|
||||
"Panic keychain cleanup incomplete; recovery remains pending",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
|
||||
// Clear UserDefaults identity data
|
||||
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
||||
@@ -1193,7 +1294,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
// Reset nickname to anonymous
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
saveNickname()
|
||||
userDefaults.set(nickname, forKey: nicknameKey)
|
||||
|
||||
// Clear favorites and peer mappings
|
||||
// Clear through SecureIdentityStateManager instead of directly
|
||||
@@ -1265,46 +1366,43 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Clear Nostr identity associations
|
||||
idBridge.clearAllAssociations()
|
||||
|
||||
// Disconnect from all peers and clear persistent identity
|
||||
// This will force creation of a new identity (new fingerprint) on next launch
|
||||
meshService.emergencyDisconnectAll()
|
||||
// Replace the BLE identity while keeping the radio stopped. It may
|
||||
// reopen only after the durable panic transaction commits.
|
||||
if let bleService = meshService as? BLEService {
|
||||
bleService.resetIdentityForPanic(currentNickname: nickname)
|
||||
}
|
||||
|
||||
// No need to force UserDefaults synchronization
|
||||
|
||||
// Reinitialize Nostr with new identity
|
||||
// This will generate new Nostr keys derived from new Noise keys.
|
||||
// Skipped under tests: connecting the shared relay singleton starts
|
||||
// real network/reconnect work that never completes and would keep the
|
||||
// test process alive (the singleton, unlike a discardable instance, is
|
||||
// never deallocated to cancel it).
|
||||
if !TestEnvironment.isRunningTests {
|
||||
Task { @MainActor in
|
||||
// Small delay to ensure cleanup completes
|
||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||
|
||||
// Reinitialize Nostr relay manager with new identity. Reuse the
|
||||
// shared singleton — every other component (NostrTransport, geohash
|
||||
// subscriptions, AppRuntime observers) is bound to `.shared`, so
|
||||
// creating a fresh instance here would split relay state and leave
|
||||
// sends running against a disconnected manager.
|
||||
nostrRelayManager = NostrRelayManager.shared
|
||||
setupNostrMessageHandling()
|
||||
nostrRelayManager?.connect()
|
||||
}
|
||||
bleService.resetIdentityForPanic(
|
||||
currentNickname: nickname,
|
||||
restartServices: false
|
||||
)
|
||||
} else {
|
||||
meshService.setNickname(nickname)
|
||||
}
|
||||
|
||||
// The wipe must finish before this security action returns. A detached
|
||||
// task could otherwise lose a race with a new capture or app exit and
|
||||
// leave pre-panic media behind.
|
||||
let panicCompleted: Bool
|
||||
do {
|
||||
try panicMediaWipe()
|
||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||
try panicRecoveryOperations.wipeMedia(recoveryIntent)
|
||||
if keychainWipeCompleted {
|
||||
try panicRecoveryOperations.complete()
|
||||
panicCompleted = true
|
||||
SecureLogger.info(
|
||||
"🗑️ Deleted all media files during panic clear",
|
||||
category: .session
|
||||
)
|
||||
} else {
|
||||
// Do not clear either durable recovery marker. Startup must
|
||||
// retry the entire transaction before any transport restarts.
|
||||
panicCompleted = false
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||
panicCompleted = false
|
||||
SecureLogger.error(
|
||||
"Panic transaction did not commit; services remain stopped: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
panicRecoveryBlocked = !panicCompleted
|
||||
|
||||
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
|
||||
// the host user's real cache tree just as the default media wipe does.
|
||||
@@ -1314,9 +1412,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
}
|
||||
#endif
|
||||
|
||||
// Force immediate UI update for panic mode
|
||||
// UI updates immediately - no flushing needed
|
||||
guard panicCompleted else { return false }
|
||||
|
||||
if let bleService = meshService as? BLEService {
|
||||
// Startup recovery reopens admission but leaves actual service
|
||||
// start to the bootstrapper immediately after this method.
|
||||
bleService.completePanicReset(
|
||||
restartServices: restartServices
|
||||
)
|
||||
}
|
||||
|
||||
if restartServices {
|
||||
// All persistent state and media are gone. Bring each service back
|
||||
// only now, under the new identity.
|
||||
if !(meshService is BLEService) {
|
||||
meshService.startServices()
|
||||
}
|
||||
|
||||
if !TestEnvironment.isRunningTests {
|
||||
nostrRelayManager = NostrRelayManager.shared
|
||||
setupNostrMessageHandling()
|
||||
}
|
||||
panicNetworkLifecycle.restart()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
||||
|
||||
@@ -188,8 +188,25 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
|
||||
Task {
|
||||
let finalDuration = Date().timeIntervalSince(startDate)
|
||||
if let url = await session.finish(),
|
||||
isValidRecording(at: url, duration: finalDuration) {
|
||||
if let url = await session.finish() {
|
||||
// Panic and a newer hold both invalidate this completion.
|
||||
// Never route an old recording using a post-panic target.
|
||||
guard generation == holdGeneration else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return
|
||||
}
|
||||
guard isValidRecording(
|
||||
at: url,
|
||||
duration: finalDuration
|
||||
) else {
|
||||
guard state == .idle else { return }
|
||||
state = .error(
|
||||
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||
? "Recording is too short."
|
||||
: "Recording failed to save."
|
||||
)
|
||||
return
|
||||
}
|
||||
completion(url)
|
||||
} else {
|
||||
guard generation == holdGeneration, state == .idle else { return }
|
||||
@@ -206,6 +223,17 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
finish(completion: nil)
|
||||
}
|
||||
|
||||
/// Invalidates in-flight permission/start/finalize callbacks and tears
|
||||
/// down an active microphone before the panic transaction continues.
|
||||
func panicWipe() {
|
||||
holdGeneration &+= 1
|
||||
let session = activeSession
|
||||
activeSession = nil
|
||||
state = .idle
|
||||
isLiveStreaming = false
|
||||
session?.panicCancelSynchronously()
|
||||
}
|
||||
|
||||
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let fileSize = attributes[.size] as? NSNumber,
|
||||
|
||||
@@ -79,6 +79,9 @@ struct ContentView: View {
|
||||
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
||||
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
||||
}
|
||||
appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in
|
||||
voiceRecordingVM?.panicWipe()
|
||||
}
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async {
|
||||
isNicknameFieldFocused = false
|
||||
@@ -229,6 +232,7 @@ struct ContentView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
appChromeModel.setPanicPreparation(nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user