diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index fdcf44a..82abbdc 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -1,5 +1,13 @@ import Library import SwiftUI +#if !os(tvOS) + import UniformTypeIdentifiers + #if canImport(UIKit) + import UIKit + #elseif canImport(AppKit) + import AppKit + #endif +#endif public struct LogView: View { @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 } @@ -180,6 +196,24 @@ private struct LogViewContent: View { } label: { 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() Button(role: .destructive, action: viewModel.clearLogs) { 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 diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 7708de5..4135406 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -1,6 +1,12 @@ import Combine import Foundation import Library +import SwiftUI +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif @MainActor public class LogViewModel: ObservableObject { @@ -9,6 +15,9 @@ public class LogViewModel: ObservableObject { @Published public var searchText = "" @Published public var isSearching = false @Published public var filteredLogs: [LogEntry] = [] + @Published public var alert: Alert? + @Published public var showFileExporter = false + @Published public var logFileURL: URL? private let commandClient: CommandClient @@ -53,4 +62,35 @@ public class LogViewModel: ObservableObject { commandClient.logList.removeAll() 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 } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 614f6d6..9fd7fcd 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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**." : { "localizations" : { "zh-Hans" : {