Tools View & Crash Report & OOM Report
This commit is contained in:
@@ -30,6 +30,13 @@ public enum AppConfiguration {
|
||||
"\(packageName).system"
|
||||
}
|
||||
|
||||
public static var packetTunnelBundleIDs: [String] {
|
||||
if extensionBundleID == systemExtensionBundleID {
|
||||
return [extensionBundleID]
|
||||
}
|
||||
return [extensionBundleID, systemExtensionBundleID]
|
||||
}
|
||||
|
||||
public static var fileProviderDomainID: String {
|
||||
"\(packageName).workingdir"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
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
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
import CrashReporter
|
||||
import Foundation
|
||||
import Libbox
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
private let logger = Logger(category: "CrashReportManager")
|
||||
|
||||
public struct CrashReport: Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let date: Date
|
||||
public let fileURL: URL
|
||||
public var isRead: Bool
|
||||
public let origin: String?
|
||||
}
|
||||
|
||||
public struct CrashReportFile: Identifiable, Hashable, Sendable {
|
||||
public enum Kind: String, Sendable {
|
||||
case goLog
|
||||
case nativeLog
|
||||
case metadata
|
||||
case configContent
|
||||
}
|
||||
|
||||
public let id: Kind
|
||||
public let displayName: String
|
||||
public let fileURL: URL
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class CrashReportManager: ObservableObject {
|
||||
@Published public private(set) var reports: [CrashReport] = []
|
||||
@Published public private(set) var unreadCount: Int = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
public nonisolated func refresh() async {
|
||||
let reports = await BlockingIO.run {
|
||||
Self.archivePendingCrashLogs()
|
||||
Self.importPendingNativeCrashReports()
|
||||
Self.coalesceArchivedCrashReports()
|
||||
return Self.scanCrashReports()
|
||||
}
|
||||
await MainActor.run {
|
||||
self.reports = reports
|
||||
self.unreadCount = reports.filter { !$0.isRead }.count
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func archivePendingCrashLogs() {
|
||||
for source in ["NetworkExtension", "Application"] {
|
||||
let url = FilePath.workingDirectory.appendingPathComponent("CrashReport-\(source).log")
|
||||
let oldURL = FilePath.workingDirectory.appendingPathComponent("CrashReport-\(source).log.old")
|
||||
archivePendingGoCrashLog(url, source: source)
|
||||
archivePendingGoCrashLog(oldURL, source: source)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
collectAndArchiveCrashArtifactsViaHelper()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private nonisolated static func collectAndArchiveCrashArtifactsViaHelper() {
|
||||
guard HelperServiceManager.rootHelperStatus == .enabled else {
|
||||
logger.debug("collectAndArchiveCrashArtifactsViaHelper: root helper not enabled, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
let artifacts: CrashArtifactsResult
|
||||
do {
|
||||
artifacts = try RootHelperClient.shared.collectAllCrashArtifacts()
|
||||
} catch {
|
||||
logger.warning("collectAndArchiveCrashArtifactsViaHelper: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
|
||||
var configContent: String?
|
||||
for crashLog in artifacts.crashLogs {
|
||||
if crashLog.fileName == ReportArchive.configFileName {
|
||||
let trimmed = crashLog.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
configContent = crashLog.content
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
guard !crashLog.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
let metadata: CrashReportMetadata
|
||||
if crashLog.fileName.contains("RootHelper") {
|
||||
metadata = CrashReportMetadataBuilder.normalized(
|
||||
CrashReportMetadataBuilder.rootHelperGoMetadata(crashDate: crashLog.modificationDate),
|
||||
content: crashLog.content
|
||||
)
|
||||
} else {
|
||||
metadata = CrashReportMetadataBuilder.normalized(
|
||||
CrashReportMetadataBuilder.systemExtensionGoMetadata(crashDate: crashLog.modificationDate),
|
||||
content: crashLog.content
|
||||
)
|
||||
}
|
||||
|
||||
_ = try? CrashReportArchive.writeArchivedReport(
|
||||
contents: CrashReportArtifactContents(goLog: crashLog.content, configContent: configContent),
|
||||
date: crashLog.modificationDate,
|
||||
metadata: metadata
|
||||
)
|
||||
}
|
||||
|
||||
for (data, source) in [
|
||||
(artifacts.extensionNativeCrashData, "NetworkExtension"),
|
||||
(artifacts.helperNativeCrashData, "RootHelper"),
|
||||
] {
|
||||
guard let data, !data.isEmpty else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
let crashReport = try PLCrashReport(data: data)
|
||||
guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS),
|
||||
!text.isEmpty
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let crashDate = crashReport.systemInfo.timestamp ?? Date()
|
||||
let metadata = CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: source)
|
||||
_ = try CrashReportArchive.writeArchivedReport(
|
||||
contents: CrashReportArtifactContents(nativeLog: text),
|
||||
date: crashDate,
|
||||
metadata: metadata
|
||||
)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private nonisolated static func archivePendingGoCrashLog(_ url: URL, source: String) {
|
||||
guard let content = try? String(contentsOf: url, encoding: .utf8),
|
||||
!content.isEmpty else { return }
|
||||
|
||||
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return
|
||||
}
|
||||
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||
let crashDate = (attrs?[.modificationDate] as? Date) ?? Date()
|
||||
let metadata = CrashReportMetadataBuilder.normalized(
|
||||
CrashReportMetadataBuilder.goMetadata(source: source, crashDate: crashDate),
|
||||
content: content
|
||||
)
|
||||
|
||||
let configContent = readAndCleanConfigSnapshot()
|
||||
|
||||
do {
|
||||
_ = try CrashReportArchive.writeArchivedReport(
|
||||
contents: CrashReportArtifactContents(goLog: content, configContent: configContent),
|
||||
date: crashDate,
|
||||
metadata: metadata
|
||||
)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func readAndCleanConfigSnapshot() -> String? {
|
||||
let url = FilePath.workingDirectory.appendingPathComponent(ReportArchive.configFileName)
|
||||
guard let content = try? String(contentsOf: url, encoding: .utf8),
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return content
|
||||
}
|
||||
|
||||
private nonisolated static func scanCrashReports() -> [CrashReport] {
|
||||
let dir = CrashReportArchive.crashReportsDirectory
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: dir, includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
|
||||
options: .skipsHiddenFiles
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return files
|
||||
.filter {
|
||||
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
}
|
||||
.compactMap { url -> CrashReport? in
|
||||
let date = CrashReportArchive.crashDate(for: url)
|
||||
?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
|
||||
?? Date.distantPast
|
||||
let origin = CrashReportArchive.readMetadata(for: url)?.deviceOrigin
|
||||
return CrashReport(
|
||||
id: url.lastPathComponent,
|
||||
date: date,
|
||||
fileURL: url,
|
||||
isRead: FileManager.default.fileExists(atPath: url.appendingPathComponent(ReportArchive.readMarkerFileName).path),
|
||||
origin: origin
|
||||
)
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
private nonisolated static func importPendingNativeCrashReports() {
|
||||
var pendingReports: [(bundleIdentifier: String, source: String)] = []
|
||||
for bundleIdentifier in AppConfiguration.packetTunnelBundleIDs {
|
||||
pendingReports.append((bundleIdentifier, "NetworkExtension"))
|
||||
}
|
||||
if let appBundleIdentifier = Bundle.main.bundleIdentifier {
|
||||
pendingReports.append((appBundleIdentifier, "Application"))
|
||||
}
|
||||
for (bundleIdentifier, source) in pendingReports {
|
||||
let reportURL = CrashReportArchive.pendingNativeCrashReportURL(bundleIdentifier: bundleIdentifier)
|
||||
guard let data = try? Data(contentsOf: reportURL), !data.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let crashReport = try PLCrashReport(data: data)
|
||||
guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS),
|
||||
!text.isEmpty
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: reportURL.path)
|
||||
let crashDate = crashReport.systemInfo.timestamp
|
||||
?? (attrs?[.modificationDate] as? Date)
|
||||
?? Date()
|
||||
let metadata = CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: source)
|
||||
let configContent = readAndCleanConfigSnapshot()
|
||||
_ = try CrashReportArchive.writeArchivedReport(
|
||||
contents: CrashReportArtifactContents(nativeLog: text, configContent: configContent),
|
||||
date: crashDate,
|
||||
metadata: metadata
|
||||
)
|
||||
try? FileManager.default.removeItem(at: reportURL)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func coalesceArchivedCrashReports() {
|
||||
let records = loadArchivedReportRecords()
|
||||
let goOnlyRecords = records.filter { $0.contents.goLog != nil && $0.contents.nativeLog == nil }
|
||||
let nativeOnlyRecords = records.filter { $0.contents.nativeLog != nil && $0.contents.goLog == nil }
|
||||
guard !goOnlyRecords.isEmpty, !nativeOnlyRecords.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
var usedGoReportURLs: Set<URL> = []
|
||||
for nativeRecord in nativeOnlyRecords {
|
||||
guard let goRecord = matchingGoReport(for: nativeRecord, among: goOnlyRecords, excluding: usedGoReportURLs) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let mergedMetadata = CrashReportMetadataBuilder.mergedMetadata(
|
||||
go: goRecord.metadata,
|
||||
goContent: goRecord.contents.goLog ?? "",
|
||||
native: nativeRecord.metadata,
|
||||
nativeContent: nativeRecord.contents.nativeLog ?? ""
|
||||
)
|
||||
let mergedContents = CrashReportArtifactContents(
|
||||
goLog: goRecord.contents.goLog,
|
||||
nativeLog: nativeRecord.contents.nativeLog,
|
||||
configContent: goRecord.contents.configContent ?? nativeRecord.contents.configContent
|
||||
)
|
||||
|
||||
do {
|
||||
try CrashReportArchive.rewriteArchivedReport(
|
||||
at: goRecord.reportURL,
|
||||
contents: mergedContents,
|
||||
metadata: mergedMetadata
|
||||
)
|
||||
CrashReportArchive.removeArtifact(at: nativeRecord.reportURL)
|
||||
usedGoReportURLs.insert(goRecord.reportURL)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated func availableFiles(for report: CrashReport) async -> [CrashReportFile] {
|
||||
await BlockingIO.run {
|
||||
let fm = FileManager.default
|
||||
var files: [CrashReportFile] = []
|
||||
let metadataURL = CrashReportArchive.metadataURL(for: report.fileURL)
|
||||
if fm.fileExists(atPath: metadataURL.path) {
|
||||
files.append(CrashReportFile(id: .metadata, displayName: "Metadata", fileURL: metadataURL))
|
||||
}
|
||||
let nativeURL = CrashReportArchive.nativeLogURL(for: report.fileURL)
|
||||
if fm.fileExists(atPath: nativeURL.path) {
|
||||
files.append(CrashReportFile(id: .nativeLog, displayName: "Crash Report", fileURL: nativeURL))
|
||||
}
|
||||
let goURL = CrashReportArchive.goLogURL(for: report.fileURL)
|
||||
if fm.fileExists(atPath: goURL.path) {
|
||||
files.append(CrashReportFile(id: .goLog, displayName: "Go Crash Log", fileURL: goURL))
|
||||
}
|
||||
let configURL = CrashReportArchive.configURL(for: report.fileURL)
|
||||
if fm.fileExists(atPath: configURL.path) {
|
||||
files.append(CrashReportFile(id: .configContent, displayName: "Configuration", fileURL: configURL))
|
||||
}
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
public func markAsRead(_ report: CrashReport) {
|
||||
FileManager.default.createFile(atPath: report.fileURL.appendingPathComponent(ReportArchive.readMarkerFileName).path, contents: nil)
|
||||
if let idx = reports.firstIndex(where: { $0.id == report.id }), !reports[idx].isRead {
|
||||
reports[idx].isRead = true
|
||||
unreadCount = max(0, unreadCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated func delete(_ report: CrashReport) async {
|
||||
await BlockingIO.run {
|
||||
CrashReportArchive.removeArtifact(at: report.fileURL)
|
||||
}
|
||||
await MainActor.run {
|
||||
let wasUnread = reports.first { $0.id == report.id }.map { !$0.isRead } ?? false
|
||||
reports.removeAll { $0.id == report.id }
|
||||
if wasUnread {
|
||||
unreadCount = max(0, unreadCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated func deleteAll() async {
|
||||
let dir = CrashReportArchive.crashReportsDirectory
|
||||
await BlockingIO.run {
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
}
|
||||
await MainActor.run {
|
||||
reports.removeAll()
|
||||
unreadCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func loadArchivedReportRecords() -> [ArchivedCrashReportRecord] {
|
||||
let dir = CrashReportArchive.crashReportsDirectory
|
||||
guard let reportURLs = try? FileManager.default.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
|
||||
options: .skipsHiddenFiles
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return reportURLs
|
||||
.filter {
|
||||
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
}
|
||||
.compactMap { reportURL in
|
||||
let contents = CrashReportArchive.readContents(for: reportURL)
|
||||
guard let metadata = CrashReportArchive.readMetadata(for: reportURL),
|
||||
!contents.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let date = CrashReportArchive.crashDate(for: reportURL)
|
||||
?? (try? reportURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
|
||||
?? Date.distantPast
|
||||
return ArchivedCrashReportRecord(
|
||||
reportURL: reportURL,
|
||||
date: date,
|
||||
contents: contents,
|
||||
metadata: metadata
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func matchingGoReport(
|
||||
for nativeRecord: ArchivedCrashReportRecord,
|
||||
among goRecords: [ArchivedCrashReportRecord],
|
||||
excluding excludedReportURLs: Set<URL>
|
||||
) -> ArchivedCrashReportRecord? {
|
||||
goRecords
|
||||
.filter { !excludedReportURLs.contains($0.reportURL) }
|
||||
.filter { canMerge($0, nativeRecord) }
|
||||
.min { lhs, rhs in
|
||||
abs(lhs.date.timeIntervalSince(nativeRecord.date)) < abs(rhs.date.timeIntervalSince(nativeRecord.date))
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func canMerge(_ goRecord: ArchivedCrashReportRecord, _ nativeRecord: ArchivedCrashReportRecord) -> Bool {
|
||||
if let goSource = goRecord.metadata.source,
|
||||
let nativeSource = nativeRecord.metadata.source,
|
||||
goSource != nativeSource
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
if let goBundleIdentifier = goRecord.metadata.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!goBundleIdentifier.isEmpty,
|
||||
let nativeBundleIdentifier = nativeRecord.metadata.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!nativeBundleIdentifier.isEmpty,
|
||||
goBundleIdentifier != nativeBundleIdentifier
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
return abs(goRecord.date.timeIntervalSince(nativeRecord.date)) <= 10
|
||||
}
|
||||
}
|
||||
|
||||
private struct ArchivedCrashReportRecord {
|
||||
let reportURL: URL
|
||||
let date: Date
|
||||
let contents: CrashReportArtifactContents
|
||||
let metadata: CrashReportMetadata
|
||||
}
|
||||
|
||||
enum CrashReportMetadataBuilder {
|
||||
static func goMetadata(source: String, crashDate: Date) -> CrashReportMetadata {
|
||||
CrashReportMetadata(
|
||||
source: source,
|
||||
crashedAt: CrashReportArchive.iso8601String(from: crashDate)
|
||||
)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
static func systemExtensionGoMetadata(crashDate: Date) -> CrashReportMetadata {
|
||||
CrashReportMetadata(
|
||||
source: "NetworkExtension",
|
||||
bundleIdentifier: AppConfiguration.systemExtensionBundleID,
|
||||
crashedAt: CrashReportArchive.iso8601String(from: crashDate)
|
||||
)
|
||||
}
|
||||
|
||||
static func rootHelperGoMetadata(crashDate: Date) -> CrashReportMetadata {
|
||||
CrashReportMetadata(
|
||||
source: "RootHelper",
|
||||
bundleIdentifier: AppConfiguration.rootHelperBundleID,
|
||||
crashedAt: CrashReportArchive.iso8601String(from: crashDate)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
static func mergedMetadata(
|
||||
go: CrashReportMetadata,
|
||||
goContent: String,
|
||||
native: CrashReportMetadata,
|
||||
nativeContent: String
|
||||
) -> CrashReportMetadata {
|
||||
normalized(
|
||||
CrashReportMetadata(
|
||||
source: firstNonEmpty(native.source, go.source),
|
||||
bundleIdentifier: firstNonEmpty(native.bundleIdentifier, go.bundleIdentifier),
|
||||
processName: firstNonEmpty(native.processName, go.processName),
|
||||
processPath: firstNonEmpty(native.processPath, go.processPath),
|
||||
startedAt: firstNonEmpty(native.startedAt, go.startedAt),
|
||||
appVersion: firstNonEmpty(go.appVersion, native.appVersion),
|
||||
appMarketingVersion: firstNonEmpty(go.appMarketingVersion, native.appMarketingVersion),
|
||||
coreVersion: firstNonEmpty(go.coreVersion, native.coreVersion),
|
||||
goVersion: firstNonEmpty(go.goVersion, native.goVersion),
|
||||
crashedAt: earliestTimestamp(go.crashedAt, native.crashedAt),
|
||||
signalName: firstNonEmpty(native.signalName, go.signalName),
|
||||
signalCode: firstNonEmpty(native.signalCode, go.signalCode),
|
||||
exceptionName: firstNonEmpty(go.exceptionName, native.exceptionName),
|
||||
exceptionReason: firstNonEmpty(go.exceptionReason, native.exceptionReason)
|
||||
),
|
||||
content: CrashReportArchive.displayContent(for: CrashReportArtifactContents(goLog: goContent, nativeLog: nativeContent))
|
||||
)
|
||||
}
|
||||
|
||||
static func nativeMetadata(for crashReport: PLCrashReport, content: String, source: String) -> CrashReportMetadata {
|
||||
let processInfo = crashReport.hasProcessInfo ? crashReport.processInfo : nil
|
||||
return normalized(
|
||||
CrashReportMetadata(
|
||||
source: source,
|
||||
bundleIdentifier: crashReport.applicationInfo.applicationIdentifier,
|
||||
processName: processInfo?.processName,
|
||||
processPath: processInfo?.processPath,
|
||||
startedAt: processInfo?.processStartTime.map(CrashReportArchive.iso8601String(from:)),
|
||||
appVersion: crashReport.applicationInfo.applicationVersion,
|
||||
appMarketingVersion: crashReport.applicationInfo.applicationMarketingVersion,
|
||||
crashedAt: crashReport.systemInfo.timestamp.map(CrashReportArchive.iso8601String(from:)),
|
||||
signalName: crashReport.signalInfo.name,
|
||||
signalCode: crashReport.signalInfo.code,
|
||||
exceptionName: crashReport.hasExceptionInfo ? crashReport.exceptionInfo.exceptionName : nil,
|
||||
exceptionReason: crashReport.hasExceptionInfo ? crashReport.exceptionInfo.exceptionReason : nil
|
||||
),
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
static func normalized(_ metadata: CrashReportMetadata, content: String? = nil) -> CrashReportMetadata {
|
||||
let bundleIdentifier = normalizedString(metadata.bundleIdentifier)
|
||||
let processBundle = bundleIdentifier.flatMap(bundle(for:))
|
||||
let processPath = firstNonEmpty(
|
||||
metadata.processPath,
|
||||
normalizedString(processBundle?.executableURL?.path)
|
||||
)
|
||||
let executableNameFromPath = processPath.flatMap {
|
||||
normalizedString(URL(fileURLWithPath: $0).lastPathComponent)
|
||||
}
|
||||
let appBundle = containingAppBundle(for: processBundle) ?? currentAppBundle()
|
||||
let parsedDetails = parseCrashDetails(from: content)
|
||||
|
||||
return CrashReportMetadata(
|
||||
source: metadata.source,
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
processName: firstNonEmpty(
|
||||
metadata.processName,
|
||||
normalizedString(processBundle?.executableURL?.lastPathComponent),
|
||||
executableNameFromPath,
|
||||
bundleIdentifier
|
||||
),
|
||||
processPath: processPath,
|
||||
startedAt: normalizedString(metadata.startedAt),
|
||||
appVersion: firstNonEmpty(bundleBuildVersion(appBundle), metadata.appVersion),
|
||||
appMarketingVersion: firstNonEmpty(bundleMarketingVersion(appBundle), metadata.appMarketingVersion),
|
||||
coreVersion: firstNonEmpty(metadata.coreVersion, normalizedString(LibboxVersion())),
|
||||
goVersion: firstNonEmpty(metadata.goVersion, normalizedString(LibboxGoVersion())),
|
||||
crashedAt: normalizedString(metadata.crashedAt),
|
||||
signalName: firstNonEmpty(metadata.signalName, parsedDetails.signalName),
|
||||
signalCode: firstNonEmpty(metadata.signalCode, parsedDetails.signalCode),
|
||||
exceptionName: firstNonEmpty(metadata.exceptionName, parsedDetails.exceptionName),
|
||||
exceptionReason: firstNonEmpty(metadata.exceptionReason, parsedDetails.exceptionReason)
|
||||
)
|
||||
}
|
||||
|
||||
private static func bundle(for bundleIdentifier: String) -> Bundle? {
|
||||
if Bundle.main.bundleIdentifier == bundleIdentifier {
|
||||
return Bundle.main
|
||||
}
|
||||
return discoveredBundles[bundleIdentifier]
|
||||
}
|
||||
|
||||
private static func currentAppBundle() -> Bundle {
|
||||
containingAppBundle(for: Bundle.main) ?? Bundle.main
|
||||
}
|
||||
|
||||
private static func containingAppBundle(for bundle: Bundle?) -> Bundle? {
|
||||
guard let bundle else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentURL = bundle.bundleURL
|
||||
while currentURL.path != "/" {
|
||||
if currentURL.pathExtension.lowercased() == "app" {
|
||||
return Bundle(url: currentURL)
|
||||
}
|
||||
let parentURL = currentURL.deletingLastPathComponent()
|
||||
if parentURL == currentURL {
|
||||
break
|
||||
}
|
||||
currentURL = parentURL
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func bundleBuildVersion(_ bundle: Bundle?) -> String? {
|
||||
normalizedString(bundle?.infoDictionary?["CFBundleVersion"] as? String)
|
||||
}
|
||||
|
||||
private static func bundleMarketingVersion(_ bundle: Bundle?) -> String? {
|
||||
normalizedString(bundle?.infoDictionary?["CFBundleShortVersionString"] as? String)
|
||||
}
|
||||
|
||||
private static func normalizedString(_ value: String?) -> String? {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty,
|
||||
trimmed != "unknown"
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func firstNonEmpty(_ values: String?...) -> String? {
|
||||
for value in values {
|
||||
if let value = normalizedString(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static let iso8601Formatter = ISO8601DateFormatter()
|
||||
|
||||
private static func earliestTimestamp(_ values: String?...) -> String? {
|
||||
let timestamps = values.compactMap { value -> (String, Date)? in
|
||||
guard let value = normalizedString(value),
|
||||
let date = iso8601Formatter.date(from: value)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return (value, date)
|
||||
}
|
||||
if let earliest = timestamps.min(by: { $0.1 < $1.1 }) {
|
||||
return earliest.0
|
||||
}
|
||||
for value in values {
|
||||
if let value = normalizedString(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static let discoveredBundles: [String: Bundle] = {
|
||||
var bundles: [String: Bundle] = [:]
|
||||
|
||||
func addBundle(_ bundle: Bundle?) {
|
||||
guard let bundle,
|
||||
let bundleIdentifier = bundle.bundleIdentifier
|
||||
else {
|
||||
return
|
||||
}
|
||||
bundles[bundleIdentifier] = bundle
|
||||
}
|
||||
|
||||
let appBundle = currentAppBundle()
|
||||
addBundle(appBundle)
|
||||
addBundle(Bundle.main)
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: appBundle.bundleURL,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return bundles
|
||||
}
|
||||
|
||||
let bundleExtensions: Set = ["app", "appex", "systemextension"]
|
||||
for case let url as URL in enumerator {
|
||||
let pathExtension = url.pathExtension.lowercased()
|
||||
guard bundleExtensions.contains(pathExtension) else {
|
||||
continue
|
||||
}
|
||||
addBundle(Bundle(url: url))
|
||||
enumerator.skipDescendants()
|
||||
}
|
||||
|
||||
return bundles
|
||||
}()
|
||||
|
||||
private struct ParsedCrashDetails {
|
||||
var signalName: String?
|
||||
var signalCode: String?
|
||||
var exceptionName: String?
|
||||
var exceptionReason: String?
|
||||
}
|
||||
|
||||
private static func parseCrashDetails(from content: String?) -> ParsedCrashDetails {
|
||||
guard let content else {
|
||||
return ParsedCrashDetails()
|
||||
}
|
||||
|
||||
var details = ParsedCrashDetails()
|
||||
for rawLine in content.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !line.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
if details.exceptionReason == nil {
|
||||
if line.hasPrefix("panic: ") {
|
||||
details.exceptionName = "panic"
|
||||
details.exceptionReason = normalizedString(String(line.dropFirst("panic: ".count)))
|
||||
} else if line.hasPrefix("fatal error: ") {
|
||||
details.exceptionName = "fatal error"
|
||||
details.exceptionReason = normalizedString(String(line.dropFirst("fatal error: ".count)))
|
||||
}
|
||||
}
|
||||
|
||||
if details.signalName == nil,
|
||||
let parsedSignal = parseSignal(from: line)
|
||||
{
|
||||
details.signalName = parsedSignal.name
|
||||
details.signalCode = parsedSignal.code
|
||||
}
|
||||
|
||||
if details.exceptionReason != nil,
|
||||
details.signalName != nil
|
||||
{
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
private static func parseSignal(from line: String) -> (name: String?, code: String?)? {
|
||||
let signalSection: Substring
|
||||
if let range = line.range(of: "[signal ") {
|
||||
signalSection = line[range.upperBound...]
|
||||
} else if line.hasPrefix("signal ") {
|
||||
signalSection = line.dropFirst("signal ".count)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let signalName = normalizedString(
|
||||
String(signalSection.prefix { character in
|
||||
character != ":" && character != "]" && !character.isWhitespace
|
||||
})
|
||||
)
|
||||
guard signalName != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var signalCode: String?
|
||||
if let codeRange = signalSection.range(of: " code=") {
|
||||
let codeSection = signalSection[codeRange.upperBound...]
|
||||
signalCode = normalizedString(
|
||||
String(codeSection.prefix { character in
|
||||
character != "]" && !character.isWhitespace
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (signalName, signalCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import CrashReporter
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
public enum NativeCrashReporter {
|
||||
private static let logger = Logger(category: "NativeCrashReporter")
|
||||
private static let installLock = NSLock()
|
||||
private static var reporter: PLCrashReporter?
|
||||
|
||||
public static func installForCurrentProcess(basePath: URL? = nil) {
|
||||
installLock.lock()
|
||||
defer {
|
||||
installLock.unlock()
|
||||
}
|
||||
|
||||
guard reporter == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
let crashBasePath = basePath ?? CrashReportArchive.pendingNativeCrashBaseDirectory
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: crashBasePath, withIntermediateDirectories: true)
|
||||
let config = PLCrashReporterConfig(
|
||||
signalHandlerType: .BSD,
|
||||
symbolicationStrategy: [],
|
||||
basePath: crashBasePath.path
|
||||
)
|
||||
guard let crashReporter = PLCrashReporter(configuration: config) else {
|
||||
logger.warning("Failed to create PLCrashReporter instance")
|
||||
return
|
||||
}
|
||||
try crashReporter.enableAndReturnError()
|
||||
reporter = crashReporter
|
||||
} catch {
|
||||
logger.warning("Failed to enable native crash reporting: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
public static func loadAndPurgePendingCrashReportData() -> Data? {
|
||||
installLock.lock()
|
||||
guard let reporter else {
|
||||
installLock.unlock()
|
||||
return nil
|
||||
}
|
||||
installLock.unlock()
|
||||
|
||||
guard reporter.hasPendingCrashReport() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = try? reporter.loadPendingCrashReportDataAndReturnError()
|
||||
reporter.purgePendingCrashReport()
|
||||
return data
|
||||
}
|
||||
|
||||
public static func archiveLiveReportForCurrentProcess() {
|
||||
installLock.lock()
|
||||
guard let reporter else {
|
||||
installLock.unlock()
|
||||
return
|
||||
}
|
||||
installLock.unlock()
|
||||
|
||||
do {
|
||||
let data = try reporter.generateLiveReportAndReturnError()
|
||||
let crashReport = try PLCrashReport(data: data)
|
||||
guard let text = PLCrashReportTextFormatter.stringValue(for: crashReport, with: PLCrashReportTextFormatiOS),
|
||||
!text.isEmpty
|
||||
else {
|
||||
return
|
||||
}
|
||||
let crashDate = crashReport.systemInfo.timestamp ?? Date()
|
||||
_ = try CrashReportArchive.writeArchivedReport(
|
||||
contents: CrashReportArtifactContents(nativeLog: text),
|
||||
date: crashDate,
|
||||
metadata: CrashReportMetadataBuilder.nativeMetadata(for: crashReport, content: text, source: "Application")
|
||||
)
|
||||
} catch {
|
||||
logger.warning("Failed to archive live native crash report: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
public struct OOMReportMetadata: 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 recordedAt: String?
|
||||
public var memoryUsage: String?
|
||||
public var availableMemory: String?
|
||||
public var deviceOrigin: String?
|
||||
}
|
||||
|
||||
public enum OOMReportArchive {
|
||||
static var reportsDirectory: URL {
|
||||
FilePath.workingDirectory.appendingPathComponent("oom_reports", isDirectory: true)
|
||||
}
|
||||
|
||||
static func metadataURL(for artifactURL: URL) -> URL {
|
||||
artifactURL.appendingPathComponent(ReportArchive.metadataFileName)
|
||||
}
|
||||
|
||||
static func configURL(for artifactURL: URL) -> URL {
|
||||
artifactURL.appendingPathComponent(ReportArchive.configFileName)
|
||||
}
|
||||
|
||||
public static func readMetadata(for artifactURL: URL) -> OOMReportMetadata? {
|
||||
guard let data = try? Data(contentsOf: metadataURL(for: artifactURL)) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(OOMReportMetadata.self, from: data)
|
||||
}
|
||||
|
||||
static func profileFiles(for artifactURL: URL) -> [URL] {
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: artifactURL,
|
||||
includingPropertiesForKeys: [.fileSizeKey],
|
||||
options: .skipsHiddenFiles
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
let excluded: Set<String> = [ReportArchive.metadataFileName, ReportArchive.configFileName]
|
||||
return files
|
||||
.filter { !excluded.contains($0.lastPathComponent) }
|
||||
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
}
|
||||
|
||||
static func removeArtifact(at artifactURL: URL) {
|
||||
ReportArchive.removeArtifact(at: artifactURL)
|
||||
}
|
||||
|
||||
static func reportDate(for artifactURL: URL) -> Date? {
|
||||
ReportArchive.parseArtifactDate(for: artifactURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
private let logger = Logger(category: "OOMReportManager")
|
||||
|
||||
public struct OOMReport: Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let date: Date
|
||||
public let fileURL: URL
|
||||
public var isRead: Bool
|
||||
public let origin: String?
|
||||
}
|
||||
|
||||
public struct OOMReportFile: Identifiable, Hashable, Sendable {
|
||||
public enum Kind: String, Sendable {
|
||||
case metadata
|
||||
case configContent
|
||||
case profile
|
||||
}
|
||||
|
||||
public let id: String
|
||||
public let kind: Kind
|
||||
public let displayName: String
|
||||
public let fileURL: URL
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class OOMReportManager: ObservableObject {
|
||||
@Published public private(set) var reports: [OOMReport] = []
|
||||
@Published public private(set) var unreadCount: Int = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
public nonisolated func refresh() async {
|
||||
let reports = await BlockingIO.run {
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
Self.collectAndArchiveOOMReportsViaHelper()
|
||||
}
|
||||
#endif
|
||||
return Self.scanReports()
|
||||
}
|
||||
await MainActor.run {
|
||||
self.reports = reports
|
||||
self.unreadCount = reports.filter { !$0.isRead }.count
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func scanReports() -> [OOMReport] {
|
||||
let dir = OOMReportArchive.reportsDirectory
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: dir, includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
|
||||
options: .skipsHiddenFiles
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return files
|
||||
.filter {
|
||||
(try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
}
|
||||
.compactMap { url -> OOMReport? in
|
||||
let date = OOMReportArchive.reportDate(for: url)
|
||||
?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
|
||||
?? Date.distantPast
|
||||
let origin = OOMReportArchive.readMetadata(for: url)?.deviceOrigin
|
||||
return OOMReport(
|
||||
id: url.lastPathComponent,
|
||||
date: date,
|
||||
fileURL: url,
|
||||
isRead: FileManager.default.fileExists(atPath: url.appendingPathComponent(ReportArchive.readMarkerFileName).path),
|
||||
origin: origin
|
||||
)
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
public nonisolated func availableFiles(for report: OOMReport) async -> [OOMReportFile] {
|
||||
await BlockingIO.run {
|
||||
let fm = FileManager.default
|
||||
var files: [OOMReportFile] = []
|
||||
|
||||
let metadataURL = OOMReportArchive.metadataURL(for: report.fileURL)
|
||||
if fm.fileExists(atPath: metadataURL.path) {
|
||||
files.append(OOMReportFile(id: "metadata", kind: .metadata, displayName: "Metadata", fileURL: metadataURL))
|
||||
}
|
||||
|
||||
let configURL = OOMReportArchive.configURL(for: report.fileURL)
|
||||
if fm.fileExists(atPath: configURL.path) {
|
||||
files.append(OOMReportFile(id: "config", kind: .configContent, displayName: "Configuration", fileURL: configURL))
|
||||
}
|
||||
|
||||
for profileURL in OOMReportArchive.profileFiles(for: report.fileURL) {
|
||||
let name = profileURL.lastPathComponent
|
||||
files.append(OOMReportFile(id: name, kind: .profile, displayName: name, fileURL: profileURL))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
public func markAsRead(_ report: OOMReport) {
|
||||
FileManager.default.createFile(atPath: report.fileURL.appendingPathComponent(ReportArchive.readMarkerFileName).path, contents: nil)
|
||||
if let idx = reports.firstIndex(where: { $0.id == report.id }), !reports[idx].isRead {
|
||||
reports[idx].isRead = true
|
||||
unreadCount = max(0, unreadCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated func delete(_ report: OOMReport) async {
|
||||
await BlockingIO.run {
|
||||
OOMReportArchive.removeArtifact(at: report.fileURL)
|
||||
}
|
||||
await MainActor.run {
|
||||
let wasUnread = reports.first { $0.id == report.id }.map { !$0.isRead } ?? false
|
||||
reports.removeAll { $0.id == report.id }
|
||||
if wasUnread {
|
||||
unreadCount = max(0, unreadCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated func deleteAll() async {
|
||||
let dir = OOMReportArchive.reportsDirectory
|
||||
await BlockingIO.run {
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
}
|
||||
await MainActor.run {
|
||||
reports.removeAll()
|
||||
unreadCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private nonisolated static func collectAndArchiveOOMReportsViaHelper() {
|
||||
guard HelperServiceManager.rootHelperStatus == .enabled else {
|
||||
return
|
||||
}
|
||||
|
||||
let artifacts: OOMReportArtifactsResult
|
||||
do {
|
||||
artifacts = try RootHelperClient.shared.collectOOMReportArtifacts()
|
||||
} catch {
|
||||
logger.warning("collectOOMReportArtifacts: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
|
||||
let reportsDir = OOMReportArchive.reportsDirectory
|
||||
for report in artifacts.reports {
|
||||
let destURL = reportsDir.appendingPathComponent(report.directoryName, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: destURL, withIntermediateDirectories: true)
|
||||
for file in report.files {
|
||||
let fileURL = destURL.appendingPathComponent(file.name)
|
||||
try file.data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
} catch {
|
||||
logger.warning("write OOM report \(report.directoryName): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user