Tools View & Crash Report & OOM Report

This commit is contained in:
世界
2026-04-23 08:12:52 +08:00
parent 73180f0230
commit b48f4ad97f
46 changed files with 4279 additions and 533 deletions
+8 -18
View File
@@ -22,10 +22,6 @@ import Foundation
public enum SharedPreferences {
public static let selectedProfileID = Preference<Int64>("selected_profile_id", defaultValue: -1)
#if !os(macOS)
public static let ignoreMemoryLimit = Preference<Bool>("ignore_memory_limit", defaultValue: false)
#endif
#if os(iOS)
private static let excludeLocalNetworksByDefault = true
#elseif os(macOS)
@@ -43,7 +39,7 @@ public enum SharedPreferences {
#endif
public static func resetPacketTunnel() async {
#if os(macOS)
#if !os(tvOS)
let names = [
includeAllNetworks.name,
excludeAPNs.name,
@@ -52,24 +48,18 @@ public enum SharedPreferences {
enforceRoutes.name,
excludeDeviceCommunication.name,
]
#elseif os(tvOS)
let names = [ignoreMemoryLimit.name]
#else
let names = [
ignoreMemoryLimit.name,
includeAllNetworks.name,
excludeAPNs.name,
excludeLocalNetworks.name,
excludeCellularServices.name,
enforceRoutes.name,
excludeDeviceCommunication.name,
]
try? await batchDelete(names)
#endif
try? await batchDelete(names)
}
public static let maxLogLines = Preference<Int>("max_log_lines", defaultValue: 300)
#if os(macOS)
public static let oomKillerEnabled = Preference<Bool>("oom_killer_enabled", defaultValue: false)
public static let oomMemoryLimitMB = Preference<Int>("oom_memory_limit_mb", defaultValue: 50)
public static let oomKillerKillConnections = Preference<Bool>("oom_killer_kill_connections", defaultValue: false)
#endif
#if os(macOS)
public static let showMenuBarExtra = Preference<Bool>("show_menu_bar_extra", defaultValue: true)
public static let menuBarExtraInBackground = Preference<Bool>("menu_bar_extra_in_background", defaultValue: false)
@@ -1,3 +1,4 @@
import Combine
import Foundation
import SwiftUI
#if canImport(UIKit)
@@ -182,6 +183,12 @@ public struct ImportRemoteProfileRequest: Hashable, Identifiable {
@MainActor
public class ExtensionEnvironments: ObservableObject {
@Published public var commandClient = CommandClient([.log, .status, .groups, .clashMode])
public let crashReportManager = CrashReportManager()
public let oomReportManager = OOMReportManager()
public var totalUnreadReportCount: Int {
crashReportManager.unreadCount + oomReportManager.unreadCount
}
@Published public var extensionProfileLoading = true
@Published public var extensionProfile: ExtensionProfile?
@Published public var emptyProfiles = false
@@ -193,8 +200,19 @@ public class ExtensionEnvironments: ObservableObject {
public let profileUpdate = ObjectWillChangePublisher()
public let selectedProfileUpdate = ObjectWillChangePublisher()
public let openSettings = ObjectWillChangePublisher()
private var cancellables = Set<AnyCancellable>()
public init() {
crashReportManager.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
oomReportManager.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
if Variant.screenshotMode {
extensionProfileLoading = false
extensionProfile = .mock
@@ -205,6 +223,8 @@ public class ExtensionEnvironments: ObservableObject {
public func postReload() {
Task {
await reload()
await crashReportManager.refresh()
await oomReportManager.refresh()
}
}
@@ -1,12 +1,14 @@
import Foundation
import Libbox
import NetworkExtension
import os
import UserNotifications
#if os(macOS)
import CoreWLAN
#endif
public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol, LibboxCommandServerHandlerProtocol {
private static let logger = Logger(category: "ExtensionPlatformInterface")
private let tunnel: ExtensionProvider
private var networkSettings: NEPacketTunnelNetworkSettings?
@@ -449,11 +451,17 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc
}
}
public func triggerNativeCrash() throws {
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) {
fatalError("debug native crash")
}
}
public func writeDebugMessage(_ message: String?) {
guard let message else {
return
}
tunnel.writeMessage(message)
Self.logger.debug("\(message, privacy: .public)")
}
func reset() {
+4 -2
View File
@@ -216,8 +216,10 @@ public class ExtensionProfile: ObservableObject {
let configContent = try await profile.readAsync()
options["configContent"] = NSString(string: configContent)
#if !os(macOS)
options["ignoreMemoryLimit"] = await NSNumber(value: SharedPreferences.ignoreMemoryLimit.get())
#if os(macOS)
options["oomKillerEnabled"] = await NSNumber(value: SharedPreferences.oomKillerEnabled.get())
options["oomMemoryLimitMB"] = await NSNumber(value: SharedPreferences.oomMemoryLimitMB.get())
options["oomKillerKillConnections"] = await NSNumber(value: SharedPreferences.oomKillerKillConnections.get())
#endif
options["systemProxyEnabled"] = await NSNumber(value: SharedPreferences.systemProxyEnabled.get())
options["excludeDefaultRoute"] = await NSNumber(value: SharedPreferences.excludeDefaultRoute.get())
+28 -13
View File
@@ -80,6 +80,22 @@ open class ExtensionProvider: NEPacketTunnelProvider {
private var locationDelegate: stubLocationDelegate?
#endif
override public init() {
#if os(macOS)
if Variant.useSystemExtension {
NativeCrashReporter.installForCurrentProcess(
basePath: FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("NativeCrash")
)
} else {
NativeCrashReporter.installForCurrentProcess()
}
#else
NativeCrashReporter.installForCurrentProcess()
#endif
super.init()
}
override open func startTunnel(options startOptions: [String: NSObject]?) async throws {
let basePath: String
let workingPath: String
@@ -132,6 +148,8 @@ open class ExtensionProvider: NEPacketTunnelProvider {
options.tempPath = tempPath
options.logMaxLines = 3000
options.debug = SharedPreferences.inDebug
options.crashReportSource = "NetworkExtension"
#if os(tvOS)
if let port = effectiveOptions["commandServerPort"] as? NSNumber {
@@ -142,24 +160,21 @@ open class ExtensionProvider: NEPacketTunnelProvider {
}
#endif
#if os(macOS)
options.oomKillerEnabled = (effectiveOptions["oomKillerEnabled"] as? NSNumber)?.boolValue ?? false
let oomMemoryLimitMB = (effectiveOptions["oomMemoryLimitMB"] as? NSNumber)?.int64Value ?? 0
options.oomMemoryLimit = oomMemoryLimitMB * 1024 * 1024
options.oomKillerDisabled = !((effectiveOptions["oomKillerKillConnections"] as? NSNumber)?.boolValue ?? false)
#else
options.oomKillerEnabled = true
#endif
var setupError: NSError?
LibboxSetup(options, &setupError)
if let setupError {
throw ExtensionStartupError("(packet-tunnel) error: setup service: \(setupError.localizedDescription)")
}
let stderrPath = URL(fileURLWithPath: tempPath, isDirectory: true).appendingPathComponent("stderr.log").path
var stderrError: NSError?
LibboxRedirectStderr(stderrPath, &stderrError)
if let stderrError {
throw ExtensionStartupError("(packet-tunnel) redirect stderr error: \(stderrError.localizedDescription)")
}
#if !os(macOS)
let ignoreMemoryLimit = (effectiveOptions["ignoreMemoryLimit"] as? NSNumber)?.boolValue ?? false
LibboxSetMemoryLimit(!ignoreMemoryLimit)
#endif
var error: NSError?
commandServer = LibboxNewCommandServer(platformInterface, platformInterface, &error)
if let error {
@@ -179,7 +194,6 @@ open class ExtensionProvider: NEPacketTunnelProvider {
}
#endif
writeMessage("(packet-tunnel): Here I stand")
do {
try await startService()
} catch {
@@ -190,6 +204,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
#endif
throw error
}
writeMessage("(packet-tunnel): Here I stand")
#if os(macOS)
if Variant.useSystemExtension {
xpcService.markServiceReady()
+176 -49
View File
@@ -60,6 +60,121 @@
}
}
@objc(CrashLogFileResult) public class CrashLogFileResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var fileName: String
@objc public var content: String
@objc public var modificationDate: Date
public init(fileName: String, content: String, modificationDate: Date) {
self.fileName = fileName
self.content = content
self.modificationDate = modificationDate
}
public required init?(coder: NSCoder) {
fileName = coder.decodeObject(of: NSString.self, forKey: "fileName") as? String ?? ""
content = coder.decodeObject(of: NSString.self, forKey: "content") as? String ?? ""
modificationDate = coder.decodeObject(of: NSDate.self, forKey: "modificationDate") as? Date ?? Date()
}
public func encode(with coder: NSCoder) {
coder.encode(fileName as NSString, forKey: "fileName")
coder.encode(content as NSString, forKey: "content")
coder.encode(modificationDate as NSDate, forKey: "modificationDate")
}
}
@objc(CrashArtifactsResult) public class CrashArtifactsResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var crashLogs: [CrashLogFileResult] = []
@objc public var helperNativeCrashData: Data?
@objc public var extensionNativeCrashData: Data?
override public init() {
super.init()
}
public required init?(coder: NSCoder) {
let logClasses = [NSArray.self, CrashLogFileResult.self] as [AnyClass]
crashLogs = coder.decodeObject(of: logClasses, forKey: "crashLogs") as? [CrashLogFileResult] ?? []
helperNativeCrashData = coder.decodeObject(of: NSData.self, forKey: "helperNativeCrashData") as? Data
extensionNativeCrashData = coder.decodeObject(of: NSData.self, forKey: "extensionNativeCrashData") as? Data
}
public func encode(with coder: NSCoder) {
coder.encode(crashLogs as NSArray, forKey: "crashLogs")
coder.encode(helperNativeCrashData as NSData?, forKey: "helperNativeCrashData")
coder.encode(extensionNativeCrashData as NSData?, forKey: "extensionNativeCrashData")
}
}
@objc(OOMReportFileResult) public class OOMReportFileResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var name: String
@objc public var data: Data
public init(name: String, data: Data) {
self.name = name
self.data = data
}
public required init?(coder: NSCoder) {
name = coder.decodeObject(of: NSString.self, forKey: "name") as? String ?? ""
data = coder.decodeObject(of: NSData.self, forKey: "data") as? Data ?? Data()
}
public func encode(with coder: NSCoder) {
coder.encode(name as NSString, forKey: "name")
coder.encode(data as NSData, forKey: "data")
}
}
@objc(OOMReportDirectoryResult) public class OOMReportDirectoryResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var directoryName: String
@objc public var files: [OOMReportFileResult] = []
public init(directoryName: String, files: [OOMReportFileResult]) {
self.directoryName = directoryName
self.files = files
}
public required init?(coder: NSCoder) {
directoryName = coder.decodeObject(of: NSString.self, forKey: "directoryName") as? String ?? ""
let fileClasses = [NSArray.self, OOMReportFileResult.self] as [AnyClass]
files = coder.decodeObject(of: fileClasses, forKey: "files") as? [OOMReportFileResult] ?? []
}
public func encode(with coder: NSCoder) {
coder.encode(directoryName as NSString, forKey: "directoryName")
coder.encode(files as NSArray, forKey: "files")
}
}
@objc(OOMReportArtifactsResult) public class OOMReportArtifactsResult: NSObject, NSSecureCoding {
public static let supportsSecureCoding = true
@objc public var reports: [OOMReportDirectoryResult] = []
override public init() {
super.init()
}
public required init?(coder: NSCoder) {
let reportClasses = [NSArray.self, OOMReportDirectoryResult.self] as [AnyClass]
reports = coder.decodeObject(of: reportClasses, forKey: "reports") as? [OOMReportDirectoryResult] ?? []
}
public func encode(with coder: NSCoder) {
coder.encode(reports as NSArray, forKey: "reports")
}
}
@objc public protocol RootHelperProtocol {
func findConnectionOwner(
ipProtocol: Int32,
@@ -76,6 +191,10 @@
func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint, reply: @escaping (NSError?) -> Void)
func closeNeighborMonitor(reply: @escaping (NSError?) -> Void)
func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void)
func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void)
func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void)
func triggerGoCrash(reply: @escaping (NSError?) -> Void)
func triggerNativeCrash(reply: @escaping (NSError?) -> Void)
}
public enum RootHelperXPC {
@@ -87,6 +206,24 @@
argumentIndex: 0,
ofReply: true
)
let crashArtifactClasses = NSSet(array: [
CrashArtifactsResult.self, NSArray.self, CrashLogFileResult.self, NSData.self,
]) as! Set<AnyHashable>
interface.setClasses(
crashArtifactClasses,
for: #selector(RootHelperProtocol.collectAllCrashArtifacts(reply:)),
argumentIndex: 0,
ofReply: true
)
let oomArtifactClasses = NSSet(array: [
OOMReportArtifactsResult.self, NSArray.self, OOMReportDirectoryResult.self, OOMReportFileResult.self, NSData.self,
]) as! Set<AnyHashable>
interface.setClasses(
oomArtifactClasses,
for: #selector(RootHelperProtocol.collectOOMReportArtifacts(reply:)),
argumentIndex: 0,
ofReply: true
)
let endpointClasses = NSSet(array: [NSXPCListenerEndpoint.self]) as! Set<AnyHashable>
interface.setClasses(
endpointClasses,
@@ -141,10 +278,10 @@
return newConnection
}
private func performXPCCall<T>(
private func performXPCCallOptional<T>(
_ operation: String,
call: (RootHelperProtocol, @escaping (T?, NSError?) -> Void) -> Void
) throws -> T {
) throws -> T? {
let semaphore = DispatchSemaphore(value: 0)
var result: T?
var resultError: NSError?
@@ -184,13 +321,18 @@
throw error
}
guard let value = result else {
let error = NSError(domain: "RootHelper", code: -1, userInfo: [
return result
}
private func performXPCCall<T>(
_ operation: String,
call: (RootHelperProtocol, @escaping (T?, NSError?) -> Void) -> Void
) throws -> T {
guard let value: T = try performXPCCallOptional(operation, call: call) else {
throw NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "\(operation) returned nil",
])
throw error
}
return value
}
@@ -287,51 +429,36 @@
}
}
public func collectAllCrashArtifacts() throws -> CrashArtifactsResult {
try performXPCCall("collectAllCrashArtifacts") { proxy, reply in
proxy.collectAllCrashArtifacts(reply: reply)
}
}
public func collectOOMReportArtifacts() throws -> OOMReportArtifactsResult {
try performXPCCall("collectOOMReportArtifacts") { proxy, reply in
proxy.collectOOMReportArtifacts(reply: reply)
}
}
public func triggerGoCrash() throws {
try performXPCCallVoid("triggerGoCrash") { proxy, reply in
proxy.triggerGoCrash(reply: reply)
}
}
public func triggerNativeCrash() throws {
try performXPCCallVoid("triggerNativeCrash") { proxy, reply in
proxy.triggerNativeCrash(reply: reply)
}
}
public func getVersion() throws -> String {
let semaphore = DispatchSemaphore(value: 0)
var result: String?
var resultError: NSError?
let conn = getConnection()
guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in
logger.error("getVersion XPC error: \(error.localizedDescription)")
resultError = error as NSError
semaphore.signal()
}) as? RootHelperProtocol else {
connectionLock.lock()
connection = nil
connectionLock.unlock()
conn.invalidate()
throw NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "Failed to get RootHelper proxy",
])
try performXPCCall("getVersion") { proxy, reply in
proxy.getVersion { version in
reply(version as String?, nil)
}
}
proxy.getVersion { version in
result = version
semaphore.signal()
}
let timeout = DispatchTime.now() + .seconds(5)
if semaphore.wait(timeout: timeout) == .timedOut {
let error = NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "getVersion request timeout",
])
logger.error("getVersion: timeout")
throw error
}
if let error = resultError {
throw error
}
guard let value = result else {
throw NSError(domain: "RootHelper", code: -1, userInfo: [
NSLocalizedDescriptionKey: "getVersion returned nil",
])
}
return value
}
}
#endif
+7
View File
@@ -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"
}
+258
View File
@@ -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
}()
}
+726
View File
@@ -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)
}
}
+82
View File
@@ -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)")
}
}
}
+60
View File
@@ -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)
}
}
+164
View File
@@ -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
}