diff --git a/ApplicationLibrary/Service/ReportTransfer.swift b/ApplicationLibrary/Service/ReportTransfer.swift new file mode 100644 index 0000000..9428d5e --- /dev/null +++ b/ApplicationLibrary/Service/ReportTransfer.swift @@ -0,0 +1,91 @@ +import BinaryCodable +import Foundation +import Library + +public enum ReportType: String, Codable { + case crash + case oom + + public var directoryName: String { + switch self { + case .crash: return "crash_reports" + case .oom: return "oom_reports" + } + } +} + +public enum ReportTransferMessageType: UInt8 { + case error = 0 + case report = 1 + case complete = 2 + case ack = 3 +} + +public struct ReportTransferPayload: Codable { + public var reportType: ReportType + public var timestamp: TimeInterval + public var files: [ReportTransferFile] + + public init(reportType: ReportType, timestamp: TimeInterval, files: [ReportTransferFile]) { + self.reportType = reportType + self.timestamp = timestamp + self.files = files + } +} + +public struct ReportTransferFile: Codable { + public var name: String + public var data: Data + + public init(name: String, data: Data) { + self.name = name + self.data = data + } +} + +public struct ReportTransferError: LocalizedError { + public let errorDescription: String? + + public init(_ message: String) { + errorDescription = message + } +} + +public enum ReportTransferService { + public static let applicationServiceName = "sing-box:report-transfer" +} + +public enum ReportTransferMessage { + public static func encodeReport(_ payload: ReportTransferPayload) throws -> Data { + var data = Data([ReportTransferMessageType.report.rawValue]) + try data.append(BinaryEncoder().encode(payload)) + return data + } + + public static func encodeComplete() -> Data { + Data([ReportTransferMessageType.complete.rawValue]) + } + + public static func encodeAck() -> Data { + Data([ReportTransferMessageType.ack.rawValue]) + } + + public static func encodeError(_ message: String) -> Data { + var data = Data([ReportTransferMessageType.error.rawValue]) + data.append(Data(message.utf8)) + return data + } + + public static func decodeType(_ data: Data) -> ReportTransferMessageType? { + guard !data.isEmpty else { return nil } + return ReportTransferMessageType(rawValue: data[0]) + } + + public static func decodeReport(_ data: Data) throws -> ReportTransferPayload { + try BinaryDecoder().decode(ReportTransferPayload.self, from: data.dropFirst()) + } + + public static func decodeError(_ data: Data) -> String { + String(data: data.dropFirst(), encoding: .utf8) ?? "Unknown error" + } +} diff --git a/ApplicationLibrary/Service/ReportTransferServer.swift b/ApplicationLibrary/Service/ReportTransferServer.swift new file mode 100644 index 0000000..4e91d31 --- /dev/null +++ b/ApplicationLibrary/Service/ReportTransferServer.swift @@ -0,0 +1,141 @@ +#if os(iOS) + + import Foundation + import Library + import Network + import os + import UIKit + + private let logger = Logger(category: "ReportTransferServer") + + public extension Notification.Name { + static let reportReceived = Notification.Name("reportReceived") + } + + public class ReportTransferServer { + private var listener: NWListener + + @available(iOS 16.0, *) + public init() throws { + listener = try NWListener(using: .applicationService) + listener.service = NWListener.Service(applicationService: ReportTransferService.applicationServiceName) + listener.newConnectionHandler = { connection in + connection.stateUpdateHandler = { state in + if state == .ready { + Task.detached { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100) + await ReportTransferConnection(connection).process() + } + } + } + connection.start(queue: .global()) + } + } + + public func start() { + listener.start(queue: .global()) + } + + public func cancel() { + listener.cancel() + } + + class ReportTransferConnection { + private let connection: NWSocket + private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + + init(_ connection: NWConnection) { + self.connection = NWSocket(connection) + } + + func process() async { + beginBackgroundTask() + defer { endBackgroundTask() } + + var receivedCount = 0 + var lastReportType: ReportType? + do { + while true { + let message = try await connection.read() + guard let type = ReportTransferMessage.decodeType(message) else { + continue + } + switch type { + case .report: + let payload = try ReportTransferMessage.decodeReport(message) + try importReport(payload) + lastReportType = payload.reportType + receivedCount += 1 + case .complete: + logger.info("report transfer server: received \(receivedCount) report(s)") + if receivedCount > 0 { + let reportType = lastReportType + await MainActor.run { + NotificationCenter.default.post(name: .reportReceived, object: reportType) + } + } + try await connection.write(ReportTransferMessage.encodeAck()) + return + case .error: + let errorMsg = ReportTransferMessage.decodeError(message) + logger.warning("report transfer server: client error: \(errorMsg)") + return + case .ack: + return + } + } + } catch { + logger.warning("report transfer server: \(error.localizedDescription)") + await writeError(error.localizedDescription) + } + } + + private func importReport(_ payload: ReportTransferPayload) throws { + let reportsDir = FilePath.workingDirectory.appendingPathComponent(payload.reportType.directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: reportsDir, withIntermediateDirectories: true) + + let date = Date(timeIntervalSince1970: payload.timestamp) + let artifactURL = ReportArchive.nextAvailableArtifactURL(in: reportsDir, for: date) + try FileManager.default.createDirectory(at: artifactURL, withIntermediateDirectories: true) + + for file in payload.files { + let fileURL = artifactURL.appendingPathComponent(file.name) + if file.name == ReportArchive.metadataFileName { + try writeMetadataWithDeviceOrigin(file.data, to: fileURL) + } else { + try file.data.write(to: fileURL, options: .atomic) + } + } + } + + private func writeMetadataWithDeviceOrigin(_ data: Data, to url: URL) throws { + guard var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + try data.write(to: url, options: .atomic) + return + } + json["deviceOrigin"] = ReportArchive.tvOSDeviceOrigin + let patched = try JSONSerialization.data(withJSONObject: json) + try patched.write(to: url, options: .atomic) + } + + private func writeError(_ message: String) async { + try? await connection.write(ReportTransferMessage.encodeError(message)) + } + + private func beginBackgroundTask() { + backgroundTaskID = UIApplication.shared.beginBackgroundTask { [weak self] in + logger.warning("report transfer server: background task expiring") + self?.connection.cancel() + self?.endBackgroundTask() + } + } + + private func endBackgroundTask() { + guard backgroundTaskID != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTaskID) + backgroundTaskID = .invalid + } + } + } + +#endif diff --git a/ApplicationLibrary/Views/Abstract/MetadataFormView.swift b/ApplicationLibrary/Views/Abstract/MetadataFormView.swift new file mode 100644 index 0000000..e03aa96 --- /dev/null +++ b/ApplicationLibrary/Views/Abstract/MetadataFormView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +private struct OrderedStringMap { + let entries: [(key: String, value: String)] + + init?(data: Data) { + guard let json = String(data: data, encoding: .utf8) else { return nil } + var entries: [(key: String, value: String)] = [] + var rest = json[...] + + func skip(_ ch: Character) -> Bool { + rest = rest.drop(while: \.isWhitespace) + guard rest.first == ch else { return false } + rest = rest.dropFirst() + return true + } + + func readString() -> String? { + rest = rest.drop(while: \.isWhitespace) + guard rest.first == "\"" else { return nil } + rest = rest.dropFirst() + var s = "" + while let ch = rest.first, ch != "\"" { + if ch == "\\" { rest = rest.dropFirst() } + if let c = rest.first { s.append(c); rest = rest.dropFirst() } + } + if !rest.isEmpty { rest = rest.dropFirst() } + return s + } + + guard skip("{") else { return nil } + while true { + guard let key = readString(), skip(":"), let value = readString() else { break } + if !value.isEmpty { entries.append((key: key, value: value)) } + if !skip(",") { break } + } + self.entries = entries + } +} + +@MainActor +public struct MetadataFormView: View { + @State private var entries: [(key: String, value: String)] = [] + @State private var isLoading = true + + let url: URL + let title: String + + public init(url: URL, title: String) { + self.url = url + self.title = title + } + + public var body: some View { + FormView { + if !isLoading { + Section { + ForEach(entries, id: \.key) { entry in + FormTextItem(LocalizedStringKey(entry.key), entry.value) + } + } + } + } + .overlay { + if isLoading { + ProgressView() + } else if entries.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } + } + .onAppear { + Task.detached { + let loaded = loadEntries() + await MainActor.run { + entries = loaded + isLoading = false + } + } + } + .navigationTitle(title) + } + + private nonisolated func loadEntries() -> [(key: String, value: String)] { + guard let data = try? Data(contentsOf: url), + let map = OrderedStringMap(data: data) + else { + return [] + } + return map.entries + } +} diff --git a/ApplicationLibrary/Views/Abstract/PlainTextView.swift b/ApplicationLibrary/Views/Abstract/PlainTextView.swift new file mode 100644 index 0000000..b13cc24 --- /dev/null +++ b/ApplicationLibrary/Views/Abstract/PlainTextView.swift @@ -0,0 +1,95 @@ +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +import SwiftUI + +#if os(tvOS) + struct PlainTextView: UIViewRepresentable { + let content: String + + private static let monoFont = UIFont.monospacedSystemFont(ofSize: 24, weight: .regular) + + func makeUIView(context _: Context) -> UITextView { + let textView = UITextView() + // isSelectable must be true for UITextView to be focusable on tvOS. + // Without focus, the Siri Remote cannot scroll the content. + // SwiftUI ScrollView + Text / LazyVStack + .focusable() do NOT work + // reliably inside navigation destinations on tvOS. + textView.isSelectable = true + textView.isUserInteractionEnabled = true + textView.isScrollEnabled = true + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 40, left: 40, bottom: 40, right: 40) + textView.textContainer.lineFragmentPadding = 0 + textView.font = Self.monoFont + textView.textColor = .label + textView.text = content + textView.panGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + return textView + } + + func updateUIView(_: UITextView, context _: Context) {} + } + +#elseif os(iOS) + struct PlainTextView: UIViewRepresentable { + let content: String + + private static let monoFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) + + func makeUIView(context _: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + textView.textContainer.lineFragmentPadding = 0 + textView.font = Self.monoFont + textView.textColor = .label + textView.text = content + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return textView + } + + func updateUIView(_: UITextView, context _: Context) {} + } + +#elseif os(macOS) + struct PlainTextView: NSViewRepresentable { + let content: String + + private static let monoFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + + func makeNSView(context _: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + + let textView = NSTextView() + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 16, height: 16) + textView.font = Self.monoFont + textView.textColor = .labelColor + textView.autoresizingMask = [.width] + textView.string = content + + if let textContainer = textView.textContainer { + textContainer.widthTracksTextView = true + textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) + textContainer.lineFragmentPadding = 0 + } + + scrollView.documentView = textView + return scrollView + } + + func updateNSView(_: NSScrollView, context _: Context) {} + } +#endif diff --git a/ApplicationLibrary/Views/Abstract/ShareButton.swift b/ApplicationLibrary/Views/Abstract/ShareButton.swift index 9c91fe5..5806365 100644 --- a/ApplicationLibrary/Views/Abstract/ShareButton.swift +++ b/ApplicationLibrary/Views/Abstract/ShareButton.swift @@ -82,7 +82,7 @@ public struct ShareButtonCompat: View { do { let shareItem = try await itemURL() await MainActor.run { - presentShareController(shareItem) + presentShareSheet(shareItem) } } catch { await MainActor.run { @@ -91,22 +91,6 @@ public struct ShareButtonCompat: View { } } - private func presentShareController(_ item: URL) { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.keyWindow?.rootViewController - else { - return - } - var topViewController = rootViewController - while let presented = topViewController.presentedViewController { - topViewController = presented - } - topViewController.present( - UIActivityViewController(activityItems: [item], applicationActivities: nil), - animated: true - ) - } - #elseif os(macOS) private nonisolated func shareItemAsync() async { do { @@ -125,7 +109,7 @@ public struct ShareButtonCompat: View { } #if os(macOS) - private struct SharingServicePicker: NSViewRepresentable { + struct SharingServicePicker: NSViewRepresentable { @Binding private var isPresented: Bool @Binding private var alert: AlertState? @Binding private var item: URL? diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift index a33a157..06abae3 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift @@ -361,7 +361,7 @@ public struct ProfileCard: View { url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json") } #if os(iOS) - presentShareController(url) + presentShareSheet(url) #elseif os(macOS) let anchorView = viewModel.shareButtonView ?? NSApp.keyWindow?.contentView ?? NSView() NSSharingServicePicker(items: [url]).show( @@ -400,23 +400,6 @@ public struct ProfileCard: View { } } - #if os(iOS) - private func presentShareController(_ item: URL) { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.keyWindow?.rootViewController - else { - return - } - var topViewController = rootViewController - while let presented = topViewController.presentedViewController { - topViewController = presented - } - topViewController.present( - UIActivityViewController(activityItems: [item], applicationActivities: nil), - animated: true - ) - } - #endif #endif private func prepareQRSShare(_ profile: ProfilePreview) { diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 884da54..05128b4 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -155,7 +155,7 @@ public class LogDataModel: ObservableObject { do { let text = getLogsText() let dateString = Self.dateFormatter.string(from: Date()) - let tempDirectory = FileManager.default.temporaryDirectory + let tempDirectory = FilePath.cacheDirectory let fileURL = tempDirectory.appendingPathComponent("logs-\(dateString).txt") try text.write(to: fileURL, atomically: true, encoding: .utf8) logFileURL = fileURL diff --git a/ApplicationLibrary/Views/NavigationPage.swift b/ApplicationLibrary/Views/NavigationPage.swift index 6ff2771..b46833a 100644 --- a/ApplicationLibrary/Views/NavigationPage.swift +++ b/ApplicationLibrary/Views/NavigationPage.swift @@ -13,6 +13,7 @@ public enum NavigationPage: Int, CaseIterable, Identifiable { case connections #endif case logs + case tools case settings } @@ -23,6 +24,8 @@ public extension NavigationPage { self = .dashboard case "logs": self = .logs + case "tools": + self = .tools case "settings": self = .settings #if os(macOS) @@ -38,7 +41,7 @@ public extension NavigationPage { #if os(macOS) static var macosDefaultPages: [NavigationPage] { - [.logs, .settings] + [.logs, .tools, .settings] } #endif @@ -59,6 +62,8 @@ public extension NavigationPage { #endif case .logs: return String(localized: "Logs") + case .tools: + return String(localized: "Tools") case .settings: return String(localized: "Settings") } @@ -76,6 +81,8 @@ public extension NavigationPage { #endif case .logs: return "list.bullet.rectangle" + case .tools: + return "terminal.fill" case .settings: return "gear.circle.fill" } @@ -95,6 +102,8 @@ public extension NavigationPage { #endif case .logs: LogView() + case .tools: + ToolsView() case .settings: SettingView() } diff --git a/ApplicationLibrary/Views/Setting/MacAppView.swift b/ApplicationLibrary/Views/Setting/MacAppView.swift index a1d1ead..247e67c 100644 --- a/ApplicationLibrary/Views/Setting/MacAppView.swift +++ b/ApplicationLibrary/Views/Setting/MacAppView.swift @@ -23,20 +23,21 @@ public struct AppView: View { @State private var isLoading = true @State private var selectedLanguage: String? + @State private var cacheSize: Int64 = 0 + @State private var cacheSizeText = "" #if os(macOS) @State private var startAtLogin = false @Environment(\.showMenuBarExtra) private var showMenuBarExtra @Environment(\.menuBarExtraSpeedMode) private var menuBarExtraSpeedMode @State private var menuBarExtraInBackground = false + @State private var systemExtensionInstalled = false @State private var helperStatusLoaded = false @State private var rootHelperRegistrationStatus: SMAppService.Status = .notRegistered @EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var updateManager: UpdateManager @State private var updateTrack: UpdateTrack = .stable @State private var checkUpdateEnabled = false - @State private var cacheSize: Int64 = 0 - @State private var cacheSizeText = "" #endif @State private var alert: AlertState? @@ -96,34 +97,37 @@ public struct AppView: View { } } - if Variant.useSystemExtension { - FormTextItem("Cache Size", cacheSizeText) - if cacheSize > 0 { - // Safe: System Extension's working directory is in its own container - // (/var/root/Library/Containers/…), not under the app's cacheDirectory. - FormButton(role: .destructive) { - Task.detached { - let cacheDir = FilePath.cacheDirectory - if let contents = try? FileManager.default.contentsOfDirectory( - at: cacheDir, - includingPropertiesForKeys: nil - ) { - for item in contents { - try? FileManager.default.removeItem(at: item) - } - } - await MainActor.run { - cacheSize = 0 - cacheSizeText = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file) + #endif + + FormTextItem("Cache Size", cacheSizeText) + if cacheSize > 0 { + FormButton(role: .destructive) { + Task.detached { + let cacheDir = FilePath.cacheDirectory + let workingDir = FilePath.workingDirectory + if let contents = try? FileManager.default.contentsOfDirectory( + at: cacheDir, + includingPropertiesForKeys: nil + ) { + for item in contents { + if item.lastPathComponent == workingDir.lastPathComponent { + continue } + try? FileManager.default.removeItem(at: item) } - } label: { - Label("Clear Cache", systemImage: "trash") - .foregroundColor(.red) + } + await MainActor.run { + cacheSize = 0 + cacheSizeText = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file) } } + } label: { + Label("Clear Cache", systemImage: "trash") + .foregroundColor(.red) } + } + #if os(macOS) if Variant.useSystemExtension { Section("Update Settings") { Picker("Update Track", selection: $updateTrack) { @@ -203,19 +207,29 @@ public struct AppView: View { } Section("System Extension") { - FormButton { - Task { - await updateSystemExtension() + if systemExtensionInstalled { + FormButton { + Task { + await updateSystemExtension() + } + } label: { + Label("Update", systemImage: "arrow.down.doc.fill") } - } label: { - Label("Update", systemImage: "arrow.down.doc.fill") - } - FormButton(role: .destructive) { - Task { - await uninstallSystemExtension() + FormButton(role: .destructive) { + Task { + await uninstallSystemExtension() + } + } label: { + Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red) + } + } else { + FormButton { + Task { + await installSystemExtension() + } + } label: { + Label("Install", systemImage: "lock.doc.fill") } - } label: { - Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red) } } @@ -289,6 +303,7 @@ public struct AppView: View { startAtLogin = SMAppService.mainApp.status == .enabled menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get() if Variant.useSystemExtension { + systemExtensionInstalled = await SystemExtension.isInstalled() let trackString = await SharedPreferences.updateTrack.get() updateTrack = UpdateTrack.resolved(from: trackString) checkUpdateEnabled = await SharedPreferences.checkUpdateEnabled.get() @@ -299,9 +314,9 @@ public struct AppView: View { if Variant.useSystemExtension { refreshHelperStatus() helperStatusLoaded = true - refreshCacheSize() } #endif + refreshCacheSize() } private static func currentLanguage() -> String? { @@ -384,6 +399,20 @@ public struct AppView: View { } } + private func installSystemExtension() async { + do { + if let result = try await SystemExtension.install() { + if result == .willCompleteAfterReboot { + alert = AlertState(errorMessage: String(localized: "Need Reboot")) + return + } + } + systemExtensionInstalled = true + } catch { + alert = AlertState(action: "install system extension", error: error) + } + } + private func updateSystemExtension() async { do { if let result = try await SystemExtension.install(forceUpdate: true) { @@ -410,6 +439,7 @@ public struct AppView: View { if let result = try await SystemExtension.uninstall() { switch result { case .completed: + systemExtensionInstalled = false alert = AlertState( title: String(localized: "Uninstall"), message: String(localized: "System Extension removed.") @@ -452,32 +482,34 @@ public struct AppView: View { NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app")) } - private func refreshCacheSize() { - Task.detached { - let size = Self.calculateDirSize(FilePath.cacheDirectory) - await MainActor.run { - cacheSize = size - cacheSizeText = ByteCountFormatter.string(fromByteCount: size, countStyle: .file) - } - } - } - - private static func calculateDirSize(_ dir: URL) -> Int64 { - guard let enumerator = FileManager.default.enumerator( - at: dir, - includingPropertiesForKeys: [.fileSizeKey], - options: [.skipsHiddenFiles] - ) else { - return 0 - } - var size: Int64 = 0 - for case let fileURL as URL in enumerator { - if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { - size += Int64(fileSize) - } - } - return size - } - #endif + + private func refreshCacheSize() { + Task.detached { + let total = Self.calculateDirSize(FilePath.cacheDirectory) + let working = Self.calculateDirSize(FilePath.workingDirectory) + let size = max(total - working, 0) + await MainActor.run { + cacheSize = size + cacheSizeText = ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + } + } + + private static func calculateDirSize(_ dir: URL) -> Int64 { + guard let enumerator = FileManager.default.enumerator( + at: dir, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + var size: Int64 = 0 + for case let fileURL as URL in enumerator { + if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + size += Int64(fileSize) + } + } + return size + } } diff --git a/ApplicationLibrary/Views/Setting/PacketTunnelView.swift b/ApplicationLibrary/Views/Setting/PacketTunnelView.swift index 8a4a29f..8cf32c3 100644 --- a/ApplicationLibrary/Views/Setting/PacketTunnelView.swift +++ b/ApplicationLibrary/Views/Setting/PacketTunnelView.swift @@ -6,10 +6,6 @@ struct PacketTunnelView: View { @State private var isLoading = true @State private var alert: AlertState? - #if !os(macOS) - @State private var ignoreMemoryLimit = false - #endif - @State private var includeAllNetworks = false @State private var excludeAPNs = false @State private var excludeCellularServices = false @@ -28,15 +24,6 @@ struct PacketTunnelView: View { } } else { FormView { - #if !os(macOS) - FormToggle("Ignore Memory Limit", """ - Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken devices. - """, $ignoreMemoryLimit) { newValue in - await SharedPreferences.ignoreMemoryLimit.set(newValue) - await restartService() - } - #endif - #if !os(tvOS) FormToggle("includeAllNetworks", """ If this property is true, the system routes network traffic through the tunnel except traffic for designated system services necessary for maintaining expected device functionality. You can exclude some types of traffic using the **excludeAPNs**, **excludeLocalNetworks**, and **excludeCellularServices** properties in combination with this property. @@ -135,9 +122,6 @@ struct PacketTunnelView: View { @MainActor private func loadSettings() async { - #if !os(macOS) - ignoreMemoryLimit = await SharedPreferences.ignoreMemoryLimit.get() - #endif #if !os(tvOS) includeAllNetworks = await SharedPreferences.includeAllNetworks.get() excludeLocalNetworks = await SharedPreferences.excludeLocalNetworks.get() diff --git a/ApplicationLibrary/Views/Setting/ServiceLogView.swift b/ApplicationLibrary/Views/Setting/ServiceLogView.swift deleted file mode 100644 index 1d06772..0000000 --- a/ApplicationLibrary/Views/Setting/ServiceLogView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import Library -import SwiftUI - -@MainActor -public struct ServiceLogView: View { - @Environment(\.dismiss) private var dismiss - @StateObject private var viewModel = ServiceLogViewModel() - - private let logFont = Font.system(.caption, design: .monospaced) - - public init() {} - - public var body: some View { - Group { - if viewModel.isLoading { - ProgressView().onAppear { - Task { - await viewModel.loadContent() - } - } - } else { - if viewModel.isEmpty { - Text("Empty content") - } else { - ScrollView { - Text(viewModel.content) - .font(logFont) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .padding() - } - } - } - .toolbar { - if !viewModel.isEmpty { - #if !os(tvOS) - ShareButtonCompat($viewModel.alert) { - Label("Export", systemImage: "square.and.arrow.up.fill") - } itemURL: { - try await viewModel.generateShareFileAsync() - } - #endif - Button(role: .destructive) { - Task { - await viewModel.deleteContent(dismiss: dismiss) - } - } label: { - #if !os(tvOS) - Label("Delete", systemImage: "trash.fill") - #else - Image(systemName: "trash.fill") - .tint(.red) - #endif - } - } - } - .alert($viewModel.alert) - .navigationTitle("Service Log") - #if os(tvOS) - .focusable() - #endif - } -} diff --git a/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift b/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift deleted file mode 100644 index 0aa4124..0000000 --- a/ApplicationLibrary/Views/Setting/ServiceLogViewModel.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation -import Library -import SwiftUI - -@MainActor -final class ServiceLogViewModel: BaseViewModel { - @Published var content = "" - - override init() { - super.init() - isLoading = true - } - - var isEmpty: Bool { - content.isEmpty - } - - nonisolated func loadContent() async { - let primaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log") - let secondaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log.old") - var content = await BlockingIO.run { - if let primaryContent = try? String(contentsOf: primaryLogURL), !primaryContent.isEmpty { - return primaryContent - } - return (try? String(contentsOf: secondaryLogURL)) ?? "" - } - #if DEBUG - if content.isEmpty { - content = "Empty content" - } - #endif - if !content.isEmpty { - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let machineName = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - var deviceInfo = String("Machine: ") + machineName + "\n" - #if os(iOS) - await deviceInfo += String("System: ") + (UIDevice.current.systemName) + " " + (UIDevice.current.systemVersion) + "\n" - #elseif os(macOS) - deviceInfo += String("System: ") + "macOS " + ProcessInfo().operatingSystemVersionString + "\n" - #endif - content = deviceInfo + "\n" + content - } - await MainActor.run { [content] in - self.content = content - isLoading = false - } - } - - nonisolated func deleteContent(dismiss: DismissAction) async { - let primaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log") - let secondaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log.old") - await BlockingIO.run { - try? FileManager.default.removeItem(at: primaryLogURL) - try? FileManager.default.removeItem(at: secondaryLogURL) - } - await MainActor.run { - dismiss() - isLoading = true - } - } - - func generateShareFile() throws -> URL { - try content.generateShareFile(name: "service.log") - } - - func generateShareFileAsync() async throws -> URL { - let content = content - return try await BlockingIO.run { - try content.generateShareFile(name: "service.log") - } - } -} diff --git a/ApplicationLibrary/Views/Setting/SettingView.swift b/ApplicationLibrary/Views/Setting/SettingView.swift index 534b51a..cfe18fd 100644 --- a/ApplicationLibrary/Views/Setting/SettingView.swift +++ b/ApplicationLibrary/Views/Setting/SettingView.swift @@ -149,14 +149,17 @@ public struct SettingView: View { } #endif - @StateObject private var viewModel = SettingViewModel() public init() {} public var body: some View { FormView { Section { - ForEach([Tabs.app, Tabs.core, Tabs.packetTunnel, Tabs.onDemandRules, Tabs.profileOverride]) { it in - it.navigationLink - } + Tabs.app.navigationLink + Tabs.core.navigationLink + #if !os(tvOS) + Tabs.packetTunnel.navigationLink + #endif + Tabs.onDemandRules.navigationLink + Tabs.profileOverride.navigationLink } #if !os(tvOS) Section("About") { @@ -193,25 +196,6 @@ public struct SettingView: View { #endif } #endif - Section("Debug") { - FormNavigationLink { - ServiceLogView() - } label: { - Label("Service Log", systemImage: "doc.on.clipboard") - } - FormTextItem("Taiwan Flag Available", "touchid") { - if viewModel.isLoading { - Text("Loading...") - .onAppear { - Task.detached { - await viewModel.checkTaiwanFlagAvailability() - } - } - } else { - Text(viewModel.taiwanFlagAvailable.toString()) - } - } - } } #if os(macOS) .formNavigationDestination(for: SettingsPage.self) { page in diff --git a/ApplicationLibrary/Views/Tools/CrashReportDetailView.swift b/ApplicationLibrary/Views/Tools/CrashReportDetailView.swift new file mode 100644 index 0000000..f7a4a65 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/CrashReportDetailView.swift @@ -0,0 +1,164 @@ +import Library +import SwiftUI + +@MainActor +public struct CrashReportDetailView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var environments: ExtensionEnvironments + + @State private var alert: AlertState? + @State private var files: [CrashReportFile] = [] + @State private var isLoading = true + + #if os(macOS) + @State private var sharePresented = false + @State private var shareItemURL: URL? + #elseif os(tvOS) + @State private var showExport = false + #endif + + let report: CrashReport + + public init(report: CrashReport) { + self.report = report + } + + private var manager: CrashReportManager { + environments.crashReportManager + } + + #if !os(tvOS) + private func shareReport(includeConfig: Bool) async { + do { + let zipURL = try await createReportZip( + reportID: report.id, fileURL: report.fileURL, + cacheSubdirectory: ReportType.crash.directoryName, includeConfig: includeConfig + ) + #if os(iOS) + presentShareSheet(zipURL) + #elseif os(macOS) + shareItemURL = zipURL + sharePresented = true + #endif + } catch { + alert = AlertState(action: "export crash reports", error: error) + } + } + #endif + + public var body: some View { + FormView { + if !isLoading, !files.isEmpty { + Section("Files") { + ForEach(files) { file in + if file.id == .metadata { + FormNavigationLink { + MetadataFormView(url: file.fileURL, title: file.displayName) + } label: { + Text(file.displayName) + } + } else { + FormNavigationLink { + ReportFileContentView(fileURL: file.fileURL, displayName: file.displayName) + } label: { + Text(file.displayName) + } + } + } + } + } + } + .overlay { + if isLoading { + ProgressView() + } else if files.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } + } + .onAppear { + Task { + files = await manager.availableFiles(for: report) + manager.markAsRead(report) + isLoading = false + } + } + .alert($alert) + #if os(tvOS) + .navigationDestination(isPresented: $showExport) { + ExportReportView(reportType: .crash, reportURL: report.fileURL, reportDate: report.date) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + #elseif os(macOS) + .background(SharingServicePicker($sharePresented, $alert, $shareItemURL)) + #endif + .toolbar { + if !isLoading, !files.isEmpty { + #if os(tvOS) + ToolbarItem(placement: .confirmationAction) { + Button { + showExport = true + } label: { + Image(systemName: "square.and.arrow.up") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await manager.delete(report) + dismiss() + } + } label: { + Image(systemName: "trash.fill") + } + .tint(.red) + } + #else + if files.contains(where: { $0.id == .configContent }) { + Menu { + Button { + Task { + await shareReport(includeConfig: false) + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + Button { + Task { + await shareReport(includeConfig: true) + } + } label: { + Label("Share With Configuration", systemImage: "square.and.arrow.up.on.square") + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } else { + Button { + Task { + await shareReport(includeConfig: false) + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } + Button(role: .destructive) { + Task { + await manager.delete(report) + dismiss() + } + } label: { + Label("Delete", systemImage: "trash.fill") + .foregroundStyle(.red) + } + .tint(.red) + #endif + } + } + .navigationTitle(report.date.formatted(date: .abbreviated, time: .shortened)) + } +} diff --git a/ApplicationLibrary/Views/Tools/CrashReportListView.swift b/ApplicationLibrary/Views/Tools/CrashReportListView.swift new file mode 100644 index 0000000..20e0be3 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/CrashReportListView.swift @@ -0,0 +1,249 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public struct CrashReportListView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @State private var isLoading = true + @State private var alert: AlertState? + #if os(tvOS) + @State private var showCrashTrigger = false + @State private var selectedReport: CrashReport? + #endif + + public init() {} + + private var manager: CrashReportManager { + environments.crashReportManager + } + + public var body: some View { + FormView { + if !isLoading { + Section { + if manager.reports.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } else { + ForEach(manager.reports) { report in + #if os(tvOS) + Button { + selectedReport = report + } label: { + reportLabel(report) + } + #else + FormNavigationLink { + CrashReportDetailView(report: report) + } label: { + reportLabel(report) + } + #endif + } + } + } header: { + Text("Reports") + } footer: { + Text("You will receive a report when a crash occurs.") + } + } + } + .overlay { + if isLoading { + ProgressView() + } + } + .onAppear { + Task { + await manager.refresh() + isLoading = false + } + } + .navigationTitle("Crash Report") + .alert($alert) + #if os(tvOS) + .navigationDestination(item: $selectedReport) { report in + CrashReportDetailView(report: report) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + #endif + #if os(tvOS) + .navigationDestination(isPresented: $showCrashTrigger) { + CrashTriggerView() + } + .toolbar { + if SharedPreferences.inDebug { + ToolbarItem(placement: .confirmationAction) { + Button { + showCrashTrigger = true + } label: { + Image(systemName: "ant.fill") + } + } + } + if !manager.reports.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await manager.deleteAll() + } + } label: { + Image(systemName: "trash.fill") + } + .tint(.red) + } + } + } + #else + .toolbar { + if !manager.reports.isEmpty || SharedPreferences.inDebug { + Menu { + if SharedPreferences.inDebug { + Menu { + Menu("Application") { + Button("Go Crash") { + LibboxTriggerGoPanic() + } + Button("Native Crash") { + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) { + fatalError("debug native crash") + } + } + } + if let profile = environments.extensionProfile { + NetworkExtensionCrashMenu(profile: profile) + } + #if os(macOS) + RootHelperCrashMenu() + #endif + } label: { + Label("Crash Trigger", systemImage: "ant.fill") + } + } + if !manager.reports.isEmpty { + Button(role: .destructive) { + Task { + await manager.deleteAll() + } + } label: { + Label("Delete All", systemImage: "trash.fill") + } + } + } label: { + Label("Others", systemImage: "line.3.horizontal.circle") + } + } + } + #endif + } + + private func reportLabel(_ report: CrashReport) -> some View { + ReportLabel(date: report.date, isRead: report.isRead, origin: report.origin) + } +} + +#if os(tvOS) + private struct CrashTriggerView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var environments: ExtensionEnvironments + + var body: some View { + Form { + Section("Application") { + Button("Go Crash") { + LibboxTriggerGoPanic() + } + Button("Native Crash") { + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) { + fatalError("debug native crash") + } + } + } + if let profile = environments.extensionProfile, profile.status.isConnectedStrict { + Section("NetworkExtension") { + Button("Go Crash") { + try? LibboxNewStandaloneCommandClient()?.triggerGoCrash() + dismiss() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + Button("Native Crash") { + try? LibboxNewStandaloneCommandClient()?.triggerNativeCrash() + dismiss() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + } + } + } + .navigationTitle("Crash Trigger") + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + } +#else + private struct NetworkExtensionCrashMenu: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @ObservedObject var profile: ExtensionProfile + + var body: some View { + if profile.status.isConnectedStrict { + Menu("NetworkExtension") { + Button("Go Crash") { + try? LibboxNewStandaloneCommandClient()?.triggerGoCrash() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + Button("Native Crash") { + try? LibboxNewStandaloneCommandClient()?.triggerNativeCrash() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + } + } + } + } +#endif + +#if os(macOS) + private struct RootHelperCrashMenu: View { + @EnvironmentObject private var environments: ExtensionEnvironments + + var body: some View { + if Variant.useSystemExtension, HelperServiceManager.rootHelperStatus == .enabled { + Menu("RootHelper") { + Button("Go Crash") { + try? RootHelperClient.shared.triggerGoCrash() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + Button("Native Crash") { + try? RootHelperClient.shared.triggerNativeCrash() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await environments.crashReportManager.refresh() + } + } + } + } + } + } +#endif diff --git a/ApplicationLibrary/Views/Tools/ExportReportView.swift b/ApplicationLibrary/Views/Tools/ExportReportView.swift new file mode 100644 index 0000000..ffdc96f --- /dev/null +++ b/ApplicationLibrary/Views/Tools/ExportReportView.swift @@ -0,0 +1,173 @@ +#if os(tvOS) + + import DeviceDiscoveryUI + import Library + import Network + import SwiftUI + + @MainActor + public struct ExportReportView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = ExportReportViewModel() + + let reportType: ReportType + let reportURL: URL + let reportDate: Date + + public init(reportType: ReportType, reportURL: URL, reportDate: Date) { + self.reportType = reportType + self.reportURL = reportURL + self.reportDate = reportDate + } + + public var body: some View { + VStack(alignment: .center) { + if !viewModel.selected { + Form { + Section { + EmptyView() + } footer: { + Text("To export this report to your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**.") + } + + DevicePicker( + .applicationService(name: ReportTransferService.applicationServiceName) + ) { endpoint in + viewModel.selected = true + Task { + await viewModel.handleEndpoint(endpoint, reportType: reportType, reportURL: reportURL, reportDate: reportDate) + } + } label: { + Text("Select Device") + } fallback: { + EmptyView() + } parameters: { + .applicationService + } + } + } else if viewModel.exportComplete { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.green) + Text("Export Complete") + .font(.headline) + } + } else { + VStack(spacing: 16) { + ProgressView() + Text("Sending...") + } + } + } + .focusSection() + .alert($viewModel.alert) + .navigationTitle("Export Report") + .onChange(of: viewModel.exportComplete) { newValue in + if newValue { + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) + dismiss() + } + } + } + } + } + + @MainActor + private final class ExportReportViewModel: BaseViewModel { + @Published var selected = false + @Published var exportComplete = false + + private var connection: NWConnection? + private var socket: NWSocket? + + func reset() { + cancelConnection() + selected = false + } + + private func cancelConnection() { + if let connection { + connection.stateUpdateHandler = nil + connection.cancel() + self.connection = nil + } + if let socket { + socket.cancel() + self.socket = nil + } + } + + func handleEndpoint(_ endpoint: NWEndpoint, reportType: ReportType, reportURL: URL, reportDate: Date) async { + let connection = NWConnection(to: endpoint, using: NWParameters.applicationService) + self.connection = connection + let socket = NWSocket(connection) + self.socket = socket + + connection.stateUpdateHandler = { state in + switch state { + case let .failed(error): + DispatchQueue.main.async { [self] in + reset() + alert = AlertState(action: "connect to device", error: error) + } + default: break + } + } + connection.start(queue: .global()) + + do { + try await sendReport(reportType: reportType, reportURL: reportURL, reportDate: reportDate, via: socket) + cancelConnection() + exportComplete = true + } catch { + alert = AlertState(action: "export report", error: error) + reset() + } + } + + private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws { + let fm = FileManager.default + guard let fileURLs = try? fm.contentsOfDirectory( + at: reportURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) else { + throw ReportTransferError("Report is empty") + } + + var files: [ReportTransferFile] = [] + for fileURL in fileURLs { + guard let data = try? Data(contentsOf: fileURL) else { continue } + files.append(ReportTransferFile(name: fileURL.lastPathComponent, data: data)) + } + + guard !files.isEmpty else { + throw ReportTransferError("Report is empty") + } + + let payload = ReportTransferPayload( + reportType: reportType, + timestamp: reportDate.timeIntervalSince1970, + files: files + ) + try await socket.write(ReportTransferMessage.encodeReport(payload)) + try await socket.write(ReportTransferMessage.encodeComplete()) + + let response = try await socket.read() + guard let responseType = ReportTransferMessage.decodeType(response) else { + throw NWSocketError.connectionClosed + } + switch responseType { + case .ack: + break + case .error: + throw ReportTransferError(ReportTransferMessage.decodeError(response)) + default: + throw NWSocketError.connectionClosed + } + } + } + +#endif diff --git a/ApplicationLibrary/Views/Tools/OOMReportDetailView.swift b/ApplicationLibrary/Views/Tools/OOMReportDetailView.swift new file mode 100644 index 0000000..d385c44 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/OOMReportDetailView.swift @@ -0,0 +1,166 @@ +import Library +import SwiftUI + +@MainActor +public struct OOMReportDetailView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var environments: ExtensionEnvironments + + @State private var alert: AlertState? + @State private var files: [OOMReportFile] = [] + @State private var isLoading = true + + #if os(macOS) + @State private var sharePresented = false + @State private var shareItemURL: URL? + #elseif os(tvOS) + @State private var showExport = false + #endif + + let report: OOMReport + + public init(report: OOMReport) { + self.report = report + } + + private var manager: OOMReportManager { + environments.oomReportManager + } + + #if !os(tvOS) + private func shareReport(includeConfig: Bool) async { + do { + let zipURL = try await createReportZip( + reportID: report.id, fileURL: report.fileURL, + cacheSubdirectory: ReportType.oom.directoryName, includeConfig: includeConfig + ) + #if os(iOS) + presentShareSheet(zipURL) + #elseif os(macOS) + shareItemURL = zipURL + sharePresented = true + #endif + } catch { + alert = AlertState(action: "export OOM report", error: error) + } + } + #endif + + public var body: some View { + FormView { + if !isLoading, !files.isEmpty { + Section("Files") { + ForEach(files) { file in + if file.kind == .metadata { + FormNavigationLink { + MetadataFormView(url: file.fileURL, title: file.displayName) + } label: { + Text(file.displayName) + } + } else if file.kind == .configContent { + FormNavigationLink { + ReportFileContentView(fileURL: file.fileURL, displayName: file.displayName) + } label: { + Text(file.displayName) + } + } else { + Text(file.displayName) + } + } + } + } + } + .overlay { + if isLoading { + ProgressView() + } else if files.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } + } + .onAppear { + Task { + files = await manager.availableFiles(for: report) + manager.markAsRead(report) + isLoading = false + } + } + .alert($alert) + #if os(tvOS) + .navigationDestination(isPresented: $showExport) { + ExportReportView(reportType: .oom, reportURL: report.fileURL, reportDate: report.date) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + #elseif os(macOS) + .background(SharingServicePicker($sharePresented, $alert, $shareItemURL)) + #endif + .toolbar { + if !isLoading, !files.isEmpty { + #if os(tvOS) + ToolbarItem(placement: .confirmationAction) { + Button { + showExport = true + } label: { + Image(systemName: "square.and.arrow.up") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await manager.delete(report) + dismiss() + } + } label: { + Image(systemName: "trash.fill") + } + .tint(.red) + } + #else + if files.contains(where: { $0.kind == .configContent }) { + Menu { + Button { + Task { + await shareReport(includeConfig: false) + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + Button { + Task { + await shareReport(includeConfig: true) + } + } label: { + Label("Share With Configuration", systemImage: "square.and.arrow.up.on.square") + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } else { + Button { + Task { + await shareReport(includeConfig: false) + } + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } + Button(role: .destructive) { + Task { + await manager.delete(report) + dismiss() + } + } label: { + Label("Delete", systemImage: "trash.fill") + .foregroundStyle(.red) + } + .tint(.red) + #endif + } + } + .navigationTitle(report.date.formatted(date: .abbreviated, time: .shortened)) + } +} diff --git a/ApplicationLibrary/Views/Tools/OOMReportListView.swift b/ApplicationLibrary/Views/Tools/OOMReportListView.swift new file mode 100644 index 0000000..e3a37ec --- /dev/null +++ b/ApplicationLibrary/Views/Tools/OOMReportListView.swift @@ -0,0 +1,246 @@ +import Libbox +import Library +import SwiftUI + +@MainActor +public struct OOMReportListView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @State private var isLoading = true + #if os(tvOS) + @State private var selectedReport: OOMReport? + #endif + #if os(macOS) + @State private var oomKillerEnabled = false + @State private var oomMemoryLimitMB = 50 + @State private var oomKillerKillConnections = false + @State private var alert: AlertState? + #endif + + public init() {} + + private var manager: OOMReportManager { + environments.oomReportManager + } + + public var body: some View { + FormView { + if !isLoading { + Section { + if manager.reports.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + } else { + ForEach(manager.reports) { report in + #if os(tvOS) + Button { + selectedReport = report + } label: { + reportLabel(report) + } + #else + FormNavigationLink { + OOMReportDetailView(report: report) + } label: { + reportLabel(report) + } + #endif + } + } + } header: { + Text("Reports") + } footer: { + #if os(macOS) + Text("When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection.") + #else + Text("You will receive a report when the service runs out of memory. You can also manually trigger report collection.") + #endif + } + + #if os(macOS) + Section { + FormToggle("Enable Memory Limit", """ + Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit. + """, $oomKillerEnabled) { newValue in + await SharedPreferences.oomKillerEnabled.set(newValue) + await restartService() + } + + if oomKillerEnabled { + Picker("Memory Limit", selection: $oomMemoryLimitMB) { + ForEach(Self.memoryLimitOptions, id: \.self) { value in + Text(LibboxFormatMemoryBytes(Int64(value) * 1024 * 1024)) + .tag(value) + } + } + .onChange(of: oomMemoryLimitMB) { _ in + Task { + await SharedPreferences.oomMemoryLimitMB.set(oomMemoryLimitMB) + await restartService() + } + } + + FormToggle("Kill Connections", """ + Kill all connections to free memory when the service memory exceeds the limit. + """, $oomKillerKillConnections) { newValue in + await SharedPreferences.oomKillerKillConnections.set(newValue) + await restartService() + } + } + } header: { + Text("Settings") + } + #endif + } + } + .overlay { + if isLoading { + ProgressView() + } + } + .onAppear { + Task { + await manager.refresh() + #if os(macOS) + oomKillerEnabled = await SharedPreferences.oomKillerEnabled.get() + let storedLimit = await SharedPreferences.oomMemoryLimitMB.get() + if Self.memoryLimitOptions.contains(storedLimit) { + oomMemoryLimitMB = storedLimit + } else { + oomMemoryLimitMB = Self.memoryLimitOptions.first! + await SharedPreferences.oomMemoryLimitMB.set(oomMemoryLimitMB) + } + oomKillerKillConnections = await SharedPreferences.oomKillerKillConnections.get() + #endif + isLoading = false + } + } + .navigationTitle("OOM Report") + #if os(macOS) + .alert($alert) + #endif + #if os(tvOS) + .navigationDestination(item: $selectedReport) { report in + OOMReportDetailView(report: report) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + #endif + .toolbar { + #if os(tvOS) + if !manager.reports.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await manager.deleteAll() + } + } label: { + Image(systemName: "trash.fill") + } + .tint(.red) + } + } + if let profile = environments.extensionProfile { + ToolbarItem(placement: .confirmationAction) { + OOMReportTriggerButton(manager: manager, profile: profile) + } + } + #else + if let profile = environments.extensionProfile { + OOMReportToolbarMenu(manager: manager, profile: profile) + } else if !manager.reports.isEmpty { + Menu { + Button(role: .destructive) { + Task { + await manager.deleteAll() + } + } label: { + Label("Delete All", systemImage: "trash.fill") + } + } label: { + Label("Others", systemImage: "line.3.horizontal.circle") + } + } + #endif + } + } + + private func reportLabel(_ report: OOMReport) -> some View { + ReportLabel(date: report.date, isRead: report.isRead, origin: report.origin) + } + + #if os(macOS) + private static let memoryLimitOptions = [50, 100, 200, 300, 500, 750, 1024] + + private func restartService() async { + guard let profile = environments.extensionProfile, profile.status.isConnected else { + return + } + do { + try await profile.restart() + } catch { + alert = AlertState(action: "restart service", error: error) + } + } + #endif +} + +#if os(tvOS) + private struct OOMReportTriggerButton: View { + let manager: OOMReportManager + @ObservedObject var profile: ExtensionProfile + @State private var alert: AlertState? + + var body: some View { + Button { + triggerOOMReport(profile: profile, manager: manager, alert: &alert) + } label: { + Image(systemName: "memorychip") + } + .alert($alert) + } + } +#else + private struct OOMReportToolbarMenu: View { + let manager: OOMReportManager + @ObservedObject var profile: ExtensionProfile + @State private var alert: AlertState? + + var body: some View { + Menu { + Button { + triggerOOMReport(profile: profile, manager: manager, alert: &alert) + } label: { + Label("Fetch Memory Report", systemImage: "memorychip") + } + if !manager.reports.isEmpty { + Button(role: .destructive) { + Task { + await manager.deleteAll() + } + } label: { + Label("Delete All", systemImage: "trash.fill") + } + } + } label: { + Label("Others", systemImage: "line.3.horizontal.circle") + } + .alert($alert) + } + } +#endif + +@MainActor +private func triggerOOMReport(profile: ExtensionProfile, manager: OOMReportManager, alert: inout AlertState?) { + guard profile.status.isConnectedStrict else { + alert = AlertState(errorMessage: String(localized: "Service not started")) + return + } + try? LibboxNewStandaloneCommandClient()?.triggerOOMReport() + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC) + await manager.refresh() + } +} diff --git a/ApplicationLibrary/Views/Tools/ReportShared.swift b/ApplicationLibrary/Views/Tools/ReportShared.swift new file mode 100644 index 0000000..33957ff --- /dev/null +++ b/ApplicationLibrary/Views/Tools/ReportShared.swift @@ -0,0 +1,127 @@ +import Libbox +import Library +import SwiftUI + +#if canImport(UIKit) + import UIKit +#endif + +struct ReportLabel: View { + let date: Date + let isRead: Bool + let origin: String? + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(isRead ? .clear : .blue) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(date, format: .dateTime) + .fontWeight(isRead ? .regular : .semibold) + HStack(spacing: 4) { + Image(systemName: origin == ReportArchive.tvOSDeviceOrigin ? "appletv.fill" : Self.localDeviceIcon) + Text(origin == ReportArchive.tvOSDeviceOrigin ? "Apple TV" : "Local") + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + #if os(iOS) + private static let localDeviceIcon = "iphone" + #elseif os(macOS) + private static let localDeviceIcon = "desktopcomputer" + #elseif os(tvOS) + private static let localDeviceIcon = "appletv.fill" + #endif +} + +@MainActor +struct ReportFileContentView: View { + @State private var content = "" + @State private var isLoading = true + + let fileURL: URL + let displayName: String + + var body: some View { + Group { + if isLoading { + ProgressView() + .onAppear { + Task { + content = await Self.loadContent(fileURL: fileURL) + isLoading = false + } + } + } else if content.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + #if os(iOS) + ScrollView { + PlainTextView(content: content) + } + #else + PlainTextView(content: content) + #endif + } + } + .navigationTitle(displayName) + } + + private nonisolated static func loadContent(fileURL: URL) async -> String { + await BlockingIO.run { + guard let data = try? Data(contentsOf: fileURL) else { + return "" + } + return String(data: data, encoding: .utf8) ?? "" + } + } +} + +#if !os(tvOS) + @MainActor + func createReportZip(reportID: String, fileURL: URL, cacheSubdirectory: String, includeConfig: Bool) async throws -> URL { + try await BlockingIO.run { + let tempDir = FilePath.cacheDirectory.appendingPathComponent(cacheSubdirectory, isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let tempURL = tempDir.appendingPathComponent("\(reportID).zip") + try? FileManager.default.removeItem(at: tempURL) + let strippedURL = tempDir.appendingPathComponent(reportID, isDirectory: true) + try? FileManager.default.removeItem(at: strippedURL) + try FileManager.default.copyItem(at: fileURL, to: strippedURL) + try? FileManager.default.removeItem(at: strippedURL.appendingPathComponent(ReportArchive.readMarkerFileName)) + if !includeConfig { + try? FileManager.default.removeItem(at: strippedURL.appendingPathComponent(ReportArchive.configFileName)) + } + var error: NSError? + LibboxCreateZipArchive(strippedURL.path, tempURL.path, &error) + try? FileManager.default.removeItem(at: strippedURL) + if let error { throw error } + return tempURL + } + } + + #if os(iOS) + @MainActor + func presentShareSheet(_ item: URL) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.keyWindow?.rootViewController + else { + return + } + var topViewController = rootViewController + while let presented = topViewController.presentedViewController { + topViewController = presented + } + topViewController.present( + UIActivityViewController(activityItems: [item], applicationActivities: nil), + animated: true + ) + } + #endif +#endif diff --git a/ApplicationLibrary/Views/Tools/ToolsView.swift b/ApplicationLibrary/Views/Tools/ToolsView.swift new file mode 100644 index 0000000..e344291 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/ToolsView.swift @@ -0,0 +1,97 @@ +import Library +import SwiftUI + +@MainActor +public struct ToolsView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @StateObject private var viewModel = SettingViewModel() + #if os(iOS) + @State private var showCrashReportList = false + @State private var showOOMReportList = false + #endif + + public init() {} + + public var body: some View { + FormView { + Section("Debug") { + #if os(iOS) + NavigationLink(isActive: $showCrashReportList) { + CrashReportListView() + } label: { + Label("Crash Report", systemImage: "ladybug.fill") + .badge(environments.crashReportManager.unreadCount) + } + .onReceive(NotificationCenter.default.publisher(for: .reportReceived)) { notification in + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_MSEC * 300) + if let reportType = notification.object as? ReportType { + switch reportType { + case .crash: + showCrashReportList = true + case .oom: + showOOMReportList = true + } + } + } + } + NavigationLink(isActive: $showOOMReportList) { + OOMReportListView() + } label: { + Label("OOM Report", systemImage: "memorychip") + .badge(environments.oomReportManager.unreadCount) + } + #else + FormNavigationLink { + CrashReportListView() + } label: { + #if os(tvOS) + HStack { + Label("Crash Report", systemImage: "ladybug.fill") + Spacer() + if environments.crashReportManager.unreadCount > 0 { + Text("\(environments.crashReportManager.unreadCount)") + .foregroundStyle(.secondary) + } + } + #else + Label("Crash Report", systemImage: "ladybug.fill") + .badge(environments.crashReportManager.unreadCount) + #endif + } + #endif + #if !os(iOS) + FormNavigationLink { + OOMReportListView() + } label: { + #if os(tvOS) + HStack { + Label("OOM Report", systemImage: "memorychip") + Spacer() + if environments.oomReportManager.unreadCount > 0 { + Text("\(environments.oomReportManager.unreadCount)") + .foregroundStyle(.secondary) + } + } + #else + Label("OOM Report", systemImage: "memorychip") + .badge(environments.oomReportManager.unreadCount) + #endif + } + #endif + FormTextItem("Taiwan Flag Available", "touchid") { + if viewModel.isLoading { + Text("Loading...") + .onAppear { + Task.detached { + await viewModel.checkTaiwanFlagAvailability() + } + } + } else { + Text(viewModel.taiwanFlagAvailable.toString()) + } + } + } + } + } +} diff --git a/HelperService/RootHelperService.swift b/HelperService/RootHelperService.swift index 4dbead3..e6c7265 100644 --- a/HelperService/RootHelperService.swift +++ b/HelperService/RootHelperService.swift @@ -27,6 +27,7 @@ class RootHelperService: NSObject { private var pathMonitor: NWPathMonitor? private var pendingNATFlush: DispatchWorkItem? private var tunInterfaceName: String? + var pendingCrashLogs: [CrashLogFileResult] = [] func start() { listener = NSXPCListener(machServiceName: AppConfiguration.rootHelperMachService) @@ -142,6 +143,128 @@ extension RootHelperService: RootHelperProtocol { reply(nil) } + static func readCrashLogFiles() -> [CrashLogFileResult] { + var results: [CrashLogFileResult] = [] + + let crashLogSearchPaths: [(directory: String, fileNames: [String])] = [ + (WorkingDirectoryManager.extensionWorkingDirectoryPath, [ + "CrashReport-NetworkExtension.log", + "CrashReport-NetworkExtension.log.old", + ]), + (WorkingDirectoryManager.helperWorkingDirectoryPath, [ + "CrashReport-RootHelper.log", + "CrashReport-RootHelper.log.old", + ]), + (WorkingDirectoryManager.extensionBasePath, [ + "configuration.json", + ]), + (WorkingDirectoryManager.helperBasePath, [ + "configuration.json", + ]), + ] + + for searchPath in crashLogSearchPaths { + for fileName in searchPath.fileNames { + let filePath = (searchPath.directory as NSString).appendingPathComponent(fileName) + guard FileManager.default.fileExists(atPath: filePath), + let content = try? String(contentsOfFile: filePath, encoding: .utf8), + !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + continue + } + + let attrs = try? FileManager.default.attributesOfItem(atPath: filePath) + let modificationDate = (attrs?[.modificationDate] as? Date) ?? Date() + + results.append(CrashLogFileResult( + fileName: fileName, + content: content, + modificationDate: modificationDate + )) + + try? FileManager.default.removeItem(atPath: filePath) + } + } + + return results + } + + func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void) { + let result = CrashArtifactsResult() + + var crashLogs = pendingCrashLogs + pendingCrashLogs.removeAll() + crashLogs.append(contentsOf: Self.readCrashLogFiles()) + result.crashLogs = crashLogs + + result.helperNativeCrashData = NativeCrashReporter.loadAndPurgePendingCrashReportData() + + let extensionReportURL = CrashReportArchive.pendingNativeCrashReportURL( + basePath: URL(fileURLWithPath: WorkingDirectoryManager.extensionNativeCrashBasePath, isDirectory: true), + bundleIdentifier: AppConfiguration.systemExtensionBundleID + ) + if let data = try? Data(contentsOf: extensionReportURL), !data.isEmpty { + result.extensionNativeCrashData = data + try? FileManager.default.removeItem(at: extensionReportURL) + } + + reply(result, nil) + } + + func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void) { + let result = OOMReportArtifactsResult() + let oomReportsPath = WorkingDirectoryManager.extensionOOMReportsPath + let fm = FileManager.default + + guard fm.fileExists(atPath: oomReportsPath), + let entries = try? fm.contentsOfDirectory(atPath: oomReportsPath) + else { + reply(result, nil) + return + } + + for entry in entries { + let dirPath = (oomReportsPath as NSString).appendingPathComponent(entry) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { + continue + } + + guard let fileNames = try? fm.contentsOfDirectory(atPath: dirPath) else { + continue + } + + var files: [OOMReportFileResult] = [] + for fileName in fileNames { + let filePath = (dirPath as NSString).appendingPathComponent(fileName) + guard let data = fm.contents(atPath: filePath) else { + continue + } + files.append(OOMReportFileResult(name: fileName, data: data)) + } + + if !files.isEmpty { + result.reports.append(OOMReportDirectoryResult(directoryName: entry, files: files)) + } + + try? fm.removeItem(atPath: dirPath) + } + + reply(result, nil) + } + + func triggerGoCrash(reply: @escaping (NSError?) -> Void) { + reply(nil) + LibboxTriggerGoPanic() + } + + func triggerNativeCrash(reply: @escaping (NSError?) -> Void) { + reply(nil) + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(200)) { + fatalError("debug native crash") + } + } + func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) { logger.info("closeNeighborMonitor") closeNeighborMonitorInternal() diff --git a/HelperService/WorkingDirectoryManager.swift b/HelperService/WorkingDirectoryManager.swift index cc1fd09..137ffd8 100644 --- a/HelperService/WorkingDirectoryManager.swift +++ b/HelperService/WorkingDirectoryManager.swift @@ -2,12 +2,44 @@ import Foundation import Library enum WorkingDirectoryManager { - private static var workingDirectoryPath: String { - "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/Working" + static var extensionBasePath: String { + "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data" + } + + static var extensionWorkingDirectoryPath: String { + (extensionBasePath as NSString).appendingPathComponent("Working") + } + + static var tempDirectoryPath: String { + "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/Temp" + } + + static var helperBasePath: String { + "/var/root/Library/Containers/\(AppConfiguration.rootHelperBundleID)/Data" + } + + static var helperWorkingDirectoryPath: String { + (helperBasePath as NSString).appendingPathComponent("Working") + } + + static var helperTempDirectoryPath: String { + (helperBasePath as NSString).appendingPathComponent("Temp") + } + + static var helperNativeCrashBasePath: String { + (helperBasePath as NSString).appendingPathComponent("NativeCrash") + } + + static var extensionNativeCrashBasePath: String { + "/var/root/Library/Containers/\(AppConfiguration.systemExtensionBundleID)/Data/NativeCrash" + } + + static var extensionOOMReportsPath: String { + (extensionWorkingDirectoryPath as NSString).appendingPathComponent("oom_reports") } static func getSize() -> Int64 { - let path = workingDirectoryPath + let path = extensionWorkingDirectoryPath guard FileManager.default.fileExists(atPath: path) else { return 0 } @@ -28,7 +60,7 @@ enum WorkingDirectoryManager { } static func clean() throws { - let path = workingDirectoryPath + let path = extensionWorkingDirectoryPath guard FileManager.default.fileExists(atPath: path) else { return } diff --git a/HelperService/main.swift b/HelperService/main.swift index 5ae3b8b..f7b01b2 100644 --- a/HelperService/main.swift +++ b/HelperService/main.swift @@ -1,5 +1,22 @@ import Foundation +import Libbox +import Library + +NativeCrashReporter.installForCurrentProcess( + basePath: URL(fileURLWithPath: WorkingDirectoryManager.helperNativeCrashBasePath, isDirectory: true) +) + +let pendingCrashLogs = RootHelperService.readCrashLogFiles() + +let setupOptions = LibboxSetupOptions() +setupOptions.basePath = WorkingDirectoryManager.helperBasePath +setupOptions.workingPath = WorkingDirectoryManager.helperWorkingDirectoryPath +setupOptions.tempPath = WorkingDirectoryManager.helperTempDirectoryPath +setupOptions.crashReportSource = "RootHelper" +var setupError: NSError? +LibboxSetup(setupOptions, &setupError) let service = RootHelperService() +service.pendingCrashLogs = pendingCrashLogs service.start() dispatchMain() diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index 173f7f2..e2c252f 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -22,10 +22,6 @@ import Foundation public enum SharedPreferences { public static let selectedProfileID = Preference("selected_profile_id", defaultValue: -1) - #if !os(macOS) - public static let ignoreMemoryLimit = Preference("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("max_log_lines", defaultValue: 300) + #if os(macOS) + public static let oomKillerEnabled = Preference("oom_killer_enabled", defaultValue: false) + public static let oomMemoryLimitMB = Preference("oom_memory_limit_mb", defaultValue: 50) + public static let oomKillerKillConnections = Preference("oom_killer_kill_connections", defaultValue: false) + #endif + #if os(macOS) public static let showMenuBarExtra = Preference("show_menu_bar_extra", defaultValue: true) public static let menuBarExtraInBackground = Preference("menu_bar_extra_in_background", defaultValue: false) diff --git a/Library/Network/ExtensionEnvironments.swift b/Library/Network/ExtensionEnvironments.swift index a73b2b5..33dce00 100644 --- a/Library/Network/ExtensionEnvironments.swift +++ b/Library/Network/ExtensionEnvironments.swift @@ -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() 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() } } diff --git a/Library/Network/ExtensionPlatformInterface.swift b/Library/Network/ExtensionPlatformInterface.swift index 43c40c0..e6ce034 100644 --- a/Library/Network/ExtensionPlatformInterface.swift +++ b/Library/Network/ExtensionPlatformInterface.swift @@ -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() { diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index b3b52bc..c09f66c 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -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()) diff --git a/Library/Network/ExtensionProvider.swift b/Library/Network/ExtensionProvider.swift index 4e7f5f2..79c0264 100644 --- a/Library/Network/ExtensionProvider.swift +++ b/Library/Network/ExtensionProvider.swift @@ -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() diff --git a/Library/Network/RootHelperXPC.swift b/Library/Network/RootHelperXPC.swift index 3e75f44..43eb7fd 100644 --- a/Library/Network/RootHelperXPC.swift +++ b/Library/Network/RootHelperXPC.swift @@ -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 + 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 + interface.setClasses( + oomArtifactClasses, + for: #selector(RootHelperProtocol.collectOOMReportArtifacts(reply:)), + argumentIndex: 0, + ofReply: true + ) let endpointClasses = NSSet(array: [NSXPCListenerEndpoint.self]) as! Set interface.setClasses( endpointClasses, @@ -141,10 +278,10 @@ return newConnection } - private func performXPCCall( + private func performXPCCallOptional( _ 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( + _ 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 diff --git a/Library/Shared/AppConfiguration.swift b/Library/Shared/AppConfiguration.swift index a5975c9..9c8c8d7 100644 --- a/Library/Shared/AppConfiguration.swift +++ b/Library/Shared/AppConfiguration.swift @@ -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" } diff --git a/Library/Shared/CrashReportArchive.swift b/Library/Shared/CrashReportArchive.swift new file mode 100644 index 0000000..c2ea597 --- /dev/null +++ b/Library/Shared/CrashReportArchive.swift @@ -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 + }() +} diff --git a/Library/Shared/CrashReportManager.swift b/Library/Shared/CrashReportManager.swift new file mode 100644 index 0000000..14b0513 --- /dev/null +++ b/Library/Shared/CrashReportManager.swift @@ -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 = [] + 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 + ) -> 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) + } +} diff --git a/Library/Shared/NativeCrashReporter.swift b/Library/Shared/NativeCrashReporter.swift new file mode 100644 index 0000000..12ae675 --- /dev/null +++ b/Library/Shared/NativeCrashReporter.swift @@ -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)") + } + } +} diff --git a/Library/Shared/OOMReportArchive.swift b/Library/Shared/OOMReportArchive.swift new file mode 100644 index 0000000..d178520 --- /dev/null +++ b/Library/Shared/OOMReportArchive.swift @@ -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 = [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) + } +} diff --git a/Library/Shared/OOMReportManager.swift b/Library/Shared/OOMReportManager.swift new file mode 100644 index 0000000..f644e49 --- /dev/null +++ b/Library/Shared/OOMReportManager.swift @@ -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 +} diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 87f8ac6..b8c0b2d 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -75,6 +75,9 @@ } } }, + "%@ (%lld)" : { + "shouldTranslate" : false + }, "%@ %@" : { "localizations" : { "en" : { @@ -833,6 +836,37 @@ } } }, + "Apple TV" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple TV" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple TV" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple TV" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple TV" + } + } + } + }, + "Application" : { + "shouldTranslate" : false + }, "Are you sure to import profile %@?" : { "localizations" : { "fa" : { @@ -2044,6 +2078,37 @@ } } }, + "Crash Report" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گزارش خرابی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчёт о сбое" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "崩溃报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當機報告" + } + } + } + }, + "Crash Trigger" : { + "shouldTranslate" : false + }, "Create" : { "localizations" : { "fa" : { @@ -2353,6 +2418,34 @@ } } }, + "Delete All" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف همه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部刪除" + } + } + } + }, "Deprecated Warning" : { "localizations" : { "fa" : { @@ -2633,34 +2726,6 @@ } } }, - "Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken devices." : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "برای sing-box محدودیت اجباری حافظه اعمال نکنید. این کار در دستگاه‌های بدون جیلبریک باعث OOM می‌شود." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не применять к sing-box принудительное ограничение памяти. На невзломанных устройствах это приведет к OOM." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "不要对 sing-box 强制执行内存限制。这将导致未越狱设备出现 OOM。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "不要對 sing-box 強制執行內存限制。這將導致未越獄設備出現 OOM。" - } - } - } - }, "Do not show warnings about usages of deprecated features." : { "localizations" : { "fa" : { @@ -3025,6 +3090,34 @@ } } }, + "Empty" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خالی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пусто" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "空" + } + } + } + }, "Empty connections" : { "localizations" : { "fa" : { @@ -3053,34 +3146,6 @@ } } }, - "Empty content" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "بدون محتوا" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Нет содержимого" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无内容" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "無內容" - } - } - } - }, "Empty groups" : { "localizations" : { "fa" : { @@ -3221,6 +3286,34 @@ } } }, + "Enable Memory Limit" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فعال‌سازی محدودیت حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить ограничение памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用内存限制" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用記憶體限制" + } + } + } + }, "Enabled" : { "localizations" : { "fa" : { @@ -3484,30 +3577,58 @@ }, "shouldTranslate" : false }, - "Export" : { + "Export Complete" : { "localizations" : { "fa" : { "stringUnit" : { "state" : "translated", - "value" : "خروجی" + "value" : "صادرات کامل شد" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Экспорт" + "value" : "Экспорт завершён" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "导出" + "value" : "导出完成" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "導出" + "value" : "匯出完成" + } + } + } + }, + "Export Report" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "صادرات گزارش" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экспорт отчёта" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出報告" } } } @@ -3596,6 +3717,34 @@ } } }, + "Fetch Memory Report" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "دریافت گزارش حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получить отчёт о памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "获取内存报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取得記憶體報告" + } + } + } + }, "File" : { "localizations" : { "fa" : { @@ -3708,6 +3857,34 @@ } } }, + "Files" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فایل‌ها" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файлы" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檔案" + } + } + } + }, "Filter" : { "localizations" : { "fa" : { @@ -3899,6 +4076,9 @@ } } }, + "Go Crash" : { + "shouldTranslate" : false + }, "Goroutines" : { "localizations" : { "fa" : { @@ -4391,34 +4571,6 @@ } } }, - "Ignore Memory Limit" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "نادیده‌گرفتن محدودیت حافظه" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Игнорировать ограничение памяти" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "忽略内存限制" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "忽略內存限制" - } - } - } - }, "Import" : { "localizations" : { "fa" : { @@ -4890,6 +5042,62 @@ } } }, + "Kill all connections to free memory when the service memory exceeds the limit." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنگام تجاوز حافظه سرویس از حد مجاز، تمام اتصالات را برای آزادسازی حافظه قطع کنید." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить все соединения для освобождения памяти при превышении лимита памяти сервиса." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当服务内存超出限制时,终止所有连接以释放内存。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當服務記憶體超出限制時,終止所有連線以釋放記憶體。" + } + } + } + }, + "Kill Connections" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "قطع اتصالات" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить соединения" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终止连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "終止連線" + } + } + } + }, "Language" : { "localizations" : { "fa" : { @@ -5143,29 +5351,6 @@ } } }, - "Machine: " : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "Machine: " - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Machine: " - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Machine: " - } - } - }, - "shouldTranslate" : false - }, "Managing working directory requires Helper Service." : { "localizations" : { "fa" : { @@ -5273,6 +5458,34 @@ } } }, + "Memory Limit" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "محدودیت حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ограничение памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存限制" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "記憶體限制" + } + } + } + }, "Metadata" : { "localizations" : { "fa" : { @@ -5525,6 +5738,9 @@ } } }, + "Native Crash" : { + "shouldTranslate" : false + }, "Need Reboot" : { "localizations" : { "fa" : { @@ -5581,6 +5797,9 @@ } } }, + "NetworkExtension" : { + "shouldTranslate" : false + }, "Never Connect" : { "localizations" : { "fa" : { @@ -5920,6 +6139,34 @@ } } }, + "OOM Report" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گزارش کمبود حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчёт о нехватке памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存不足报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "記憶體不足報告" + } + } + } + }, "Open" : { "localizations" : { "fa" : { @@ -6453,6 +6700,34 @@ } } }, + "Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "یک محدودیت نرم حافظه برای سرویس تعیین کنید. سرویس چندین فرآیند را انجام خواهد داد تا سعی کند در محدوده این محدودیت حافظه باقی بماند." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Задайте мягкое ограничение памяти для сервиса. Сервис будет выполнять различные процессы, чтобы оставаться в пределах этого ограничения." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为服务提供软内存限制。服务将执行多个进程以尝试保持在此内存限制范围内。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為服務提供軟記憶體限制。服務將執行多個程序以嘗試保持在此記憶體限制範圍內。" + } + } + } + }, "QRS" : { "localizations" : { "fa" : { @@ -6560,6 +6835,34 @@ } } }, + "Read from connection: %@" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خواندن از اتصال: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтение из соединения: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "读取连接:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "讀取連線:%@" + } + } + } + }, "Real-time Speed" : { "localizations" : { "fa" : { @@ -6723,6 +7026,62 @@ } } }, + "Remote error: %@" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطای راه دور: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка удалённого подключения: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程错误:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "遠端錯誤:%@" + } + } + } + }, + "Reports" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گزارش‌ها" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчёты" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "報告" + } + } + } + }, "Required" : { "localizations" : { "fa" : { @@ -6836,6 +7195,9 @@ } } }, + "RootHelper" : { + "shouldTranslate" : false + }, "Rules" : { "localizations" : { "fa" : { @@ -7088,6 +7450,34 @@ } } }, + "Sending..." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال ارسال..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправка..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送中..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "傳送中..." + } + } + } + }, "Service is Running" : { "localizations" : { "fa" : { @@ -7116,34 +7506,6 @@ } } }, - "Service Log" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گزارش سرویس" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Журнал службы" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "服务日志" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "服務日誌" - } - } - } - }, "Service not started" : { "localizations" : { "fa" : { @@ -7368,6 +7730,34 @@ } } }, + "Share With Configuration" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اشتراک‌گذاری با پیکربندی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться с конфигурацией" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "附带配置分享" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "附帶配置分享" + } + } + } + }, "Show in Menu Bar" : { "localizations" : { "fa" : { @@ -7979,29 +8369,6 @@ } } }, - "System: " : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "System: " - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "System: " - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "System: " - } - } - }, - "shouldTranslate" : false - }, "Taiwan Flag Available" : { "localizations" : { "fa" : { @@ -8198,6 +8565,34 @@ } } }, + "To export this report to your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "برای صادرات این گزارش به iPhone یا iPad، مطمئن شوید که sing-box در هر دو دستگاه **نسخه یکسان** است و **VPN غیرفعال** است." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтобы экспортировать этот отчёт на iPhone или iPad, убедитесь, что sing-box на обоих устройствах **одной версии** и **VPN отключён**." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "要将此报告导出到 iPhone 或 iPad,请确保两台设备上的 sing-box 为**相同版本**且**已关闭 VPN**。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "要將此報告匯出至 iPhone 或 iPad,請確認兩部裝置上的 sing-box 為**相同版本**且**已關閉 VPN**。" + } + } + } + }, "To File" : { "localizations" : { "fa" : { @@ -8254,6 +8649,34 @@ } } }, + "Tools" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابزارها" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工具" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "工具" + } + } + } + }, "Traffic" : { "localizations" : { "fa" : { @@ -8310,6 +8733,35 @@ } } }, + "Trigger OOM Report" : { + "extractionState" : "stale", + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایجاد گزارش کمبود حافظه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вызвать отчёт о нехватке памяти" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "触发内存不足报告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "觸發記憶體不足報告" + } + } + } + }, "true" : { "localizations" : { "fa" : { @@ -8506,6 +8958,34 @@ } } }, + "Unknown message type %u" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نوع پیام ناشناخته %u" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестный тип сообщения %u" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知消息类型 %u" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知訊息類型 %u" + } + } + } + }, "Unsaved Changes" : { "localizations" : { "fa" : { @@ -8919,6 +9399,34 @@ } } }, + "When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنگامی که محدودیت حافظه فعال است، در صورت تجاوز حافظه سرویس از حد مجاز، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включённом ограничении памяти вы получите отчёт, если память сервиса превысит лимит. Вы также можете вручную запросить сбор отчёта." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用内存限制后,当服务内存超出限制时,您将会收到报告。您也可以手动触发收集报告。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用記憶體限制後,當服務記憶體超出限制時,您將會收到報告。您也可以手動觸發收集報告。" + } + } + } + }, "Wi-Fi" : { "localizations" : { "fa" : { @@ -9058,6 +9566,62 @@ } } } + }, + "You will receive a report when a crash occurs." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنگام وقوع خرابی، گزارشی دریافت خواهید کرد." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При сбое приложения вы получите отчёт." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当遇到崩溃时,您将会收到报告。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當遇到崩潰時,您將會收到報告。" + } + } + } + }, + "You will receive a report when the service runs out of memory. You can also manually trigger report collection." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنگام کمبود حافظه سرویس، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При нехватке памяти сервиса вы получите отчёт. Вы также можете вручную запросить сбор отчёта." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当服务内存不足时,您将会收到报告。您也可以手动触发收集报告。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當服務記憶體不足時,您將會收到報告。您也可以手動觸發收集報告。" + } + } + } } }, "version" : "1.0" diff --git a/MacLibrary/ApplicationDelegate.swift b/MacLibrary/ApplicationDelegate.swift index db2a949..b1ce382 100644 --- a/MacLibrary/ApplicationDelegate.swift +++ b/MacLibrary/ApplicationDelegate.swift @@ -7,11 +7,13 @@ import UserNotifications open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { public func applicationDidFinishLaunching(_: Notification) { + NativeCrashReporter.installForCurrentProcess() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath options.workingPath = FilePath.workingDirectory.relativePath options.tempPath = FilePath.cacheDirectory.relativePath + options.crashReportSource = "Application" var error: NSError? LibboxSetup(options, &error) LibboxSetLocale(Locale.current.identifier) diff --git a/MacLibrary/SidebarView.swift b/MacLibrary/SidebarView.swift index e6f7cc4..e9360e2 100644 --- a/MacLibrary/SidebarView.swift +++ b/MacLibrary/SidebarView.swift @@ -26,10 +26,12 @@ private struct SidebarContentView: View { } ForEach(NavigationPage.macosDefaultPages, id: \.self) { it in it.label + .badge(it == .tools ? environments.totalUnreadReportCount : 0) } } else { ForEach(NavigationPage.allCases.filter { $0.visible(profile) }, id: \.self) { it in it.label + .badge(it == .tools ? environments.totalUnreadReportCount : 0) } } } @@ -95,6 +97,7 @@ public struct SidebarView: View { List(selection: $localSelection) { ForEach(NavigationPage.allCases.filter { $0.visible(nil) }, id: \.self) { it in it.label + .badge(it == .tools ? environments.totalUnreadReportCount : 0) } } .listStyle(.sidebar) diff --git a/SFI/ApplicationDelegate.swift b/SFI/ApplicationDelegate.swift index 37e7733..04b5f4f 100644 --- a/SFI/ApplicationDelegate.swift +++ b/SFI/ApplicationDelegate.swift @@ -9,13 +9,16 @@ import UserNotifications class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { private var profileServer: ProfileServer? + private var reportTransferServer: ReportTransferServer? func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + NativeCrashReporter.installForCurrentProcess() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath options.workingPath = FilePath.workingDirectory.relativePath options.tempPath = FilePath.cacheDirectory.relativePath + options.crashReportSource = "Application" var error: NSError? LibboxSetup(options, &error) LibboxSetLocale(Locale.current.identifier) @@ -77,6 +80,16 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCe } catch { NSLog("setup profile server error: \(error.localizedDescription)") } + do { + let reportTransferServer = try ReportTransferServer() + reportTransferServer.start() + await MainActor.run { + self.reportTransferServer = reportTransferServer + } + NSLog("started report transfer server") + } catch { + NSLog("setup report transfer server error: \(error.localizedDescription)") + } registerFileProviderDomain() } } diff --git a/SFI/Info.plist b/SFI/Info.plist index 30bb80c..67b1cb2 100644 --- a/SFI/Info.plist +++ b/SFI/Info.plist @@ -58,6 +58,10 @@ NSApplicationServiceIdentifier sing-box:profile + + NSApplicationServiceIdentifier + sing-box:report-transfer + NSUbiquitousContainers diff --git a/SFI/MainView.swift b/SFI/MainView.swift index 15e9544..a8ccba2 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -73,6 +73,7 @@ struct MainView: View { } .tag(page) .tabItem { page.label } + .badge(page == .tools ? environments.totalUnreadReportCount : 0) } } } @@ -176,6 +177,13 @@ struct MainView: View { environments.connect() } } + .onReceive(NotificationCenter.default.publisher(for: .reportReceived)) { _ in + Task { + await environments.crashReportManager.refresh() + await environments.oomReportManager.refresh() + selection = .tools + } + } .environment(\.selection, $selection) .environment(\.importProfile, $importProfile) .environment(\.importRemoteProfile, $importRemoteProfile) diff --git a/SFT/ApplicationDelegate.swift b/SFT/ApplicationDelegate.swift index e58e9d3..7a0141c 100644 --- a/SFT/ApplicationDelegate.swift +++ b/SFT/ApplicationDelegate.swift @@ -6,6 +6,7 @@ import UIKit class ApplicationDelegate: NSObject, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + NativeCrashReporter.installForCurrentProcess() NSLog("Here I stand") let options = LibboxSetupOptions() options.basePath = FilePath.sharedDirectory.relativePath @@ -28,6 +29,7 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate { } options.commandServerListenPort = port options.commandServerSecret = secret + options.crashReportSource = "Application" var error: NSError? LibboxSetup(options, &error) LibboxSetLocale(Locale.current.identifier) diff --git a/SFT/Info.plist b/SFT/Info.plist index 3ff9136..1f7a0ad 100644 --- a/SFT/Info.plist +++ b/SFT/Info.plist @@ -42,6 +42,17 @@ NSApplicationServiceUsageDescription Import sing-box profile from other devices + + NSApplicationServiceIdentifier + sing-box:report-transfer + NSApplicationServicePlatformSupport + + iOS + iPadOS + + NSApplicationServiceUsageDescription + Export crash reports to other devices + UIBackgroundModes diff --git a/SFT/MainView.swift b/SFT/MainView.swift index 9304d01..b7f91f5 100644 --- a/SFT/MainView.swift +++ b/SFT/MainView.swift @@ -28,7 +28,13 @@ struct MainView: View { .focusSection() } .tag(page) - .tabItem { page.label } + .tabItem { + if page == .tools, environments.totalUnreadReportCount > 0 { + Label("\(page.title) (\(environments.totalUnreadReportCount))", systemImage: "terminal.fill") + } else { + page.label + } + } } } .onAppear { diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index eb42e0f..6f5e7c6 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 3A4FB1572A73467F007012B9 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A4FB1582A73467F007012B9 /* Library.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3A4FB15C2A73468C007012B9 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; }; + 3A5AA1BA2F7DB10900BA2A0D /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 3A5AA1B92F7DB10900BA2A0D /* CrashReporter */; }; 3A5F26C82A503D4A00C27EDF /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; 3A5F26C92A503D4A00C27EDF /* Library.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3A648D542A4EF4C700D95A12 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AF342B12A4AA520002B34AC /* NetworkExtension.framework */; }; @@ -743,6 +744,7 @@ 3A017F922A4AB2E4009149FA /* GRDB in Frameworks */, 3AFE19402EF5677100F61E06 /* SystemConfiguration.framework in Frameworks */, 3A76504C2A4F08BA003945C5 /* Libbox.xcframework in Frameworks */, + 3A5AA1BA2F7DB10900BA2A0D /* CrashReporter in Frameworks */, 3A7E90382A46778E00D53052 /* BinaryCodable in Frameworks */, 3AF3A3D22B2207F3001FD7C1 /* libresolv.tbd in Frameworks */, ); @@ -1205,6 +1207,7 @@ packageProductDependencies = ( 3A7E90372A46778E00D53052 /* BinaryCodable */, 3A017F912A4AB2E4009149FA /* GRDB */, + 3A5AA1B92F7DB10900BA2A0D /* CrashReporter */, ); productName = Library; productReference = 3AEC211D2A459B4700A63465 /* Library.framework */; @@ -1372,6 +1375,7 @@ 3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */, 3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, + 3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */, ); productRefGroup = 3AEC20C72A45991900A63465 /* Products */; projectDirPath = ""; @@ -3354,6 +3358,14 @@ minimumVersion = 2.4.1; }; }; + 3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/microsoft/plcrashreporter.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.12.2; + }; + }; 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/christophhagen/BinaryCodable"; @@ -3398,6 +3410,11 @@ package = 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; productName = MarkdownUI; }; + 3A5AA1B92F7DB10900BA2A0D /* CrashReporter */ = { + isa = XCSwiftPackageProductDependency; + package = 3A5AA1B82F7DB10900BA2A0D /* XCRemoteSwiftPackageReference "plcrashreporter" */; + productName = CrashReporter; + }; 3A7E90372A46778E00D53052 /* BinaryCodable */ = { isa = XCSwiftPackageProductDependency; package = 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */; diff --git a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 00d86e7..8a51177 100644 --- a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8da21fbbe117848311fb8e66b6d976022dc80720a4c3ace8c3db96e20d68e580", + "originHash" : "a5ae9234a9bc428c00d8f7d82f435f7ccec7ff57efcb585b429182b0f1b5f9a4", "pins" : [ { "identity" : "binarycodable", @@ -64,6 +64,15 @@ "version" : "6.0.1" } }, + { + "identity" : "plcrashreporter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/microsoft/plcrashreporter.git", + "state" : { + "revision" : "0254f941c646b1ed17b243654723d0f071e990d0", + "version" : "1.12.2" + } + }, { "identity" : "qrcode", "kind" : "remoteSourceControl",