mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 08:25:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce7532c02d | ||
|
|
8b8ad2893e | ||
|
|
79b921aeb9 |
+3
-3
@@ -17,8 +17,8 @@ bitchat is designed for private, account-free communication. This policy describ
|
|||||||
|
|
||||||
1. **Identity and cryptographic keys**
|
1. **Identity and cryptographic keys**
|
||||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||||
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||||
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
|
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
|
||||||
|
|
||||||
2. **Nickname, preferences, and relationships**
|
2. **Nickname, preferences, and relationships**
|
||||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||||
@@ -121,7 +121,7 @@ No cryptographic system can protect content after a recipient reads, copies, scr
|
|||||||
|
|
||||||
## Your Controls
|
## Your Controls
|
||||||
|
|
||||||
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||||
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
||||||
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
||||||
- **No account:** The project operates no account record for you to request or export.
|
- **No account:** The project operates no account record for you to request or export.
|
||||||
|
|||||||
@@ -39,15 +39,17 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
@Published private(set) var messages: [BitchatMessage] = []
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
@Published private(set) var isUnread: Bool = false
|
@Published private(set) var isUnread: Bool = false
|
||||||
|
|
||||||
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
||||||
/// delivery-status lookup. Kept in sync on every mutation:
|
/// dedup and delivery-status lookup. Logical indexes are physical array
|
||||||
/// - tail append: single insert
|
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
||||||
/// - out-of-order insert: suffix reindex from the insertion point
|
/// instead of rewriting every surviving dictionary entry. This matters
|
||||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
/// after the 1337-message cap is reached, when every steady-state tail
|
||||||
/// rebuild does not change the asymptotics, and trim only happens once
|
/// append evicts one old row.
|
||||||
/// the cap (1337) is reached. Simple and correct beats the
|
///
|
||||||
/// offset-tracking alternative here.
|
/// Out-of-order inserts and middle removals still reindex only the
|
||||||
|
/// affected suffix. Full filtering resets the offset while rebuilding.
|
||||||
private var indexByMessageID: [String: Int] = [:]
|
private var indexByMessageID: [String: Int] = [:]
|
||||||
|
private var indexOffset = 0
|
||||||
|
|
||||||
fileprivate init(id: ConversationID, cap: Int) {
|
fileprivate init(id: ConversationID, cap: Int) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -61,7 +63,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func message(withID messageID: String) -> BitchatMessage? {
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
guard let index = indexByMessageID[messageID] else { return nil }
|
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||||
return messages[index]
|
return messages[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
reindex(from: index)
|
reindex(from: index)
|
||||||
} else {
|
} else {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = messages.count - 1
|
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
@@ -111,7 +113,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// timeline position (in-place updates like media progress reuse the
|
/// timeline position (in-place updates like media progress reuse the
|
||||||
/// original timestamp); a new message goes through ordered insertion.
|
/// original timestamp); a new message goes through ordered insertion.
|
||||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||||
if let index = indexByMessageID[message.id] {
|
if let index = physicalIndex(forMessageID: message.id) {
|
||||||
messages[index] = message
|
messages[index] = message
|
||||||
return .updated
|
return .updated
|
||||||
}
|
}
|
||||||
@@ -125,7 +127,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||||
/// Returns `true` when the status was applied.
|
/// Returns `true` when the status was applied.
|
||||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let index = indexByMessageID[messageID] else { return false }
|
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||||
let message = messages[index]
|
let message = messages[index]
|
||||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||||
|
|
||||||
@@ -142,7 +144,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// observers still need an @Published emission to re-render.
|
/// observers still need an @Published emission to re-render.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||||
guard let index = indexByMessageID[messageID] else { return false }
|
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||||
messages[index] = messages[index]
|
messages[index] = messages[index]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -157,10 +159,14 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// Removes a single message by ID. Returns the removed message, or
|
/// Removes a single message by ID. Returns the removed message, or
|
||||||
/// `nil` when no message with that ID exists.
|
/// `nil` when no message with that ID exists.
|
||||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||||
guard let index = indexByMessageID[messageID] else { return nil }
|
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||||
let removed = messages.remove(at: index)
|
let removed = messages.remove(at: index)
|
||||||
indexByMessageID.removeValue(forKey: messageID)
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
reindex(from: index)
|
if index == 0 {
|
||||||
|
indexOffset += 1
|
||||||
|
} else {
|
||||||
|
reindex(from: index)
|
||||||
|
}
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +183,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
for id in removedIDs {
|
for id in removedIDs {
|
||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
|
indexOffset = 0
|
||||||
reindex(from: 0)
|
reindex(from: 0)
|
||||||
return removedIDs
|
return removedIDs
|
||||||
}
|
}
|
||||||
@@ -184,6 +191,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
fileprivate func clearMessages() {
|
fileprivate func clearMessages() {
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
indexByMessageID.removeAll()
|
indexByMessageID.removeAll()
|
||||||
|
indexOffset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Diagnostics
|
// MARK: Diagnostics
|
||||||
@@ -205,9 +213,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
let message = messages[position]
|
let message = messages[position]
|
||||||
// Count equality + every message resolving to its own position
|
// Count equality + every message resolving to its own position
|
||||||
// proves the index is exactly the inverse map (no stale extras).
|
// proves the index is exactly the inverse map (no stale extras).
|
||||||
if let index = indexByMessageID[message.id] {
|
if let logicalIndex = indexByMessageID[message.id] {
|
||||||
if index != position {
|
let expectedIndex = indexOffset + position
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
if logicalIndex != expectedIndex {
|
||||||
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||||
@@ -269,10 +278,17 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
private func reindex(from start: Int) {
|
private func reindex(from start: Int) {
|
||||||
for index in start..<messages.count {
|
for index in start..<messages.count {
|
||||||
indexByMessageID[messages[index].id] = index
|
indexByMessageID[messages[index].id] = indexOffset + index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
||||||
|
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
||||||
|
let index = logicalIndex - indexOffset
|
||||||
|
guard messages.indices.contains(index) else { return nil }
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||||
private func trimIfNeeded() -> [String] {
|
private func trimIfNeeded() -> [String] {
|
||||||
guard messages.count > cap else { return [] }
|
guard messages.count > cap else { return [] }
|
||||||
@@ -282,7 +298,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
messages.removeFirst(overflow)
|
messages.removeFirst(overflow)
|
||||||
reindex(from: 0)
|
indexOffset += overflow
|
||||||
return trimmedIDs
|
return trimmedIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,8 +860,8 @@ extension Conversation {
|
|||||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||||
func _testCorruptIndexEntries() {
|
func _testCorruptIndexEntries() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
indexByMessageID[messages[0].id] = 1
|
indexByMessageID[messages[0].id] = indexOffset + 1
|
||||||
indexByMessageID[messages[1].id] = 0
|
indexByMessageID[messages[1].id] = indexOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||||
@@ -859,8 +875,8 @@ extension Conversation {
|
|||||||
func _testCorruptOrderingPreservingIndex() {
|
func _testCorruptOrderingPreservingIndex() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
messages.swapAt(0, messages.count - 1)
|
messages.swapAt(0, messages.count - 1)
|
||||||
indexByMessageID[messages[0].id] = 0
|
indexByMessageID[messages[0].id] = indexOffset
|
||||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,7 +916,7 @@ extension ConversationStore {
|
|||||||
extension Conversation {
|
extension Conversation {
|
||||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = messages.count - 1
|
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import Foundation
|
|||||||
|
|
||||||
struct BLEIncomingFileStore {
|
struct BLEIncomingFileStore {
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
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
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
@@ -32,23 +24,6 @@ struct BLEIncomingFileStore {
|
|||||||
self.dateProvider = dateProvider
|
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
|
/// Resolves (and creates) an incoming-media directory for callers that
|
||||||
/// write progressively instead of via `save` (live voice captures).
|
/// write progressively instead of via `save` (live voice captures).
|
||||||
func incomingDirectory(subdirectory: String) throws -> URL {
|
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||||
@@ -138,18 +113,15 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func filesDirectory() throws -> URL {
|
private func filesDirectory() throws -> URL {
|
||||||
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
let root = try baseDirectory ?? fileManager.url(
|
||||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return filesDir
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rootDirectory() throws -> URL {
|
|
||||||
try baseDirectory ?? fileManager.url(
|
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
in: .userDomainMask,
|
in: .userDomainMask,
|
||||||
appropriateFor: nil,
|
appropriateFor: nil,
|
||||||
create: true
|
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 {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
|||||||
@@ -11,13 +11,6 @@ import BitFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
enum KeychainInstallLifecycleAction: Equatable {
|
|
||||||
case markerPresent
|
|
||||||
case bootstrapMarker
|
|
||||||
case clearStaleKeys
|
|
||||||
case retryLater
|
|
||||||
}
|
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
/// Default keychain for components that construct their own rather than
|
/// Default keychain for components that construct their own rather than
|
||||||
/// having one injected. Under test this is an in-memory keychain: the
|
/// having one injected. Under test this is an in-memory keychain: the
|
||||||
@@ -48,80 +41,27 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||||
// device locked (identity-cache saves failed with -25308 throughout
|
// device locked (identity-cache saves failed with -25308 throughout
|
||||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||||
// restoration must be able to read the noise keys before the user
|
// restoration must be able to read the noise keys before the user
|
||||||
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
||||||
// migrating through device backups onto a second device.
|
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
||||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
reconcileInstallLifecycle()
|
|
||||||
migrateAccessibilityIfNeeded()
|
migrateAccessibilityIfNeeded()
|
||||||
#endif
|
#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)
|
#if os(iOS)
|
||||||
|
|
||||||
private static let installMarkerAccount = "install_lifecycle_marker"
|
|
||||||
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
|
|
||||||
|
|
||||||
/// 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
|
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||||
/// the right class on their own (saves are delete-then-add), but the
|
/// the right class on their own (saves are delete-then-add), but the
|
||||||
/// long-lived identity keys are written once and would otherwise stay
|
/// long-lived identity keys are written once and would otherwise stay
|
||||||
/// unreadable while the device is locked.
|
/// unreadable while the device is locked.
|
||||||
private func migrateAccessibilityIfNeeded() {
|
private func migrateAccessibilityIfNeeded() {
|
||||||
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
||||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||||
|
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
@@ -136,7 +76,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
case errSecSuccess, errSecItemNotFound:
|
case errSecSuccess, errSecItemNotFound:
|
||||||
// Nothing to migrate on a fresh install; both are terminal.
|
// Nothing to migrate on a fresh install; both are terminal.
|
||||||
UserDefaults.standard.set(true, forKey: flag)
|
UserDefaults.standard.set(true, forKey: flag)
|
||||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
|
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
||||||
default:
|
default:
|
||||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||||
// leave the flag unset so the next launch retries.
|
// leave the flag unset so the next launch retries.
|
||||||
@@ -551,15 +491,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
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
|
return totalDeleted > 0
|
||||||
}
|
}
|
||||||
@@ -595,39 +526,18 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Save data with a custom service name
|
/// Save data with a custom service name
|
||||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||||
let primaryKeyQuery: [String: Any] = [
|
var query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
kSecAttrAccount as String: key
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
]
|
]
|
||||||
var addQuery = primaryKeyQuery
|
if let accessible = accessible {
|
||||||
addQuery.merge([
|
query[kSecAttrAccessible as String] = accessible
|
||||||
kSecValueData as String: data,
|
}
|
||||||
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
|
||||||
kSecAttrSynchronizable as String: false
|
|
||||||
]) { _, new in new }
|
|
||||||
|
|
||||||
// Delete by the item's primary key only. Value/accessibility fields
|
SecItemDelete(query as CFDictionary)
|
||||||
// are add attributes, not valid selectors for replacing an existing
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
// 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
|
/// Load data from a custom service
|
||||||
|
|||||||
@@ -226,21 +226,6 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
assemblies.values.contains { $0.messageID == message.id }
|
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
|
/// 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
|
/// 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
|
/// name), swap it into the existing live bubble and report `true` so the
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ final class ChatMediaTransferCoordinator {
|
|||||||
|
|
||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
private var preparationGeneration: UInt64 = 0
|
|
||||||
|
|
||||||
init(context: any ChatMediaTransferContext) {
|
init(context: any ChatMediaTransferContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -99,14 +98,13 @@ final class ChatMediaTransferCoordinator {
|
|||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
let generation = preparationGeneration
|
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
do {
|
do {
|
||||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self else { return }
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
@@ -118,13 +116,13 @@ final class ChatMediaTransferCoordinator {
|
|||||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self 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"))
|
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 {
|
} catch {
|
||||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self 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"))
|
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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,15 +132,11 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func processThenSendImage(_ image: UIImage?) {
|
func processThenSendImage(_ image: UIImage?) {
|
||||||
guard let image else { return }
|
guard let image else { return }
|
||||||
let generation = preparationGeneration
|
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(image)
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else {
|
guard let self else { return }
|
||||||
try? FileManager.default.removeItem(at: processedURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.sendImage(from: processedURL)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -153,15 +147,11 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
func processThenSendImage(from url: URL?) {
|
func processThenSendImage(from url: URL?) {
|
||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
let generation = preparationGeneration
|
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(at: url)
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else {
|
guard let self else { return }
|
||||||
try? FileManager.default.removeItem(at: processedURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.sendImage(from: processedURL)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -180,7 +170,6 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = context.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
let generation = preparationGeneration
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try ImageUtils.validateImageSource(at: sourceURL)
|
try ImageUtils.validateImageSource(at: sourceURL)
|
||||||
@@ -195,10 +184,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else {
|
guard let self else { return }
|
||||||
try? FileManager.default.removeItem(at: prepared.outputURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let message = self.enqueueMediaMessage(
|
let message = self.enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer
|
||||||
@@ -215,13 +201,13 @@ final class ChatMediaTransferCoordinator {
|
|||||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self else { return }
|
||||||
self.context.addSystemMessage("Image is too large to send.")
|
self.context.addSystemMessage("Image is too large to send.")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self else { return }
|
||||||
self.context.addSystemMessage("Failed to prepare image for sending.")
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,19 +341,6 @@ final class ChatMediaTransferCoordinator {
|
|||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
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 {
|
private extension ChatMediaTransferCoordinator {
|
||||||
|
|||||||
@@ -177,10 +177,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
|
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
|
||||||
context: self,
|
|
||||||
sweepsOnInit: !TestEnvironment.isRunningTests
|
|
||||||
)
|
|
||||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||||
@@ -295,7 +292,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var nostrRelayManager: NostrRelayManager?
|
var nostrRelayManager: NostrRelayManager?
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
let keychain: KeychainManagerProtocol
|
let keychain: KeychainManagerProtocol
|
||||||
private let panicMediaWipe: () throws -> Void
|
|
||||||
/// Private group membership: keys in the keychain, metadata on disk.
|
/// Private group membership: keys in the keychain, metadata on disk.
|
||||||
let groupStore: GroupStore
|
let groupStore: GroupStore
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
@@ -803,8 +799,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationManager: LocationChannelManager = .shared,
|
locationManager: LocationChannelManager = .shared,
|
||||||
readReceiptsDefaults: UserDefaults? = nil,
|
readReceiptsDefaults: UserDefaults? = nil,
|
||||||
outboxStore: MessageOutboxStore? = nil,
|
outboxStore: MessageOutboxStore? = nil,
|
||||||
sfMetrics: StoreAndForwardMetrics? = nil,
|
sfMetrics: StoreAndForwardMetrics? = nil
|
||||||
panicMediaWipe: (() throws -> Void)? = nil
|
|
||||||
) {
|
) {
|
||||||
let conversations = conversations ?? ConversationStore()
|
let conversations = conversations ?? ConversationStore()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
@@ -819,13 +814,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
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.groupStore = GroupStore(keychain: keychain)
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
@@ -1168,11 +1156,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
// Messages are processed immediately - nothing to flush
|
// 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
|
// Clear all messages (public timelines and private chats live in the
|
||||||
// single-writer ConversationStore; the derived `messages` view and
|
// single-writer ConversationStore; the derived `messages` view and
|
||||||
// the legacy mirror empty with it)
|
// the legacy mirror empty with it)
|
||||||
@@ -1295,23 +1278,43 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wipe must finish before this security action returns. A detached
|
// Delete ALL media files (incoming and outgoing) in background
|
||||||
// task could otherwise lose a race with a new capture or app exit and
|
Task.detached(priority: .utility) {
|
||||||
// leave pre-panic media behind.
|
// Skipped under tests: the test process shares the user's real
|
||||||
do {
|
// ~/Library/Application Support/files tree, and this detached
|
||||||
try panicMediaWipe()
|
// utility-priority wipe fires at a nondeterministic time —
|
||||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
// deleting media that concurrently running tests (e.g. the
|
||||||
} catch {
|
// sendImage flow) just wrote there, and the developer's real
|
||||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
// 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)
|
||||||
|
|
||||||
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
|
// Delete the entire files directory and recreate it
|
||||||
// the host user's real cache tree just as the default media wipe does.
|
if FileManager.default.fileExists(atPath: filesDir.path) {
|
||||||
#if os(iOS)
|
try FileManager.default.removeItem(at: filesDir)
|
||||||
if !TestEnvironment.isRunningTests {
|
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()
|
Self.clearAppSwitcherSnapshots()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
// Force immediate UI update for panic mode
|
// Force immediate UI update for panic mode
|
||||||
// UI updates immediately - no flushing needed
|
// UI updates immediately - no flushing needed
|
||||||
|
|||||||
@@ -188,21 +188,6 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
|
|
||||||
let context = MockChatMediaTransferContext()
|
|
||||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
|
||||||
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
|
||||||
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
|
|
||||||
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
|
|
||||||
|
|
||||||
coordinator.resetForPanic()
|
|
||||||
|
|
||||||
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
|
|
||||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
|
||||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import BitFoundation
|
|||||||
|
|
||||||
/// Creates a ChatViewModel with mock dependencies for testing
|
/// Creates a ChatViewModel with mock dependencies for testing
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTestableViewModel(
|
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||||
panicMediaWipe: (() throws -> Void)? = nil
|
|
||||||
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let keychainHelper = MockKeychainHelper()
|
let keychainHelper = MockKeychainHelper()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||||
@@ -28,8 +26,7 @@ private func makeTestableViewModel(
|
|||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: transport,
|
transport: transport
|
||||||
panicMediaWipe: panicMediaWipe
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (viewModel, transport)
|
return (viewModel, transport)
|
||||||
@@ -1100,18 +1097,6 @@ struct ChatViewModelBluetoothTests {
|
|||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
struct ChatViewModelPanicTests {
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
|
||||||
var wipeFinished = false
|
|
||||||
let (viewModel, _) = makeTestableViewModel {
|
|
||||||
wipeFinished = true
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.panicClearAllData()
|
|
||||||
|
|
||||||
#expect(wipeFinished)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func panicClearAllData_delegatesToTransport() async {
|
func panicClearAllData_delegatesToTransport() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -45,6 +45,154 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deliberately simple O(n) model used to differentially test the store's
|
||||||
|
/// optimized logical-index bookkeeping. It models observable behavior only;
|
||||||
|
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
||||||
|
private struct ReferenceConversationTimeline {
|
||||||
|
struct Message: Equatable {
|
||||||
|
let id: String
|
||||||
|
let timestamp: Date
|
||||||
|
let content: String
|
||||||
|
var deliveryStatus: DeliveryStatus?
|
||||||
|
|
||||||
|
init(_ message: BitchatMessage) {
|
||||||
|
id = message.id
|
||||||
|
timestamp = message.timestamp
|
||||||
|
content = message.content
|
||||||
|
deliveryStatus = message.deliveryStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppendResult {
|
||||||
|
let inserted: Bool
|
||||||
|
let trimmedCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
let cap: Int
|
||||||
|
private(set) var messages: [Message] = []
|
||||||
|
|
||||||
|
func contains(_ id: String) -> Bool {
|
||||||
|
messages.contains { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
||||||
|
guard !contains(message.id) else {
|
||||||
|
return AppendResult(inserted: false, trimmedCount: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = Message(message)
|
||||||
|
var low = 0
|
||||||
|
var high = messages.count
|
||||||
|
while low < high {
|
||||||
|
let mid = (low + high) / 2
|
||||||
|
if messages[mid].timestamp <= snapshot.timestamp {
|
||||||
|
low = mid + 1
|
||||||
|
} else {
|
||||||
|
high = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.insert(snapshot, at: low)
|
||||||
|
|
||||||
|
let overflow = max(0, messages.count - cap)
|
||||||
|
if overflow > 0 {
|
||||||
|
messages.removeFirst(overflow)
|
||||||
|
}
|
||||||
|
return AppendResult(inserted: true, trimmedCount: overflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsert(_ message: BitchatMessage) -> Int {
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||||
|
messages[index] = Message(message)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return append(message).trimmedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
||||||
|
guard let index = messages.firstIndex(where: { $0.id == id }),
|
||||||
|
messages[index].deliveryStatus != status else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The differential stream uses only unique `.delivered` values (or
|
||||||
|
// an exact repeat), so no-downgrade policy is intentionally outside
|
||||||
|
// this index-focused reference model.
|
||||||
|
messages[index].deliveryStatus = status
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func remove(at index: Int) -> Message {
|
||||||
|
messages.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll(where predicate: (Message) -> Bool) {
|
||||||
|
messages.removeAll(where: predicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func clear() {
|
||||||
|
messages.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConversationStoreDifferentialRNG {
|
||||||
|
private var state: UInt64
|
||||||
|
|
||||||
|
init(seed: UInt64) {
|
||||||
|
state = seed
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func next() -> UInt64 {
|
||||||
|
state &+= 0x9E37_79B9_7F4A_7C15
|
||||||
|
var value = state
|
||||||
|
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||||
|
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||||
|
return value ^ (value >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func index(upperBound: Int) -> Int {
|
||||||
|
precondition(upperBound > 0)
|
||||||
|
return Int(next() % UInt64(upperBound))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func expectStore(
|
||||||
|
_ store: ConversationStore,
|
||||||
|
matches reference: ReferenceConversationTimeline,
|
||||||
|
issuedIDs: [String],
|
||||||
|
checkpoint: String
|
||||||
|
) {
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
||||||
|
|
||||||
|
let lookupSnapshot = reference.messages.compactMap { expected in
|
||||||
|
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
||||||
|
}
|
||||||
|
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
||||||
|
#expect(
|
||||||
|
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
||||||
|
"per-conversation ID set mismatch at \(checkpoint)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
||||||
|
let id = reference.messages[index].id
|
||||||
|
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeIDs = Set(reference.messages.map(\.id))
|
||||||
|
var checkedStaleIDs = 0
|
||||||
|
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
||||||
|
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
||||||
|
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
||||||
|
checkedStaleIDs += 1
|
||||||
|
if checkedStaleIDs == 16 { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
||||||
|
}
|
||||||
|
|
||||||
@Suite("ConversationStore")
|
@Suite("ConversationStore")
|
||||||
struct ConversationStoreTests {
|
struct ConversationStoreTests {
|
||||||
|
|
||||||
@@ -140,6 +288,282 @@ struct ConversationStoreTests {
|
|||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
||||||
|
@MainActor
|
||||||
|
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
let overflow = 64
|
||||||
|
|
||||||
|
for i in 0..<(conversation.cap + overflow) {
|
||||||
|
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(conversation.messages.first?.id == "m\(overflow)")
|
||||||
|
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
||||||
|
|
||||||
|
// Exercise a suffix reindex after the head offset has advanced, then
|
||||||
|
// trim the old head. The late row becomes the new first element.
|
||||||
|
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
||||||
|
#expect(store.append(late, to: .mesh))
|
||||||
|
#expect(conversation.messages.first?.id == "late")
|
||||||
|
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
||||||
|
|
||||||
|
// Head and middle removals, an in-place upsert, and a status update
|
||||||
|
// must all resolve through the same logical index representation.
|
||||||
|
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
||||||
|
let middleID = "m\(overflow + conversation.cap / 2)"
|
||||||
|
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
||||||
|
|
||||||
|
let probeID = "m\(overflow + 10)"
|
||||||
|
store.upsertByID(
|
||||||
|
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
||||||
|
in: .mesh
|
||||||
|
)
|
||||||
|
#expect(conversation.message(withID: probeID)?.content == "edited")
|
||||||
|
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
||||||
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
|
#expect(store.auditInvariants().isEmpty)
|
||||||
|
|
||||||
|
// Clearing resets the logical offset as well as the maps.
|
||||||
|
store.clear(.mesh)
|
||||||
|
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
||||||
|
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
||||||
|
#expect(store.auditInvariants().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("logical index offset matches a reference model under adversarial mutations")
|
||||||
|
@MainActor
|
||||||
|
func logicalIndexOffsetDifferentialStress() async {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let cap = store.conversation(for: .mesh).cap
|
||||||
|
var reference = ReferenceConversationTimeline(cap: cap)
|
||||||
|
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
||||||
|
var issuedIDs: [String] = []
|
||||||
|
var nextID = 0
|
||||||
|
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
||||||
|
var trimmedCount = 0
|
||||||
|
|
||||||
|
var tailAppendCount = 0
|
||||||
|
var outOfOrderCount = 0
|
||||||
|
var duplicateOrReuseCount = 0
|
||||||
|
var headRemovalCount = 0
|
||||||
|
var middleRemovalCount = 0
|
||||||
|
var upsertCount = 0
|
||||||
|
var deliveryUpdateCount = 0
|
||||||
|
var filterCount = 0
|
||||||
|
var clearCount = 0
|
||||||
|
|
||||||
|
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
||||||
|
let number = nextID
|
||||||
|
nextID += 1
|
||||||
|
let id = "diff-\(number)"
|
||||||
|
issuedIDs.append(id)
|
||||||
|
let resolvedTimestamp: TimeInterval
|
||||||
|
if let timestamp {
|
||||||
|
resolvedTimestamp = timestamp
|
||||||
|
} else {
|
||||||
|
resolvedTimestamp = nextTailTimestamp
|
||||||
|
nextTailTimestamp += 1
|
||||||
|
}
|
||||||
|
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
||||||
|
return makeMessage(
|
||||||
|
id: id,
|
||||||
|
timestamp: resolvedTimestamp,
|
||||||
|
content: "\(tag) \(number)\(dropMarker)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
||||||
|
let expected = reference.append(message)
|
||||||
|
let actual = store.append(message, to: .mesh)
|
||||||
|
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
||||||
|
trimmedCount += expected.trimmedCount
|
||||||
|
return expected
|
||||||
|
}
|
||||||
|
|
||||||
|
func refill(extra: Int, checkpoint: String) async {
|
||||||
|
let appendCount = max(0, cap - reference.messages.count) + extra
|
||||||
|
for index in 0..<appendCount {
|
||||||
|
appendAndCompare(
|
||||||
|
issueMessage(tag: "refill"),
|
||||||
|
checkpoint: "\(checkpoint)-\(index)"
|
||||||
|
)
|
||||||
|
if index.isMultiple(of: 64) {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start well into steady state so the offset is already non-zero
|
||||||
|
// before any mixed operations begin.
|
||||||
|
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
||||||
|
|
||||||
|
for step in 0..<1_200 {
|
||||||
|
if step == 300 || step == 900 {
|
||||||
|
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
||||||
|
reference.removeAll { $0.content.contains("[drop]") }
|
||||||
|
filterCount += 1
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "filter at step \(step)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if step == 600 {
|
||||||
|
store.clear(.mesh)
|
||||||
|
reference.clear()
|
||||||
|
clearCount += 1
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "clear at step \(step)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rng.index(upperBound: 100) {
|
||||||
|
case 0..<35:
|
||||||
|
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
||||||
|
tailAppendCount += 1
|
||||||
|
|
||||||
|
case 35..<55:
|
||||||
|
if reference.messages.isEmpty {
|
||||||
|
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
||||||
|
} else {
|
||||||
|
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
||||||
|
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
||||||
|
appendAndCompare(
|
||||||
|
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
||||||
|
checkpoint: "out-of-order append \(step)"
|
||||||
|
)
|
||||||
|
outOfOrderCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 55..<65:
|
||||||
|
if issuedIDs.isEmpty {
|
||||||
|
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
||||||
|
} else {
|
||||||
|
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
||||||
|
let message = makeMessage(
|
||||||
|
id: reusedID,
|
||||||
|
timestamp: nextTailTimestamp,
|
||||||
|
content: "duplicate-or-trimmed-reuse \(step)"
|
||||||
|
)
|
||||||
|
nextTailTimestamp += 1
|
||||||
|
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
||||||
|
duplicateOrReuseCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 65..<73:
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
let expected = reference.remove(at: 0)
|
||||||
|
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||||
|
.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == expected, "head removal mismatch at step \(step)")
|
||||||
|
headRemovalCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 73..<81:
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
let middleStart = reference.messages.count / 4
|
||||||
|
let middleWidth = max(1, reference.messages.count / 2)
|
||||||
|
let index = min(
|
||||||
|
reference.messages.count - 1,
|
||||||
|
middleStart + rng.index(upperBound: middleWidth)
|
||||||
|
)
|
||||||
|
let expected = reference.remove(at: index)
|
||||||
|
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||||
|
.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
||||||
|
middleRemovalCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 81..<90:
|
||||||
|
let message: BitchatMessage
|
||||||
|
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
||||||
|
let timestamp = reference.messages.isEmpty
|
||||||
|
? nil
|
||||||
|
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
.timestamp.timeIntervalSince1970
|
||||||
|
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
||||||
|
} else {
|
||||||
|
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
message = makeMessage(
|
||||||
|
id: current.id,
|
||||||
|
timestamp: current.timestamp.timeIntervalSince1970,
|
||||||
|
content: "upsert-existing \(step)",
|
||||||
|
deliveryStatus: current.deliveryStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trimmedCount += reference.upsert(message)
|
||||||
|
store.upsertByID(message, in: .mesh)
|
||||||
|
upsertCount += 1
|
||||||
|
|
||||||
|
default:
|
||||||
|
let id: String
|
||||||
|
let repeatedStatus: DeliveryStatus?
|
||||||
|
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
||||||
|
id = "missing-\(step)"
|
||||||
|
repeatedStatus = nil
|
||||||
|
} else {
|
||||||
|
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
id = current.id
|
||||||
|
repeatedStatus = current.deliveryStatus
|
||||||
|
}
|
||||||
|
let status: DeliveryStatus
|
||||||
|
if step.isMultiple(of: 4), let repeatedStatus {
|
||||||
|
status = repeatedStatus
|
||||||
|
} else {
|
||||||
|
status = .delivered(
|
||||||
|
to: "peer",
|
||||||
|
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let expected = reference.applyDeliveryStatus(status, to: id)
|
||||||
|
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
||||||
|
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
||||||
|
deliveryUpdateCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "mixed operation \(step)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This intentionally expensive MainActor stress test runs beside
|
||||||
|
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
||||||
|
// release the actor so their bounded waits can make progress.
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
if (step + 1).isMultiple(of: 100) {
|
||||||
|
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee another long run of one-row evictions after every other
|
||||||
|
// mutation family has perturbed and rebuilt the offset/index state.
|
||||||
|
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
||||||
|
|
||||||
|
#expect(trimmedCount > 1_200)
|
||||||
|
#expect(tailAppendCount > 300)
|
||||||
|
#expect(outOfOrderCount > 150)
|
||||||
|
#expect(duplicateOrReuseCount > 75)
|
||||||
|
#expect(headRemovalCount > 50)
|
||||||
|
#expect(middleRemovalCount > 50)
|
||||||
|
#expect(upsertCount > 75)
|
||||||
|
#expect(deliveryUpdateCount > 75)
|
||||||
|
#expect(filterCount == 2)
|
||||||
|
#expect(clearCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Upsert
|
// MARK: - Upsert
|
||||||
|
|
||||||
@Test("upsertByID replaces in place and appends when absent")
|
@Test("upsertByID replaces in place and appends when absent")
|
||||||
|
|||||||
@@ -501,6 +501,62 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 7b. ConversationStore append at the retention cap
|
||||||
|
|
||||||
|
/// Steady-state public timeline traffic after the 1337-message retention
|
||||||
|
/// cap has been reached. Every tail append evicts the oldest row, which is
|
||||||
|
/// the long-lived workload the cold `store.append` benchmark does not
|
||||||
|
/// exercise.
|
||||||
|
func testConversationStoreSteadyStateAppend() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let cap = TransportConfig.meshTimelineCap
|
||||||
|
let messagesPerPass = 500
|
||||||
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
|
||||||
|
for i in 0..<cap {
|
||||||
|
store.append(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "perf-steady-seed-\(i)",
|
||||||
|
sender: "perfsender",
|
||||||
|
content: "steady-state seed \(i)",
|
||||||
|
timestamp: base.addingTimeInterval(Double(i)),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pass = 0
|
||||||
|
var samples: [TimeInterval] = []
|
||||||
|
measure {
|
||||||
|
let startIndex = cap + pass * messagesPerPass
|
||||||
|
let start = Date()
|
||||||
|
for offset in 0..<messagesPerPass {
|
||||||
|
let i = startIndex + offset
|
||||||
|
store.append(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "perf-steady-\(i)",
|
||||||
|
sender: "perfsender",
|
||||||
|
content: "steady-state message \(i)",
|
||||||
|
timestamp: base.addingTimeInterval(Double(i)),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
}
|
||||||
|
samples.append(Date().timeIntervalSince(start))
|
||||||
|
pass += 1
|
||||||
|
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportThroughput(
|
||||||
|
"store.steadyStateAppend",
|
||||||
|
samples: samples,
|
||||||
|
operations: messagesPerPass,
|
||||||
|
unit: "messages"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||||
|
|
||||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
"store.append": 213201,
|
"store.append": 213201,
|
||||||
"store.audit": 362
|
"store.audit": 362
|
||||||
},
|
},
|
||||||
|
"_reference_local_numbers_2026_07": {
|
||||||
|
"store.steadyStateAppend_before": 2315,
|
||||||
|
"store.steadyStateAppend": 53976
|
||||||
|
},
|
||||||
"floors": {
|
"floors": {
|
||||||
"nostrInbound.fresh": 450,
|
"nostrInbound.fresh": 450,
|
||||||
"nostrInbound.duplicate": 250000,
|
"nostrInbound.duplicate": 250000,
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
"pipeline.privateIngest": 3000,
|
"pipeline.privateIngest": 3000,
|
||||||
"pipeline.publicIngest": 2400,
|
"pipeline.publicIngest": 2400,
|
||||||
"store.append": 48000,
|
"store.append": 48000,
|
||||||
|
"store.steadyStateAppend": 10000,
|
||||||
"store.audit": 70
|
"store.audit": 70
|
||||||
},
|
},
|
||||||
"_slowest_observed_ci_numbers_2026_06": {
|
"_slowest_observed_ci_numbers_2026_06": {
|
||||||
@@ -56,4 +61,4 @@
|
|||||||
"store.append": 97423,
|
"store.append": 97423,
|
||||||
"store.audit": 140
|
"store.audit": 140
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite("PreviewKeychainManager Tests")
|
@Suite("PreviewKeychainManager Tests")
|
||||||
struct PreviewKeychainManagerTests {
|
struct PreviewKeychainManagerTests {
|
||||||
|
|
||||||
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
|
|
||||||
func installLifecycleDecision() {
|
|
||||||
#expect(KeychainManager.installLifecycleAction(
|
|
||||||
containerKnowsMarker: true,
|
|
||||||
markerRead: .success(Data([1]))
|
|
||||||
) == .markerPresent)
|
|
||||||
#expect(KeychainManager.installLifecycleAction(
|
|
||||||
containerKnowsMarker: false,
|
|
||||||
markerRead: .success(Data([1]))
|
|
||||||
) == .clearStaleKeys)
|
|
||||||
#expect(KeychainManager.installLifecycleAction(
|
|
||||||
containerKnowsMarker: false,
|
|
||||||
markerRead: .itemNotFound
|
|
||||||
) == .bootstrapMarker)
|
|
||||||
#expect(KeychainManager.installLifecycleAction(
|
|
||||||
containerKnowsMarker: false,
|
|
||||||
markerRead: .deviceLocked
|
|
||||||
) == .retryLater)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||||
func previewKeychainManagerRoundTripsData() {
|
func previewKeychainManagerRoundTripsData() {
|
||||||
let manager = PreviewKeychainManager()
|
let manager = PreviewKeychainManager()
|
||||||
|
|||||||
@@ -370,46 +370,6 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
|
|
||||||
let base = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
defer { try? FileManager.default.removeItem(at: base) }
|
|
||||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
|
||||||
let subdirectories = [
|
|
||||||
"voicenotes/incoming",
|
|
||||||
"voicenotes/outgoing",
|
|
||||||
"images/incoming",
|
|
||||||
"images/outgoing",
|
|
||||||
"files/incoming",
|
|
||||||
"files/outgoing"
|
|
||||||
]
|
|
||||||
|
|
||||||
for subdirectory in subdirectories {
|
|
||||||
let directory = base
|
|
||||||
.appendingPathComponent("files", isDirectory: true)
|
|
||||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
||||||
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
|
|
||||||
}
|
|
||||||
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
|
|
||||||
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
||||||
try Data("legacy".utf8).write(to: unmanaged)
|
|
||||||
|
|
||||||
try store.panicWipe()
|
|
||||||
|
|
||||||
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
|
|
||||||
for subdirectory in subdirectories {
|
|
||||||
let directory = base
|
|
||||||
.appendingPathComponent("files", isDirectory: true)
|
|
||||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
|
||||||
var isDirectory: ObjCBool = false
|
|
||||||
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
|
|
||||||
#expect(isDirectory.boolValue)
|
|
||||||
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Residual risk: private-message metadata such as timing, radio adjacency, ciphert
|
|||||||
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
||||||
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
||||||
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
||||||
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal.
|
||||||
|
|
||||||
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu
|
|||||||
|
|
||||||
## Panic Wipe Coverage
|
## Panic Wipe Coverage
|
||||||
|
|
||||||
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test.
|
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test.
|
||||||
|
|
||||||
## Release Review Checklist
|
## Release Review Checklist
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user