Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift
T
gentpan 8875b6e709 每项独立菜单栏与详情弹窗、网络详情与 DNS、侧边栏主窗口与统一外观
- 菜单栏可每项独立(点击弹出该项窄详情)或合并为一个(点击打开主窗口),详情区块可在设置中勾选
- 网络详情:镜像流量历史、ICMP 连接探测格子、接口与 Wi-Fi、本地与公网 IPv4 / IPv6、SVG 国旗归属地与 ASN、各进程流量
- DNS:刷新缓存,一键切换自动 / Cloudflare / Google / 腾讯 / 阿里云或手动填写;辅助工具协议升至 3,服务名与地址二次校验
- 主窗口:侧边栏切换仪表盘、各指标详情与工具页,宽高可调,卡片按比例伸缩
- 统一外观:白底蓝 / 黑底蓝,窗口、设置与弹窗共用底色,去掉玻璃效果
- 新增网络解析、DNS 校验与国旗代码校验的单元测试;更新 README、架构文档与隐私说明
2026-09-13 05:39:10 +05:00

198 lines
7.4 KiB
Swift

import Foundation
import HelperShared
import Metrics
import Observation
/// 一次连接探测:latency 为往返毫秒数,nil 表示超时或不可达
public struct ProbeSample: Sendable, Equatable {
public let latency: Double?
}
/// 网络详情:接口与地址、公网 IP、连接探测、各进程流量与 DNS 配置
@MainActor
@Observable
public final class NetworkController {
/// 主窗口显示 120 次,弹窗显示最近 60 次
public static let probeCapacity = 120
public private(set) var details: NetworkDetails?
public private(set) var publicAddresses: PublicAddresses?
public private(set) var isLookingUpPublic = false
public private(set) var probes = History<ProbeSample>(capacity: probeCapacity)
public private(set) var processes: [NetworkProcessUsage] = []
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private var probeTask: Task<Void, Never>?
@ObservationIgnored private var detailTask: Task<Void, Never>?
@ObservationIgnored private var probedAddress: String?
@ObservationIgnored private var lastPublicLookup: (date: Date, localIPv4: [String])?
@ObservationIgnored private var isPaused = false
@ObservationIgnored private var wantsDetail = false
init(settings: AppSettings) {
self.settings = settings
}
// MARK: 统计
/// 最近一次成功探测的延迟
var latency: Double? { probes.elements.last?.latency }
/// 抖动:相邻两次成功探测的延迟差的平均值
var jitter: Double? {
let values = probes.elements.suffix(20).compactMap(\.latency)
guard values.count > 2 else { return nil }
let differences = zip(values.dropFirst(), values).map { abs($0 - $1) }
return differences.reduce(0, +) / Double(differences.count)
}
var lossRate: Double? {
let recent = probes.elements
guard !recent.isEmpty else { return nil }
return Double(recent.filter { $0.latency == nil }.count) / Double(recent.count)
}
var probeAddress: String? {
settings.probeTarget.address(router: details?.physical?.router)
}
// MARK: 生命周期
/// 菜单栏显示网络项或网络详情打开时持续探测
func updateProbing(networkShown: Bool) {
let shouldRun = settings.probeEnabled && networkShown && !isPaused
if shouldRun, probeTask == nil {
startProbing()
} else if !shouldRun {
probeTask?.cancel()
probeTask = nil
}
}
/// 探测目标或间隔变化时重新开始,历史清空避免混入不同目标的数据
func restartProbing() {
guard probeTask != nil else { return }
probeTask?.cancel()
probeTask = nil
probes = History(capacity: Self.probeCapacity)
startProbing()
}
func setPaused(_ paused: Bool, networkShown: Bool) {
isPaused = paused
updateProbing(networkShown: networkShown)
applyDetailState()
}
/// 网络详情打开时每 2 秒刷新接口信息与进程流量,关闭后停止
func setDetailVisible(_ visible: Bool) {
wantsDetail = visible
applyDetailState()
}
private func applyDetailState() {
let shouldRun = wantsDetail && !isPaused
guard shouldRun != (detailTask != nil) else { return }
detailTask?.cancel()
detailTask = nil
// 关闭时保留上次的进程列表,再次打开时不会先闪一下空白
guard shouldRun else { return }
detailTask = Task { [weak self] in
var sampler = NetworkProcessSampler()
var tick = 0
while !Task.isCancelled {
// 接口信息每 4 秒读一次,进程流量每 2 秒
if tick % 2 == 0 {
let details = await Task.detached { NetworkDetailsReader.read() }.value
guard !Task.isCancelled, let self else { return }
if details != self.details { self.details = details }
if tick == 0 { self.lookUpPublicAddressesIfStale() }
}
let (usage, next) = await Task.detached { [sampler] in
var copy = sampler
let usage = copy.sample()
return (usage, copy)
}.value
sampler = next
guard !Task.isCancelled, let self else { return }
self.processes = usage
tick += 1
try? await Task.sleep(for: .seconds(2))
}
}
}
func refreshDetails() async {
details = await Task.detached { NetworkDetailsReader.read() }.value
}
// MARK: 公网 IP
func lookUpPublicAddresses() {
guard settings.publicIPLookup, !isLookingUpPublic else { return }
isLookingUpPublic = true
let localIPv4 = details?.physical?.ipv4 ?? []
Task {
let result = await PublicAddressLookup.fetch()
publicAddresses = result
lastPublicLookup = (Date(), localIPv4)
isLookingUpPublic = false
}
}
/// 10 分钟内且本地地址未变化时沿用上次结果
private func lookUpPublicAddressesIfStale() {
guard settings.publicIPLookup else { return }
let localIPv4 = details?.physical?.ipv4 ?? []
if let last = lastPublicLookup, Date().timeIntervalSince(last.date) < 600, last.localIPv4 == localIPv4 { return }
lookUpPublicAddresses()
}
func clearPublicAddresses() {
publicAddresses = nil
lastPublicLookup = nil
}
// MARK: 探测
private func startProbing() {
probeTask = Task { [weak self] in
var sequence: UInt16 = 0
let clock = ContinuousClock()
while !Task.isCancelled {
guard let self else { return }
let interval = Double(self.settings.probeSeconds)
if self.details == nil { await self.refreshDetails() }
let started = clock.now
guard let address = self.probeAddress else {
try? await Task.sleep(for: .seconds(interval))
continue
}
sequence &+= 1
let current = sequence
let latency = await Task.detached {
ConnectivityProbe.ping(address, sequence: current, timeout: min(interval, 1.5))
}.value
guard !Task.isCancelled else { return }
self.probes.append(ProbeSample(latency: latency))
try? await Task.sleep(until: started + .seconds(interval), tolerance: .milliseconds(100), clock: clock)
}
}
}
// MARK: 截图
/// 截图时换成文档示例地址,不暴露本机的 IP 与硬件地址
func maskForSnapshot() {
guard var details else { return }
details.physical?.ipv4 = ["192.168.1.20"]
details.physical?.ipv6 = ["2001:db8::20"]
details.physical?.router = "192.168.1.1"
details.physical?.hardwareAddress = "a4:83:e7:12:34:56"
details.dnsServers = details.physical?.manualDNS.isEmpty == false ? details.physical!.manualDNS : ["192.168.1.1"]
self.details = details
publicAddresses = PublicAddresses(ipv4: "203.0.113.24", ipv6: nil, countryCode: "CN",
city: "Shanghai", asn: "AS64500", organization: "EXAMPLE NETWORK")
}
}