Add export for logs
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
import Library
|
import Library
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if !os(tvOS)
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
public struct LogView: View {
|
public struct LogView: View {
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@@ -41,6 +49,14 @@ private struct LogViewContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alertBinding($viewModel.alert)
|
||||||
|
.background(
|
||||||
|
LogExportView(
|
||||||
|
showFileExporter: $viewModel.showFileExporter,
|
||||||
|
logFileURL: $viewModel.logFileURL,
|
||||||
|
alert: $viewModel.alert
|
||||||
|
)
|
||||||
|
)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +196,24 @@ private struct LogViewContent: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Log Level", systemImage: "slider.horizontal.3")
|
Label("Log Level", systemImage: "slider.horizontal.3")
|
||||||
}
|
}
|
||||||
|
#if !os(tvOS)
|
||||||
|
Menu {
|
||||||
|
Button(action: viewModel.copyToClipboard) {
|
||||||
|
Label("To Clipboard", systemImage: "doc.on.clipboard")
|
||||||
|
}
|
||||||
|
Button(action: {
|
||||||
|
viewModel.prepareLogFile()
|
||||||
|
viewModel.showFileExporter = true
|
||||||
|
}) {
|
||||||
|
Label("To File", systemImage: "arrow.down.doc")
|
||||||
|
}
|
||||||
|
Button(action: viewModel.prepareLogFile) {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Save", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Divider()
|
Divider()
|
||||||
Button(role: .destructive, action: viewModel.clearLogs) {
|
Button(role: .destructive, action: viewModel.clearLogs) {
|
||||||
Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash")
|
Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash")
|
||||||
@@ -204,4 +238,97 @@ private struct LogViewContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct LogExportView: View {
|
||||||
|
@Binding var showFileExporter: Bool
|
||||||
|
@Binding var logFileURL: URL?
|
||||||
|
@Binding var alert: Alert?
|
||||||
|
@State private var showShareSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.fileExporter(
|
||||||
|
isPresented: $showFileExporter,
|
||||||
|
document: logFileURL.map { LogTextDocument(url: $0) },
|
||||||
|
contentType: .plainText,
|
||||||
|
defaultFilename: "logs.txt"
|
||||||
|
) { result in
|
||||||
|
if case let .failure(error) = result {
|
||||||
|
alert = Alert(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showShareSheet) {
|
||||||
|
if let url = logFileURL {
|
||||||
|
#if os(iOS)
|
||||||
|
ShareViewController(activityItems: [url])
|
||||||
|
#elseif os(macOS)
|
||||||
|
ShareView(items: [url], alert: $alert)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: logFileURL) { newValue in
|
||||||
|
if newValue != nil, !showFileExporter {
|
||||||
|
showShareSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: showShareSheet) { newValue in
|
||||||
|
if !newValue {
|
||||||
|
logFileURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LogTextDocument: FileDocument {
|
||||||
|
static var readableContentTypes: [UTType] { [.plainText] }
|
||||||
|
|
||||||
|
private let url: URL
|
||||||
|
|
||||||
|
init(url: URL) {
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
init(configuration: ReadConfiguration) throws {
|
||||||
|
guard let data = configuration.file.regularFileContents else {
|
||||||
|
throw CocoaError(.fileReadCorruptFile)
|
||||||
|
}
|
||||||
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try data.write(to: tempURL)
|
||||||
|
url = tempURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper {
|
||||||
|
try FileWrapper(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private struct ShareViewController: UIViewControllerRepresentable {
|
||||||
|
let activityItems: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context _: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_: UIActivityViewController, context _: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#elseif os(macOS)
|
||||||
|
private struct ShareView: NSViewRepresentable {
|
||||||
|
let items: [Any]
|
||||||
|
@Binding var alert: Alert?
|
||||||
|
|
||||||
|
func makeNSView(context _: Context) -> NSView {
|
||||||
|
let view = NSView()
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context _: Context) {
|
||||||
|
let picker = NSSharingServicePicker(items: items)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Library
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public class LogViewModel: ObservableObject {
|
public class LogViewModel: ObservableObject {
|
||||||
@@ -9,6 +15,9 @@ public class LogViewModel: ObservableObject {
|
|||||||
@Published public var searchText = ""
|
@Published public var searchText = ""
|
||||||
@Published public var isSearching = false
|
@Published public var isSearching = false
|
||||||
@Published public var filteredLogs: [LogEntry] = []
|
@Published public var filteredLogs: [LogEntry] = []
|
||||||
|
@Published public var alert: Alert?
|
||||||
|
@Published public var showFileExporter = false
|
||||||
|
@Published public var logFileURL: URL?
|
||||||
|
|
||||||
private let commandClient: CommandClient
|
private let commandClient: CommandClient
|
||||||
|
|
||||||
@@ -53,4 +62,35 @@ public class LogViewModel: ObservableObject {
|
|||||||
commandClient.logList.removeAll()
|
commandClient.logList.removeAll()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
public func getLogsText() -> String {
|
||||||
|
filteredLogs.map(\.message).joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func copyToClipboard() {
|
||||||
|
let text = getLogsText()
|
||||||
|
#if os(iOS)
|
||||||
|
UIPasteboard.general.string = text
|
||||||
|
#elseif os(macOS)
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public func prepareLogFile() {
|
||||||
|
do {
|
||||||
|
let text = getLogsText()
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateFormat = "yyyy-MM-dd-HH:mm:ss"
|
||||||
|
let dateString = dateFormatter.string(from: Date())
|
||||||
|
let tempDirectory = FileManager.default.temporaryDirectory
|
||||||
|
let fileURL = tempDirectory.appendingPathComponent("logs-\(dateString).txt")
|
||||||
|
try text.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||||
|
logFileURL = fileURL
|
||||||
|
} catch {
|
||||||
|
alert = Alert(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1590,6 +1590,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"To Clipboard" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "到剪贴板"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"To File" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "到文件"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"To import configurations from your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**." : {
|
"To import configurations from your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
|
|||||||
Reference in New Issue
Block a user