Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift
T

676 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import HelperShared
import Localization
import Metrics
import SwiftUI
struct NetworkPopover: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let settings = model.settings
let store = model.store
let rate = store.network
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) {
RateHero(title: tr("下载"), bytesPerSecond: rate?.downloadBytesPerSecond, color: DS.NetworkPalette.download)
Rectangle().fill(DS.Palette.border).frame(width: DS.Size.stroke, height: DS.Space.s8)
RateHero(title: tr("上传"), bytesPerSecond: rate?.uploadBytesPerSecond, color: DS.NetworkPalette.upload)
}
}
ForEach(MenuBarItem.network.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .networkHistory: TrafficHistorySection()
case .networkProbe: ProbeSection()
case .networkInterface: InterfaceSection()
case .networkAddresses: AddressSection()
case .networkPurity: PuritySection()
case .networkDNS: DNSSection()
case .networkProcesses: NetworkProcessesSection()
default: EmptyView()
}
}
}
}
private struct RateHero: View {
let title: String
let bytesPerSecond: Double?
let color: NSColor
var body: some View {
let parts = (bytesPerSecond.map { Format.menuBarRate($0) } ?? "— KB/s").split(separator: " ", maxSplits: 1).map(String.init)
VStack(alignment: .leading, spacing: DS.Space.s1) {
HeroValue(value: parts.first ?? "—", unit: parts.count > 1 ? parts[1] : nil)
HStack(spacing: DS.Space.s1) {
Circle().fill(Color(nsColor: color)).frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - 流量历史
private struct TrafficHistorySection: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let store = model.store
let upload = store.uploadHistory.elements
let download = store.downloadHistory.elements
// 峰值放在标题行,图表上不压任何文字
SectionCard(title: PopoverSection.networkHistory.title, trailing: {
HStack(spacing: DS.Space.s1) {
Text(tr("60 秒峰值"))
peak("↑", upload, color: DS.NetworkPalette.upload)
peak("↓", download, color: DS.NetworkPalette.download)
}
.monospacedDigit()
}) {
MirroredRateChart(upload: upload, download: download,
height: isDetailPage ? DS.Size.chartHeight * 3 : DS.Size.chartHeight + DS.Space.s2)
}
}
private func peak(_ arrow: String, _ values: [Double], color: NSColor) -> some View {
HStack(spacing: DS.Space.s1 / 2) {
Text(verbatim: arrow).foregroundStyle(Color(nsColor: color)).fontWeight(.semibold)
Text(verbatim: Format.menuBarRate(max(values.max() ?? 0, MirroredRateChart.floor)))
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
// MARK: - 连接探测
private struct ProbeSection: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
@Bindable var settings = model.settings
let network = model.network
// 只看通不通,标题右侧不再写探测地址与频率
SectionCard(title: PopoverSection.networkProbe.title) {
if settings.probeEnabled {
// 只用来看网络通不通:60 格是最近 60 次探测,排成两行细格
ProbeGrid(samples: network.probes.elements, columns: NetworkController.probeCapacity / 2, rows: 2)
} else {
HStack {
Text(tr("定时 ping 一个地址,记录网络是否通畅")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer(minLength: DS.Space.s2)
Button(tr("开启")) { settings.probeEnabled = true }
.buttonStyle(DSButtonStyle(kind: .ghost))
}
}
}
}
}
// MARK: - 接口
private struct InterfaceSection: View {
@Environment(AppModel.self) private var model
var body: some View {
let network = model.network
let details = network.details
let physical = details?.physical
let totals = model.store.network
SectionCard(title: PopoverSection.networkInterface.title, trailing: {
// 右上角重置:把上传 / 下载从现在起重新累计,标出起算时间
if let totals {
if let since = network.trafficBaseline?.date {
Text(verbatim: tr("自 \(since.formatted(Date.FormatStyle(locale: L10n.locale).month().day().hour().minute())) 起"))
}
MiniIconButton(systemName: "arrow.counterclockwise",
help: tr("重置上传与下载统计:从现在起重新累计。重启后自动回到开机后的累计;右键可改回")) {
network.resetTraffic(totals)
}
.contextMenu {
if network.trafficBaseline != nil {
Button(tr("改回开机后累计")) { network.clearTrafficBaseline() }
}
}
}
}) {
if let physical {
InfoRow(label: tr("接口"), text: tr("\(physical.displayName)(\(physical.bsdName))"))
InfoRow(label: tr("状态")) {
StatusBadge(text: physical.isUp ? tr("已连接") : tr("未连接"), tone: physical.isUp ? .success : .error)
}
InfoRow(label: tr("物理地址")) { CopyableText(text: physical.hardwareAddress ?? "—") }
if let wifi = physical.wifi {
if let ssid = wifi.ssid { InfoRow(label: tr("网络名称"), text: ssid) }
InfoRow(label: tr("信号强度"), text: "\(wifi.rssi) dBm · \(signalQuality(wifi.rssi))")
InfoRow(label: tr("传输速率"), text: "\(Int(wifi.transmitRate)) Mbps")
}
} else {
Text(details == nil ? tr("正在读取…") : tr("未连接网络")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
if let tunnel = details?.tunnel {
InfoRow(label: tr("VPN / 代理"), text: tr("\(tunnel.name)(\(tunnel.bsdName))"))
}
if let totals {
let counted = network.trafficTotals(totals)
let sinceReset = network.trafficBaseline != nil
InfoRow(label: sinceReset ? tr("重置后下载") : tr("开机后下载"), text: Format.bytes(counted.download, base: .decimal))
InfoRow(label: sinceReset ? tr("重置后上传") : tr("开机后上传"), text: Format.bytes(counted.upload, base: .decimal))
}
}
}
private func signalQuality(_ rssi: Int) -> String {
rssi >= -50 ? tr("极好") : rssi >= -60 ? tr("良好") : rssi >= -70 ? tr("一般") : tr("较弱")
}
}
// MARK: - 地址
private struct AddressSection: View {
@Environment(AppModel.self) private var model
@Environment(\.isPopover) private var isPopover
var body: some View {
let network = model.network
let physical = network.details?.physical
let lookup = model.settings.publicIPLookup
let publicAddresses = network.publicAddresses
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
header
// 本地地址与路由器只在主窗口的网络页显示,菜单栏弹窗里只看公网
if !isPopover {
InfoRow(label: tr("本地 IPv4")) { CopyableText(text: physical?.ipv4.first ?? "—") }
// 没有 IPv6 就不占行:网卡没有 IPv6 地址时不显示本地 IPv6,拿不到公网 IPv6 时不显示公网 IPv6
if let localIPv6 = physical?.ipv6.first {
InfoRow(label: tr("本地 IPv6")) { CopyableText(text: localIPv6) }
}
InfoRow(label: tr("路由器")) { CopyableText(text: physical?.router ?? "—") }
}
if lookup {
InfoRow(label: tr("公网 IPv4")) { publicValue(publicAddresses?.ipv4, loading: network.isLookingUpPublic) }
if let publicIPv6 = publicAddresses?.ipv6 {
InfoRow(label: tr("公网 IPv6")) { CopyableText(text: publicIPv6) }
}
if let publicAddresses, let code = publicAddresses.countryCode {
InfoRow(label: tr("归属地")) {
HStack(spacing: DS.Space.s2) {
FlagImage(countryCode: code)
Text(verbatim: location(publicAddresses, code: code))
.lineLimit(1)
.truncationMode(.tail)
}
}
}
if let asn = publicAddresses?.asn {
InfoRow(label: "ASN") { CopyableText(text: asn) }
}
if let organization = publicAddresses?.organization {
InfoRow(label: tr("网络运营方")) { Text(verbatim: organization).lineLimit(1).truncationMode(.middle) }
}
// 原生 / 广播、IP 类型、网络类型做成徽章,一眼看结论;反向解析、住宅概率等细节不在弹窗里占行
if let publicAddresses, !Self.badges(publicAddresses).isEmpty {
FlowLayout(spacing: DS.Space.s2) {
ForEach(Self.badges(publicAddresses), id: \.text) { badge in
TagBadge(icon: badge.icon, text: badge.text, tone: badge.tone)
}
}
.padding(.top, DS.Space.s1)
}
} else {
InfoRow(label: tr("公网 IP"), text: tr("查询已关闭"))
}
}
}
@ViewBuilder
private func publicValue(_ value: String?, loading: Bool) -> some View {
if loading && value == nil {
Text(tr("查询中…")).foregroundStyle(DS.Palette.textTertiary)
} else {
CopyableText(text: value ?? "—")
}
}
/// 国家 · 省 / 州 · 城市;英文界面优先用英文地名,重复的相邻项只留一个
private func location(_ addresses: PublicAddresses, code: String) -> String {
let country = Locale(identifier: L10n.isEnglish ? "en" : "zh-Hans").localizedString(forRegionCode: code) ?? code
let region = L10n.isEnglish ? (addresses.regionEnglish ?? addresses.region) : addresses.region
let city = L10n.isEnglish ? (addresses.cityEnglish ?? addresses.city) : addresses.city
var parts: [String] = [country]
for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) }
return parts.joined(separator: " · ")
}
/// 标题行:IP 地址 ……… [IPv4 | IPv6] ⟳;数据来源的字标只放在下面的纯净度区块里
private var header: some View {
let network = model.network
return HStack(spacing: DS.Space.s2) {
Text(PopoverSection.networkAddresses.title)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.fixedSize()
Spacer(minLength: 0)
if model.settings.publicIPLookup {
HStack(spacing: DS.Space.s1) {
if network.hasDualStack {
PillSwitch(selection: Binding(get: { network.publicFamily }, set: { network.publicFamily = $0 }),
options: IPFamily.allCases.map { ($0, $0.title) })
}
RefreshButton(loading: network.isLookingUpPublic, help: tr("强制刷新:忽略缓存,立即重新查询公网 IP 与归属地")) {
network.lookUpPublicAddresses(force: true)
}
}
}
MiniIconButton(systemName: "arrow.triangle.branch", help: tr("出口与分流:检查 VPN 与代理是否生效、各网站从哪个出口出去")) {
model.openEgressWindow()
}
}
}
struct Badge: Equatable {
let icon: String
let text: String
let tone: TagBadge.Tone
}
/// 结论性徽章:原生 / 广播、住宅 / 机房、ASN 的类型
static func badges(_ addresses: PublicAddresses) -> [Badge] {
var badges: [Badge] = []
if let isNative = addresses.isNative {
badges.append(isNative ? Badge(icon: "checkmark.shield.fill", text: tr("原生 IP"), tone: .success)
: Badge(icon: "antenna.radiowaves.left.and.right", text: tr("广播 IP"), tone: .warning))
}
if let ipType = addresses.ipType?.lowercased() {
switch ipType {
case "residential ip": badges.append(Badge(icon: "house.fill", text: tr("住宅 IP"), tone: .success))
case "datacenter ip", "hosting ip": badges.append(Badge(icon: "server.rack", text: tr("机房 IP"), tone: .warning))
case "mobile ip": badges.append(Badge(icon: "iphone.radiowaves.left.and.right", text: tr("移动网络 IP"), tone: .success))
case "business ip": badges.append(Badge(icon: "building.2.fill", text: tr("企业 IP"), tone: .success))
default: badges.append(Badge(icon: "questionmark.circle", text: addresses.ipType ?? "", tone: .neutral))
}
}
if let asnType = addresses.asnType {
switch asnType {
case "isp": badges.append(Badge(icon: "wifi.router.fill", text: "ISP", tone: .success))
case "mobile": badges.append(Badge(icon: "antenna.radiowaves.left.and.right", text: tr("移动运营商"), tone: .success))
case "business": badges.append(Badge(icon: "building.2.fill", text: tr("企业网络"), tone: .success))
case "education": badges.append(Badge(icon: "graduationcap.fill", text: tr("教育网"), tone: .success))
case "government": badges.append(Badge(icon: "building.columns.fill", text: tr("政府"), tone: .success))
case "hosting": badges.append(Badge(icon: "server.rack", text: tr("机房"), tone: .warning))
default: break
}
}
return badges
}
static func networkTypeLabel(_ raw: String) -> String {
switch raw.lowercased() {
case "residential", "isp": tr("住宅宽带")
case "business": tr("企业")
case "hosting", "datacenter", "data center": tr("机房")
case "mobile", "cellular": tr("移动网络")
case "education": tr("教育网")
case "government": tr("政府")
default: raw
}
}
static func ipTypeLabel(_ raw: String) -> String {
switch raw.lowercased() {
case "residential ip": tr("住宅 IP")
case "datacenter ip", "hosting ip": tr("机房 IP")
case "mobile ip": tr("移动网络 IP")
case "business ip": tr("企业 IP")
default: raw
}
}
}
// MARK: - IP 纯净度
/// cleanip.io 给出的纯净度评分与风险标记
private struct PuritySection: View {
@Environment(AppModel.self) private var model
@Environment(\.isPopover) private var isPopover
var body: some View {
// 菜单栏弹窗里默认收起成一行,只显示分数与等级
if isPopover, !model.settings.isExpanded(.networkPurity) {
let summary = collapsedSummary
CollapsedSection(title: PopoverSection.networkPurity.title, summary: summary.text, summaryColor: summary.color) {
withAnimation(DS.Motion.quick) { model.settings.setExpanded(.networkPurity, true) }
}
} else {
card
}
}
private var collapsedSummary: (text: String, color: Color) {
let network = model.network
if let purity = network.publicAddresses?.purity {
return ("\(purity.score) \(purity.grade)", DS.Grade.color(for: purity.score))
}
if !model.settings.publicIPLookup { return (tr("查询已关闭"), DS.Palette.textTertiary) }
if network.isLookingUpPublic { return (tr("正在查询…"), DS.Palette.textTertiary) }
return ("—", DS.Palette.textTertiary)
}
@ViewBuilder
private var card: some View {
let settings = model.settings
let network = model.network
let addresses = network.publicAddresses
let hint = tr("纯净度看信誉、来路、邻居与网络类型四项;机房 IP 常见信誉高、来路和类型偏低。数据来自 cleanip.io")
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
header(addresses)
if let purity = addresses?.purity {
// 分数在左、cleanip.io 字标在右,色带在下占满整行
HStack(spacing: DS.Space.s2) {
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) {
Text(verbatim: "\(purity.score)")
.dsFont(.xxl, weight: .semibold)
.foregroundStyle(DS.Grade.color(for: purity.score))
.monospacedDigit()
Text(verbatim: purity.grade)
.dsFont(.base, weight: .semibold)
.foregroundStyle(DS.Grade.color(for: purity.score))
Text(verbatim: "/ 100").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
.help(hint)
Spacer(minLength: DS.Space.s2)
CleanIPLink(height: DS.Size.iconInline,
url: addresses?.reportURL ?? CleanIPLink.site,
help: tr("评分由 cleanip.io 提供,点击查看完整报告"))
}
ScoreBand(score: purity.score).help(hint)
if let risk = addresses?.risk {
InfoRow(label: tr("风险评分")) {
Text(verbatim: "\(risk.score)/100" + (risk.label.map { " · \(Self.riskLabel($0))" } ?? ""))
}
InfoRow(label: tr("风险标记")) {
Text(verbatim: risk.flags.isEmpty ? tr("未检出") : risk.flags.map(Self.flagLabel).joined(separator: "、"))
.foregroundStyle(risk.flags.isEmpty ? DS.Palette.success : DS.Palette.error)
}
}
if let recommendation = purity.recommendation, !L10n.isEnglish {
Text(verbatim: recommendation)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
} else if !settings.publicIPLookup {
Text(tr("查询已关闭")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
} else if network.isLookingUpPublic {
Text(tr("正在查询…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
} else {
Text(tr("暂时没有拿到评分,稍后会再试")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
/// 标题行:IP 纯净度 [IPv4] 置信度 92% ……… [↗ ⟳ ⌃]
/// 置信度是标题旁的一行浅色小字,不再用带框徽章;右侧的完整报告、刷新、收起并成一组按钮
private func header(_ addresses: PublicAddresses?) -> some View {
let network = model.network
return HStack(spacing: DS.Space.s2) {
Text(PopoverSection.networkPurity.title)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.fixedSize()
if let family = network.shownFamily {
TagBadge(text: family.title, tone: .primary, compact: true)
}
if let confidence = addresses?.purity?.confidence {
HStack(spacing: DS.Space.s1 / 2) {
Text(tr("置信度")).foregroundStyle(DS.Palette.textTertiary)
Text(verbatim: "\(confidence)%")
.fontWeight(.medium)
.foregroundStyle(Self.confidenceColor(confidence))
.monospacedDigit()
}
.dsFont(.xs)
.lineLimit(1)
.fixedSize()
.help(tr("cleanip.io 对这次评分的把握:各数据源结论越一致越高"))
}
Spacer(minLength: 0)
HeaderActionGroup {
if let url = addresses?.reportURL {
MiniIconButton(systemName: "arrow.up.forward", help: tr("在 cleanip.io 查看这个 IP 的完整报告")) {
NSWorkspace.shared.open(url)
}
}
RefreshButton(loading: network.isLookingUpPublic, help: tr("强制刷新:忽略缓存,立即重新查询公网 IP 与归属地")) {
network.lookUpPublicAddresses(force: true)
}
if isPopover {
MiniIconButton(systemName: "chevron.up", help: tr("收起")) {
withAnimation(DS.Motion.quick) { model.settings.setExpanded(.networkPurity, false) }
}
}
}
}
}
/// 置信度高的绿、中等的灰、低的黄
static func confidenceColor(_ confidence: Int) -> Color {
confidence >= 80 ? DS.Palette.success : confidence >= 50 ? DS.Palette.textSecondary : DS.Palette.warning
}
static func riskLabel(_ raw: String) -> String {
switch raw.lowercased() {
case "very clean": tr("非常干净")
case "clean": tr("干净")
case "low risk", "low": tr("低风险")
case "medium risk", "medium", "moderate": tr("中等风险")
case "high risk", "high": tr("高风险")
case "very high risk", "critical": tr("极高风险")
default: raw
}
}
static func flagLabel(_ flag: String) -> String {
switch flag {
case "vpn": "VPN"
case "proxy": tr("代理")
case "residentialProxy": tr("住宅代理")
case "tor": "Tor"
case "relay": tr("中继")
case "datacenter": tr("机房")
case "hosting": tr("托管")
case "abuser": tr("滥用记录")
default: flag
}
}
}
// MARK: - DNS
private struct DNSSection: View {
@Environment(AppModel.self) private var model
@Environment(\.isPopover) private var isPopover
@State private var editingManual = false
@State private var manualText = ""
var body: some View {
// 菜单栏弹窗里默认收起成一行,只显示正在使用的 DNS
if isPopover, !model.settings.isExpanded(.networkDNS) {
let servers = model.network.details?.dnsServers ?? []
CollapsedSection(title: PopoverSection.networkDNS.title,
summary: servers.first.map { servers.count > 1 ? "\($0) +\(servers.count - 1)" : $0 } ?? "—") {
withAnimation(DS.Motion.quick) { model.settings.setExpanded(.networkDNS, true) }
}
} else {
card
}
}
@ViewBuilder
private var card: some View {
let network = model.network
let maintenance = model.maintenance
let physical = network.details?.physical
let manual = physical?.manualDNS ?? []
let matched = DNSPreset.matching(manual)
// 走代理隧道时标题旁放一个叹号,悬停才显示说明;刷新缓存是图标按钮,放在标题行右侧
let tunnelWarning = network.details?.tunnel.map { tunnel in
AnyView(Image(systemName: "exclamationmark.circle")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.warning)
.help(tr("流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。"))
.accessibilityLabel(tr("流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。")))
}
// 网络服务与线路做成两个徽章:Wi-Fi / 有线,直连或经过哪个代理
let actions = HStack(spacing: DS.Space.s2) {
if let physical {
TagBadge(icon: physical.wifi != nil ? "wifi" : "cable.connector", text: physical.serviceName, tone: .neutral, compact: true)
if let tunnel = network.details?.tunnel {
TagBadge(icon: "globe", text: tr("经 \(tunnel.name)"), tone: .primary, compact: true)
} else {
TagBadge(icon: "arrow.right", text: tr("直连"), tone: .neutral, compact: true)
}
}
RefreshButton(loading: maintenance.running == .flushDNS, help: tr("刷新 DNS 缓存")) {
Task { await maintenance.run(.flushDNS) }
}
.disabled(maintenance.running != nil)
}
SectionCard(title: PopoverSection.networkDNS.title, titleAccessory: tunnelWarning, trailing: {
if isPopover {
actions.collapseButton(.networkDNS, settings: model.settings)
} else {
actions
}
}) {
InfoRow(label: tr("正在使用")) {
Text(verbatim: network.details.map { $0.dnsServers.isEmpty ? "—" : $0.dnsServers.joined(separator: "\n") } ?? "—")
}
// 配置方式直接是一个下拉菜单:选预设立即切换,选“手动”展开输入框;比六个并排的按钮省两行
if let physical {
let isCustom = !manual.isEmpty && matched == nil
HStack(spacing: DS.Space.s2) {
Text(tr("配置方式")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer(minLength: DS.Space.s2)
if isCustom, !editingManual {
MiniIconButton(systemName: "pencil", help: tr("修改地址")) {
manualText = manual.joined(separator: ", ")
editingManual = true
}
}
Picker(tr("配置方式"), selection: Binding<DNSPreset?>(
get: { editingManual ? nil : manual.isEmpty ? .automatic : matched },
set: { choice in
if let choice {
editingManual = false
apply(choice.servers, service: physical.serviceName)
} else {
manualText = manual.joined(separator: ", ")
editingManual = true
}
})) {
ForEach(DNSPreset.allCases) { preset in
Text(preset.title).tag(DNSPreset?.some(preset))
}
Divider()
Text(tr("手动")).tag(DNSPreset?.none)
}
.pickerStyle(.menu)
.labelsHidden()
.controlSize(.small)
.fixedSize()
.disabled(maintenance.isApplyingDNS)
}
} else {
InfoRow(label: tr("配置方式"), text: manual.isEmpty ? tr("自动(由路由器分配)") : matched?.title ?? tr("手动"))
}
if let physical {
if editingManual {
HStack(spacing: DS.Space.s2) {
TextField(tr("例如 1.1.1.1, 8.8.8.8"), text: $manualText)
.textFieldStyle(.plain)
.dsFont(.xs)
.padding(.horizontal, DS.Space.s2)
.frame(height: DS.Size.controlHeight)
.background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.stroke))
.onSubmit { applyManual(service: physical.serviceName) }
Button(tr("应用")) { applyManual(service: physical.serviceName) }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(DNSConfiguration.parse(manualText)?.isEmpty != false)
}
}
}
if maintenance.isApplyingDNS {
Text(tr("正在修改 DNS…")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
} else if let outcome = maintenance.dnsOutcome ?? maintenance.outcomes[.flushDNS] {
Text(outcome.text)
.dsFont(.xs)
.foregroundStyle(outcome.isError ? DS.Palette.error : DS.Palette.success)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private func applyManual(service: String) {
guard let servers = DNSConfiguration.parse(manualText), !servers.isEmpty else { return }
editingManual = false
apply(servers, service: service)
}
private func apply(_ servers: [String], service: String) {
Task {
await model.maintenance.setDNSServers(servers, service: service)
await model.network.refreshDetails()
}
}
}
// MARK: - 进程
private struct NetworkProcessesSection: View {
@Environment(AppModel.self) private var model
var body: some View {
let processes = model.network.processes
SectionCard(title: PopoverSection.networkProcesses.title, trailing: {
HStack(spacing: 0) {
Text(tr("下载")).frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(tr("上传")).frame(width: DS.Size.valueColumn, alignment: .trailing)
}
}) {
ProcessList(count: processes.count, rowCount: 5, emptyText: tr("正在统计各进程流量…")) { index in
let process = processes[index]
HStack(spacing: 0) {
ProcessNameLabel(icon: AppIconCache.shared.image(bundlePath: process.appBundlePath), name: process.localizedName)
Text(verbatim: Format.menuBarRate(process.download))
.frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(verbatim: Format.menuBarRate(process.upload))
.frame(width: DS.Size.valueColumn, alignment: .trailing)
}
.dsFont(.xs)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
.explainable(.init(process))
}
}
}
}