Files
sing-box-for-apple/Library/Shared/CrashReportManager.swift
T

727 lines
29 KiB
Swift

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)
}
}