import AppKit import Foundation import MacToolsPluginKit @MainActor final class IPOverviewViewModel: ObservableObject { private enum RefreshMode { case full case publicIP } private enum StorageKey { static let cachedState = "cachedState" static let hidesSensitiveInfo = "hidesSensitiveInfo" } private struct CachedState: Codable { let cachedAt: Date let fullSnapshotCachedAt: Date? let snapshot: IPOverviewSnapshot let connectivityResults: [IPOverviewConnectivityResult] let webRTCResults: [IPOverviewLeakTestResult] let dnsLeakResults: [IPOverviewLeakTestResult] let networkQualityState: IPOverviewNetworkQualityRunState } private static let cacheTimeToLive: TimeInterval = 24 * 60 * 60 @Published private(set) var snapshot: IPOverviewSnapshot = .empty { didSet { onSnapshotChange?() } } @Published private(set) var connectivityResults: [IPOverviewConnectivityResult] = [] { didSet { onSnapshotChange?() } } @Published private(set) var isCheckingConnectivity = false { didSet { onSnapshotChange?() } } @Published private(set) var webRTCResults: [IPOverviewLeakTestResult] = [] { didSet { onSnapshotChange?() } } @Published private(set) var dnsLeakResults: [IPOverviewLeakTestResult] = [] { didSet { onSnapshotChange?() } } @Published private(set) var isCheckingWebRTC = false { didSet { onSnapshotChange?() } } @Published private(set) var isCheckingDNSLeak = false { didSet { onSnapshotChange?() } } @Published private(set) var networkQualityState: IPOverviewNetworkQualityRunState = .waiting { didSet { onSnapshotChange?() } } @Published private(set) var isShowingDetails = false { didSet { onSnapshotChange?() } } @Published private(set) var hidesSensitiveInfo = false { didSet { onSnapshotChange?() } } private let provider: any IPOverviewProviding private let connectivityChecker: any IPOverviewConnectivityChecking private let leakTester: any IPOverviewLeakTesting private let networkQualityMeasurer: any IPOverviewNetworkQualityMeasuring private let storage: PluginStorage private let localization: PluginLocalization private let currentDate: @MainActor () -> Date private var refreshTask: Task? private var refreshMode: RefreshMode? private var refreshGeneration = 0 private var connectivityTask: Task? private var webRTCTask: Task? private var dnsLeakTask: Task? private var networkQualityTask: Task? private var stateCachedAt: Date? private var fullSnapshotCachedAt: Date? var onSnapshotChange: (() -> Void)? var isRefreshingAll: Bool { snapshot.isRefreshing || isCheckingConnectivity || isCheckingWebRTC || isCheckingDNSLeak || isMeasuringNetworkQuality } var isMeasuringNetworkQuality: Bool { if case .running = networkQualityState { return true } return false } init( provider: (any IPOverviewProviding)? = nil, connectivityChecker: (any IPOverviewConnectivityChecking)? = nil, leakTester: (any IPOverviewLeakTesting)? = nil, networkQualityMeasurer: (any IPOverviewNetworkQualityMeasuring)? = nil, storage: PluginStorage = UserDefaultsPluginStorage(pluginID: "ip-overview"), localization: PluginLocalization = PluginLocalization(bundle: .main), currentDate: @escaping @MainActor () -> Date = { Date() } ) { self.localization = localization self.provider = provider ?? IPOverviewService(localization: localization) self.connectivityChecker = connectivityChecker ?? IPOverviewConnectivityService(localization: localization) self.leakTester = leakTester ?? IPOverviewLeakTestService(localization: localization) self.networkQualityMeasurer = networkQualityMeasurer ?? IPOverviewNetworkQualityService(localization: localization) self.storage = storage self.currentDate = currentDate self.hidesSensitiveInfo = storage.bool(forKey: StorageKey.hidesSensitiveInfo) self.connectivityResults = Self.loadConnectivityTargets(storage: storage).map { IPOverviewConnectivityResult(id: $0.id, target: $0, status: .waiting) } self.webRTCResults = IPOverviewWebRTCTarget.defaults.map { IPOverviewLeakTestResult(id: $0.id, name: $0.name, status: .waiting) } self.dnsLeakResults = Self.dnsLeakProbes(localization: localization).map { IPOverviewLeakTestResult(id: $0.id, name: $0.name, status: .waiting) } restoreCachedStateIfFresh() } deinit { refreshTask?.cancel() connectivityTask?.cancel() webRTCTask?.cancel() dnsLeakTask?.cancel() networkQualityTask?.cancel() } @discardableResult func refreshIfNeeded() -> Task? { guard !hasFreshSnapshot else { return nil } return refreshPublicIP() } @discardableResult func refresh() -> Task? { guard refreshTask == nil else { return nil } snapshot.isRefreshing = true snapshot.errorMessage = nil refreshMode = .full refreshGeneration += 1 let generation = refreshGeneration let task = Task { @MainActor [weak self] in guard let self else { return } let nextSnapshot = await provider.collectSnapshot() defer { clearRefreshTask(generation: generation) } guard !Task.isCancelled, refreshGeneration == generation else { return } snapshot = nextSnapshot cacheCurrentState(includeFullSnapshot: true) } refreshTask = task return task } @discardableResult func refreshPublicIP() -> Task? { guard refreshTask == nil else { return nil } snapshot.isRefreshing = true snapshot.errorMessage = nil refreshMode = .publicIP refreshGeneration += 1 let generation = refreshGeneration let currentSnapshot = snapshot let task = Task { @MainActor [weak self] in guard let self else { return } let nextSnapshot = await provider.collectPublicIPSnapshot(preserving: currentSnapshot) defer { clearRefreshTask(generation: generation) } guard !Task.isCancelled, refreshGeneration == generation else { return } snapshot = nextSnapshot cacheCurrentState() } refreshTask = task return task } func refreshAllIfNeeded() { guard !hasFreshDetailState else { checkDiagnosticsIfNeeded() measureNetworkQualityIfNeeded() return } refreshAll() } func refreshAll() { if refreshMode == .publicIP { cancelRefresh() } refresh() checkConnectivity() checkWebRTCLeak() checkDNSLeak() measureNetworkQuality() } func showDetails() { isShowingDetails = true } func showSummary() { isShowingDetails = false } func toggleSensitiveInfoVisibility() { hidesSensitiveInfo.toggle() storage.set(hidesSensitiveInfo, forKey: StorageKey.hidesSensitiveInfo) } func cancel() { cancelRefresh() connectivityTask?.cancel() connectivityTask = nil isCheckingConnectivity = false webRTCTask?.cancel() webRTCTask = nil isCheckingWebRTC = false dnsLeakTask?.cancel() dnsLeakTask = nil isCheckingDNSLeak = false networkQualityTask?.cancel() networkQualityTask = nil if isMeasuringNetworkQuality { networkQualityState = .failed(localization.string("speed.error.cancelled", defaultValue: "测速已取消")) } } func copy(_ value: String?) { guard let value, !value.isEmpty else { return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(value, forType: .string) } func checkConnectivity() { guard connectivityTask == nil else { return } isCheckingConnectivity = true connectivityResults = connectivityResults.map { IPOverviewConnectivityResult(id: $0.id, target: $0.target, status: .checking) } let targets = connectivityResults.map(\.target) connectivityTask = Task { @MainActor [weak self] in guard let self else { return } defer { connectivityTask = nil isCheckingConnectivity = false } var nextResults: [IPOverviewConnectivityResult] = [] for target in targets { guard !Task.isCancelled else { return } let result = await connectivityChecker.check(target: target) nextResults.append(result) connectivityResults = targets.map { candidate in nextResults.first(where: { $0.id == candidate.id }) ?? IPOverviewConnectivityResult( id: candidate.id, target: candidate, status: .checking ) } } cacheCurrentState() } } func addConnectivityTarget(name: String, urlString: String) -> String? { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedURLString = Self.normalizedURLString(urlString) guard !trimmedName.isEmpty else { return localization.string("customTarget.error.emptyName", defaultValue: "请输入名称") } guard let normalizedURLString, URL(string: normalizedURLString) != nil else { return localization.string("customTarget.error.invalidURL", defaultValue: "请输入有效 URL") } let target = IPOverviewConnectivityTarget( id: "custom-\(UUID().uuidString)", name: trimmedName, urlString: normalizedURLString, isCustom: true ) let targets = connectivityResults.map(\.target) + [target] saveConnectivityTargets(targets) connectivityResults.append(IPOverviewConnectivityResult( id: target.id, target: target, status: .waiting )) cacheCurrentState() return nil } func removeConnectivityTarget(id: String) { guard connectivityResults.contains(where: { $0.id == id && $0.target.isCustom }) else { return } let targets = connectivityResults .map(\.target) .filter { $0.id != id } saveConnectivityTargets(targets) connectivityResults.removeAll { $0.id == id } cacheCurrentState() } func checkWebRTCLeak() { guard webRTCTask == nil else { return } isCheckingWebRTC = true let targets = IPOverviewWebRTCTarget.defaults webRTCResults = targets.map { IPOverviewLeakTestResult(id: $0.id, name: $0.name, status: .checking) } webRTCTask = Task { @MainActor [weak self] in guard let self else { return } defer { webRTCTask = nil isCheckingWebRTC = false } var nextResults: [IPOverviewLeakTestResult] = [] for target in targets { guard !Task.isCancelled else { return } let result = await leakTester.checkWebRTC(target: target) nextResults.append(result) webRTCResults = targets.map { candidate in nextResults.first(where: { $0.id == candidate.id }) ?? IPOverviewLeakTestResult(id: candidate.id, name: candidate.name, status: .checking) } } cacheCurrentState() } } func checkDNSLeak() { guard dnsLeakTask == nil else { return } isCheckingDNSLeak = true let probes = Self.dnsLeakProbes(localization: localization) dnsLeakResults = probes.map { IPOverviewLeakTestResult(id: $0.id, name: $0.name, status: .checking) } dnsLeakTask = Task { @MainActor [weak self] in guard let self else { return } defer { dnsLeakTask = nil isCheckingDNSLeak = false } var nextResults: [IPOverviewLeakTestResult] = [] for probe in probes { guard !Task.isCancelled else { return } let result = await leakTester.checkDNS(id: probe.id, name: probe.name) nextResults.append(result) dnsLeakResults = probes.map { candidate in nextResults.first(where: { $0.id == candidate.id }) ?? IPOverviewLeakTestResult(id: candidate.id, name: candidate.name, status: .checking) } } cacheCurrentState() } } func measureNetworkQuality() { guard networkQualityTask == nil else { return } networkQualityState = .running(.started()) networkQualityTask = Task { @MainActor [weak self] in guard let self else { return } let result = await networkQualityMeasurer.measure { [weak self] event in await MainActor.run { self?.handleNetworkQualityProgress(event) } } defer { networkQualityTask = nil } guard !Task.isCancelled else { return } switch result { case .success(let measurement): networkQualityState = .completed( measurement, completedNetworkQualityProgress(for: measurement) ) case .failure(let message): networkQualityState = .failed(message) } cacheCurrentState() } } private func completedNetworkQualityProgress( for measurement: IPOverviewNetworkQualityMeasurement ) -> IPOverviewNetworkQualityProgress { let existingProgress: IPOverviewNetworkQualityProgress if case .running(let progress) = networkQualityState { existingProgress = progress } else { existingProgress = .started() } return IPOverviewNetworkQualityProgress( startedAt: existingProgress.startedAt, phase: .measuringLatency, downloadSamples: Self.completedNetworkQualitySamples( existingProgress.downloadSamples, fallback: measurement.downloadMbps ), uploadSamples: Self.completedNetworkQualitySamples( existingProgress.uploadSamples, fallback: measurement.uploadMbps ) ) } private func handleNetworkQualityProgress(_ event: IPOverviewNetworkQualityProgressEvent) { guard case .running(var progress) = networkQualityState else { return } switch event { case .phase(let phase): progress.phase = phase case .download(let value): progress.phase = .measuringDownload progress.downloadSamples = Self.appendingNetworkQualitySample( value, to: progress.downloadSamples ) case .upload(let value): progress.phase = .measuringUpload progress.uploadSamples = Self.appendingNetworkQualitySample( value, to: progress.uploadSamples ) } networkQualityState = .running(progress) } func checkDiagnosticsIfNeeded() { if connectivityResults.contains(where: { $0.status == .waiting }) { checkConnectivity() } if webRTCResults.contains(where: { $0.status == .waiting }) { checkWebRTCLeak() } if dnsLeakResults.contains(where: { $0.status == .waiting }) { checkDNSLeak() } } private var hasFreshSnapshot: Bool { guard let freshnessDate = snapshot.lastUpdated else { return false } return Self.isFresh(freshnessDate, now: currentDate()) } private var hasFreshDetailState: Bool { guard let fullSnapshotCachedAt else { return false } return Self.isFresh(fullSnapshotCachedAt, now: currentDate()) } private func measureNetworkQualityIfNeeded() { switch networkQualityState { case .waiting, .failed: measureNetworkQuality() case .running, .completed: return } } @discardableResult private func restoreCachedStateIfFresh() -> Bool { guard let data = storage.data(forKey: StorageKey.cachedState), let cachedState = try? JSONDecoder().decode(CachedState.self, from: data), Self.isFresh(cachedState.cachedAt, now: currentDate()) else { return false } var restoredSnapshot = cachedState.snapshot restoredSnapshot.isRefreshing = false snapshot = restoredSnapshot connectivityResults = Self.restoredConnectivityResults(cachedState.connectivityResults) webRTCResults = Self.restoredLeakResults(cachedState.webRTCResults) dnsLeakResults = Self.restoredLeakResults(cachedState.dnsLeakResults) networkQualityState = Self.restoredNetworkQualityState(cachedState.networkQualityState) stateCachedAt = cachedState.cachedAt fullSnapshotCachedAt = cachedState.fullSnapshotCachedAt return true } private func cacheCurrentState(includeFullSnapshot: Bool = false) { let cachedAt = currentDate() var cachedSnapshot = snapshot cachedSnapshot.isRefreshing = false let nextFullSnapshotCachedAt = includeFullSnapshot ? cachedAt : fullSnapshotCachedAt let cachedState = CachedState( cachedAt: cachedAt, fullSnapshotCachedAt: nextFullSnapshotCachedAt, snapshot: cachedSnapshot, connectivityResults: Self.restoredConnectivityResults(connectivityResults), webRTCResults: Self.restoredLeakResults(webRTCResults), dnsLeakResults: Self.restoredLeakResults(dnsLeakResults), networkQualityState: Self.restoredNetworkQualityState(networkQualityState) ) guard let data = try? JSONEncoder().encode(cachedState) else { return } storage.set(data, forKey: StorageKey.cachedState) stateCachedAt = cachedAt fullSnapshotCachedAt = nextFullSnapshotCachedAt } private func saveConnectivityTargets(_ targets: [IPOverviewConnectivityTarget]) { guard let data = try? JSONEncoder().encode(targets.filter(\.isCustom)) else { return } storage.set(data, forKey: "customConnectivityTargets") } private static func loadConnectivityTargets(storage: PluginStorage) -> [IPOverviewConnectivityTarget] { guard let data = storage.data(forKey: "customConnectivityTargets"), let customTargets = try? JSONDecoder().decode([IPOverviewConnectivityTarget].self, from: data) else { return IPOverviewConnectivityTarget.defaults } return IPOverviewConnectivityTarget.defaults + customTargets } private static func normalizedURLString(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") { return trimmed } return "https://\(trimmed)" } private static func appendingNetworkQualitySample(_ value: Double, to samples: [Double]) -> [Double] { let maximumSampleCount = 28 let nextSamples = samples + [value] if nextSamples.count <= maximumSampleCount { return nextSamples } return Array(nextSamples.suffix(maximumSampleCount)) } private static func completedNetworkQualitySamples(_ samples: [Double], fallback: Double?) -> [Double] { if !samples.isEmpty { return samples } return fallback.map { [$0] } ?? [] } private static func isFresh(_ date: Date, now: Date) -> Bool { date >= now || now.timeIntervalSince(date) < cacheTimeToLive } private static func restoredConnectivityResults( _ results: [IPOverviewConnectivityResult] ) -> [IPOverviewConnectivityResult] { results.map { result in if case .checking = result.status { return IPOverviewConnectivityResult(id: result.id, target: result.target, status: .waiting) } return result } } private static func restoredLeakResults(_ results: [IPOverviewLeakTestResult]) -> [IPOverviewLeakTestResult] { results.map { result in if case .checking = result.status { return IPOverviewLeakTestResult(id: result.id, name: result.name, status: .waiting) } return result } } private static func restoredNetworkQualityState( _ state: IPOverviewNetworkQualityRunState ) -> IPOverviewNetworkQualityRunState { if case .running = state { return .waiting } return state } private func cancelRefresh() { refreshGeneration += 1 refreshTask?.cancel() refreshTask = nil refreshMode = nil snapshot.isRefreshing = false } private func clearRefreshTask(generation: Int) { guard refreshGeneration == generation else { return } refreshTask = nil refreshMode = nil } private static func dnsLeakProbes(localization: PluginLocalization) -> [(id: String, name: String)] { (1...4).map { ( id: "dns-\($0)", name: localization.format("dns.probe.name", defaultValue: "DNS 出口检测 #%d", $0) ) } } }