import Foundation public struct CrashReportMetadata: Codable, Sendable { public var source: String? public var bundleIdentifier: String? public var processName: String? public var processPath: String? public var startedAt: String? public var appVersion: String? public var appMarketingVersion: String? public var coreVersion: String? public var goVersion: String? public var crashedAt: String? public var signalName: String? public var signalCode: String? public var exceptionName: String? public var exceptionReason: String? public var deviceOrigin: String? public init( source: String? = nil, bundleIdentifier: String? = nil, processName: String? = nil, processPath: String? = nil, startedAt: String? = nil, appVersion: String? = nil, appMarketingVersion: String? = nil, coreVersion: String? = nil, goVersion: String? = nil, crashedAt: String? = nil, signalName: String? = nil, signalCode: String? = nil, exceptionName: String? = nil, exceptionReason: String? = nil, deviceOrigin: String? = nil ) { self.source = source self.bundleIdentifier = bundleIdentifier self.processName = processName self.processPath = processPath self.startedAt = startedAt self.appVersion = appVersion self.appMarketingVersion = appMarketingVersion self.coreVersion = coreVersion self.goVersion = goVersion self.crashedAt = crashedAt self.signalName = signalName self.signalCode = signalCode self.exceptionName = exceptionName self.exceptionReason = exceptionReason self.deviceOrigin = deviceOrigin } } public struct CrashReportArtifactContents { public var goLog: String? public var nativeLog: String? public var configContent: String? public init(goLog: String? = nil, nativeLog: String? = nil, configContent: String? = nil) { self.goLog = goLog self.nativeLog = nativeLog self.configContent = configContent } public var isEmpty: Bool { let goBody = goLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let nativeBody = nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return goBody.isEmpty && nativeBody.isEmpty } } public enum ReportArchive { public static let readMarkerFileName = ".read" public static let metadataFileName = "metadata.json" public static let configFileName = "configuration.json" public static let tvOSDeviceOrigin = "tvOS" public static let timestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH-mm-ss" formatter.timeZone = TimeZone(identifier: "UTC") return formatter }() public static func parseArtifactDate(for artifactURL: URL) -> Date? { let name = artifactURL.lastPathComponent let components = name.components(separatedBy: "-") let baseName: String if components.count > 5, let suffix = components.last, Int(suffix) != nil { baseName = components.dropLast().joined(separator: "-") } else { baseName = components.joined(separator: "-") } return timestampFormatter.date(from: baseName) } public static func nextAvailableArtifactURL(in directory: URL, for date: Date) -> URL { let baseName = timestampFormatter.string(from: date) var index = 0 while true { let suffix = index == 0 ? "" : "-\(index)" let artifactURL = directory.appendingPathComponent(baseName + suffix, isDirectory: true) if !FileManager.default.fileExists(atPath: artifactURL.path) { return artifactURL } index += 1 } } static func removeArtifact(at artifactURL: URL) { try? FileManager.default.removeItem(at: artifactURL) } } public enum CrashReportArchive { static let pendingNativeCrashDirectoryName = "native_crash_pending" static let pendingNativeCrashStorageDirectoryName = "com.plausiblelabs.crashreporter.data" static let pendingNativeCrashReportFileName = "live_report.plcrash" static let goLogFileName = "go.log" static let nativeLogFileName = "native.log" static var crashReportsDirectory: URL { FilePath.workingDirectory.appendingPathComponent("crash_reports", isDirectory: true) } static var pendingNativeCrashBaseDirectory: URL { FilePath.sharedDirectory.appendingPathComponent(pendingNativeCrashDirectoryName, isDirectory: true) } static func metadataURL(for artifactURL: URL) -> URL { artifactURL.appendingPathComponent(ReportArchive.metadataFileName) } static func goLogURL(for artifactURL: URL) -> URL { artifactURL.appendingPathComponent(goLogFileName) } static func nativeLogURL(for artifactURL: URL) -> URL { artifactURL.appendingPathComponent(nativeLogFileName) } static func configURL(for artifactURL: URL) -> URL { artifactURL.appendingPathComponent(ReportArchive.configFileName) } static func pendingNativeCrashReportURL(bundleIdentifier: String) -> URL { pendingNativeCrashReportURL(basePath: pendingNativeCrashBaseDirectory, bundleIdentifier: bundleIdentifier) } public static func pendingNativeCrashReportURL(basePath: URL, bundleIdentifier: String) -> URL { basePath .appendingPathComponent(pendingNativeCrashStorageDirectoryName, isDirectory: true) .appendingPathComponent(bundleIdentifier.replacingOccurrences(of: "/", with: "_"), isDirectory: true) .appendingPathComponent(pendingNativeCrashReportFileName) } public static func writeArchivedReport(contents: CrashReportArtifactContents, date: Date, metadata: CrashReportMetadata) throws -> URL { guard !contents.isEmpty else { throw NSError(domain: "CrashReportArchive", code: 1, userInfo: [NSLocalizedDescriptionKey: "Empty crash report"]) } let dir = crashReportsDirectory try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let artifactURL = nextAvailableArtifactURL(for: date) try rewriteArchivedReport(at: artifactURL, contents: contents, metadata: metadata) return artifactURL } static func rewriteArchivedReport(at artifactURL: URL, contents: CrashReportArtifactContents, metadata: CrashReportMetadata) throws { guard !contents.isEmpty else { throw NSError(domain: "CrashReportArchive", code: 1, userInfo: [NSLocalizedDescriptionKey: "Empty crash report"]) } try FileManager.default.createDirectory(at: artifactURL, withIntermediateDirectories: true) if let goLog = contents.goLog?.trimmingCharacters(in: .whitespacesAndNewlines), !goLog.isEmpty { try goLog.write(to: goLogURL(for: artifactURL), atomically: true, encoding: .utf8) } else { try? FileManager.default.removeItem(at: goLogURL(for: artifactURL)) } if let nativeLog = contents.nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines), !nativeLog.isEmpty { try nativeLog.write(to: nativeLogURL(for: artifactURL), atomically: true, encoding: .utf8) } else { try? FileManager.default.removeItem(at: nativeLogURL(for: artifactURL)) } if let configContent = contents.configContent?.trimmingCharacters(in: .whitespacesAndNewlines), !configContent.isEmpty { try configContent.write(to: configURL(for: artifactURL), atomically: true, encoding: .utf8) } else { try? FileManager.default.removeItem(at: configURL(for: artifactURL)) } let metadataData = try metadataEncoder.encode(metadata) try metadataData.write(to: metadataURL(for: artifactURL), options: .atomic) } public static func readMetadata(for artifactURL: URL) -> CrashReportMetadata? { guard let data = try? Data(contentsOf: metadataURL(for: artifactURL)) else { return nil } return try? JSONDecoder().decode(CrashReportMetadata.self, from: data) } public static func readContents(for artifactURL: URL) -> CrashReportArtifactContents { let goLog = try? String(contentsOf: goLogURL(for: artifactURL), encoding: .utf8) let nativeLog = try? String(contentsOf: nativeLogURL(for: artifactURL), encoding: .utf8) let configContent = try? String(contentsOf: configURL(for: artifactURL), encoding: .utf8) return CrashReportArtifactContents(goLog: goLog, nativeLog: nativeLog, configContent: configContent) } static func removeArtifact(at artifactURL: URL) { ReportArchive.removeArtifact(at: artifactURL) } static func crashDate(for artifactURL: URL) -> Date? { ReportArchive.parseArtifactDate(for: artifactURL) } static func iso8601String(from date: Date) -> String { iso8601Formatter.string(from: date) } static func displayContent(for contents: CrashReportArtifactContents) -> String { let goBody = contents.goLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let nativeBody = contents.nativeLog?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if nativeBody.isEmpty { return goBody } if goBody.isEmpty { return nativeBody } var sections: [String] = [] sections.append("===== Go Crash =====\n\n" + goBody) sections.append("===== Native Crash =====\n\n" + nativeBody) return sections.joined(separator: "\n\n") } private static func nextAvailableArtifactURL(for date: Date) -> URL { ReportArchive.nextAvailableArtifactURL(in: crashReportsDirectory, for: date) } private static let iso8601Formatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] formatter.timeZone = TimeZone(secondsFromGMT: 0) return formatter }() private static let metadataEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.outputFormatting = [] return encoder }() }