Files
gentpan 645d161cbd 清理死代码、死译文与重复代码
- 删掉没人调用的函数、属性与解码字段:网络弹窗的两个标签函数、supportsStyleOverride、probedAddress、countryEn、辅助工具客户端的单风扇恢复自动封装(协议与辅助工具端保留,不动 XPC 接口)
- 译文表删掉 19 条不再使用的条目,补上测速窗口“开始”按钮漏掉的英文
- 网络弹窗与出口窗口各自一份的归属地格式化和 IP 类型徽章合并为 GeoText;两个折线图里重复的参考线绘制提成 ChartGrid
- 官网样式删掉没人用的 .mb-drop__ext
- server/sync/sync 是误提交的 Go 编译产物,从仓库移除并忽略
2026-09-21 03:38:08 +05:00

759 lines
32 KiB
Swift
Raw Permalink 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 Localization
import Metrics
import SwiftUI
/// 出口与分流窗口:总览(结论、统计、本机网络 → 代理出口的线路)、网站分流表、本机的代理方式
struct EgressWindowView: View {
@Environment(AppModel.self) private var model
var body: some View {
let egress = model.egress
VStack(spacing: 0) {
// 滚动视图会自动向上延伸到标题栏下面:顶栏垫上底色、放在它之上,滚动的内容才不会盖住按钮
EgressHeader()
.background(DS.Palette.background)
.zIndex(1)
PageScroll {
if let report = egress.report, let analysis = egress.analysis {
let context = EgressContext(report: report, analysis: analysis)
OverviewCard(context: context)
SitesCard(context: context)
EnvironmentCard(environment: egress.environment ?? report.environment)
EgressFootnote()
} else {
FirstCheckCard()
}
}
}
.background(DS.Palette.background)
// 内容延伸到透明标题栏下方,顶栏与红绿灯按钮在同一行
.ignoresSafeArea()
}
}
/// 视图共用的查询:某个目标的测量、某个出口的归属地
private struct EgressContext {
let report: EgressController.Report
let analysis: EgressAnalysis
func hit(_ target: EgressTarget, _ route: EgressRoute) -> EgressHit? {
EgressAnalysis.hit(report.hits, target, route)
}
func geo(_ ip: String?) -> EgressGeo? {
ip.flatMap { report.geo[$0] }
}
/// 网站表里的条目(不含基准目标),按出口分组后的数量
var siteCounts: (proxy: Int, direct: Int, unknown: Int, failed: Int) {
let siteIDs = Set(EgressTarget.sites.map(\.id))
var counts = (proxy: 0, direct: 0, unknown: 0, failed: 0)
for exit in analysis.exits {
let count = exit.entries.filter { siteIDs.contains($0.targetID) }.count
switch exit.path {
case .proxy: counts.proxy += count
case .direct: counts.direct += count
case .unknown: counts.unknown += count
}
}
counts.unknown += analysis.unassignedTargetIDs.filter(siteIDs.contains).count
counts.failed = analysis.failedTargetIDs.filter(siteIDs.contains).count
return counts
}
}
// MARK: - 顶栏
private struct EgressHeader: View {
@Environment(AppModel.self) private var model
var body: some View {
let egress = model.egress
HStack(spacing: DS.Space.s3) {
HStack(spacing: 0) {
Text(tr("出口与分流"))
.dsFont(.base, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Spacer(minLength: DS.Space.s3)
}
.frame(maxHeight: .infinity)
.background(WindowDragArea())
if let date = egress.report?.date, !egress.isChecking {
TimelineView(.periodic(from: .now, by: 30)) { context in
Text(verbatim: EgressText.checkedAt(date, now: context.date))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
}
Button { egress.check() } label: {
HStack(spacing: DS.Space.s1) {
if egress.isChecking {
ProgressView().controlSize(.mini)
Text(tr("检测中 \(egress.progress)/\(EgressProber.jobCount)")).monospacedDigit()
} else {
Image(systemName: "arrow.clockwise")
Text(tr("重新检测"))
}
}
.lineLimit(1)
.fixedSize()
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(egress.isChecking)
}
// 让出红绿灯按钮,再隔开一段,标题不贴着最右边的按钮
.padding(.leading, DS.Size.trafficLightsWidth + DS.Space.s4)
.padding(.trailing, DS.Space.s3 + DS.Space.s1)
.frame(height: DS.Size.windowHeader)
}
}
private struct FirstCheckCard: View {
@Environment(AppModel.self) private var model
var body: some View {
Card(spacing: DS.Space.s3) {
Text(tr("正在检测出口与分流…"))
.dsFont(.base, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
ProgressView(value: Double(model.egress.progress), total: Double(EgressProber.jobCount))
.tint(DS.Palette.primary)
Text(tr("分别经物理网卡、系统代理与 VPN 隧道访问检测目标,约需 10 秒。"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
// MARK: - 总览
private struct OverviewCard: View {
@Environment(AppModel.self) private var model
let context: EgressContext
var body: some View {
let analysis = context.analysis
let summary = EgressText.verdict(analysis, report: context.report)
Card(spacing: DS.Space.s4) {
HStack(alignment: .top, spacing: DS.Space.s3) {
Image(systemName: summary.icon)
.font(.system(size: DS.TextSize.lg.rawValue, weight: .semibold))
.foregroundStyle(summary.tone.color)
.frame(width: DS.Space.s8 + DS.Space.s2, height: DS.Space.s8 + DS.Space.s2)
.background(summary.tone.color.opacity(0.12), in: Circle())
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(verbatim: summary.title)
.dsFont(.lg, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: summary.detail)
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: DS.Space.s4)
SiteStats(counts: context.siteCounts, verified: analysis.verdict != .unverified)
}
HairlineDivider()
RouteDiagram(context: context)
if model.egress.networkChanged || !analysis.findings.isEmpty {
VStack(alignment: .leading, spacing: DS.Space.s2) {
if model.egress.networkChanged {
InfoBanner(icon: "arrow.triangle.2.circlepath", text: tr("网络环境变了,下面是之前的结果,正在重新检测。"), tone: .primary)
}
ForEach(Array(analysis.findings.enumerated()), id: \.offset) { _, finding in
let banner = EgressText.finding(finding)
InfoBanner(icon: banner.icon, text: banner.text, tone: banner.tone)
}
}
}
}
}
}
private struct SiteStats: View {
let counts: (proxy: Int, direct: Int, unknown: Int, failed: Int)
let verified: Bool
var body: some View {
HStack(spacing: DS.Space.s4) {
if verified {
stat(tr("经代理"), counts.proxy, tone: .primary)
divider
stat(tr("直连"), counts.direct, tone: .neutral)
} else {
stat(tr("可访问"), counts.proxy + counts.direct + counts.unknown, tone: .neutral)
}
divider
stat(tr("连不上"), counts.failed, tone: counts.failed > 0 ? .error : .neutral)
}
.fixedSize()
}
private var divider: some View {
Rectangle().fill(DS.Palette.border).frame(width: DS.Size.stroke, height: DS.Space.s8)
}
private func stat(_ label: String, _ value: Int, tone: Tone) -> some View {
VStack(alignment: .trailing, spacing: 0) {
Text(verbatim: "\(value)")
.dsFont(.xl, weight: .semibold)
.foregroundStyle(tone == .neutral ? DS.Palette.textPrimary : tone.color)
.monospacedDigit()
Text(verbatim: label)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
/// 本机网络 ──代理──▶ 代理出口。没有代理出口时只画本机网络
private struct RouteDiagram: View {
let context: EgressContext
var body: some View {
let analysis = context.analysis
let environment = context.report.environment
let rawV4 = context.hit(.cloudflare, .interface)?.ip ?? context.hit(.domestic, .interface)?.ip
let rawV6 = context.hit(.cloudflareIPv6, .interface)?.ip
let proxyExits = analysis.exits.filter { $0.path != .direct }
let primary = proxyExits.first
HStack(alignment: .top, spacing: DS.Space.s4) {
ExitNode(caption: tr("本机网络"),
subtitle: environment.physicalName.map { tr("经 \($0) 直接连接") } ?? tr("不经过代理"),
ipv4: rawV4, ipv6: rawV6, geo: context.geo(rawV4) ?? context.geo(rawV6),
country: context.hit(.cloudflare, .interface)?.country,
missing: tr("VPN 不允许绕开它连接"))
if let primary {
RouteConnector(name: environment.tunnelName ?? environment.apps.first ?? tr("代理"),
detail: environment.tunnelInterface ?? (environment.proxies.isEmpty ? nil : tr("系统代理")))
let systemV6 = context.hit(.cloudflareIPv6, .system)?.ip
ExitNode(caption: primary.path == .unknown ? tr("出口") : tr("代理出口"),
subtitle: proxyExits.count > 1 ? tr("另有 \(proxyExits.count - 1) 个出口,见网站分流") : tr("按系统代理访问国际网站"),
ipv4: primary.ip.contains(":") ? nil : primary.ip,
ipv6: primary.ip.contains(":") ? primary.ip : systemV6,
geo: context.geo(primary.ip), country: primary.country, missing: "—")
}
}
}
}
private struct RouteConnector: View {
let name: String
let detail: String?
var body: some View {
VStack(spacing: DS.Space.s1) {
Text(verbatim: name)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.primary)
.lineLimit(1)
HStack(spacing: 0) {
Circle().fill(DS.Palette.primary).frame(width: DS.Space.s1 + DS.Space.s1 / 2, height: DS.Space.s1 + DS.Space.s1 / 2)
Rectangle().fill(DS.Palette.primary.opacity(0.5)).frame(height: DS.Size.stroke)
Image(systemName: "arrowtriangle.right.fill")
.font(.system(size: DS.TextSize.xs.rawValue - DS.Space.s1))
.foregroundStyle(DS.Palette.primary)
}
if let detail {
Text(verbatim: detail)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
}
}
.frame(width: DS.Space.s16 + DS.Space.s12)
.padding(.top, DS.Space.s6)
}
}
private struct ExitNode: View {
let caption: String
let subtitle: String
let ipv4: String?
let ipv6: String?
let geo: EgressGeo?
let country: String?
let missing: String
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.s1) {
HStack(spacing: DS.Space.s1) {
Text(verbatim: caption).dsFont(.xs, weight: .semibold).foregroundStyle(DS.Palette.textSecondary)
Text(verbatim: subtitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1)
}
if let address = ipv4 ?? ipv6 {
HStack(spacing: DS.Space.s2) {
if let code = geo?.countryCode ?? country { FlagImage(countryCode: code, height: DS.Space.s4) }
CopyableText(text: address)
.dsFont(.lg, weight: .semibold)
.monospacedDigit()
}
.padding(.top, DS.Space.s1)
let location = EgressText.location(geo, fallback: country)
if !location.isEmpty {
Text(verbatim: location).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
}
HStack(spacing: DS.Space.s2) {
if let organization = geo?.organization {
Text(verbatim: organization)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
}
if let type = geo?.ipType.flatMap(GeoText.ipTypeBadge) {
TagBadge(text: type.text, tone: type.tone, compact: true)
}
}
if ipv4 != nil {
if let ipv6 {
HStack(spacing: DS.Space.s1) {
Text(verbatim: "IPv6").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textTertiary)
CopyableText(text: ipv6).dsFont(.xs).monospacedDigit()
}
} else {
Text(tr("不支持 IPv6")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
} else {
Text(verbatim: missing)
.dsFont(.sm)
.foregroundStyle(DS.Palette.textTertiary)
.padding(.top, DS.Space.s1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - 网站分流
private enum SiteFilter: Hashable, CaseIterable {
case all, ai, media, social, developer, domestic
var title: String {
switch self {
case .all: tr("全部")
case .ai: "AI"
case .media: tr("影音")
case .social: tr("社交")
case .developer: tr("开发")
case .domestic: tr("国内")
}
}
func includes(_ target: EgressTarget) -> Bool {
switch self {
case .all: true
case .ai: target.category == .ai
case .media: target.category == .media
case .social: target.category == .social
case .developer: target.category == .developer
case .domestic: target.category == .domestic
}
}
}
private struct SitesCard: View {
let context: EgressContext
@State private var filter = SiteFilter.all
var body: some View {
let groups = siteGroups
Card(padding: 0, spacing: 0) {
HStack(spacing: DS.Space.s3) {
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(tr("网站分流")).dsFont(.base, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(tr("接入 Cloudflare 的网站能实测出口,其余按国内 / 国际规则推断"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
}
Spacer(minLength: DS.Space.s3)
SegmentedControl(selection: $filter, options: SiteFilter.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth * 2)
}
.padding(DS.Space.s4)
SiteColumnsHeader()
ForEach(groups) { group in
SiteGroupHeader(group: group, context: context)
ForEach(group.rows, id: \.target.id) { row in
SiteTableRow(target: row.target, hit: context.hit(row.target, .system), measured: row.measured, failed: group.kind == .failed)
}
}
if groups.isEmpty {
Text(tr("这一类没有检测结果"))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textTertiary)
.frame(maxWidth: .infinity)
.padding(DS.Space.s6)
}
// 最后一行的悬停底色不贴到卡片圆角
Color.clear.frame(height: DS.Space.s2)
}
}
struct SiteGroup: Identifiable {
enum Kind: Equatable { case exit(EgressAnalysis.Exit), unassigned, failed }
let kind: Kind
let rows: [(target: EgressTarget, measured: Bool)]
var id: String {
switch kind {
case .exit(let exit): exit.ip
case .unassigned: "unassigned"
case .failed: "failed"
}
}
}
private var siteGroups: [SiteGroup] {
let analysis = context.analysis
func target(_ id: String) -> EgressTarget? {
EgressTarget.sites.first { $0.id == id && filter.includes($0) }
}
var groups: [SiteGroup] = []
for exit in analysis.exits {
let rows = exit.entries.compactMap { entry in target(entry.targetID).map { ($0, entry.measured) } }
if !rows.isEmpty { groups.append(SiteGroup(kind: .exit(exit), rows: rows)) }
}
let unassigned = analysis.unassignedTargetIDs.compactMap(target).map { ($0, false) }
if !unassigned.isEmpty { groups.append(SiteGroup(kind: .unassigned, rows: unassigned)) }
let failed = analysis.failedTargetIDs.compactMap(target).map { ($0, false) }
if !failed.isEmpty { groups.append(SiteGroup(kind: .failed, rows: failed)) }
return groups
}
}
/// 列宽:出口依据、Cloudflare 节点、延迟
private enum SiteColumn {
static let basis = DS.Size.valueColumn
static let colo = DS.Size.labelColumn
static let latency = DS.Size.valueColumn + DS.Space.s12
}
private struct SiteColumnsHeader: View {
var body: some View {
HStack(spacing: DS.Space.s3) {
Text(tr("网站")).frame(maxWidth: .infinity, alignment: .leading)
Text(tr("出口依据")).frame(width: SiteColumn.basis, alignment: .leading)
Text(tr("节点")).frame(width: SiteColumn.colo, alignment: .leading)
.help(tr("Cloudflare 接入节点(机场代码)"))
Text(tr("延迟")).frame(width: SiteColumn.latency, alignment: .trailing)
}
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textTertiary)
.padding(.horizontal, DS.Space.s4)
.padding(.bottom, DS.Space.s2)
}
}
private struct SiteGroupHeader: View {
let group: SitesCard.SiteGroup
let context: EgressContext
var body: some View {
HStack(spacing: DS.Space.s2) {
switch group.kind {
case .exit(let exit):
let badge = EgressText.pathBadge(exit.path)
let geo = context.geo(exit.ip)
TagBadge(icon: badge.icon, text: badge.text, tone: badge.tone, compact: true)
if let code = geo?.countryCode ?? exit.country { FlagImage(countryCode: code) }
CopyableText(text: exit.ip)
.dsFont(.xs, weight: .semibold)
.monospacedDigit()
let location = EgressText.location(geo, fallback: exit.country)
if !location.isEmpty {
Text(verbatim: location)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
}
case .unassigned:
TagBadge(icon: "questionmark.circle", text: tr("出口未知"), tone: .neutral, compact: true)
case .failed:
TagBadge(icon: "xmark.circle.fill", text: tr("连不上"), tone: .warning, compact: true)
}
Spacer(minLength: DS.Space.s2)
Text(tr("\(group.rows.count) 个网站"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize()
}
.padding(.horizontal, DS.Space.s4)
.padding(.vertical, DS.Space.s2)
.frame(maxWidth: .infinity, alignment: .leading)
.background(DS.Palette.track)
}
}
private struct SiteTableRow: View {
let target: EgressTarget
let hit: EgressHit?
let measured: Bool
let failed: Bool
@State private var hovering = false
var body: some View {
HStack(spacing: DS.Space.s3) {
HStack(spacing: DS.Space.s3) {
SiteLogo(name: target.logo, fallback: target.name)
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: target.name).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
Text(verbatim: target.host).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Group {
if failed {
Text(verbatim: "—").foregroundStyle(DS.Palette.textTertiary)
} else if measured {
Label(tr("实测"), systemImage: "checkmark.circle.fill").foregroundStyle(DS.Palette.success)
} else {
Label(tr("推断"), systemImage: "circle.dashed").foregroundStyle(DS.Palette.textTertiary)
}
}
.labelStyle(CompactLabelStyle())
.dsFont(.xs)
.frame(width: SiteColumn.basis, alignment: .leading)
.help(measured ? tr("读到了这次连接的出口") : failed ? "" : tr("网站没有接入 Cloudflare,读不到出口,按国内 / 国际规则归到对应出口"))
Text(verbatim: hit?.colo ?? "—")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospaced()
.frame(width: SiteColumn.colo, alignment: .leading)
LatencyCell(milliseconds: failed ? nil : hit?.milliseconds)
.frame(width: SiteColumn.latency, alignment: .trailing)
}
.padding(.horizontal, DS.Space.s4)
.padding(.vertical, DS.Space.s2)
.background(hovering ? DS.Palette.surfaceHover : .clear)
.contentShape(Rectangle())
.onHover { hovering = $0 }
}
}
private struct CompactLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: DS.Space.s1) {
configuration.icon
configuration.title
}
}
}
/// 网站图标放在浅色圆角底上,深色模式下黑色字标也看得清;没有图标时显示首字
private struct SiteLogo: View {
let name: String?
let fallback: String
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: DS.Radius.md - DS.Space.s1 / 2, style: .continuous)
.fill(DS.Palette.logoTile)
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md - DS.Space.s1 / 2, style: .continuous)
.strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke))
if let name, let image = LogoCache.shared.image(named: name, template: false) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
} else {
Text(verbatim: String(fallback.prefix(1)))
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.neutral500)
}
}
.frame(width: DS.Space.s6 + DS.Space.s1, height: DS.Space.s6 + DS.Space.s1)
.accessibilityHidden(true)
}
}
/// 5 格信号条 + 毫秒数:越快格子越多,颜色按快慢分绿、橙、红
private struct LatencyCell: View {
let milliseconds: Int?
var body: some View {
let tone = milliseconds.map(Self.tone) ?? .error
let filled = milliseconds.map(Self.bars) ?? 0
HStack(spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1 / 2) {
ForEach(0..<5, id: \.self) { index in
RoundedRectangle(cornerRadius: DS.Size.stroke)
.fill(index < filled ? tone.color : DS.Palette.track)
.frame(width: DS.Space.s1, height: DS.Space.s3)
}
}
Text(verbatim: milliseconds.map { "\($0) ms" } ?? tr("超时"))
.dsFont(.xs, weight: .medium)
.foregroundStyle(milliseconds == nil ? DS.Palette.error : DS.Palette.textPrimary)
.monospacedDigit()
.frame(width: DS.Size.valueColumn, alignment: .trailing)
}
}
static func tone(_ milliseconds: Int) -> Tone {
milliseconds < 300 ? .success : milliseconds < 1000 ? .warning : .error
}
static func bars(_ milliseconds: Int) -> Int {
switch milliseconds {
case ..<150: 5
case ..<300: 4
case ..<600: 3
case ..<1000: 2
default: 1
}
}
}
// MARK: - 代理方式
private struct EnvironmentCard: View {
let environment: ProxyEnvironment
var body: some View {
SectionCard(title: tr("代理方式")) {
LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s8), GridItem(.flexible())],
alignment: .leading, spacing: DS.Space.s2) {
InfoRow(label: tr("VPN / 隧道"),
text: environment.tunnelName.map { name in
[name, environment.tunnelInterface].compactMap { $0 }.joined(separator: " · ")
} ?? tr("未使用"))
InfoRow(label: tr("系统代理"), text: systemProxy)
InfoRow(label: "DNS", text: dns)
InfoRow(label: tr("代理软件"), text: environment.apps.isEmpty ? tr("未发现") : environment.apps.joined(separator: tr("、")))
}
}
}
/// 同一地址的几种代理合成一条:HTTP、HTTPS 127.0.0.1:6152
private var systemProxy: String {
var groups: [(address: String, kinds: [String])] = []
for proxy in environment.proxies {
let address = "\(proxy.host):\(proxy.port)"
let kind = proxy.kind.rawValue.uppercased()
if let index = groups.firstIndex(where: { $0.address == address }) {
groups[index].kinds.append(kind)
} else {
groups.append((address, [kind]))
}
}
var parts = groups.map { "\($0.kinds.joined(separator: "/")) \($0.address)" }
if environment.pacURL != nil { parts.append(tr("自动代理配置")) }
return parts.isEmpty ? tr("未设置") : parts.joined(separator: "\n")
}
private var dns: String {
guard !environment.dnsServers.isEmpty else { return "—" }
let servers = environment.dnsServers.joined(separator: ", ")
return environment.usesFakeIPDNS ? tr("\(servers)(代理接管)") : servers
}
}
private struct EgressFootnote: View {
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) {
Text(tr("归属地数据来自")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
CleanIPLink(height: DS.Space.s3, url: URL(string: "https://cleanip.io/outbound")!, help: "cleanip.io/outbound")
Text(tr("· 结果反映 OpenStats 自己发出的请求,按应用分流的规则下其他应用可能走不同出口"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(2)
}
.padding(.horizontal, DS.Space.s1)
}
}
// MARK: - 文案
@MainActor
private enum EgressText {
struct Summary {
let icon: String
let tone: Tone
let title: String
let detail: String
}
static func verdict(_ analysis: EgressAnalysis, report: EgressController.Report) -> Summary {
let environment = report.environment
let proxyName = environment.tunnelName ?? environment.apps.first ?? tr("代理")
let proxyExit = analysis.exits.first { $0.path == .proxy }
let proxyLocation = proxyExit.map { location(report.geo[$0.ip], fallback: $0.country) } ?? ""
let exitText = proxyLocation.isEmpty ? tr("代理出口") : tr("代理出口(\(proxyLocation))")
switch analysis.verdict {
case .offline:
return Summary(icon: "wifi.exclamationmark", tone: .error, title: tr("网络不通"),
detail: tr("所有检测目标都连不上。请检查网络连接,或者代理软件是否正常运行。"))
case .noProxy:
return Summary(icon: "checkmark", tone: .success, title: tr("未使用代理"),
detail: tr("没有检测到 VPN 或系统代理,所有流量都从本机网络直接出去。"))
case .proxyInactive:
return Summary(icon: "exclamationmark", tone: .warning, title: tr("代理没有生效"),
detail: tr("检测到 \(proxyName),但测试的网站都在直连,没有经过代理出口。"))
case .split:
return Summary(icon: "checkmark", tone: .success, title: tr("分流正常"),
detail: tr("国内网站直连,国际网站经 \(proxyName) 的\(exitText)。"))
case .mixed:
return Summary(icon: "arrow.triangle.branch", tone: .primary, title: tr("按规则分流"),
detail: tr("部分网站直连、部分经代理,每个网站走哪个出口见下方。"))
case .fullProxy where analysis.rawCountry == "CN":
return Summary(icon: "exclamationmark", tone: .warning, title: tr("全局代理"),
detail: tr("国内网站也绕到了\(exitText),访问会变慢,也可能触发风控。"))
case .fullProxy:
return Summary(icon: "checkmark", tone: .success, title: tr("全部经代理"),
detail: tr("测试的网站都经 \(proxyName) 的\(exitText),没有暴露本机网络的地址。"))
case .unverified:
return Summary(icon: "questionmark", tone: .primary, title: tr("无法确认是否经过代理"),
detail: tr("VPN 不允许绕开它连接,读不到本机网络原本的出口。下方按出口列出各网站。"))
}
}
static func finding(_ finding: EgressAnalysis.Finding) -> (icon: String, text: String, tone: Tone) {
switch finding {
case .ipv6Leak(let address):
("exclamationmark.triangle.fill", tr("IPv6 没有经过代理:只走 IPv6 的连接会直接暴露本机地址 \(address)。"), .warning)
case .ipv6Blocked:
("checkmark.shield.fill", tr("代理出口不支持 IPv6,IPv6 连接也不会绕过代理泄露地址。"), .success)
case .ipv6Proxied:
("checkmark.shield.fill", tr("IPv6 连接也经过代理。"), .success)
case .socketBypass:
("exclamationmark.triangle.fill",
tr("只设置了系统代理:浏览器等应用走代理,命令行工具、游戏等不读代理设置的程序仍在直连。要接管全部流量,可以开启增强模式或 TUN 模式。"), .warning)
case .domesticProxied:
("exclamationmark.triangle.fill", tr("国内网站走了代理,访问会绕路变慢。"), .warning)
}
}
static func pathBadge(_ path: EgressAnalysis.Path) -> (icon: String, text: String, tone: TagBadge.Tone) {
switch path {
case .proxy: ("globe", tr("经代理"), .primary)
case .direct: ("house.fill", tr("直连"), .neutral)
case .unknown: ("circle.dashed", tr("出口"), .neutral)
}
}
/// 国家 · 省 / 州 · 城市;英文界面优先用英文地名,重复的相邻项只留一个
static func location(_ geo: EgressGeo?, fallback country: String?) -> String {
guard let code = geo?.countryCode ?? country else { return "" }
return GeoText.location(countryCode: code, region: geo?.region, regionEnglish: geo?.regionEnglish,
city: geo?.city, cityEnglish: geo?.cityEnglish)
}
static func checkedAt(_ date: Date, now: Date) -> String {
let minutes = Int(now.timeIntervalSince(date) / 60)
return minutes < 1 ? tr("刚刚检测") : tr("\(Format.duration(minutes: minutes))前检测")
}
}