From 5e9f7b1ff85a3a1ee3770f84e2689edd3bb2beea Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 10:42:10 -0400 Subject: [PATCH] Add opt-in local crash and hang evidence --- BitMatch-iPad/BitMatch_iPadApp.swift | 2 + BitMatch-iPad/Views/ModularContentView.swift | 4 + BitMatch/App/BitMatchApp.swift | 9 + BitMatch/Views/PreferencesWindow.swift | 4 + BitMatchTests/GeneralSettingsTests.swift | 3 + CHANGELOG.md | 1 + .../LocalCrashEvidenceStore.swift | 335 ++++++++++++++ .../MetricKitCrashEvidenceCollector.swift | 96 ++++ .../TransferDiagnosticStore.swift | 20 +- .../LocalCrashEvidenceTests.swift | 415 ++++++++++++++++++ Shared/Core/Models/GeneralSettings.swift | 6 + .../Views/LocalCrashEvidenceSettingView.swift | 58 +++ Shared/Views/TransferLibraryView.swift | 2 +- docs/DIAGNOSTICS.md | 12 +- docs/TODO.md | 2 +- 15 files changed, 963 insertions(+), 6 deletions(-) create mode 100644 Packages/BitMatchEngine/Sources/BitMatchEngine/LocalCrashEvidenceStore.swift create mode 100644 Packages/BitMatchEngine/Sources/BitMatchEngine/MetricKitCrashEvidenceCollector.swift create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/LocalCrashEvidenceTests.swift create mode 100644 Shared/Views/LocalCrashEvidenceSettingView.swift diff --git a/BitMatch-iPad/BitMatch_iPadApp.swift b/BitMatch-iPad/BitMatch_iPadApp.swift index 05198976..0a314b69 100644 --- a/BitMatch-iPad/BitMatch_iPadApp.swift +++ b/BitMatch-iPad/BitMatch_iPadApp.swift @@ -12,6 +12,8 @@ struct BitMatch_iPadApp: App { init() { SharedLogger.transferEvent(.appStarted, run: nil) + MetricKitCrashEvidenceCollector.shared.configure( + enabled: UserDefaults.standard.bool(forKey: LocalCrashEvidenceStore.consentKey)) UNUserNotificationCenter.current().delegate = notifDelegate // Register background task handler (Info.plist contains permitted identifier) registerBackgroundTasks() diff --git a/BitMatch-iPad/Views/ModularContentView.swift b/BitMatch-iPad/Views/ModularContentView.swift index 5b9eda2b..29dc7d55 100644 --- a/BitMatch-iPad/Views/ModularContentView.swift +++ b/BitMatch-iPad/Views/ModularContentView.swift @@ -311,6 +311,10 @@ struct SettingsSheetView: View { .disabled(coordinator.isOperationInProgress || coordinator.isReplayingQueuedTransfer) } + Section("Diagnostics") { + LocalCrashEvidenceSettingView(settings: generalSettings) + } + Section(GeneralSettingsPresentation.notificationsSection) { Toggle(GeneralSettingsPresentation.notifyAttention, isOn: $generalSettings.notifyWhenCardNeedsAttention) Toggle(GeneralSettingsPresentation.notifyFinish, isOn: $generalSettings.notifyWhenTransferOrQueueFinishes) diff --git a/BitMatch/App/BitMatchApp.swift b/BitMatch/App/BitMatchApp.swift index f49de4c1..58cdcf3b 100644 --- a/BitMatch/App/BitMatchApp.swift +++ b/BitMatch/App/BitMatchApp.swift @@ -67,6 +67,15 @@ struct BitMatchApp: App { #else let environment = MacAppEnvironment.make() #endif + #if DEBUG + if !MacUnitTestHost.isRunning() { + MetricKitCrashEvidenceCollector.shared.configure( + enabled: UserDefaults.standard.bool(forKey: LocalCrashEvidenceStore.consentKey)) + } + #else + MetricKitCrashEvidenceCollector.shared.configure( + enabled: UserDefaults.standard.bool(forKey: LocalCrashEvidenceStore.consentKey)) + #endif _environment = StateObject(wrappedValue: environment) _updater = StateObject(wrappedValue: UpdaterController(coordinator: environment.coordinator)) appDelegate.coordinator = environment.coordinator diff --git a/BitMatch/Views/PreferencesWindow.swift b/BitMatch/Views/PreferencesWindow.swift index 67704a03..42667b45 100644 --- a/BitMatch/Views/PreferencesWindow.swift +++ b/BitMatch/Views/PreferencesWindow.swift @@ -148,6 +148,10 @@ struct PreferencesWindow: View { .disabled(coordinator.isOperationInProgress || coordinator.isReplayingQueuedTransfer) } + Section("Diagnostics") { + LocalCrashEvidenceSettingView(settings: generalSettings) + } + Section("Updates") { Toggle( "Check for updates automatically", diff --git a/BitMatchTests/GeneralSettingsTests.swift b/BitMatchTests/GeneralSettingsTests.swift index d3b0c056..8c5b3f4a 100644 --- a/BitMatchTests/GeneralSettingsTests.swift +++ b/BitMatchTests/GeneralSettingsTests.swift @@ -46,6 +46,7 @@ struct GeneralSettingsTests { #expect(!settings.queueNewCardsAutomatically) #expect(!settings.autoEjectWhenSafe) #expect(!settings.playSounds) + #expect(!settings.collectLocalCrashEvidence) #expect(!settings.notificationPromptWasAnswered) } @@ -64,6 +65,7 @@ struct GeneralSettingsTests { migrated.queueNewCardsAutomatically = true migrated.autoEjectWhenSafe = true migrated.playSounds = true + migrated.collectLocalCrashEvidence = true migrated.markNotificationPromptAnswered() let restored = GeneralSettings(defaults: box.defaults) @@ -73,6 +75,7 @@ struct GeneralSettingsTests { #expect(restored.queueNewCardsAutomatically) #expect(restored.autoEjectWhenSafe) #expect(restored.playSounds) + #expect(restored.collectLocalCrashEvidence) #expect(restored.notificationPromptWasAnswered) } diff --git a/CHANGELOG.md b/CHANGELOG.md index dff9ca56..d857d8c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - Mac: The menu bar shows the current transfer and its destinations, or the most recent result when idle. Closing the main window during a transfer hides it while the work continues; Open BitMatch brings the same window back. Quit still asks before stopping active work. - Expanded transfer details show bytes written and read back for each destination, with separate live rates. These are measurements of active file I/O, not a drive-health or verification verdict. +- Optional local Apple crash and hang summaries can be added to a diagnostics export. This is off by default, never uploaded by BitMatch, and Apple may not deliver a report. - Keep copy and verification failures below 100% in live progress; the engine emits “Complete” only when all selected files pass the chosen mode. The final safety verdict still includes later ASC MHL and report steps. - Ignore a disappearing root `.fseventsd` metadata directory during card scans without hiding ordinary files. A regular source file with that name now contributes to the source fingerprint. - Bound saved JSON, MHL and adjacent checksum-manifest reads during loading. Damaged, oversized, conflicting or empty adjacent records are reported instead of silently falling back to partial evidence, including when another companion looks valid. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/LocalCrashEvidenceStore.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/LocalCrashEvidenceStore.swift new file mode 100644 index 00000000..35dd864b --- /dev/null +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/LocalCrashEvidenceStore.swift @@ -0,0 +1,335 @@ +import Foundation + +/// An intentionally small allowlist of Apple crash/hang information. Raw +/// MetricKit payloads, messages, paths and binary names never enter this type. +public struct LocalCrashEvidence: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { case crash, hang } + + public struct Frame: Codable, Equatable, Sendable { + public let binaryUUID: UUID + public let offset: UInt64 + + public init(binaryUUID: UUID, offset: UInt64) { + self.binaryUUID = binaryUUID + self.offset = offset + } + } + + public let kind: Kind + public let periodBegin: Date + public let periodEnd: Date + public let receivedAt: Date + public let appVersion: String + public let build: String + public let processID: Int32? + public let signal: Int? + public let exceptionType: Int? + public let exceptionCode: Int64? + public let hangSeconds: Double? + public let frames: [Frame] + + private enum CodingKeys: String, CodingKey { + case kind, periodBegin, periodEnd, receivedAt, appVersion, build + case processID, signal, exceptionType, exceptionCode, hangSeconds, frames + } + + public init(kind: Kind, periodBegin: Date, periodEnd: Date, receivedAt: Date = Date(), + appVersion: String, build: String, processID: Int32? = nil, + signal: Int? = nil, exceptionType: Int? = nil, exceptionCode: Int64? = nil, + hangSeconds: Double? = nil, frames: [Frame] = []) { + self.kind = kind + self.periodBegin = periodBegin + self.periodEnd = periodEnd + self.receivedAt = receivedAt + self.appVersion = Self.safeVersion(appVersion) + self.build = Self.safeVersion(build) + self.processID = processID + self.signal = signal + self.exceptionType = exceptionType + self.exceptionCode = exceptionCode + self.hangSeconds = hangSeconds.flatMap { $0.isFinite && $0 >= 0 ? min($0, 86_400) : nil } + self.frames = Array(frames.prefix(8)) + } + + /// Files are local, but still treat their contents as untrusted at export + /// time. Synthesized decoding would bypass the allowlist in init above. + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.init(kind: try values.decode(Kind.self, forKey: .kind), + periodBegin: try values.decode(Date.self, forKey: .periodBegin), + periodEnd: try values.decode(Date.self, forKey: .periodEnd), + receivedAt: try values.decode(Date.self, forKey: .receivedAt), + appVersion: try values.decode(String.self, forKey: .appVersion), + build: try values.decode(String.self, forKey: .build), + processID: try values.decodeIfPresent(Int32.self, forKey: .processID), + signal: try values.decodeIfPresent(Int.self, forKey: .signal), + exceptionType: try values.decodeIfPresent(Int.self, forKey: .exceptionType), + exceptionCode: try values.decodeIfPresent(Int64.self, forKey: .exceptionCode), + hangSeconds: try values.decodeIfPresent(Double.self, forKey: .hangSeconds), + frames: try values.decode([Frame].self, forKey: .frames)) + } + + private static func safeVersion(_ value: String) -> String { + let numericPrefix = value.unicodeScalars.prefix { + CharacterSet(charactersIn: "0123456789.").contains($0) + } + let cleaned = String(numericPrefix.prefix(16)).trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return cleaned.isEmpty ? "unknown" : cleaned + } +} + +/// Consent and file operations use one lock. A callback captures a generation +/// before decoding and may only persist while that exact consent is still live. +/// This also covers opt-out followed by a quick opt-in. +public final class LocalCrashEvidenceStore: @unchecked Sendable { + public static let consentKey = "BitMatchCollectLocalCrashEvidence" + private static let consentBoundaryKey = "BitMatchCrashEvidenceConsentBoundary" + public static let shared = LocalCrashEvidenceStore( + directory: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("BitMatch/CrashEvidence", isDirectory: true), + consentProvider: { UserDefaults.standard.bool(forKey: LocalCrashEvidenceStore.consentKey) } + ) + + private let directory: URL + private let defaults: UserDefaults + private let consentProvider: @Sendable () -> Bool + private let now: @Sendable () -> Date + private let removeItem: @Sendable (URL) throws -> Void + private let synchronizeDefaults: @Sendable (UserDefaults) -> Bool + private let lock = NSLock() + private var enabled = false + private var needsFreshEpoch = false + private var generation: UInt64 = 0 + private var acceptedAfter: Date? + private var writeFailed = false + private var deletionFailed = false + private let maxRecords = 16 + private var file: URL { directory.appendingPathComponent("system-evidence.json") } + private var epochFile: URL { directory.appendingPathComponent("system-evidence-epoch.json") } + + public init(directory: URL, defaults: UserDefaults = .standard, + consentProvider: @escaping @Sendable () -> Bool = { true }, + now: @escaping @Sendable () -> Date = { Date() }, + removeItem: @escaping @Sendable (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }, + synchronizeDefaults: @escaping @Sendable (UserDefaults) -> Bool = { $0.synchronize() }) { + self.directory = directory + self.defaults = defaults + self.consentProvider = consentProvider + self.now = now + self.removeItem = removeItem + self.synchronizeDefaults = synchronizeDefaults + } + + public func setEnabled(_ value: Bool) { + lock.lock() + defer { lock.unlock() } + generation &+= 1 + enabled = false + acceptedAfter = nil + if value && consentProvider() { + do { + let epoch: Date + let boundary = defaults.object(forKey: Self.consentBoundaryKey) as? Date + let diskEpoch = FileManager.default.fileExists(atPath: epochFile.path) + ? try readEpoch() : nil + if !needsFreshEpoch, let boundary, let diskEpoch, + !diskEpoch.blocked, diskEpoch.acceptedAfter == boundary { + epoch = boundary + let records: [LocalCrashEvidence] + do { records = try readRecords() } + catch { + writeFailed = true + try removeExistingFile(file) + records = [] + } + if records.contains(where: { $0.periodBegin <= epoch }) { + try removeExistingFile(file) + } + } else { + // A missing/stale disk epoch means a new opt-in or an + // interrupted clear. Remove old bytes before enabling. + try removeExistingFile(file) + epoch = now() + try saveBoundary(epoch) + try saveEpoch(epoch) + } + acceptedAfter = epoch + enabled = true + needsFreshEpoch = false + deletionFailed = false + } catch { + writeFailed = true + } // No durable consent epoch or stale-file cleanup, no collection. + } else { + needsFreshEpoch = true + deletionFailed = false + let disabledAt = now() + let hadEvidence = FileManager.default.fileExists(atPath: file.path) + || FileManager.default.fileExists(atPath: epochFile.path) + // The default-off app has nothing to revoke. Avoid reporting a + // preference write error just for launching with consent off. + guard hadEvidence else { return } + do { try saveEpoch(disabledAt, blocked: true) } + catch { writeFailed = true } + do { try saveBoundary(disabledAt) } + catch { writeFailed = true } + let removedEvidence = removeFile(file) + let removedEpoch = removeFile(epochFile) + deletionFailed = !(removedEvidence && removedEpoch) + } + } + + /// Clearing while still opted in invalidates callbacks already in flight. + public func clear() { + lock.lock() + defer { lock.unlock() } + generation &+= 1 + enabled = false + acceptedAfter = nil + deletionFailed = false + let epoch = now() + // Write a durable tombstone before removing the records. If Clear is + // interrupted, a later launch cannot mistake the old epoch for live + // consent, even if removal of the record file failed. + var tombstoneSaved = false + do { try saveEpoch(epoch, blocked: true); tombstoneSaved = true } + catch { writeFailed = true } + var boundarySaved = false + do { try saveBoundary(epoch); boundarySaved = true } + catch { writeFailed = true } + let removedEvidence = removeFile(file) + guard boundarySaved && removedEvidence else { + needsFreshEpoch = true + if !tombstoneSaved && !boundarySaved { _ = removeFile(epochFile) } + return + } + do { + try saveEpoch(epoch) + acceptedAfter = epoch + enabled = consentProvider() + needsFreshEpoch = false + deletionFailed = false + } catch { + writeFailed = true + needsFreshEpoch = true + } // Keep collection off if the new epoch cannot persist. + } + + public func collectionToken() -> UInt64? { + lock.lock() + defer { lock.unlock() } + guard enabled && consentProvider() && acceptedAfter != nil else { return nil } + return generation + } + + public func append(_ evidence: LocalCrashEvidence, token: UInt64) { + lock.lock() + defer { lock.unlock() } + guard enabled && consentProvider() && token == generation, + let acceptedAfter, evidence.periodBegin > acceptedAfter, + evidence.periodEnd >= evidence.periodBegin else { return } + do { + var records = try readRecords() + // MetricKit can redeliver the same daily payload after a restart. + guard !records.contains(where: { $0 == evidence || $0.sameDiagnostic(as: evidence) }) else { return } + records.append(evidence) + records = Array(records.suffix(maxRecords)) + try ensurePrivateDirectory() + let data = try JSONEncoder.localEvidence.encode(records) + try data.write(to: file, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + } catch { writeFailed = true } // Never changes a transfer outcome. + } + + public func snapshot() -> (enabled: Bool, records: [LocalCrashEvidence], + recordingHadErrors: Bool, deletionHadErrors: Bool) { + lock.lock() + defer { lock.unlock() } + guard enabled && consentProvider(), let acceptedAfter else { + return (false, [], writeFailed, deletionFailed) + } + do { + return (true, try readRecords().filter { $0.periodBegin > acceptedAfter }, writeFailed, deletionFailed) + } + catch { return (true, [], true, deletionFailed) } + } + + private struct Epoch: Codable { + let acceptedAfter: Date + let blocked: Bool + } + + private func saveBoundary(_ date: Date) throws { + defaults.set(date, forKey: Self.consentBoundaryKey) + guard synchronizeDefaults(defaults) else { throw CocoaError(.fileWriteUnknown) } + } + + private func readEpoch() throws -> Epoch { + let handle = try FileHandle(forReadingFrom: epochFile) + defer { try? handle.close() } + let data = try handle.read(upToCount: 129) ?? Data() + guard data.count <= 128 else { throw CocoaError(.fileReadTooLarge) } + return try JSONDecoder().decode(Epoch.self, from: data) + } + + private func saveEpoch(_ date: Date, blocked: Bool = false) throws { + try ensurePrivateDirectory() + try JSONEncoder().encode(Epoch(acceptedAfter: date, blocked: blocked)) + .write(to: epochFile, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: epochFile.path) + } + + private func ensurePrivateDirectory() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + } + + private func removeExistingFile(_ url: URL) throws { + if FileManager.default.fileExists(atPath: url.path) { + do { try removeItem(url) } + catch { deletionFailed = true; throw error } + } + } + + @discardableResult private func removeFile(_ url: URL) -> Bool { + do { try removeExistingFile(url); return true } + catch { writeFailed = true; return false } + } + + private func readRecords() throws -> [LocalCrashEvidence] { + guard FileManager.default.fileExists(atPath: file.path) else { return [] } + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + let data = try handle.read(upToCount: 64 * 1024 + 1) ?? Data() + guard data.count <= 64 * 1024 else { throw CocoaError(.fileReadTooLarge) } + let decoded = try JSONDecoder.localEvidence.decode([LocalCrashEvidence].self, from: data) + return Array(decoded.suffix(maxRecords)) + } +} + +private extension LocalCrashEvidence { + func sameDiagnostic(as other: Self) -> Bool { + kind == other.kind && periodBegin == other.periodBegin && periodEnd == other.periodEnd + && appVersion == other.appVersion && build == other.build && processID == other.processID + && signal == other.signal && exceptionType == other.exceptionType + && exceptionCode == other.exceptionCode && hangSeconds == other.hangSeconds + && frames == other.frames + } +} + +private extension JSONEncoder { + static var localEvidence: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } +} + +private extension JSONDecoder { + static var localEvidence: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/MetricKitCrashEvidenceCollector.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/MetricKitCrashEvidenceCollector.swift new file mode 100644 index 00000000..e57ad2d6 --- /dev/null +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/MetricKitCrashEvidenceCollector.swift @@ -0,0 +1,96 @@ +import Foundation +import MetricKit + +/// MetricKit delivers previous-session diagnostics when Apple makes them +/// available, sometimes much later. This subscriber is intentionally separate +/// from transfer state: a report can never change a transfer's result. +public final class MetricKitCrashEvidenceCollector: NSObject, MXMetricManagerSubscriber, @unchecked Sendable { + public static let shared = MetricKitCrashEvidenceCollector(store: .shared) + + private let store: LocalCrashEvidenceStore + private let lock = NSLock() + private var subscribed = false + + public init(store: LocalCrashEvidenceStore) { + self.store = store + } + + public func configure(enabled: Bool) { + lock.lock() + defer { lock.unlock() } + store.setEnabled(enabled) + let shouldSubscribe = enabled && store.collectionToken() != nil + if shouldSubscribe && !subscribed { + MXMetricManager.shared.add(self) + subscribed = true + } else if !shouldSubscribe && subscribed { + MXMetricManager.shared.remove(self) + subscribed = false + } + } + + public func didReceive(_ payloads: [MXDiagnosticPayload]) { + guard let token = store.collectionToken() else { return } + for payload in payloads { + // The callback may race with opt-out or Clear. append checks the + // original token and the current preference under the store lock. + for crash in payload.crashDiagnostics ?? [] { + store.append(Self.crashSummary(crash, payload: payload), token: token) + } + for hang in payload.hangDiagnostics ?? [] { + store.append(Self.hangSummary(hang, payload: payload), token: token) + } + } + } + + private static func crashSummary(_ crash: MXCrashDiagnostic, payload: MXDiagnosticPayload) -> LocalCrashEvidence { + LocalCrashEvidence(kind: .crash, + periodBegin: payload.timeStampBegin, periodEnd: payload.timeStampEnd, + appVersion: crash.applicationVersion, build: crash.metaData.applicationBuildVersion, + processID: positivePID(crash.metaData.pid), signal: crash.signal?.intValue, + exceptionType: crash.exceptionType?.intValue, exceptionCode: crash.exceptionCode?.int64Value, + frames: MetricKitFrameParser.frames(from: crash.callStackTree.jsonRepresentation())) + } + + private static func hangSummary(_ hang: MXHangDiagnostic, payload: MXDiagnosticPayload) -> LocalCrashEvidence { + LocalCrashEvidence(kind: .hang, + periodBegin: payload.timeStampBegin, periodEnd: payload.timeStampEnd, + appVersion: hang.applicationVersion, build: hang.metaData.applicationBuildVersion, + processID: positivePID(hang.metaData.pid), + hangSeconds: hang.hangDuration.converted(to: UnitDuration.seconds).value, + frames: MetricKitFrameParser.frames(from: hang.callStackTree.jsonRepresentation())) + } + + private static func positivePID(_ pid: pid_t) -> Int32? { pid > 0 ? pid : nil } +} + +/// Parse only symbolication-safe numbers and UUIDs from Apple's stack JSON. +/// In particular, never retain binaryName, absolute address, or raw JSON. +enum MetricKitFrameParser { + static func frames(from data: Data) -> [LocalCrashEvidence.Frame] { + guard data.count <= 1_000_000, + let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let tree = root["callStackTree"] as? [String: Any], + let stacks = tree["callStacks"] as? [[String: Any]] else { return [] } + + // If Apple does not identify an attributed thread, don't guess one. + guard let attributed = stacks.first(where: { $0["threadAttributed"] as? Bool == true }), + let roots = attributed["callStackRootFrames"] as? [[String: Any]] else { return [] } + var pending = Array(roots.prefix(16).reversed()) + var result: [LocalCrashEvidence.Frame] = [] + var visited = 0 + while let frame = pending.popLast(), result.count < 8, visited < 64 { + visited += 1 + if let rawUUID = frame["binaryUUID"] as? String, + let uuid = UUID(uuidString: rawUUID), + let number = frame["offsetIntoBinaryTextSegment"] as? NSNumber, + number.int64Value >= 0 { + result.append(.init(binaryUUID: uuid, offset: number.uint64Value)) + } + if let children = frame["subFrames"] as? [[String: Any]] { + pending.append(contentsOf: children.prefix(16).reversed()) + } + } + return result + } +} diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferDiagnosticStore.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferDiagnosticStore.swift index d63146f8..a57450aa 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferDiagnosticStore.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferDiagnosticStore.swift @@ -50,6 +50,7 @@ public final class TransferDiagnosticStore: @unchecked Sendable { var footprintMB: Int? } private let directory: URL + private let systemEvidenceStore: LocalCrashEvidenceStore private let limit: Int private let session = UUID() private let lock = NSLock() @@ -57,9 +58,11 @@ public final class TransferDiagnosticStore: @unchecked Sendable { private var current: URL { directory.appendingPathComponent("current.jsonl") } private var previous: URL { directory.appendingPathComponent("previous.jsonl") } - init(directory: URL, limit: Int = 256 * 1024) { + init(directory: URL, limit: Int = 256 * 1024, + systemEvidenceStore: LocalCrashEvidenceStore = .shared) { self.directory = directory self.limit = limit + self.systemEvidenceStore = systemEvidenceStore } func record(_ event: SharedLogger.TransferEvent, run: UUID?, configure: (inout Event) -> Void = { _ in }) { @@ -138,7 +141,7 @@ public final class TransferDiagnosticStore: @unchecked Sendable { } } struct Export: Encodable { - let formatVersion = 1 + let formatVersion = 2 let exportedAt: Date let operatingSystem: String let appVersion: String @@ -146,7 +149,13 @@ public final class TransferDiagnosticStore: @unchecked Sendable { let recordingHadErrors: Bool let incompleteRecords: Int let events: [Event] + let systemEvidenceEnabled: Bool + let systemEvidenceRecordingHadErrors: Bool + let systemEvidenceDeletionHadErrors: Bool + let systemEvidenceNote: String + let systemEvidence: [LocalCrashEvidence] } + let systemEvidence = systemEvidenceStore.snapshot() let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 @@ -154,6 +163,11 @@ public final class TransferDiagnosticStore: @unchecked Sendable { operatingSystem: ProcessInfo.processInfo.operatingSystemVersionString, appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", build: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown", - recordingHadErrors: writeFailed, incompleteRecords: incompleteRecords, events: events)) + recordingHadErrors: writeFailed, incompleteRecords: incompleteRecords, events: events, + systemEvidenceEnabled: systemEvidence.enabled, + systemEvidenceRecordingHadErrors: systemEvidence.recordingHadErrors, + systemEvidenceDeletionHadErrors: systemEvidence.deletionHadErrors, + systemEvidenceNote: "Apple MetricKit may deliver crash or main-thread hang reports later, or not at all. Absence does not rule out a crash, memory kill, force quit, or background stall. Reports are not tied to a transfer by this export.", + systemEvidence: systemEvidence.records)) } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/LocalCrashEvidenceTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/LocalCrashEvidenceTests.swift new file mode 100644 index 00000000..c655162d --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/LocalCrashEvidenceTests.swift @@ -0,0 +1,415 @@ +import Foundation +import XCTest +@testable import BitMatchEngine + +final class LocalCrashEvidenceTests: XCTestCase { + private func isolatedDefaults() throws -> UserDefaults { + let name = "BitMatch.LocalCrashEvidenceTests.\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + addTeardownBlock { UserDefaults(suiteName: name)?.removePersistentDomain(forName: name) } + return defaults + } + + private final class Consent: @unchecked Sendable { + private let lock = NSLock() + private var value = true + func read() -> Bool { lock.lock(); defer { lock.unlock() }; return value } + func revoke() { lock.lock(); defer { lock.unlock() }; value = false } + } + + private final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var value: Date + init(_ seconds: TimeInterval) { value = Date(timeIntervalSince1970: seconds) } + func read() -> Date { lock.lock(); defer { lock.unlock() }; return value } + func advance(to seconds: TimeInterval) { + lock.lock(); defer { lock.unlock() }; value = Date(timeIntervalSince1970: seconds) + } + } + + private final class RemovalFault: @unchecked Sendable { + private let lock = NSLock() + private var active = false + private var failEpoch = false + func shouldFail(_ url: URL) -> Bool { + lock.lock(); defer { lock.unlock() } + return active && (url.lastPathComponent == "system-evidence.json" + || (failEpoch && url.lastPathComponent == "system-evidence-epoch.json")) + } + func set(_ value: Bool, epoch: Bool = false) { + lock.lock(); defer { lock.unlock() }; active = value; failEpoch = epoch + } + } + + private final class SyncFault: @unchecked Sendable { + private let lock = NSLock() + private var failing = false + func allow() -> Bool { lock.lock(); defer { lock.unlock() }; return !failing } + func setFailing(_ value: Bool) { lock.lock(); defer { lock.unlock() }; failing = value } + } + + private func fixture() throws -> (URL, LocalCrashEvidenceStore) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("crash-evidence-test-\(UUID())", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + return (root, LocalCrashEvidenceStore(directory: root, defaults: try isolatedDefaults(), + now: { Date(timeIntervalSince1970: 50) }, synchronizeDefaults: { _ in true })) + } + + private func evidence(_ value: Int = 11, periodBegin: TimeInterval = 100) -> LocalCrashEvidence { + LocalCrashEvidence(kind: .crash, periodBegin: Date(timeIntervalSince1970: periodBegin), + periodEnd: Date(timeIntervalSince1970: TimeInterval(200 + value)), + appVersion: "0.2.9 PRIVATE_FOOTAGE", build: "5 SECRET_SET", + signal: value, exceptionType: 1, exceptionCode: 2, + frames: [.init(binaryUUID: UUID(uuidString: "70B89F27-1634-3580-A695-57CDB41D7743")!, offset: 165_304)]) + } + + func testDefaultOffAndDelayedCallbackCannotRestoreEvidenceAfterOptOut() throws { + let (root, store) = try fixture() + XCTAssertNil(store.collectionToken()) + store.setEnabled(true) + let delayedCallbackToken = try XCTUnwrap(store.collectionToken()) + store.append(evidence(), token: delayedCallbackToken) + XCTAssertEqual(store.snapshot().records.count, 1) + + store.setEnabled(false) + XCTAssertFalse(FileManager.default.fileExists(atPath: root.appendingPathComponent("system-evidence.json").path)) + store.append(evidence(12), token: delayedCallbackToken) + XCTAssertTrue(store.snapshot().records.isEmpty) + + // Re-enabling must not make the old callback's token valid again. + store.setEnabled(true) + store.append(evidence(13), token: delayedCallbackToken) + XCTAssertTrue(store.snapshot().records.isEmpty) + let liveToken = try XCTUnwrap(store.collectionToken()) + store.append(evidence(14), token: liveToken) + XCTAssertEqual(store.snapshot().records.map(\.signal), [14]) + } + + func testDefaultOffLaunchDoesNotRequirePreferenceSync() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("default-off-\(UUID())") + let store = LocalCrashEvidenceStore(directory: root, defaults: try isolatedDefaults(), + synchronizeDefaults: { _ in false }) + store.setEnabled(false) + XCTAssertNil(store.collectionToken()) + XCTAssertFalse(store.snapshot().recordingHadErrors) + XCTAssertFalse(FileManager.default.fileExists(atPath: root.path)) + } + + func testClearInvalidatesCallbackAndDuplicatesAreIgnored() throws { + let (_, store) = try fixture() + store.setEnabled(true) + let token = try XCTUnwrap(store.collectionToken()) + store.append(evidence(), token: token) + store.append(evidence(), token: token) + XCTAssertEqual(store.snapshot().records.count, 1) + store.clear() + store.append(evidence(12), token: token) + XCTAssertTrue(store.snapshot().records.isEmpty) + } + + func testClearEpochBlocksLaterRedeliveryAcrossRestartAndReenable() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("epoch-test-\(UUID())") + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let clock = Clock(50) + let defaults = try isolatedDefaults() + let store = LocalCrashEvidenceStore(directory: root, defaults: defaults, now: { clock.read() }, + synchronizeDefaults: { _ in true }) + store.setEnabled(true) + let firstToken = try XCTUnwrap(store.collectionToken()) + store.append(evidence(), token: firstToken) + XCTAssertEqual(store.snapshot().records.count, 1) + + clock.advance(to: 150) + store.clear() + XCTAssertTrue(store.snapshot().records.isEmpty) + store.append(evidence(12), token: firstToken) // Already processing at Clear. + let afterClear = try XCTUnwrap(store.collectionToken()) + store.append(evidence(12), token: afterClear) // Delivered after Clear. + XCTAssertTrue(store.snapshot().records.isEmpty) + + let restarted = LocalCrashEvidenceStore(directory: root, defaults: defaults, now: { clock.read() }, + synchronizeDefaults: { _ in true }) + restarted.setEnabled(true) + let restartToken = try XCTUnwrap(restarted.collectionToken()) + restarted.append(evidence(13), token: restartToken) // Old payload redelivered. + XCTAssertTrue(restarted.snapshot().records.isEmpty) + restarted.append(evidence(14, periodBegin: 160), token: restartToken) + XCTAssertEqual(restarted.snapshot().records.map(\.signal), [14]) + + restarted.setEnabled(false) + XCTAssertFalse(FileManager.default.fileExists(atPath: root.appendingPathComponent("system-evidence-epoch.json").path)) + clock.advance(to: 170) + restarted.setEnabled(true) + let reenabledToken = try XCTUnwrap(restarted.collectionToken()) + restarted.append(evidence(15, periodBegin: 160), token: reenabledToken) + XCTAssertTrue(restarted.snapshot().records.isEmpty) + restarted.append(evidence(16, periodBegin: 180), token: reenabledToken) + XCTAssertEqual(restarted.snapshot().records.map(\.signal), [16]) + } + + func testPersistRechecksPreferenceEvenBeforeSubscriberIsRemoved() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("consent-test-\(UUID())") + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let consent = Consent() + let store = LocalCrashEvidenceStore(directory: root, defaults: try isolatedDefaults(), + consentProvider: { consent.read() }, + now: { Date(timeIntervalSince1970: 50) }, synchronizeDefaults: { _ in true }) + store.setEnabled(true) + let token = try XCTUnwrap(store.collectionToken()) + consent.revoke() // Models the Settings write racing a queued Apple callback. + store.append(evidence(), token: token) + XCTAssertTrue(store.snapshot().records.isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: root.appendingPathComponent("system-evidence.json").path)) + } + + func testOversizedEvidenceFileIsNeverReadIntoExport() throws { + let (root, store) = try fixture() + store.setEnabled(true) + try Data(repeating: 0, count: 65 * 1024) + .write(to: root.appendingPathComponent("system-evidence.json")) + let snapshot = store.snapshot() + XCTAssertTrue(snapshot.enabled) + XCTAssertTrue(snapshot.records.isEmpty) + XCTAssertTrue(snapshot.recordingHadErrors) + } + + func testEpochPersistenceFailureDisablesCollection() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("blocked-epoch-\(UUID())") + try Data().write(to: root) // A file blocks creation of the private directory. + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let store = LocalCrashEvidenceStore(directory: root, defaults: try isolatedDefaults(), + now: { Date(timeIntervalSince1970: 50) }, synchronizeDefaults: { _ in true }) + store.setEnabled(true) + XCTAssertNil(store.collectionToken()) + XCTAssertFalse(store.snapshot().enabled) + XCTAssertTrue(store.snapshot().recordingHadErrors) + } + + func testConsentBoundaryPersistenceFailureDisablesCollection() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("blocked-consent-\(UUID())") + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let store = LocalCrashEvidenceStore(directory: root, defaults: try isolatedDefaults(), + now: { Date(timeIntervalSince1970: 50) }, synchronizeDefaults: { _ in false }) + store.setEnabled(true) + XCTAssertNil(store.collectionToken()) + XCTAssertFalse(store.snapshot().enabled) + XCTAssertTrue(store.snapshot().recordingHadErrors) + XCTAssertFalse(FileManager.default.fileExists(atPath: root.appendingPathComponent("system-evidence.json").path)) + } + + func testFailedDeletionKeepsCollectionOffAndIsVisibleAfterRestart() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("delete-fault-\(UUID())") + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let clock = Clock(50) + let fault = RemovalFault() + let defaults = try isolatedDefaults() + let remove: @Sendable (URL) throws -> Void = { url in + if fault.shouldFail(url) { + throw CocoaError(.fileWriteNoPermission) + } + try FileManager.default.removeItem(at: url) + } + let store = LocalCrashEvidenceStore(directory: root, defaults: defaults, + now: { clock.read() }, removeItem: remove, synchronizeDefaults: { _ in true }) + store.setEnabled(true) + store.append(evidence(), token: try XCTUnwrap(store.collectionToken())) + + fault.set(true) + clock.advance(to: 150) + store.clear() + XCTAssertNil(store.collectionToken()) + XCTAssertTrue(store.snapshot().deletionHadErrors) + XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent("system-evidence.json").path)) + let exportStore = TransferDiagnosticStore(directory: root.appendingPathComponent("transfer"), + systemEvidenceStore: store) + let export = try XCTUnwrap(JSONSerialization.jsonObject(with: exportStore.exportData()) as? [String: Any]) + XCTAssertEqual(export["systemEvidenceDeletionHadErrors"] as? Bool, true) + XCTAssertEqual((export["systemEvidence"] as? [Any])?.count, 0) + store.setEnabled(true) + XCTAssertNil(store.collectionToken()) // Re-enable must retry deletion first. + + let restarted = LocalCrashEvidenceStore(directory: root, defaults: defaults, + now: { clock.read() }, removeItem: remove, synchronizeDefaults: { _ in true }) + restarted.setEnabled(true) + XCTAssertNil(restarted.collectionToken()) // Old bytes remain after restart. + XCTAssertTrue(restarted.snapshot().deletionHadErrors) + + fault.set(false) + restarted.setEnabled(true) + XCTAssertNotNil(restarted.collectionToken()) + XCTAssertFalse(restarted.snapshot().deletionHadErrors) + XCTAssertTrue(restarted.snapshot().records.isEmpty) + + restarted.append(evidence(14, periodBegin: 160), token: try XCTUnwrap(restarted.collectionToken())) + fault.set(true) + restarted.setEnabled(false) + XCTAssertTrue(restarted.snapshot().deletionHadErrors) + XCTAssertNil(restarted.collectionToken()) + restarted.setEnabled(true) + XCTAssertNil(restarted.collectionToken()) + fault.set(false) + clock.advance(to: 170) + restarted.setEnabled(true) + XCTAssertNotNil(restarted.collectionToken()) + XCTAssertTrue(restarted.snapshot().records.isEmpty) + } + + func testBothFilesFailToDeleteThenRestartCannotRestoreOldConsent() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("both-delete-fault-\(UUID())") + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let defaults = try isolatedDefaults() + let clock = Clock(50) + let fault = RemovalFault() + let remove: @Sendable (URL) throws -> Void = { url in + if fault.shouldFail(url) { throw CocoaError(.fileWriteNoPermission) } + try FileManager.default.removeItem(at: url) + } + let store = LocalCrashEvidenceStore(directory: root, defaults: defaults, + now: { clock.read() }, removeItem: remove, synchronizeDefaults: { _ in true }) + store.setEnabled(true) + store.append(evidence(), token: try XCTUnwrap(store.collectionToken())) + clock.advance(to: 150) + fault.set(true, epoch: true) + store.setEnabled(false) + XCTAssertNil(store.collectionToken()) + XCTAssertTrue(store.snapshot().deletionHadErrors) + XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent("system-evidence.json").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent("system-evidence-epoch.json").path)) + + // Simulate a new process where the preference has been turned on. + let restarted = LocalCrashEvidenceStore(directory: root, defaults: defaults, + now: { clock.read() }, removeItem: remove, synchronizeDefaults: { _ in true }) + restarted.setEnabled(true) + XCTAssertNil(restarted.collectionToken()) + XCTAssertTrue(restarted.snapshot().deletionHadErrors) + + fault.set(false) + clock.advance(to: 170) + restarted.setEnabled(true) + let token = try XCTUnwrap(restarted.collectionToken()) + restarted.append(evidence(12), token: token) + XCTAssertTrue(restarted.snapshot().records.isEmpty) + restarted.append(evidence(13, periodBegin: 180), token: token) + XCTAssertEqual(restarted.snapshot().records.map(\.signal), [13]) + } + + func testFailedBoundaryAndDeletionLeaveTombstoneAcrossRestart() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("combined-fault-\(UUID())") + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let defaults = try isolatedDefaults() + let clock = Clock(50) + let removal = RemovalFault() + let sync = SyncFault() + let remove: @Sendable (URL) throws -> Void = { url in + if removal.shouldFail(url) { throw CocoaError(.fileWriteNoPermission) } + try FileManager.default.removeItem(at: url) + } + let store = LocalCrashEvidenceStore(directory: root, defaults: defaults, + now: { clock.read() }, removeItem: remove, + synchronizeDefaults: { _ in sync.allow() }) + store.setEnabled(true) + store.append(evidence(), token: try XCTUnwrap(store.collectionToken())) + + clock.advance(to: 150) + removal.set(true, epoch: true) + sync.setFailing(true) + store.clear() + XCTAssertNil(store.collectionToken()) + XCTAssertTrue(store.snapshot().deletionHadErrors) + XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent("system-evidence.json").path)) + + // Model a failed preference flush: the next process sees the old + // boundary while both old evidence and the new blocked epoch survive. + defaults.set(Date(timeIntervalSince1970: 50), forKey: "BitMatchCrashEvidenceConsentBoundary") + let restarted = LocalCrashEvidenceStore(directory: root, defaults: defaults, + now: { clock.read() }, removeItem: remove, + synchronizeDefaults: { _ in true }) + restarted.setEnabled(true) + XCTAssertNil(restarted.collectionToken()) + XCTAssertTrue(restarted.snapshot().records.isEmpty) + + removal.set(false) + restarted.setEnabled(true) + let token = try XCTUnwrap(restarted.collectionToken()) + restarted.append(evidence(12), token: token) + XCTAssertTrue(restarted.snapshot().records.isEmpty) + restarted.append(evidence(13, periodBegin: 160), token: token) + XCTAssertEqual(restarted.snapshot().records.map(\.signal), [13]) + } + + func testBoundedAllowlistedExportAndNoRawStackStrings() throws { + let (root, store) = try fixture() + store.setEnabled(true) + let token = try XCTUnwrap(store.collectionToken()) + for index in 0..<40 { store.append(evidence(index), token: token) } + let fileSize = try root.appendingPathComponent("system-evidence.json") + .resourceValues(forKeys: [.fileSizeKey]).fileSize + XCTAssertLessThanOrEqual(fileSize ?? .max, 64 * 1024) + let transferStore = TransferDiagnosticStore(directory: root.appendingPathComponent("transfer"), + systemEvidenceStore: store) + let json = try transferStore.exportData() + let text = String(decoding: json, as: UTF8.self) + XCTAssertFalse(text.contains("PRIVATE_FOOTAGE")) + XCTAssertFalse(text.contains("SECRET_SET")) + XCTAssertFalse(text.contains("binaryName")) + XCTAssertFalse(text.contains("address")) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: Any]) + let rows = try XCTUnwrap(object["systemEvidence"] as? [[String: Any]]) + XCTAssertEqual(rows.count, 16) + XCTAssertEqual(rows.first?["signal"] as? Int, 24) + XCTAssertEqual(rows.last?["signal"] as? Int, 39) + XCTAssertEqual(rows.last?["appVersion"] as? String, "0.2.9") + XCTAssertEqual(rows.last?["build"] as? String, "5") + XCTAssertEqual(object["systemEvidenceEnabled"] as? Bool, true) + XCTAssertNotNil(object["systemEvidenceNote"] as? String) + store.setEnabled(false) + let disabled = try XCTUnwrap(JSONSerialization.jsonObject(with: transferStore.exportData()) as? [String: Any]) + XCTAssertEqual((disabled["systemEvidence"] as? [Any])?.count, 0) + XCTAssertEqual(disabled["systemEvidenceEnabled"] as? Bool, false) + } + + func testDecodedLocalFileCannotBypassExportSanitization() throws { + let (root, store) = try fixture() + store.setEnabled(true) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + var row = try XCTUnwrap(JSONSerialization.jsonObject(with: encoder.encode(evidence())) as? [String: Any]) + row["appVersion"] = "0.2.9 PRIVATE_FOOTAGE" + row["build"] = "5 SECRET_SET" + row["frames"] = Array(repeating: [ + "binaryUUID": "70B89F27-1634-3580-A695-57CDB41D7743", "offset": 165_304 + ] as [String: Any], count: 32) + let file = root.appendingPathComponent("system-evidence.json") + try JSONSerialization.data(withJSONObject: [row]).write(to: file) + + let decoded = try XCTUnwrap(store.snapshot().records.first) + XCTAssertEqual(decoded.appVersion, "0.2.9") + XCTAssertEqual(decoded.build, "5") + XCTAssertEqual(decoded.frames.count, 8) + let transferStore = TransferDiagnosticStore(directory: root.appendingPathComponent("transfer"), + systemEvidenceStore: store) + let export = String(decoding: try transferStore.exportData(), as: UTF8.self) + XCTAssertFalse(export.contains("PRIVATE_FOOTAGE")) + XCTAssertFalse(export.contains("SECRET_SET")) + } + + func testFrameParserRetainsOnlyAttributedUUIDAndOffset() throws { + let marker = "SECRET_FOOTAGE_" + UUID().uuidString + let json: [String: Any] = ["callStackTree": ["callStacks": [ + ["threadAttributed": false, "callStackRootFrames": [["binaryUUID": UUID().uuidString, + "offsetIntoBinaryTextSegment": 1, "binaryName": marker]]], + ["threadAttributed": true, "callStackRootFrames": [["binaryUUID": "70B89F27-1634-3580-A695-57CDB41D7743", + "offsetIntoBinaryTextSegment": 165_304, "binaryName": marker, "address": 7_170_766_264, + "subFrames": [["binaryUUID": "77A62F2E-8212-30F3-84C1-E8497440ACF8", + "offsetIntoBinaryTextSegment": 6_948, "binaryName": marker]]]]] + ]]] + let frames = MetricKitFrameParser.frames(from: try JSONSerialization.data(withJSONObject: json)) + XCTAssertEqual(frames.map(\.offset), [165_304, 6_948]) + let encoded = String(decoding: try JSONEncoder().encode(frames), as: UTF8.self) + XCTAssertFalse(encoded.contains(marker)) + XCTAssertFalse(encoded.contains("address")) + XCTAssertFalse(encoded.contains("binaryName")) + } +} diff --git a/Shared/Core/Models/GeneralSettings.swift b/Shared/Core/Models/GeneralSettings.swift index f98aa1ac..bcb95e63 100644 --- a/Shared/Core/Models/GeneralSettings.swift +++ b/Shared/Core/Models/GeneralSettings.swift @@ -1,6 +1,7 @@ import Foundation import Combine import UserNotifications +import BitMatchEngine /// Settings that shape the everyday transfer workflow on every platform. /// UserDefaults is injected so the defaults and migration stay testable. @@ -14,6 +15,7 @@ final class GeneralSettings: ObservableObject { static let playSoundsKey = "BitMatchPlaySounds" static let notificationPromptAskedKey = "BitMatchNotificationPromptAsked" static let legacyNotifyFinishKey = "BitMatchNotifyWhenTransferEnds" + static let localCrashEvidenceKey = LocalCrashEvidenceStore.consentKey private let defaults: UserDefaults @@ -35,6 +37,9 @@ final class GeneralSettings: ObservableObject { @Published var playSounds: Bool { didSet { defaults.set(playSounds, forKey: Self.playSoundsKey) } } + @Published var collectLocalCrashEvidence: Bool { + didSet { defaults.set(collectLocalCrashEvidence, forKey: Self.localCrashEvidenceKey) } + } @Published private(set) var notificationPromptWasAnswered: Bool { didSet { defaults.set(notificationPromptWasAnswered, forKey: Self.notificationPromptAskedKey) } } @@ -59,6 +64,7 @@ final class GeneralSettings: ObservableObject { queueNewCardsAutomatically = Self.bool(defaults, key: Self.queueCardsAutomaticallyKey, default: false) autoEjectWhenSafe = Self.bool(defaults, key: Self.autoEjectKey, default: false) playSounds = Self.bool(defaults, key: Self.playSoundsKey, default: false) + collectLocalCrashEvidence = Self.bool(defaults, key: Self.localCrashEvidenceKey, default: false) notificationPromptWasAnswered = Self.bool(defaults, key: Self.notificationPromptAskedKey, default: false) } diff --git a/Shared/Views/LocalCrashEvidenceSettingView.swift b/Shared/Views/LocalCrashEvidenceSettingView.swift new file mode 100644 index 00000000..f730d6cb --- /dev/null +++ b/Shared/Views/LocalCrashEvidenceSettingView.swift @@ -0,0 +1,58 @@ +import BitMatchEngine +import SwiftUI + +/// One shared control for Mac, iPhone and iPad. The collector is never started +/// unless the operator explicitly turns this on. +struct LocalCrashEvidenceSettingView: View { + @ObservedObject var settings: GeneralSettings + @State private var collectionAvailable = true + @State private var deletionFailed = false + + var body: some View { + Toggle("Save local crash and hang evidence", isOn: Binding( + get: { settings.collectLocalCrashEvidence }, + set: { enabled in + if !enabled { MetricKitCrashEvidenceCollector.shared.configure(enabled: false) } + settings.collectLocalCrashEvidence = enabled + if enabled { MetricKitCrashEvidenceCollector.shared.configure(enabled: true) } + refreshStatus() + } + )) + .onAppear { refreshStatus() } + Text("Adds a small Apple crash/hang summary to Export diagnostics when available. Off by default; BitMatch never uploads it. Turning this off deletes saved summaries.") + .font(.footnote) + .foregroundStyle(.secondary) + if deletionFailed { + Text("BitMatch couldn't delete the saved crash evidence. Please try again.") + .font(.footnote) + .foregroundStyle(.orange) + Button("Retry deletion") { + if settings.collectLocalCrashEvidence { + LocalCrashEvidenceStore.shared.clear() + MetricKitCrashEvidenceCollector.shared.configure(enabled: true) + } else { + MetricKitCrashEvidenceCollector.shared.configure(enabled: false) + } + refreshStatus() + } + } + if settings.collectLocalCrashEvidence { + if !collectionAvailable { + Text("Crash evidence could not be enabled on this device. No Apple summaries are being saved.") + .font(.footnote) + .foregroundStyle(.orange) + } + Button("Clear saved crash evidence") { + LocalCrashEvidenceStore.shared.clear() + MetricKitCrashEvidenceCollector.shared.configure(enabled: settings.collectLocalCrashEvidence) + refreshStatus() + } + } + } + + private func refreshStatus() { + let status = LocalCrashEvidenceStore.shared.snapshot() + collectionAvailable = status.enabled + deletionFailed = status.deletionHadErrors + } +} diff --git a/Shared/Views/TransferLibraryView.swift b/Shared/Views/TransferLibraryView.swift index a73b568b..3a6fecbe 100644 --- a/Shared/Views/TransferLibraryView.swift +++ b/Shared/Views/TransferLibraryView.swift @@ -145,7 +145,7 @@ struct TransferLibraryView: View { } .buttonStyle(.plain) .foregroundStyle(.primary) - Text("Recent transfer phases and errors. No footage names or paths. Nothing is sent automatically.") + Text("Recent transfer phases and errors, plus Apple crash/hang summaries if enabled in Settings and available. No footage names or paths. Nothing is sent automatically.") .font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 177af003..31758794 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -9,7 +9,17 @@ Keep the source card. Open **History → Export diagnostics…** and save the JS The export contains recent transfer and Check phases, numerical progress, filesystem types, error codes, cancellation origins, app/OS versions and random operation IDs. Events also include process memory use when available. It does **not** contain footage names, paths, production labels, bookmarks, checksums or the full transfer history. Nothing is uploaded automatically. -The local record keeps at most two 256 KiB files. Export soon after the problem, before later activity replaces the older events. A recording failure is reported in the export; diagnostics never change the result of a transfer. +**Optional Apple crash and hang evidence:** In Settings → Diagnostics, **Save local crash and hang evidence** is off by default on Mac, iPhone and iPad. If you turn it on, BitMatch asks Apple's MetricKit for crash and main-thread hang reports and saves only a small summary when Apple delivers one. The diagnostics export can then include the report period, app version/build, numeric crash codes or hang duration, and up to eight attributed stack frames as binary UUIDs and offsets. It never includes Apple's raw payload, stack names, absolute addresses, exception messages or footage details. At most 16 summaries are retained locally. Turning the setting off or choosing **Clear saved crash evidence** deletes the local summaries and prevents delayed Apple callbacks from restoring them. If deletion fails, Settings warns you and collection stays off until removal succeeds. An export already being prepared or one you saved is a separate copy; delete that file yourself if you no longer want to keep it. BitMatch never uploads this evidence automatically. + +Apple may deliver a report after a later app launch, or no report at all. This does not guarantee evidence for an out-of-memory termination, force quit, sudden power loss or a blocked background worker. The export does not claim that a MetricKit report belongs to a particular BitMatch transfer; compare its period and app version with the transfer events and any system report. An empty `systemEvidence` array is not evidence of a clean exit. + +BitMatch keeps an opt-in/clear time in app preferences as well as the private evidence folder. It accepts a report only when its **entire reporting period begins after that time**. Apple's reports can cover a broad period, so the first ambiguous period after enabling or clearing may be skipped. If BitMatch cannot save the time or remove stale evidence, collection stays off rather than storing a report with uncertain consent. + +If the device refuses **every** consent write and deletion at once, a later launch cannot prove that an earlier Clear happened. Settings warns about the failed deletion while the app is open. Retry after storage works, or remove the local `BitMatch/CrashEvidence` folder yourself. This is why a failed Clear is never reported as successful. + +MetricKit delivery for BitMatch's Developer ID/notarized Mac distribution has not yet been demonstrated. A signed Mac pilot is required before treating this as a dependable field diagnostic. This feature does not establish the causes of [issue #10](https://github.com/BitmatchApp/Bitmatch/issues/10) or [issue #23](https://github.com/BitmatchApp/Bitmatch/issues/23). + +The transfer-event record keeps at most two 256 KiB files. Export soon after the problem, before later activity replaces the older events. A recording failure is reported in the export; diagnostics never change the result of a transfer. Screenshots, transfer reports and Check's differences export can contain names and paths. Review those separately before sharing them; they are not the same as the diagnostics export. diff --git a/docs/TODO.md b/docs/TODO.md index eb38adda..639991c2 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -114,7 +114,7 @@ The priority buckets are not a strict implementation sequence: investigate P0 re - [x] **Finish local terminal progress/outcome parity.** The engine withholds `.completed` and 100% when selected rows fail. The merged local slice adds an active Finishing state while MHL/report/journal evidence settles, keeps cancelled and unexpectedly interrupted wording distinct, and refuses a safe History/Finish verdict for missing, duplicated or copied-but-unverified selected results. Mac/iPhone/iPad shared presentation and focused cancellation/terminal tests cover this local slice; a percentage alone is never a success verdict. Future remote presentation and the signed-app fault walkthrough remain separate release gates. -- [ ] **Opt-in crash reports.** Investigate Apple's MetricKit crash/hang diagnostics, off by default, with no third-party service or automatic sending. Existing local diagnostic exports already record phases, cancellations, verification breadcrumbs and memory use; they cannot establish why a process disappeared. +- [ ] **Opt-in crash reports.** A first bounded MetricKit summary is being validated: off by default on Mac/iPhone/iPad, local only, a small allowlisted export, deletion on opt-out and no automatic sending. It may arrive later or never; it cannot establish every cause of process disappearance, including an OS memory kill or force quit. Developer ID delivery is unproven: before treating this as released, validate a signed/notarized Mac delivery, physical iPhone/iPad behavior and the Settings/export walkthrough. This does not close #10 or #23. Existing transfer diagnostics still record phases, cancellations, verification breadcrumbs and memory use; neither source alone proves why a process disappeared. ## P2 — Evidence and interoperability