727 lines
29 KiB
Swift
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)
|
|
}
|
|
}
|