Tools View & Crash Report & OOM Report
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -82,7 +82,7 @@ public struct ShareButtonCompat<Label: View>: 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<Label: View>: 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<Label: View>: 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?
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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