Files
Archive/sing-box/clients/apple/ApplicationLibrary/Views/Log/LogViewModel.swift
T
2026-03-07 19:46:44 +01:00

198 lines
6.4 KiB
Swift

import Combine
import Foundation
import Libbox
import Library
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
@MainActor
public class LogDataModel: ObservableObject {
@Published public var filteredLogs: [LogEntry] = []
@Published public private(set) var visibleLogs: [LogEntry] = []
@Published public var showFileExporter = false
@Published public var logFileURL: URL?
private let commandClient: CommandClient
private weak var viewModel: LogViewModel?
private var pausedLogSnapshot: [LogEntry]?
private var lastPaused = false
private var lastProcessedLogCount = 0
private var lastEffectiveLevel: Int?
private var lastSearchText = ""
private var cancellables = Set<AnyCancellable>()
private static let maxVisibleLogs = 1000
public var isEmpty: Bool {
commandClient.logList.isEmpty
}
public var isConnected: Bool {
commandClient.isConnected
}
private func updateVisibleLogs() {
if filteredLogs.count <= Self.maxVisibleLogs {
visibleLogs = filteredLogs
} else {
visibleLogs = Array(filteredLogs.suffix(Self.maxVisibleLogs))
}
}
public init(commandClient: CommandClient, viewModel: LogViewModel) {
self.commandClient = commandClient
self.viewModel = viewModel
let debouncedSearchText = viewModel.$searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
Publishers.CombineLatest(
Publishers.CombineLatest4(
commandClient.$logList,
commandClient.$defaultLogLevel,
viewModel.$selectedLogLevel,
debouncedSearchText
),
viewModel.$isPaused
)
.receive(on: DispatchQueue.main)
.sink { [weak self] combined, isPaused in
guard let self else { return }
let (logList, defaultLogLevel, selectedLogLevel, searchText) = combined
let effectiveLevel = selectedLogLevel ?? defaultLogLevel
if isPaused, !self.lastPaused {
self.pausedLogSnapshot = logList
self.lastProcessedLogCount = 0
} else if !isPaused, self.lastPaused {
self.pausedLogSnapshot = nil
self.lastProcessedLogCount = 0
}
self.lastPaused = isPaused
let sourceList = self.pausedLogSnapshot ?? logList
if isPaused, effectiveLevel == self.lastEffectiveLevel, searchText == self.lastSearchText,
self.lastProcessedLogCount > 0
{
return
}
let canIncrement = self.lastProcessedLogCount > 0 &&
sourceList.count > self.lastProcessedLogCount &&
effectiveLevel == self.lastEffectiveLevel &&
searchText == self.lastSearchText
if canIncrement {
let newLogs = sourceList[self.lastProcessedLogCount...]
let newFilteredLogs = newLogs.filter { log in
log.level <= effectiveLevel &&
(searchText.isEmpty || log.message.contains(searchText))
}
self.filteredLogs.append(contentsOf: newFilteredLogs)
} else {
self.filteredLogs = sourceList.filter { log in
log.level <= effectiveLevel &&
(searchText.isEmpty || log.message.contains(searchText))
}
}
self.updateVisibleLogs()
self.lastProcessedLogCount = sourceList.count
self.lastEffectiveLevel = effectiveLevel
self.lastSearchText = searchText
}
.store(in: &cancellables)
}
public func clearLogs() {
viewModel?.isPaused = false
pausedLogSnapshot = nil
lastPaused = false
lastProcessedLogCount = 0
lastEffectiveLevel = nil
lastSearchText = ""
filteredLogs = []
visibleLogs = []
commandClient.clearLogs()
Task.detached {
try? LibboxNewStandaloneCommandClient()!.clearLogs()
}
}
public func getLogsText() -> String {
filteredLogs.map(\.message).joined(separator: "\n")
}
#if !os(tvOS)
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HH:mm:ss"
return formatter
}()
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 cleanupLogFile() {
guard let url = logFileURL else { return }
try? FileManager.default.removeItem(at: url)
}
public func prepareLogFile() {
cleanupLogFile()
do {
let text = getLogsText()
let dateString = Self.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 {
viewModel?.alert = AlertState(action: "prepare log file", error: error)
}
}
#endif
}
@MainActor
public class LogViewModel: BaseViewModel {
@Published public var selectedLogLevel: Int?
@Published public var isPaused = false
@Published public var searchText = ""
@Published public var isSearching = false
public let commandClient: CommandClient
public private(set) var dataModel: LogDataModel!
public init(commandClient: CommandClient, searchText: String = "") {
self.commandClient = commandClient
self.searchText = searchText
self.isSearching = !searchText.isEmpty
super.init()
dataModel = LogDataModel(commandClient: commandClient, viewModel: self)
}
public func togglePause() {
isPaused.toggle()
}
public func toggleSearch() {
isSearching.toggle()
if !isSearching {
searchText = ""
}
}
}