import SwiftUI import MacToolsPluginKit struct IPOverviewComponentView: View { private enum Layout { static let leakCardMinimumWidth: CGFloat = 240 static let leakCardMaximumWidth: CGFloat = 360 } @ObservedObject var viewModel: IPOverviewViewModel let localization: PluginLocalization let startsInDetails: Bool let showsBackButton: Bool @State private var customName = "" @State private var customURL = "" @State private var addError = "" @State private var didRunInitialDiagnostics = false @State private var presentedLeakDetails: IPOverviewLeakAssessmentKind? init( viewModel: IPOverviewViewModel, localization: PluginLocalization = PluginLocalization(bundle: .main), startsInDetails: Bool = false, showsBackButton: Bool = true ) { self.viewModel = viewModel self.localization = localization self.startsInDetails = startsInDetails self.showsBackButton = showsBackButton } var body: some View { Group { if viewModel.isShowingDetails { detailPage } else { landingCard } } .onAppear { viewModel.refreshIfNeeded() if startsInDetails { viewModel.showDetails() } } } private var landingCard: some View { Button { viewModel.showDetails() } label: { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 9) { Image(systemName: "network") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Color(nsColor: .systemBlue)) .frame(width: 30, height: 30) .background(.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) VStack(alignment: .leading, spacing: 1) { Text(localization.string("component.title", defaultValue: "IP 检测")) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) Text(viewModel.snapshot.preferredGeoInfo?.locationText ?? localization.string( "landing.subtitle", defaultValue: "公网、归属地与连通性" )) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } Spacer(minLength: 8) if viewModel.snapshot.isRefreshing { ProgressView() .controlSize(.small) } Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } HStack(alignment: .top, spacing: 8) { landingMetric( title: localization.string("landing.domestic", defaultValue: "国内出口"), value: landingEgressValue( viewModel.snapshot.domesticIPv4 ?? viewModel.snapshot.domesticIPv6 ) ) landingMetric( title: localization.string("landing.international", defaultValue: "国际出口"), value: landingEgressValue( viewModel.snapshot.internationalIPv4 ?? viewModel.snapshot.internationalIPv6 ) ) landingMetric( title: localization.string("landing.local", defaultValue: "本地"), value: viewModel.snapshot.localAddresses.first.map { displayIP($0.address) } ?? localization.string("common.waiting", defaultValue: "等待") ) } } .padding(10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .contentShape(Rectangle()) } .buttonStyle(.plain) } private var detailPage: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { detailHeader ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { networkQualitySection publicIPSection localAddressSection connectivitySection webRTCLeakSection dnsLeakSection privacyFooter } .padding(.bottom, 2) } } .frame(maxWidth: .infinity, alignment: .topLeading) .onAppear { guard !didRunInitialDiagnostics else { return } didRunInitialDiagnostics = true viewModel.refreshAllIfNeeded() } } private var detailHeader: some View { HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { if showsBackButton { Button { viewModel.showSummary() } label: { Image(systemName: "chevron.left") } .buttonStyle(.borderless) .help(localization.string("detail.back.help", defaultValue: "返回概览")) } Text(localization.string("detail.title", defaultValue: "IP 详情")) .font(PluginSettingsTheme.Typography.sectionTitle) Spacer(minLength: 8) if viewModel.snapshot.isRefreshing { ProgressView() .controlSize(.small) } Button { viewModel.toggleSensitiveInfoVisibility() } label: { Image(systemName: viewModel.hidesSensitiveInfo ? "eye.slash" : "eye") .frame(width: 14, height: 14) } .buttonStyle(.bordered) .controlSize(.small) .help(viewModel.hidesSensitiveInfo ? localization.string("privacy.showIP.help", defaultValue: "显示 IP") : localization.string("privacy.hideIP.help", defaultValue: "隐藏 IP") ) Button { viewModel.refreshAll() } label: { Label( viewModel.isRefreshingAll ? localization.string("detail.refreshing", defaultValue: "检测中") : localization.string("detail.refresh", defaultValue: "检测"), systemImage: "arrow.clockwise" ) } .buttonStyle(.borderedProminent) .controlSize(.small) .disabled(viewModel.isRefreshingAll) .help(localization.string("detail.refresh.help", defaultValue: "刷新全部检测")) Button { viewModel.copy(viewModel.snapshot.reportText(localization: localization)) } label: { Label(localization.string("detail.copyReport", defaultValue: "复制结果"), systemImage: "doc.on.doc") } .buttonStyle(.bordered) .controlSize(.small) .disabled(viewModel.snapshot.lastUpdated == nil) .help(localization.string("detail.copyReport.help", defaultValue: "复制完整检测结果")) } } private var publicIPSection: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { sectionHeader( title: localization.string("publicIP.title", defaultValue: "公网 IP"), icon: "network" ) Spacer(minLength: 4) Button { viewModel.refresh() } label: { Label( viewModel.snapshot.isRefreshing ? localization.string("status.checking", defaultValue: "检测中") : localization.string("common.refresh", defaultValue: "刷新"), systemImage: viewModel.snapshot.isRefreshing ? "hourglass" : "arrow.clockwise" ) } .buttonStyle(.bordered) .controlSize(.small) .disabled(viewModel.snapshot.isRefreshing) .help(localization.string("publicIP.refresh.help", defaultValue: "刷新公网 IP 和归属地")) } VStack(spacing: 0) { publicIPRow( title: "IPv4", route: .domestic, result: viewModel.snapshot.domesticIPv4 ) PluginSettingsListDivider() publicIPRow( title: "IPv4", route: .international, result: viewModel.snapshot.internationalIPv4 ) PluginSettingsListDivider() publicIPRow( title: "IPv6", route: .domestic, result: viewModel.snapshot.domesticIPv6 ) PluginSettingsListDivider() publicIPRow( title: "IPv6", route: .international, result: viewModel.snapshot.internationalIPv6 ) } .pluginSettingsCardBackground(.host) } } private func publicIPRow( title: String, route: IPOverviewEgressRoute, result: IPOverviewPublicIPResult? ) -> some View { let geoInfo = result.flatMap { viewModel.snapshot.geoInfoByIP[$0.ip] } return PublicIPRowView( title: title, route: route, result: result, geoInfo: geoInfo, notDetectedText: notDetectedText, geoUnavailableText: localization.string("ipCard.geoUnavailable", defaultValue: "归属地不可用"), unknownText: unknownText, localization: localization, displayIP: result.map { displayIP($0.ip) }, onCopyIP: { viewModel.copy(result?.ip) } ) .pluginSettingsListRowPadding(interactive: true) } private var localAddressSection: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { sectionHeader(title: localization.string("local.title", defaultValue: "本地局域网 IP"), icon: "wifi.router") VStack(spacing: 0) { if viewModel.snapshot.localAddresses.isEmpty { Text(localization.string("local.empty", defaultValue: "未检测到可用本地地址")) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .pluginSettingsListRowPadding() } else { ForEach(Array(viewModel.snapshot.localAddresses.prefix(5).enumerated()), id: \.element.id) { index, address in localAddressRow(address) if index < min(viewModel.snapshot.localAddresses.count, 5) - 1 { PluginSettingsListDivider() } } } } .pluginSettingsCardBackground(.host) } } private func localAddressRow(_ address: IPOverviewLocalAddress) -> some View { LocalAddressRowView( address: address, displayAddress: displayIP(address.address), localization: localization, onCopyIP: { viewModel.copy(address.address) } ) .pluginSettingsListRowPadding() } private var networkQualitySection: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { sectionHeader( title: localization.string("speed.title", defaultValue: "网络测速"), icon: "speedometer" ) Spacer(minLength: 4) Button { viewModel.measureNetworkQuality() } label: { Label( viewModel.isMeasuringNetworkQuality ? localization.string("speed.running", defaultValue: "测速中") : localization.string("common.refresh", defaultValue: "刷新"), systemImage: viewModel.isMeasuringNetworkQuality ? "hourglass" : "arrow.clockwise" ) } .buttonStyle(.bordered) .controlSize(.small) .disabled(viewModel.isMeasuringNetworkQuality) .help(localization.string("speed.start.help", defaultValue: "使用 macOS networkQuality 测量当前网络")) } NetworkQualityCardView( state: viewModel.networkQualityState, localization: localization ) } } private var connectivitySection: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { sectionHeader( title: localization.string("connectivity.title", defaultValue: "网络连通性"), icon: "point.3.connected.trianglepath.dotted" ) Spacer(minLength: 4) Button { viewModel.checkConnectivity() } label: { Label( viewModel.isCheckingConnectivity ? localization.string("status.checking", defaultValue: "检测中") : localization.string("common.refresh", defaultValue: "刷新"), systemImage: viewModel.isCheckingConnectivity ? "hourglass" : "arrow.clockwise" ) } .buttonStyle(.bordered) .controlSize(.small) .disabled(viewModel.isCheckingConnectivity) .help(localization.string("connectivity.refresh.help", defaultValue: "刷新连通性")) } VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowContentControl) { LazyVGrid(columns: [ GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8) ], spacing: 8) { ForEach(viewModel.connectivityResults) { result in connectivityTile(result) } } customTargetForm } .padding(PluginSettingsTheme.Spacing.cardContent) .pluginSettingsCardBackground(.host) } } private var webRTCLeakSection: some View { leakSection( kind: .webRTC, title: localization.string("webrtc.title", defaultValue: "WebRTC 泄露测试"), icon: "network.badge.shield.half.filled", isRunning: viewModel.isCheckingWebRTC, action: { viewModel.checkWebRTCLeak() }, results: viewModel.webRTCResults, showsNAT: true ) } private var dnsLeakSection: some View { leakSection( kind: .dns, title: localization.string("dns.title", defaultValue: "DNS 泄露测试"), icon: "octagon.fill", isRunning: viewModel.isCheckingDNSLeak, action: { viewModel.checkDNSLeak() }, results: viewModel.dnsLeakResults, showsNAT: false ) } private func leakSection( kind: IPOverviewLeakAssessmentKind, title: String, icon: String, isRunning: Bool, action: @escaping () -> Void, results: [IPOverviewLeakTestResult], showsNAT: Bool ) -> some View { let assessment = IPOverviewLeakAssessment.evaluate( kind: kind, results: results, snapshot: viewModel.snapshot, isRunning: isRunning ) return VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { Label(title, systemImage: icon) .font(PluginSettingsTheme.Typography.sectionTitle) .foregroundStyle(.secondary) Spacer(minLength: 4) Button(action: action) { Label( isRunning ? localization.string("status.checking", defaultValue: "检测中") : localization.string("common.refresh", defaultValue: "刷新"), systemImage: isRunning ? "hourglass" : "arrow.clockwise" ) } .buttonStyle(.bordered) .controlSize(.small) .disabled(isRunning) } leakSummaryCard(assessment, results: results, showsNAT: showsNAT) } } private func leakSummaryCard( _ assessment: IPOverviewLeakAssessment, results: [IPOverviewLeakTestResult], showsNAT: Bool ) -> some View { HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { Image(systemName: leakAssessmentIcon(assessment.state)) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(leakAssessmentColor(assessment.state)) .frame(width: 20, height: 20) VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(leakAssessmentTitle(assessment)) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) .foregroundStyle(leakAssessmentColor(assessment.state)) Text(leakAssessmentDetail(assessment)) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 8) HStack(spacing: 6) { Text(leakAssessmentBadge(assessment)) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(leakAssessmentColor(assessment.state)) .padding(.horizontal, 7) .padding(.vertical, 3) .background( leakAssessmentColor(assessment.state).opacity(0.12), in: Capsule() ) .fixedSize() Button { presentedLeakDetails = assessment.kind } label: { Image(systemName: "info.circle") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) .frame(width: 18, height: 18) } .buttonStyle(.plain) .help(localization.string("leak.details.help", defaultValue: "查看检测详情")) .popover( isPresented: Binding( get: { presentedLeakDetails == assessment.kind }, set: { isPresented in presentedLeakDetails = isPresented ? assessment.kind : nil } ), arrowEdge: .trailing ) { leakDetailsPopover( assessment: assessment, results: results, showsNAT: showsNAT ) } } } .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) .padding(.vertical, PluginSettingsTheme.Spacing.rowVertical) .frame(maxWidth: .infinity, alignment: .leading) .background( PluginSettingsTheme.Palette.nativeCardBackground, in: RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.card, style: .continuous) ) } private func leakDetailsPopover( assessment: IPOverviewLeakAssessment, results: [IPOverviewLeakTestResult], showsNAT: Bool ) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 6) { Image(systemName: leakAssessmentIcon(assessment.state)) .foregroundStyle(leakAssessmentColor(assessment.state)) Text(localization.string("leak.details.title", defaultValue: "检测详情")) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) Spacer(minLength: 8) Text(leakEvidenceCountText(assessment)) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(.secondary) } VStack(spacing: 0) { ForEach(Array(results.enumerated()), id: \.element.id) { index, result in LeakEvidenceListRowView( result: result, showsNAT: showsNAT, unknownText: unknownText, localization: localization, color: leakColor(result.status), primaryText: leakPrimaryText(result.status), displayIP: result.endpoint.map { displayIP($0.ip) }, onCopyIP: { viewModel.copy(result.endpoint?.ip) } ) if index < results.count - 1 { Divider() } } } .background( PluginSettingsTheme.Palette.recessedControlBackground, in: RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.control, style: .continuous) ) } .padding(12) .frame(width: 380) } private func leakAssessmentTitle(_ assessment: IPOverviewLeakAssessment) -> String { switch assessment.kind { case .webRTC: switch assessment.state { case .waiting: return localization.string("webrtc.assessment.waiting", defaultValue: "等待 WebRTC 检测") case .checking: return localization.string("webrtc.assessment.checking", defaultValue: "正在检测 WebRTC") case .clear: return localization.string("webrtc.assessment.clear", defaultValue: "未发现 WebRTC 泄露") case .warning: return localization.string("webrtc.assessment.warning", defaultValue: "可能存在 WebRTC 泄露") case .unknown: return localization.string("webrtc.assessment.unknown", defaultValue: "WebRTC 结果不完整") } case .dns: switch assessment.state { case .waiting: return localization.string("dns.assessment.waiting", defaultValue: "等待 DNS 检测") case .checking: return localization.string("dns.assessment.checking", defaultValue: "正在检测 DNS") case .clear: return localization.string("dns.assessment.clear", defaultValue: "未发现 DNS 泄露") case .warning: return localization.string("dns.assessment.warning", defaultValue: "可能存在 DNS 泄露") case .unknown: return localization.string("dns.assessment.unknown", defaultValue: "DNS 结果不完整") } } } private func leakAssessmentDetail(_ assessment: IPOverviewLeakAssessment) -> String { switch assessment.reason { case .waiting: return localization.string("leak.reason.waiting", defaultValue: "点击刷新后会检测出口是否和当前公网信息一致。") case .checking: return localization.string("leak.reason.checking", defaultValue: "正在收集各节点返回的出口信息。") case .noPublicIP: return localization.string("leak.reason.noPublicIP", defaultValue: "当前公网 IP 未获取完成,暂时无法建立对照。") case .noDNSEndpoint: return localization.string("leak.reason.noDNSEndpoint", defaultValue: "没有获取到 DNS 解析器出口,可能被网络或服务拦截。") case .webRTCMatchesPublicIP: return localization.string("leak.reason.webRTCMatchesPublicIP", defaultValue: "STUN 返回的出口与当前公网 IP 一致。") case .webRTCNoVisibleEndpoint: return localization.string("leak.reason.webRTCNoVisibleEndpoint", defaultValue: "未检测到 WebRTC 可见出口,通常表示 STUN 被阻断或不可达。") case .webRTCDifferentIP: return localization.string("leak.reason.webRTCDifferentIP", defaultValue: "STUN 返回了不同于当前公网 IP 的出口。") case .dnsMatchesEgressRegion: return localization.string("leak.reason.dnsMatchesEgressRegion", defaultValue: "DNS 解析器出口地区与当前公网出口一致。") case .dnsDifferentEgressRegion: return localization.string("leak.reason.dnsDifferentEgressRegion", defaultValue: "DNS 解析器出口地区与当前公网出口不一致。") case .dnsObservedWithoutBaselineRegion: return localization.string("leak.reason.dnsObservedWithoutBaselineRegion", defaultValue: "已获取 DNS 出口,但公网归属地不足,暂时无法对照。") } } private func leakAssessmentBadge(_ assessment: IPOverviewLeakAssessment) -> String { leakStateBadge(assessment.state) } private func leakStateBadge(_ state: IPOverviewLeakAssessmentState) -> String { switch state { case .waiting: return localization.string("leak.badge.waiting", defaultValue: "待检测") case .checking: return localization.string("leak.badge.checking", defaultValue: "检测中") case .clear: return localization.string("leak.badge.clear", defaultValue: "通过") case .warning: return localization.string("leak.badge.warning", defaultValue: "注意") case .unknown: return localization.string("leak.badge.unknown", defaultValue: "无法判断") } } private func leakEvidenceCountText(_ assessment: IPOverviewLeakAssessment) -> String { localization.format( "leak.evidence.count", defaultValue: "%d/%d 个节点有结果", assessment.observedCount, assessment.totalCount ) } private func leakAssessmentColor(_ state: IPOverviewLeakAssessmentState) -> Color { switch state { case .waiting: return .secondary case .checking: return Color(nsColor: .systemBlue) case .clear: return Color(nsColor: .systemGreen) case .warning: return Color(nsColor: .systemRed) case .unknown: return Color(nsColor: .systemOrange) } } private func leakAssessmentIcon(_ state: IPOverviewLeakAssessmentState) -> String { switch state { case .waiting: return "clock" case .checking: return "hourglass" case .clear: return "checkmark.shield" case .warning: return "exclamationmark.triangle" case .unknown: return "questionmark.circle" } } private func connectivityTile(_ result: IPOverviewConnectivityResult) -> some View { HStack(spacing: 7) { Circle() .fill(connectivityColor(result.status)) .frame(width: 7, height: 7) Text(result.target.name) .font(.caption.weight(.semibold)) .lineLimit(1) Spacer(minLength: 4) Text(connectivityText(result.status)) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) if result.target.isCustom { Button { viewModel.removeConnectivityTarget(id: result.id) } label: { Image(systemName: "xmark") .font(.caption2) } .buttonStyle(.borderless) } } .padding(.horizontal, 2) .padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) } private var customTargetForm: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { TextField(localization.string("customTarget.name.placeholder", defaultValue: "名称"), text: $customName) .textFieldStyle(.roundedBorder) TextField("https://example.com", text: $customURL) .textFieldStyle(.roundedBorder) Button { addCustomTarget() } label: { Image(systemName: "plus") } .buttonStyle(.borderless) .disabled(customName.isEmpty || customURL.isEmpty) } if !addError.isEmpty { Text(addError) .font(.caption2) .foregroundStyle(.red) } } } private var privacyFooter: some View { HStack(spacing: 6) { Image(systemName: "lock.shield") Text(localization.string( "privacy.footer", defaultValue: "公网、归属地和连通性检测会请求外部服务;本地地址仅在本机读取。" )) .lineLimit(2) Spacer(minLength: 0) } .font(.caption2) .foregroundStyle(.secondary) } private func landingMetric(title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 3) { Text(title) .font(.caption2) .foregroundStyle(.secondary) Text(value) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.middle) } .frame(maxWidth: .infinity, alignment: .leading) } private func sectionHeader(title: String, icon: String) -> some View { Label(title, systemImage: icon) .font(PluginSettingsTheme.Typography.sectionTitle) .foregroundStyle(.secondary) } private func addCustomTarget() { if let error = viewModel.addConnectivityTarget(name: customName, urlString: customURL) { addError = error return } addError = "" customName = "" customURL = "" } private func connectivityColor(_ status: IPOverviewConnectivityResult.Status) -> Color { switch status { case .waiting: return .secondary.opacity(0.45) case .checking: return Color(nsColor: .systemBlue) case .reachable(let milliseconds): return milliseconds < 250 ? Color(nsColor: .systemGreen) : Color(nsColor: .systemOrange) case .unreachable: return Color(nsColor: .systemRed) } } private func connectivityText(_ status: IPOverviewConnectivityResult.Status) -> String { switch status { case .waiting: return localization.string("status.waiting", defaultValue: "等待检测") case .checking: return localization.string("status.checking", defaultValue: "检测中") case .reachable(let milliseconds): return "\(milliseconds) ms" case .unreachable: return localization.string("connectivity.unreachable", defaultValue: "不可达") } } private func leakColor(_ status: IPOverviewLeakTestResult.Status) -> Color { switch status { case .waiting: return .secondary.opacity(0.45) case .checking: return Color(nsColor: .systemBlue) case .success: return Color(nsColor: .systemBlue) case .failure: return .secondary.opacity(0.65) } } private func leakPrimaryText(_ status: IPOverviewLeakTestResult.Status) -> String { switch status { case .waiting: return localization.string("status.waiting", defaultValue: "等待检测") case .checking: return localization.string("status.checking", defaultValue: "检测中") case .success(let endpoint): return endpoint.ip case .failure: return localization.string("leak.failed", defaultValue: "获取失败") } } private var unknownText: String { localization.string("common.unknown", defaultValue: "未知") } private var notDetectedText: String { localization.string("common.notDetected", defaultValue: "未检测到") } private func landingEgressValue(_ result: IPOverviewPublicIPResult?) -> String { if let result { return displayIP(result.ip) } if viewModel.snapshot.isRefreshing || viewModel.snapshot.lastUpdated == nil { return localization.string("common.checking", defaultValue: "检测中") } return notDetectedText } private func displayIP(_ value: String) -> String { viewModel.hidesSensitiveInfo ? IPOverviewSensitiveValueMask.maskedIP(value) : value } } private struct PublicIPRowView: View { let title: String let route: IPOverviewEgressRoute let result: IPOverviewPublicIPResult? let geoInfo: IPOverviewGeoInfo? let notDetectedText: String let geoUnavailableText: String let unknownText: String let localization: PluginLocalization let displayIP: String? let onCopyIP: () -> Void @State private var isHovered = false var body: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowContentControl) { HStack(alignment: .center, spacing: 4) { if let result { Text(displayIP ?? result.ip) .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) .layoutPriority(1) } else { Text(notDetectedText) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) } IPOverviewTagBadge(title: title, style: .addressFamily(title)) IPOverviewTagBadge(title: route.title(localization: localization), style: .egressRoute(route)) IPOverviewInlineCopyButton( help: localization.format( "copy.ip.help", defaultValue: "复制 %@ IP", title ), action: onCopyIP ) .opacity(isHovered && result != nil ? 1 : 0) .allowsHitTesting(isHovered && result != nil) Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) } if result != nil { if geoInfo != nil { publicIPDetails .padding(.top, 2) } else { unavailableGeoInfo } } } .contentShape(Rectangle()) .onHover { isHovered = $0 } } private var publicIPDetails: some View { ViewThatFits(in: .horizontal) { HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { publicIPDetailValues } LazyVGrid(columns: [ GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8) ], spacing: 8) { publicIPDetailValues } } } @ViewBuilder private var publicIPDetailValues: some View { detailValue( title: localization.string("info.location", defaultValue: "位置"), value: geoInfo?.locationText ?? geoInfo?.countryDisplayText ?? geoUnavailableText, icon: "mappin.and.ellipse" ) detailValue( title: localization.string("info.isp", defaultValue: "运营商"), value: geoInfo?.organization ?? geoInfo?.isp ?? unknownText, icon: "server.rack" ) detailValue( title: "ASN", value: geoInfo?.asn ?? unknownText, icon: "building.2" ) } private func detailValue(title: String, value: String, icon: String) -> some View { VStack(alignment: .leading, spacing: 3) { Label(title, systemImage: icon) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(.secondary) Text(value) .font(PluginSettingsTheme.Typography.rowDescription) .lineLimit(1) .truncationMode(.middle) } .frame(maxWidth: .infinity, alignment: .leading) } private var unavailableGeoInfo: some View { Text(geoUnavailableText) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 2) } } private struct LocalAddressRowView: View { let address: IPOverviewLocalAddress let displayAddress: String let localization: PluginLocalization let onCopyIP: () -> Void @State private var isHovered = false var body: some View { HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(address.interfaceName) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) HStack(alignment: .center, spacing: 4) { Text(displayAddress) .font(PluginSettingsTheme.Typography.monospacedValue) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) .layoutPriority(1) IPOverviewTagBadge(title: address.family.rawValue, style: .addressFamily(address.family.rawValue)) IPOverviewInlineCopyButton( help: localization.format( "copy.ip.help", defaultValue: "复制 %@ IP", address.family.rawValue ), action: onCopyIP ) .opacity(isHovered ? 1 : 0) .allowsHitTesting(isHovered) } } Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) } .contentShape(Rectangle()) .onHover { isHovered = $0 } } } private struct NetworkQualityCardView: View { let state: IPOverviewNetworkQualityRunState let localization: PluginLocalization var body: some View { VStack(alignment: .leading, spacing: 12) { switch state { case .waiting: chartContent( progress: .started(), measurement: nil ) case .running(let progress): chartContent( progress: progress, measurement: nil ) case .completed(let measurement, let progress): chartContent( progress: progress, measurement: measurement ) case .failed(let message): failureContent(message) } } .padding(PluginSettingsTheme.Spacing.cardContent) .pluginSettingsCardBackground(.host) } private func chartContent( progress: IPOverviewNetworkQualityProgress, measurement: IPOverviewNetworkQualityMeasurement? ) -> some View { VStack(alignment: .leading, spacing: 12) { NetworkQualityGraphPanel( progress: progress, measurement: measurement, localization: localization ) if let measurement { NetworkQualityResultStrip(measurement: measurement, localization: localization) metadataRow(measurement) } else { Text(localization.string( "speed.waiting.description", defaultValue: "点击刷新后,会顺序测量下载、上传、延迟和响应性。" )) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .lineLimit(2) } } } private func failureContent(_ message: String) -> some View { HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { Image(systemName: "exclamationmark.triangle") .pluginSettingsRowIconStyle(Color(nsColor: .systemOrange)) VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(localization.string("speed.failed.title", defaultValue: "测速失败")) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) Text(message) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .lineLimit(2) } } .frame(maxWidth: .infinity, alignment: .leading) } private func metadataRow(_ measurement: IPOverviewNetworkQualityMeasurement) -> some View { HStack(spacing: 8) { if let interfaceName = measurement.interfaceName, !interfaceName.isEmpty { Label(interfaceName, systemImage: "antenna.radiowaves.left.and.right") } if let endpoint = measurement.testEndpoint, !endpoint.isEmpty { Label(endpoint, systemImage: "server.rack") .lineLimit(1) .truncationMode(.middle) } if let totalDuration = measurement.totalPhaseDuration { Label(durationText(totalDuration), systemImage: "clock") } Spacer(minLength: 0) } .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(.secondary) } private func durationText(_ value: TimeInterval) -> String { localization.format("speed.duration.seconds", defaultValue: "%.1f 秒", value) } } private struct NetworkQualityGraphPanel: View { let progress: IPOverviewNetworkQualityProgress let measurement: IPOverviewNetworkQualityMeasurement? let localization: PluginLocalization var body: some View { HStack(alignment: .center, spacing: 18) { NetworkQualityGaugeView( value: gaugeValue, maximumValue: gaugeMaximum, subtitle: gaugeSubtitle, tint: gaugeTint ) .frame(width: 148, height: 148) VStack(alignment: .leading, spacing: 12) { NetworkQualitySparklineView( title: localization.string("speed.download", defaultValue: "下载"), valueText: valueText(measurement?.downloadMbps ?? progress.latestDownloadMbps, unit: "Mbps"), samples: sparklineSamples(progress.downloadSamples, fallback: measurement?.downloadMbps), tint: Color(nsColor: .systemTeal) ) NetworkQualitySparklineView( title: localization.string("speed.upload", defaultValue: "上传"), valueText: valueText(measurement?.uploadMbps ?? progress.latestUploadMbps, unit: "Mbps"), samples: sparklineSamples(progress.uploadSamples, fallback: measurement?.uploadMbps), tint: Color(nsColor: .systemCyan) ) } .frame(minHeight: 150, alignment: .center) .frame(maxWidth: .infinity) } .padding(.horizontal, 10) .padding(.vertical, 10) .frame(maxWidth: .infinity, minHeight: 174, alignment: .center) .background( PluginSettingsTheme.Palette.recessedControlBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.control, style: .continuous) ) } private var gaugeValue: Double? { if let measurement { return measurement.downloadMbps } switch progress.phase { case .measuringUpload: return progress.latestUploadMbps case .measuringDownload: return progress.latestDownloadMbps case .initializing, .measuringLatency: return nil } } private var gaugeSubtitle: String { if let value = gaugeValue { return valueText(value, unit: "Mbps") } return "-- Mbps" } private var gaugeMaximum: Double { let value = gaugeValue ?? 0 switch value { case ..<100: return 100 case ..<300: return 300 case ..<1_000: return 1_000 default: return (ceil(value / 1_000) * 1_000) } } private var gaugeTint: Color { if measurement != nil { return Color(nsColor: .systemTeal) } return progress.phase.tint } private func sparklineSamples(_ samples: [Double], fallback: Double?) -> [Double] { if !samples.isEmpty { return samples } return fallback.map { [$0] } ?? [] } private func valueText(_ value: Double?, unit: String) -> String { guard let value else { return "-- \(unit)" } let format = value >= 100 ? "%.0f %@" : value >= 10 ? "%.1f %@" : "%.2f %@" return String(format: format, value, unit) } } private struct NetworkQualityGaugeView: View { let value: Double? let maximumValue: Double let subtitle: String let tint: Color var body: some View { ZStack { Circle() .fill(PluginSettingsTheme.Palette.cardBackground.opacity(0.66)) .frame(width: 116, height: 116) GaugeArc(progress: 1) .stroke(.secondary.opacity(0.15), style: StrokeStyle(lineWidth: 10, lineCap: .round)) GaugeArc(progress: progress) .stroke(tint, style: StrokeStyle(lineWidth: 10, lineCap: .round)) VStack(spacing: 0) { Text(subtitle) .font(.system(size: 19.5, weight: .semibold, design: .rounded)) .foregroundStyle(.primary) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.68) } } } private var progress: Double { guard let value else { return 0.08 } return min(max(value / maximumValue, 0.08), 1) } } private struct GaugeArc: Shape { let progress: Double func path(in rect: CGRect) -> Path { var path = Path() let size = min(rect.width, rect.height) let radius = size / 2 - 10 let center = CGPoint(x: rect.midX, y: rect.midY) let start = Angle.degrees(135) let end = Angle.degrees(135 + 270 * min(max(progress, 0), 1)) path.addArc(center: center, radius: radius, startAngle: start, endAngle: end, clockwise: false) return path } } private struct NetworkQualitySparklineView: View { let title: String let valueText: String let samples: [Double] let tint: Color var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(title) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) Text(valueText) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(tint) .monospacedDigit() Spacer(minLength: 0) if let maximumText { Text(maximumText) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(.secondary) .monospacedDigit() } } GeometryReader { proxy in ZStack { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(PluginSettingsTheme.Palette.cardBackground.opacity(0.42)) NetworkQualityGrid() .stroke(.secondary.opacity(0.11), lineWidth: 0.7) if normalizedSamples.isEmpty { NetworkQualityBaseline() .stroke( .secondary.opacity(0.18), style: StrokeStyle(lineWidth: 1, dash: [4, 4]) ) } else { NetworkQualityArea(samples: normalizedSamples) .fill(tint.opacity(0.14)) NetworkQualityLine(samples: normalizedSamples) .stroke(tint, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) .shadow(color: tint.opacity(0.20), radius: 2, x: 0, y: 1) } } .frame(width: proxy.size.width, height: proxy.size.height) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } .frame(height: 54) } } private var maximumText: String? { guard let maximum = samples.max(), maximum > 0 else { return nil } let format = maximum >= 100 ? "%.0f max" : maximum >= 10 ? "%.1f max" : "%.2f max" return String(format: format, maximum) } private var normalizedSamples: [Double] { guard !samples.isEmpty else { return [] } let maximum = max(samples.max() ?? 1, 1) return samples.map { min(max($0 / maximum, 0.02), 1) } } } private extension CGRect { var networkQualityPlotRect: CGRect { insetBy(dx: 6, dy: 5) } } private struct NetworkQualityGrid: Shape { func path(in rect: CGRect) -> Path { var path = Path() let plotRect = rect.networkQualityPlotRect let columns = 12 let rows = 4 for column in 0...columns { let x = plotRect.minX + plotRect.width * CGFloat(column) / CGFloat(columns) path.move(to: CGPoint(x: x, y: plotRect.minY)) path.addLine(to: CGPoint(x: x, y: plotRect.maxY)) } for row in 0...rows { let y = plotRect.minY + plotRect.height * CGFloat(row) / CGFloat(rows) path.move(to: CGPoint(x: plotRect.minX, y: y)) path.addLine(to: CGPoint(x: plotRect.maxX, y: y)) } return path } } private struct NetworkQualityLine: Shape { let samples: [Double] func path(in rect: CGRect) -> Path { var path = Path() guard !samples.isEmpty else { return path } let plotRect = rect.networkQualityPlotRect let points = samples.enumerated().map { index, value in let x = samples.count == 1 ? plotRect.midX : plotRect.minX + plotRect.width * CGFloat(index) / CGFloat(samples.count - 1) let y = plotRect.maxY - plotRect.height * CGFloat(value) return CGPoint(x: x, y: y) } path.move(to: points[0]) for point in points.dropFirst() { path.addLine(to: point) } return path } } private struct NetworkQualityArea: Shape { let samples: [Double] func path(in rect: CGRect) -> Path { guard !samples.isEmpty else { return Path() } let plotRect = rect.networkQualityPlotRect var path = NetworkQualityLine(samples: samples).path(in: rect) let trailingX = samples.count == 1 ? plotRect.midX : plotRect.maxX let leadingX = samples.count == 1 ? plotRect.midX : plotRect.minX path.addLine(to: CGPoint(x: trailingX, y: plotRect.maxY)) path.addLine(to: CGPoint(x: leadingX, y: plotRect.maxY)) path.closeSubpath() return path } } private struct NetworkQualityBaseline: Shape { func path(in rect: CGRect) -> Path { var path = Path() let plotRect = rect.networkQualityPlotRect let y = plotRect.minY + plotRect.height * 0.68 path.move(to: CGPoint(x: plotRect.minX, y: y)) path.addLine(to: CGPoint(x: plotRect.maxX, y: y)) return path } } private struct NetworkQualityResultStrip: View { let measurement: IPOverviewNetworkQualityMeasurement let localization: PluginLocalization var body: some View { HStack(spacing: 0) { resultItem( title: localization.string("speed.download", defaultValue: "下载"), value: speedText(measurement.downloadMbps), unit: "Mbps", icon: "arrow.down.circle", tint: Color(nsColor: .systemTeal) ) Divider().padding(.vertical, 5) resultItem( title: localization.string("speed.upload", defaultValue: "上传"), value: speedText(measurement.uploadMbps), unit: "Mbps", icon: "arrow.up.circle", tint: Color(nsColor: .systemCyan) ) Divider().padding(.vertical, 5) resultItem( title: localization.string("speed.latency", defaultValue: "延迟"), value: integerText(measurement.baseRTTMilliseconds), unit: "ms", icon: "timer", tint: Color(nsColor: .systemOrange) ) Divider().padding(.vertical, 5) resultItem( title: localization.string("speed.responsiveness", defaultValue: "响应性"), value: integerText(measurement.uploadResponsivenessRPM ?? measurement.downloadResponsivenessRPM), unit: "RPM", icon: "waveform.path.ecg", tint: Color(nsColor: .systemPurple) ) } .padding(.vertical, 2) } private func resultItem( title: String, value: String, unit: String, icon: String, tint: Color ) -> some View { VStack(alignment: .leading, spacing: 6) { Label(title, systemImage: icon) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(tint) .lineLimit(1) HStack(alignment: .firstTextBaseline, spacing: 3) { Text(value) .font(.system(size: 16, weight: .semibold, design: .rounded)) .foregroundStyle(.primary) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.72) Text(unit) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(.secondary) } } .padding(.horizontal, 10) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) } private func speedText(_ value: Double?) -> String { guard let value else { return "--" } let format = value >= 100 ? "%.0f" : value >= 10 ? "%.1f" : "%.2f" return String(format: format, value) } private func integerText(_ value: Double?) -> String { guard let value else { return "--" } return String(format: "%.0f", value) } } private extension IPOverviewNetworkQualityPhase { var systemImage: String { switch self { case .initializing: return "hourglass" case .measuringDownload: return "arrow.down.circle" case .measuringUpload: return "arrow.up.circle" case .measuringLatency: return "waveform.path.ecg" } } var tint: Color { switch self { case .initializing, .measuringDownload: return Color(nsColor: .systemTeal) case .measuringUpload: return Color(nsColor: .systemCyan) case .measuringLatency: return Color(nsColor: .systemPurple) } } } private extension IPOverviewNetworkQualityGrade { var systemImage: String { switch self { case .excellent: return "checkmark.seal.fill" case .good: return "checkmark.circle.fill" case .fair: return "exclamationmark.circle.fill" case .poor: return "exclamationmark.triangle.fill" case .unknown: return "questionmark.circle" } } var tint: Color { switch self { case .excellent: return Color(nsColor: .systemGreen) case .good: return Color(nsColor: .systemTeal) case .fair: return Color(nsColor: .systemOrange) case .poor: return Color(nsColor: .systemRed) case .unknown: return Color(nsColor: .secondaryLabelColor) } } } private struct LeakEvidenceListRowView: View { let result: IPOverviewLeakTestResult let showsNAT: Bool let unknownText: String let localization: PluginLocalization let color: Color let primaryText: String let displayIP: String? let onCopyIP: () -> Void var body: some View { HStack(alignment: .top, spacing: 8) { Circle() .fill(color) .frame(width: 7, height: 7) .padding(.top, 5) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text(result.name) .font(PluginSettingsTheme.Typography.emphasizedRowTitle) .lineLimit(1) Text(statusText) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(.secondary) } if let endpoint = result.endpoint { HStack(spacing: 6) { IPOverviewCopyableMonospacedValue( value: displayIP ?? endpoint.ip, help: localization.string("copy.leakEndpoint.help", defaultValue: "复制出口 IP"), onCopy: onCopyIP ) Text(endpoint.countryDisplayText ?? unknownText) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } Text(detailText(endpoint)) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } else { Text(primaryText) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .lineLimit(1) } } } .padding(.horizontal, 9) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) } private func detailText(_ endpoint: IPOverviewLeakEndpoint) -> String { var parts: [String] = [] if let organization = endpoint.organization, !organization.isEmpty { parts.append(organization) } if showsNAT, let natType = endpoint.natType, !natType.isEmpty { parts.append(natType) } return parts.isEmpty ? unknownText : parts.joined(separator: " · ") } private var statusText: String { switch result.status { case .waiting: return localization.string("status.waiting", defaultValue: "等待检测") case .checking: return localization.string("status.checking", defaultValue: "检测中") case .success: return localization.string("leak.observed", defaultValue: "已获取") case .failure: return localization.string("leak.failed", defaultValue: "获取失败") } } } private struct IPOverviewTagBadge: View { let title: String let style: IPOverviewTagBadgeStyle var body: some View { Text(title) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(style.foregroundColor) .padding(.horizontal, 6) .padding(.vertical, 2) .background( Capsule().fill(style.backgroundColor) ) .overlay( Capsule().stroke(style.strokeColor, lineWidth: 0.5) ) .fixedSize() } } private enum IPOverviewTagBadgeStyle { case ipv4 case ipv6 case domestic case international static func addressFamily(_ title: String) -> IPOverviewTagBadgeStyle { title.uppercased() == "IPV6" ? .ipv6 : .ipv4 } static func egressRoute(_ route: IPOverviewEgressRoute) -> IPOverviewTagBadgeStyle { switch route { case .domestic: return .domestic case .international: return .international } } var tint: Color { switch self { case .ipv4: return Self.adaptiveColor( light: NSColor(calibratedRed: 0.05, green: 0.32, blue: 0.72, alpha: 1), dark: .systemBlue ) case .ipv6: return Self.adaptiveColor( light: NSColor(calibratedRed: 0.42, green: 0.20, blue: 0.68, alpha: 1), dark: .systemPurple ) case .domestic: return Self.adaptiveColor( light: NSColor(calibratedRed: 0.08, green: 0.48, blue: 0.24, alpha: 1), dark: .systemGreen ) case .international: return Self.adaptiveColor( light: NSColor(calibratedRed: 0.64, green: 0.34, blue: 0.00, alpha: 1), dark: .systemOrange ) } } var foregroundColor: Color { tint } var backgroundColor: Color { tint.opacity(0.12) } var strokeColor: Color { tint.opacity(0.2) } private static func adaptiveColor(light: NSColor, dark: NSColor) -> Color { Color(nsColor: NSColor(name: nil) { appearance in appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light }) } } private struct IPOverviewCopyableMonospacedValue: View { let value: String let help: String let onCopy: () -> Void @State private var isHovered = false var body: some View { HStack(alignment: .center, spacing: 4) { Text(value) .font(PluginSettingsTheme.Typography.monospacedValue) .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) .layoutPriority(1) IPOverviewInlineCopyButton(help: help, action: onCopy) .opacity(isHovered ? 1 : 0) .allowsHitTesting(isHovered) } .contentShape(Rectangle()) .onHover { isHovered = $0 } } } private struct IPOverviewInlineCopyButton: View { let help: String let action: () -> Void var body: some View { Button(action: action) { Image(systemName: "doc.on.doc") .font(.system(size: 8.5, weight: .semibold)) .foregroundStyle(.secondary) .frame(width: 12, height: 12) .contentShape(Rectangle()) } .buttonStyle(.plain) .help(help) } }