mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 15:05:24 +00:00
Make panic wipe deterministic and device-bound
This commit is contained in:
@@ -4,6 +4,14 @@ import Foundation
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
private static let mediaSubdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
|
||||
/// Name prefix of in-flight live voice captures (progressively written by
|
||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||
@@ -24,6 +32,23 @@ struct BLEIncomingFileStore {
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
/// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves (and creates) an incoming-media directory for callers that
|
||||
/// write progressively instead of via `save` (live voice captures).
|
||||
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||
@@ -113,15 +138,18 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func rootDirectory() throws -> URL {
|
||||
try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
|
||||
@@ -11,6 +11,13 @@ import BitFoundation
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainInstallLifecycleAction: Equatable {
|
||||
case markerPresent
|
||||
case bootstrapMarker
|
||||
case clearStaleKeys
|
||||
case retryLater
|
||||
}
|
||||
|
||||
final class KeychainManager: KeychainManagerProtocol {
|
||||
/// Default keychain for components that construct their own rather than
|
||||
/// having one injected. Under test this is an in-memory keychain: the
|
||||
@@ -41,27 +48,80 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
// Use consistent service name for all keychain items
|
||||
private let service = BitchatApp.bundleID
|
||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||
private static let installMarkerAccount = "install_lifecycle_marker"
|
||||
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
|
||||
|
||||
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||
// device locked (identity-cache saves failed with -25308 throughout
|
||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||
// restoration must be able to read the noise keys before the user
|
||||
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
||||
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
||||
// migrating through device backups onto a second device.
|
||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
reconcileInstallLifecycle()
|
||||
migrateAccessibilityIfNeeded()
|
||||
#endif
|
||||
}
|
||||
|
||||
static func installLifecycleAction(
|
||||
containerKnowsMarker: Bool,
|
||||
markerRead: KeychainReadResult
|
||||
) -> KeychainInstallLifecycleAction {
|
||||
switch markerRead {
|
||||
case .success:
|
||||
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
|
||||
case .itemNotFound:
|
||||
return .bootstrapMarker
|
||||
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
|
||||
return .retryLater
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
/// Keychain items can survive app removal while the app container and its
|
||||
/// UserDefaults do not. The first version carrying this marker bootstraps
|
||||
/// without deleting existing users' identities. On a later reinstall, a
|
||||
/// surviving keychain marker plus a missing defaults marker proves the app
|
||||
/// container was replaced, so stale secrets are removed before use.
|
||||
private func reconcileInstallLifecycle() {
|
||||
let defaults = UserDefaults.standard
|
||||
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
|
||||
switch Self.installLifecycleAction(
|
||||
containerKnowsMarker: containerKnowsMarker,
|
||||
markerRead: markerRead
|
||||
) {
|
||||
case .markerPresent:
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
case .bootstrapMarker:
|
||||
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
}
|
||||
|
||||
case .clearStaleKeys:
|
||||
_ = deleteAllKeychainData()
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
|
||||
case .retryLater:
|
||||
// Do not guess that a temporarily unreadable marker is absent.
|
||||
// Leaving the defaults flag unchanged lets a later construction
|
||||
// retry once protected keychain data is available.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||
/// the right class on their own (saves are delete-then-add), but the
|
||||
/// long-lived identity keys are written once and would otherwise stay
|
||||
/// unreadable while the device is locked.
|
||||
private func migrateAccessibilityIfNeeded() {
|
||||
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
||||
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||
|
||||
let query: [String: Any] = [
|
||||
@@ -76,7 +136,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
case errSecSuccess, errSecItemNotFound:
|
||||
// Nothing to migrate on a fresh install; both are terminal.
|
||||
UserDefaults.standard.set(true, forKey: flag)
|
||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
|
||||
default:
|
||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||
// leave the flag unset so the next launch retries.
|
||||
@@ -491,6 +551,15 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
}
|
||||
|
||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
||||
|
||||
#if os(iOS)
|
||||
// The non-secret marker is intentionally recreated after a panic so a
|
||||
// later uninstall/reinstall can still be distinguished from an in-place
|
||||
// upgrade. It never restores any wiped identity or relationship data.
|
||||
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
|
||||
UserDefaults.standard.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
}
|
||||
#endif
|
||||
|
||||
return totalDeleted > 0
|
||||
}
|
||||
@@ -526,18 +595,39 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||
var query: [String: Any] = [
|
||||
let primaryKeyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
var addQuery = primaryKeyQuery
|
||||
addQuery.merge([
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
||||
kSecAttrSynchronizable as String: false
|
||||
]) { _, new in new }
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
// Delete by the item's primary key only. Value/accessibility fields
|
||||
// are add attributes, not valid selectors for replacing an existing
|
||||
// item; including them can leave the old item in place and make the
|
||||
// subsequent add fail as a duplicate.
|
||||
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(deleteStatus)),
|
||||
context: "Unable to replace custom-service keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
return
|
||||
}
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(addStatus)),
|
||||
context: "Unable to save custom-service keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load data from a custom service
|
||||
|
||||
@@ -226,6 +226,21 @@ final class ChatLiveVoiceCoordinator {
|
||||
assemblies.values.contains { $0.messageID == message.id }
|
||||
}
|
||||
|
||||
/// Stop every live file handle/player before the panic media directory is
|
||||
/// removed. This prevents an in-flight assembly from continuing to write
|
||||
/// through an unlinked file after the wipe returns.
|
||||
func resetForPanic() {
|
||||
for assembly in Array(assemblies.values) {
|
||||
cancelAssembly(assembly)
|
||||
}
|
||||
for player in drainingPlayers.values {
|
||||
player.stop()
|
||||
}
|
||||
drainingPlayers.removeAll(keepingCapacity: false)
|
||||
finishedBursts.removeAll(keepingCapacity: false)
|
||||
updatePublicTalkerIndicator()
|
||||
}
|
||||
|
||||
/// Called for every inbound private message: when it is the finalized
|
||||
/// voice note of a burst we assembled (matched by burst ID in the file
|
||||
/// name), swap it into the existing live bubble and report `true` so the
|
||||
|
||||
@@ -78,6 +78,7 @@ final class ChatMediaTransferCoordinator {
|
||||
|
||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||
private var preparationGeneration: UInt64 = 0
|
||||
|
||||
init(context: any ChatMediaTransferContext) {
|
||||
self.context = context
|
||||
@@ -98,13 +99,14 @@ final class ChatMediaTransferCoordinator {
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
let generation = preparationGeneration
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
do {
|
||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
@@ -116,13 +118,13 @@ final class ChatMediaTransferCoordinator {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
@@ -132,11 +134,15 @@ final class ChatMediaTransferCoordinator {
|
||||
#if os(iOS)
|
||||
func processThenSendImage(_ image: UIImage?) {
|
||||
guard let image else { return }
|
||||
let generation = preparationGeneration
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: processedURL)
|
||||
return
|
||||
}
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
@@ -147,11 +153,15 @@ final class ChatMediaTransferCoordinator {
|
||||
#elseif os(macOS)
|
||||
func processThenSendImage(from url: URL?) {
|
||||
guard let url else { return }
|
||||
let generation = preparationGeneration
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: processedURL)
|
||||
return
|
||||
}
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
@@ -170,6 +180,7 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
let targetPeer = context.selectedPrivateChatPeer
|
||||
let generation = preparationGeneration
|
||||
|
||||
do {
|
||||
try ImageUtils.validateImageSource(at: sourceURL)
|
||||
@@ -184,7 +195,10 @@ final class ChatMediaTransferCoordinator {
|
||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else {
|
||||
try? FileManager.default.removeItem(at: prepared.outputURL)
|
||||
return
|
||||
}
|
||||
let message = self.enqueueMediaMessage(
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||
targetPeer: targetPeer
|
||||
@@ -201,13 +215,13 @@ final class ChatMediaTransferCoordinator {
|
||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.context.addSystemMessage("Image is too large to send.")
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let self, self.preparationGeneration == generation else { return }
|
||||
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||
}
|
||||
}
|
||||
@@ -341,6 +355,19 @@ final class ChatMediaTransferCoordinator {
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
/// Invalidates detached preparation work and cancels every transfer that
|
||||
/// reached the transport. Stale tasks check the generation before they
|
||||
/// can recreate a message or send after a panic wipe.
|
||||
func resetForPanic() {
|
||||
preparationGeneration &+= 1
|
||||
let transferIDs = Set(transferIdToMessageIDs.keys)
|
||||
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
||||
messageIDToTransferId.removeAll(keepingCapacity: false)
|
||||
for transferID in transferIDs {
|
||||
context.cancelTransfer(transferID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatMediaTransferCoordinator {
|
||||
|
||||
@@ -177,7 +177,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
|
||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
|
||||
context: self,
|
||||
sweepsOnInit: !TestEnvironment.isRunningTests
|
||||
)
|
||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||
@@ -292,6 +295,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
var nostrRelayManager: NostrRelayManager?
|
||||
private let userDefaults = UserDefaults.standard
|
||||
let keychain: KeychainManagerProtocol
|
||||
private let panicMediaWipe: () throws -> Void
|
||||
/// Private group membership: keys in the keychain, metadata on disk.
|
||||
let groupStore: GroupStore
|
||||
private let nicknameKey = "bitchat.nickname"
|
||||
@@ -799,7 +803,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
locationManager: LocationChannelManager = .shared,
|
||||
readReceiptsDefaults: UserDefaults? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
sfMetrics: StoreAndForwardMetrics? = nil
|
||||
sfMetrics: StoreAndForwardMetrics? = nil,
|
||||
panicMediaWipe: (() throws -> Void)? = nil
|
||||
) {
|
||||
let conversations = conversations ?? ConversationStore()
|
||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||
@@ -814,6 +819,13 @@ 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.groupStore = GroupStore(keychain: keychain)
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
@@ -1156,6 +1168,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
func panicClearAllData() {
|
||||
// Messages are processed immediately - nothing to flush
|
||||
|
||||
// Invalidate detached media preparation and close live capture file
|
||||
// handles before clearing state or removing the media directory.
|
||||
mediaTransferCoordinator.resetForPanic()
|
||||
liveVoiceCoordinator.resetForPanic()
|
||||
|
||||
// Clear all messages (public timelines and private chats live in the
|
||||
// single-writer ConversationStore; the derived `messages` view and
|
||||
// the legacy mirror empty with it)
|
||||
@@ -1279,44 +1296,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
}
|
||||
}
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
Task.detached(priority: .utility) {
|
||||
// Skipped under tests: the test process shares the user's real
|
||||
// ~/Library/Application Support/files tree, and this detached
|
||||
// utility-priority wipe fires at a nondeterministic time —
|
||||
// deleting media that concurrently running tests (e.g. the
|
||||
// sendImage flow) just wrote there, and the developer's real
|
||||
// app data with it.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
|
||||
// Delete the entire files directory and recreate it
|
||||
if FileManager.default.fileExists(atPath: filesDir.path) {
|
||||
try FileManager.default.removeItem(at: filesDir)
|
||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||
}
|
||||
|
||||
// Recreate empty directory structure
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||
}
|
||||
|
||||
// BCH-01-013: Clear iOS app switcher snapshots
|
||||
// These are stored in Library/Caches/Snapshots/<bundle_id>/
|
||||
#if os(iOS)
|
||||
Self.clearAppSwitcherSnapshots()
|
||||
#endif
|
||||
// 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.
|
||||
do {
|
||||
try panicMediaWipe()
|
||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||
}
|
||||
|
||||
// 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.
|
||||
#if os(iOS)
|
||||
if !TestEnvironment.isRunningTests {
|
||||
Self.clearAppSwitcherSnapshots()
|
||||
}
|
||||
#endif
|
||||
|
||||
// Force immediate UI update for panic mode
|
||||
// UI updates immediately - no flushing needed
|
||||
|
||||
|
||||
Reference in New Issue
Block a user