Files
OSGKeyboard/OSGKeyboard/Services/FlowFailureLogStore.swift
T
Rocky 39002336c0
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled
feat(app): add flow diagnostics and refine interface
Improve failure recovery visibility, strengthen account-session handling, and make the cross-platform interface more consistent.
2026-08-26 14:40:06 +08:00

272 lines
9.4 KiB
Swift

// FlowFailureLogStore.swift
// OSGKeyboard · Main App
//
// Keeps a short in-memory Flow breadcrumb window and writes it only when a
// session start reaches a terminal failure. Reports contain operational state,
// never audio, transcripts, clipboard contents, prompts, or credentials.
import Foundation
struct FlowFailureLogEvent: Codable, Equatable, Sendable {
let timestamp: Date
let message: String
}
struct FlowStartupFailureReport: Codable, Equatable, Sendable {
let schemaVersion: Int
let reportID: UUID
let capturedAt: Date
let windowStartedAt: Date
let reason: String
let context: [String: String]
let appVersion: String
let buildNumber: String
let operatingSystem: String
let events: [FlowFailureLogEvent]
}
private struct FlowStartupFailureExport: Codable, Sendable {
let schemaVersion: Int
let generatedAt: Date
let reports: [FlowStartupFailureReport]
}
final class FlowFailureLogStore: @unchecked Sendable {
static let shared = FlowFailureLogStore()
static let retentionWindow: TimeInterval = 10
private let directoryURL: URL
private let maxReportCount: Int
private let maxReportAge: TimeInterval
private let maxTotalBytes: Int
private let now: () -> Date
private let fileManager: FileManager
private let lock = NSLock()
private var events: [FlowFailureLogEvent] = []
init(
directoryURL: URL = FlowFailureLogStore.defaultDirectoryURL(),
maxReportCount: Int = 20,
maxReportAge: TimeInterval = 14 * 24 * 60 * 60,
maxTotalBytes: Int = 2 * 1_024 * 1_024,
now: @escaping () -> Date = Date.init,
fileManager: FileManager = .default
) {
self.directoryURL = directoryURL
self.maxReportCount = maxReportCount
self.maxReportAge = maxReportAge
self.maxTotalBytes = maxTotalBytes
self.now = now
self.fileManager = fileManager
}
func record(_ message: String) {
let timestamp = now()
let event = FlowFailureLogEvent(
timestamp: timestamp,
message: Self.redact(message)
)
lock.withLock {
events.append(event)
pruneEvents(referenceDate: timestamp)
}
}
@discardableResult
func persistStartupFailure(
reason: String,
context: [String: String]
) -> URL? {
let capturedAt = now()
let capturedEvents = lock.withLock {
pruneEvents(referenceDate: capturedAt)
return events
}
let report = FlowStartupFailureReport(
schemaVersion: 1,
reportID: UUID(),
capturedAt: capturedAt,
windowStartedAt: capturedEvents.first?.timestamp ?? capturedAt,
reason: Self.redact(reason),
context: context.mapValues(Self.redact),
appVersion: Self.bundleValue("CFBundleShortVersionString"),
buildNumber: Self.bundleValue("CFBundleVersion"),
operatingSystem: ProcessInfo.processInfo.operatingSystemVersionString,
events: capturedEvents
)
do {
try prepareDirectory()
let fileURL = directoryURL.appendingPathComponent(
Self.reportFilename(date: capturedAt, id: report.reportID),
isDirectory: false
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
try encoder.encode(report).write(to: fileURL, options: .atomic)
try? fileManager.setAttributes(
[
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication,
.modificationDate: capturedAt
],
ofItemAtPath: fileURL.path
)
pruneReports(referenceDate: capturedAt)
return fileURL
} catch {
return nil
}
}
func reportURLs() -> [URL] {
reportFileURLs().sorted { lhs, rhs in
modificationDate(for: lhs) > modificationDate(for: rhs)
}
}
func makeExportURL() -> URL? {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let reports = reportURLs().compactMap { url -> FlowStartupFailureReport? in
guard let data = try? Data(contentsOf: url) else { return nil }
return try? decoder.decode(FlowStartupFailureReport.self, from: data)
}
guard !reports.isEmpty else { return nil }
let export = FlowStartupFailureExport(
schemaVersion: 1,
generatedAt: now(),
reports: reports
)
do {
try prepareDirectory()
let exportURL = directoryURL.appendingPathComponent(
"flow-startup-diagnostics-export.json",
isDirectory: false
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
try encoder.encode(export).write(to: exportURL, options: .atomic)
try? fileManager.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: exportURL.path
)
return exportURL
} catch {
return nil
}
}
func deleteAllReports() {
for url in reportFileURLs() {
try? fileManager.removeItem(at: url)
}
try? fileManager.removeItem(
at: directoryURL.appendingPathComponent(
"flow-startup-diagnostics-export.json",
isDirectory: false
)
)
}
private func pruneEvents(referenceDate: Date) {
let cutoff = referenceDate.addingTimeInterval(-Self.retentionWindow)
events.removeAll { $0.timestamp < cutoff }
}
private func prepareDirectory() throws {
try fileManager.createDirectory(
at: directoryURL,
withIntermediateDirectories: true
)
try? fileManager.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: directoryURL.path
)
}
private func pruneReports(referenceDate: Date) {
let sorted = reportURLs()
var retainedBytes = 0
for (index, url) in sorted.enumerated() {
let values = try? url.resourceValues(
forKeys: [.contentModificationDateKey, .fileSizeKey]
)
let modifiedAt = values?.contentModificationDate ?? .distantPast
let fileSize = values?.fileSize ?? 0
let isExpired = referenceDate.timeIntervalSince(modifiedAt) > maxReportAge
let exceedsCount = index >= maxReportCount
let exceedsSize = retainedBytes + fileSize > maxTotalBytes
if isExpired || exceedsCount || exceedsSize {
try? fileManager.removeItem(at: url)
} else {
retainedBytes += fileSize
}
}
}
private func reportFileURLs() -> [URL] {
guard let urls = try? fileManager.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return urls.filter {
$0.pathExtension == "json" && $0.lastPathComponent.hasPrefix("flow-start-failure-")
}
}
private func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]))
.flatMap(\.contentModificationDate) ?? .distantPast
}
private static func defaultDirectoryURL() -> URL {
let baseURL = (try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)) ?? FileManager.default.temporaryDirectory
return baseURL.appendingPathComponent("FlowDiagnostics", isDirectory: true)
}
private static func bundleValue(_ key: String) -> String {
Bundle.main.object(forInfoDictionaryKey: key) as? String ?? "unknown"
}
private static func reportFilename(date: Date, id: UUID) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
let timestamp = formatter.string(from: date)
.replacingOccurrences(of: ":", with: "-")
return "flow-start-failure-\(timestamp)-\(id.uuidString.lowercased()).json"
}
private static func redact(_ value: String) -> String {
var redacted = value.replacingOccurrences(
of: #"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[1-5][0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}"#,
with: "<uuid>",
options: .regularExpression
)
redacted = redacted.replacingOccurrences(
of: #"/private/var/mobile/Containers/\S+"#,
with: "<container-path>",
options: .regularExpression
)
redacted = redacted.replacingOccurrences(
of: #"(?i)(api[_-]?key|authorization|bearer|access[_-]?token|refresh[_-]?token)=\S+"#,
with: "$1=<redacted>",
options: .regularExpression
)
return redacted
}
}