Tools View & Crash Report & OOM Report

This commit is contained in:
世界
2026-04-23 08:12:52 +08:00
parent 73180f0230
commit b48f4ad97f
46 changed files with 4279 additions and 533 deletions
@@ -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))
}
}
@@ -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
@@ -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
@@ -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))
}
}
@@ -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()
}
}
@@ -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
@@ -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())
}
}
}
}
}
}