Tools View & Crash Report & OOM Report
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user