Use UIKit UIMenu on iOS to prevent SwiftUI view updates from dismissing open menus. Separate LogDataModel from LogViewModel to isolate log data changes from UI state.
461 lines
17 KiB
Swift
461 lines
17 KiB
Swift
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
|
|
|
|
public init() {}
|
|
|
|
public var body: some View {
|
|
LogViewContent(commandClient: environments.commandClient)
|
|
}
|
|
}
|
|
|
|
private struct LogViewContent: View {
|
|
@StateObject private var viewModel: LogViewModel
|
|
|
|
init(commandClient: CommandClient) {
|
|
_viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient))
|
|
}
|
|
|
|
var body: some View {
|
|
LogContentInnerView(dataModel: viewModel.dataModel, viewModel: viewModel)
|
|
#if !os(tvOS)
|
|
.applySearchable(text: $viewModel.searchText, isSearching: $viewModel.isSearching, shouldShow: viewModel.isSearching)
|
|
.toolbar {
|
|
ToolbarItemGroup {
|
|
toolbarButtons
|
|
}
|
|
}
|
|
.alert($viewModel.alert)
|
|
.background(
|
|
LogExportView(
|
|
showFileExporter: Binding(
|
|
get: { viewModel.dataModel.showFileExporter },
|
|
set: { viewModel.dataModel.showFileExporter = $0 }
|
|
),
|
|
logFileURL: Binding(
|
|
get: { viewModel.dataModel.logFileURL },
|
|
set: { viewModel.dataModel.logFileURL = $0 }
|
|
),
|
|
alert: $viewModel.alert,
|
|
cleanup: { viewModel.dataModel.cleanupLogFile() }
|
|
)
|
|
)
|
|
#endif
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
@ViewBuilder
|
|
private var toolbarButtons: some View {
|
|
if #available(iOS 17.0, macOS 14.0, *) {
|
|
Button(action: viewModel.toggleSearch) {
|
|
Label("Search", systemImage: "magnifyingglass")
|
|
}
|
|
}
|
|
Button(action: viewModel.togglePause) {
|
|
Label(
|
|
viewModel.isPaused ? NSLocalizedString("Resume", comment: "Resume log auto-scroll") : NSLocalizedString("Pause", comment: "Pause log auto-scroll"),
|
|
systemImage: viewModel.isPaused ? "play.circle" : "pause.circle"
|
|
)
|
|
}
|
|
#if canImport(UIKit)
|
|
LogMenuButton(viewModel: viewModel)
|
|
#else
|
|
LogMenuView(viewModel: viewModel)
|
|
#endif
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
#if canImport(UIKit)
|
|
private struct LogMenuButton: UIViewRepresentable {
|
|
let viewModel: LogViewModel
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
func makeUIView(context: Context) -> UIButton {
|
|
let button = UIButton(type: .system)
|
|
let config = UIImage.SymbolConfiguration(scale: .large)
|
|
button.setImage(UIImage(systemName: "line.3.horizontal.circle", withConfiguration: config), for: .normal)
|
|
button.tintColor = colorScheme == .dark ? .white : .black
|
|
button.showsMenuAsPrimaryAction = true
|
|
button.menu = createMenu()
|
|
button.setContentHuggingPriority(.required, for: .horizontal)
|
|
button.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
return button
|
|
}
|
|
|
|
func updateUIView(_ uiView: UIButton, context: Context) {
|
|
uiView.menu = createMenu()
|
|
uiView.tintColor = colorScheme == .dark ? .white : .black
|
|
}
|
|
|
|
private func createMenu() -> UIMenu {
|
|
let logLevelActions = [
|
|
UIAction(
|
|
title: NSLocalizedString("Default", comment: "Log level filter default option"),
|
|
state: viewModel.selectedLogLevel == nil ? .on : .off
|
|
) { _ in
|
|
viewModel.selectedLogLevel = nil
|
|
}
|
|
] + LogLevel.allCases.map { level in
|
|
UIAction(
|
|
title: level.name,
|
|
state: viewModel.selectedLogLevel == level.rawValue ? .on : .off
|
|
) { _ in
|
|
viewModel.selectedLogLevel = level.rawValue
|
|
}
|
|
}
|
|
|
|
let logLevelMenu = UIMenu(
|
|
title: NSLocalizedString("Log Level", comment: ""),
|
|
image: UIImage(systemName: "slider.horizontal.3"),
|
|
children: logLevelActions
|
|
)
|
|
|
|
let saveActions = [
|
|
UIAction(
|
|
title: NSLocalizedString("To Clipboard", comment: ""),
|
|
image: UIImage(systemName: "doc.on.clipboard")
|
|
) { _ in
|
|
viewModel.dataModel.copyToClipboard()
|
|
},
|
|
UIAction(
|
|
title: NSLocalizedString("To File", comment: ""),
|
|
image: UIImage(systemName: "arrow.down.doc")
|
|
) { _ in
|
|
viewModel.dataModel.prepareLogFile()
|
|
viewModel.dataModel.showFileExporter = true
|
|
},
|
|
UIAction(
|
|
title: NSLocalizedString("Share", comment: ""),
|
|
image: UIImage(systemName: "square.and.arrow.up")
|
|
) { _ in
|
|
viewModel.dataModel.prepareLogFile()
|
|
},
|
|
]
|
|
|
|
let saveMenu = UIMenu(
|
|
title: NSLocalizedString("Save", comment: ""),
|
|
image: UIImage(systemName: "square.and.arrow.down"),
|
|
children: saveActions
|
|
)
|
|
|
|
let clearAction = UIAction(
|
|
title: NSLocalizedString("Clear Logs", comment: "Clear all logs"),
|
|
image: UIImage(systemName: "trash"),
|
|
attributes: .destructive
|
|
) { _ in
|
|
viewModel.dataModel.clearLogs()
|
|
}
|
|
|
|
return UIMenu(children: [logLevelMenu, saveMenu, clearAction])
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if canImport(AppKit)
|
|
private struct LogMenuView: View {
|
|
let viewModel: LogViewModel
|
|
|
|
var body: some View {
|
|
Menu {
|
|
Picker(selection: Binding(
|
|
get: { viewModel.selectedLogLevel },
|
|
set: { viewModel.selectedLogLevel = $0 }
|
|
)) {
|
|
Text(NSLocalizedString("Default", comment: "Log level filter default option")).tag(Int?.none)
|
|
ForEach(LogLevel.allCases) { level in
|
|
Text(level.name).tag(Int?.some(level.rawValue))
|
|
}
|
|
} label: {
|
|
Label("Log Level", systemImage: "slider.horizontal.3")
|
|
}
|
|
Menu {
|
|
Button {
|
|
viewModel.dataModel.copyToClipboard()
|
|
} label: {
|
|
Label("To Clipboard", systemImage: "doc.on.clipboard")
|
|
}
|
|
Button {
|
|
viewModel.dataModel.prepareLogFile()
|
|
viewModel.dataModel.showFileExporter = true
|
|
} label: {
|
|
Label("To File", systemImage: "arrow.down.doc")
|
|
}
|
|
Button {
|
|
viewModel.dataModel.prepareLogFile()
|
|
} label: {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
} label: {
|
|
Label("Save", systemImage: "square.and.arrow.down")
|
|
}
|
|
Button(role: .destructive) {
|
|
viewModel.dataModel.clearLogs()
|
|
} label: {
|
|
Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash")
|
|
}
|
|
} label: {
|
|
Label("Filter", systemImage: "line.3.horizontal.circle")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
#endif
|
|
|
|
private struct LogContentInnerView: View {
|
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
@ObservedObject var dataModel: LogDataModel
|
|
@ObservedObject var viewModel: LogViewModel
|
|
private let logFont = Font.system(.caption2, design: .monospaced)
|
|
|
|
var body: some View {
|
|
Group {
|
|
if ApplicationLibrary.inPreview {
|
|
previewContent
|
|
} else if dataModel.isEmpty {
|
|
emptyContent
|
|
} else if dataModel.visibleLogs.isEmpty {
|
|
emptyContent
|
|
} else {
|
|
logScrollView
|
|
}
|
|
}
|
|
}
|
|
|
|
private var previewContent: some View {
|
|
let logList = [
|
|
"(packet-tunnel) log server started",
|
|
"INFO[0000] router: loaded geoip database: 250 codes",
|
|
"INFO[0000] router: loaded geosite database: 1400 codes",
|
|
"INFO[0000] router: updated default interface en0, index 11",
|
|
"inbound/tun[0]: started at utun3",
|
|
"sing-box started (1.666s)",
|
|
]
|
|
#if os(tvOS)
|
|
return ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 8) {
|
|
ForEach(logList.indices, id: \.self) { index in
|
|
Text(ANSIColors.parseAnsiString(logList[index]))
|
|
.font(logFont)
|
|
.focusable()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.padding()
|
|
}
|
|
.focusEffectDisabled()
|
|
.focusSection()
|
|
#else
|
|
let previewLogs = logList.map { message in
|
|
LogEntry(level: 4, message: message)
|
|
}
|
|
return LogTextView(
|
|
logs: previewLogs,
|
|
font: logFont,
|
|
shouldAutoScroll: false,
|
|
searchText: ""
|
|
)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyContent: some View {
|
|
if dataModel.isConnected {
|
|
Text("Empty logs")
|
|
} else {
|
|
Text("Service not started").onAppear {
|
|
environments.connect()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var logScrollView: some View {
|
|
#if os(tvOS)
|
|
ScrollViewReader { reader in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 8) {
|
|
ForEach(dataModel.visibleLogs) { logEntry in
|
|
Text(highlightedText(for: logEntry.message))
|
|
.font(logFont)
|
|
.focusable()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.padding()
|
|
}
|
|
.focusEffectDisabled()
|
|
.focusSection()
|
|
.onAppear {
|
|
scrollToLastEntry(reader)
|
|
}
|
|
.onChangeCompat(of: dataModel.visibleLogs.count) { _ in
|
|
if !viewModel.isPaused {
|
|
scrollToLastEntry(reader)
|
|
}
|
|
}
|
|
}
|
|
#else
|
|
LogTextView(
|
|
logs: dataModel.visibleLogs,
|
|
font: logFont,
|
|
shouldAutoScroll: !viewModel.isPaused,
|
|
searchText: viewModel.searchText
|
|
)
|
|
#endif
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private func highlightedText(for message: String) -> AttributedString {
|
|
var attributedString = ANSIColors.parseAnsiString(message)
|
|
|
|
if !viewModel.searchText.isEmpty {
|
|
let searchText = viewModel.searchText
|
|
let messageString = String(attributedString.characters)
|
|
var searchRange = messageString.startIndex ..< messageString.endIndex
|
|
|
|
while let range = messageString.range(of: searchText, range: searchRange) {
|
|
if let attributedRange = Range<AttributedString.Index>(range, in: attributedString) {
|
|
attributedString[attributedRange].backgroundColor = .yellow
|
|
}
|
|
searchRange = range.upperBound ..< messageString.endIndex
|
|
}
|
|
}
|
|
|
|
return attributedString
|
|
}
|
|
#endif
|
|
|
|
#if os(tvOS)
|
|
private func scrollToLastEntry(_ reader: ScrollViewProxy) {
|
|
guard let lastEntry = dataModel.visibleLogs.last else { return }
|
|
withAnimation {
|
|
reader.scrollTo(lastEntry.id, anchor: .bottom)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
private extension View {
|
|
func applySearchable(text: Binding<String>, isSearching: Binding<Bool>, shouldShow: Bool) -> some View {
|
|
if #available(iOS 17.0, macOS 14.0, *) {
|
|
if shouldShow {
|
|
return AnyView(searchable(text: text, isPresented: isSearching))
|
|
} else {
|
|
return AnyView(self)
|
|
}
|
|
} else {
|
|
return AnyView(searchable(text: text))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LogExportView: View {
|
|
@Binding var showFileExporter: Bool
|
|
@Binding var logFileURL: URL?
|
|
@Binding var alert: AlertState?
|
|
@State private var showShareSheet = false
|
|
let cleanup: () -> Void
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.fileExporter(
|
|
isPresented: $showFileExporter,
|
|
document: logFileURL.map { LogTextDocument(url: $0) },
|
|
contentType: .plainText,
|
|
defaultFilename: "logs.txt"
|
|
) { result in
|
|
cleanup()
|
|
logFileURL = nil
|
|
if case let .failure(error) = result {
|
|
alert = AlertState(error: 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 {
|
|
cleanup()
|
|
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: AlertState?
|
|
|
|
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
|