Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

690 lines
22 KiB
Swift

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<Void, Never>?
private var refreshMode: RefreshMode?
private var refreshGeneration = 0
private var connectivityTask: Task<Void, Never>?
private var webRTCTask: Task<Void, Never>?
private var dnsLeakTask: Task<Void, Never>?
private var networkQualityTask: Task<Void, Never>?
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<Void, Never>? {
guard !hasFreshSnapshot else {
return nil
}
return refreshPublicIP()
}
@discardableResult
func refresh() -> Task<Void, Never>? {
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<Void, Never>? {
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)
)
}
}
}