Files
sing-box-for-apple/ApplicationLibrary/Views/Log/LogViewModel.swift
T
世界 efa6088884 Fix widget toggle not disabling On Demand before stopping VPN
Store wasOnDemandEnabled flag in providerConfiguration so the widget
can restore On Demand state on next start. Clear the flag when On
Demand is explicitly disabled in settings or when starting from the
main app.
2026-03-09 13:20:53 +08: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
isSearching = !searchText.isEmpty
super.init()
dataModel = LogDataModel(commandClient: commandClient, viewModel: self)
}
public func togglePause() {
isPaused.toggle()
}
public func toggleSearch() {
isSearching.toggle()
if !isSearching {
searchText = ""
}
}
}