1089 lines
33 KiB
Swift
1089 lines
33 KiB
Swift
import Foundation
|
||
import MacToolsPluginKit
|
||
|
||
enum IPOverviewAddressFamily: String, CaseIterable, Codable, Sendable {
|
||
case ipv4 = "IPv4"
|
||
case ipv6 = "IPv6"
|
||
}
|
||
|
||
enum IPOverviewEgressRoute: String, CaseIterable, Codable, Sendable {
|
||
case domestic
|
||
case international
|
||
|
||
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
||
switch self {
|
||
case .domestic:
|
||
return localization.string("egress.domestic", defaultValue: "国内出口")
|
||
case .international:
|
||
return localization.string("egress.international", defaultValue: "国际出口")
|
||
}
|
||
}
|
||
}
|
||
|
||
struct IPOverviewPublicIPResult: Codable, Equatable, Sendable {
|
||
let family: IPOverviewAddressFamily
|
||
let route: IPOverviewEgressRoute
|
||
let ip: String
|
||
let source: String
|
||
|
||
init(
|
||
family: IPOverviewAddressFamily,
|
||
route: IPOverviewEgressRoute = .international,
|
||
ip: String,
|
||
source: String
|
||
) {
|
||
self.family = family
|
||
self.route = route
|
||
self.ip = ip
|
||
self.source = source
|
||
}
|
||
}
|
||
|
||
struct IPOverviewSourceResult: Identifiable, Codable, Equatable, Sendable {
|
||
enum Status: Codable, Equatable, Sendable {
|
||
case success(String)
|
||
case failure(String)
|
||
}
|
||
|
||
let id: String
|
||
let family: IPOverviewAddressFamily
|
||
let route: IPOverviewEgressRoute
|
||
let source: String
|
||
let status: Status
|
||
|
||
init(
|
||
id: String,
|
||
family: IPOverviewAddressFamily,
|
||
route: IPOverviewEgressRoute = .international,
|
||
source: String,
|
||
status: Status
|
||
) {
|
||
self.id = id
|
||
self.family = family
|
||
self.route = route
|
||
self.source = source
|
||
self.status = status
|
||
}
|
||
}
|
||
|
||
struct IPOverviewLocalAddress: Identifiable, Codable, Equatable, Sendable {
|
||
let id: String
|
||
let interfaceName: String
|
||
let address: String
|
||
let family: IPOverviewAddressFamily
|
||
}
|
||
|
||
struct IPOverviewGeoInfo: Codable, Equatable, Sendable {
|
||
let ip: String
|
||
let country: String?
|
||
let countryCode: String?
|
||
let region: String?
|
||
let city: String?
|
||
let isp: String?
|
||
let organization: String?
|
||
let asn: String?
|
||
let timezone: String?
|
||
let networkType: IPOverviewNetworkType
|
||
let isProxy: Bool?
|
||
let isHosting: Bool?
|
||
let source: String
|
||
|
||
var countryFlag: String? {
|
||
guard let countryCode else {
|
||
return nil
|
||
}
|
||
|
||
return IPOverviewFormatter.flagEmoji(countryCode: countryCode)
|
||
}
|
||
|
||
var locationText: String? {
|
||
[country, region, city]
|
||
.compactMap { value in
|
||
guard let value, !value.isEmpty else { return nil }
|
||
return value
|
||
}
|
||
.joined(separator: " / ")
|
||
.nilIfEmpty
|
||
}
|
||
|
||
var countryDisplayText: String? {
|
||
guard let country, !country.isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
if let countryFlag {
|
||
return "\(countryFlag) \(country)"
|
||
}
|
||
|
||
return country
|
||
}
|
||
|
||
var networkText: String? {
|
||
[asn, organization ?? isp]
|
||
.compactMap { value in
|
||
guard let value, !value.isEmpty else { return nil }
|
||
return value
|
||
}
|
||
.joined(separator: " ")
|
||
.nilIfEmpty
|
||
}
|
||
|
||
var proxyText: String {
|
||
proxyText()
|
||
}
|
||
|
||
func proxyText(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
||
switch isProxy {
|
||
case true:
|
||
return localization.string("geo.proxy.possible", defaultValue: "可能为代理")
|
||
case false:
|
||
return localization.string("geo.proxy.notDetected", defaultValue: "未识别为代理")
|
||
case nil:
|
||
return localization.string("common.unknown", defaultValue: "未知")
|
||
}
|
||
}
|
||
}
|
||
|
||
enum IPOverviewNetworkType: Codable, Sendable {
|
||
case residential
|
||
case mobile
|
||
case datacenter
|
||
case education
|
||
case business
|
||
case unknown
|
||
|
||
var rawValue: String {
|
||
title()
|
||
}
|
||
|
||
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
||
switch self {
|
||
case .residential:
|
||
return localization.string("networkType.residential", defaultValue: "住宅/宽带")
|
||
case .mobile:
|
||
return localization.string("networkType.mobile", defaultValue: "移动网络")
|
||
case .datacenter:
|
||
return localization.string("networkType.datacenter", defaultValue: "数据中心")
|
||
case .education:
|
||
return localization.string("networkType.education", defaultValue: "教育网络")
|
||
case .business:
|
||
return localization.string("networkType.business", defaultValue: "企业网络")
|
||
case .unknown:
|
||
return localization.string("common.unknown", defaultValue: "未知")
|
||
}
|
||
}
|
||
|
||
static func infer(organization: String?, isp: String?, isHosting: Bool?) -> IPOverviewNetworkType {
|
||
if isHosting == true {
|
||
return .datacenter
|
||
}
|
||
|
||
let text = [organization, isp]
|
||
.compactMap { $0?.lowercased() }
|
||
.joined(separator: " ")
|
||
|
||
guard !text.isEmpty else {
|
||
return .unknown
|
||
}
|
||
|
||
if text.contains("mobile") || text.contains("wireless") || text.contains("cellular")
|
||
|| text.contains("移动") || text.contains("unicom") || text.contains("telecom") {
|
||
return .mobile
|
||
}
|
||
|
||
if text.contains("university") || text.contains("education") || text.contains("college") {
|
||
return .education
|
||
}
|
||
|
||
if text.contains("cloud") || text.contains("hosting") || text.contains("data")
|
||
|| text.contains("amazon") || text.contains("aws") || text.contains("google")
|
||
|| text.contains("microsoft") || text.contains("azure") || text.contains("cloudflare")
|
||
|| text.contains("digitalocean") || text.contains("hetzner") || text.contains("ovh")
|
||
|| text.contains("linode") || text.contains("vultr") {
|
||
return .datacenter
|
||
}
|
||
|
||
if text.contains("business") || text.contains("enterprise") || text.contains("corp") {
|
||
return .business
|
||
}
|
||
|
||
return .residential
|
||
}
|
||
}
|
||
|
||
struct IPOverviewConnectivityTarget: Identifiable, Codable, Equatable, Sendable {
|
||
let id: String
|
||
var name: String
|
||
var urlString: String
|
||
var isCustom: Bool
|
||
|
||
var url: URL? {
|
||
URL(string: urlString)
|
||
}
|
||
|
||
static let defaults: [IPOverviewConnectivityTarget] = [
|
||
IPOverviewConnectivityTarget(
|
||
id: "wechat",
|
||
name: "WeChat",
|
||
urlString: "https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico",
|
||
isCustom: false
|
||
),
|
||
IPOverviewConnectivityTarget(
|
||
id: "google",
|
||
name: "Google",
|
||
urlString: "https://www.google.com/favicon.ico",
|
||
isCustom: false
|
||
),
|
||
IPOverviewConnectivityTarget(
|
||
id: "cloudflare",
|
||
name: "Cloudflare",
|
||
urlString: "https://www.cloudflare.com/favicon.ico",
|
||
isCustom: false
|
||
),
|
||
IPOverviewConnectivityTarget(
|
||
id: "youtube",
|
||
name: "YouTube",
|
||
urlString: "https://www.youtube.com/favicon.ico",
|
||
isCustom: false
|
||
),
|
||
IPOverviewConnectivityTarget(
|
||
id: "github",
|
||
name: "GitHub",
|
||
urlString: "https://github.com/favicon.ico",
|
||
isCustom: false
|
||
),
|
||
IPOverviewConnectivityTarget(
|
||
id: "chatgpt",
|
||
name: "ChatGPT",
|
||
urlString: "https://chatgpt.com/favicon.ico",
|
||
isCustom: false
|
||
)
|
||
]
|
||
}
|
||
|
||
struct IPOverviewConnectivityResult: Identifiable, Codable, Equatable, Sendable {
|
||
enum Status: Codable, Equatable, Sendable {
|
||
case waiting
|
||
case checking
|
||
case reachable(milliseconds: Int)
|
||
case unreachable(String)
|
||
}
|
||
|
||
let id: String
|
||
let target: IPOverviewConnectivityTarget
|
||
var status: Status
|
||
|
||
var latencyMilliseconds: Int? {
|
||
guard case .reachable(let milliseconds) = status else {
|
||
return nil
|
||
}
|
||
|
||
return milliseconds
|
||
}
|
||
}
|
||
|
||
enum IPOverviewNetworkQualityRunState: Codable, Equatable, Sendable {
|
||
case waiting
|
||
case running(IPOverviewNetworkQualityProgress)
|
||
case completed(IPOverviewNetworkQualityMeasurement, IPOverviewNetworkQualityProgress)
|
||
case failed(String)
|
||
|
||
var measurement: IPOverviewNetworkQualityMeasurement? {
|
||
guard case .completed(let measurement, _) = self else {
|
||
return nil
|
||
}
|
||
|
||
return measurement
|
||
}
|
||
}
|
||
|
||
struct IPOverviewNetworkQualityProgress: Codable, Equatable, Sendable {
|
||
var startedAt: Date
|
||
var phase: IPOverviewNetworkQualityPhase
|
||
var downloadSamples: [Double]
|
||
var uploadSamples: [Double]
|
||
|
||
static func started(at date: Date = Date()) -> IPOverviewNetworkQualityProgress {
|
||
IPOverviewNetworkQualityProgress(
|
||
startedAt: date,
|
||
phase: .initializing,
|
||
downloadSamples: [],
|
||
uploadSamples: []
|
||
)
|
||
}
|
||
|
||
var latestDownloadMbps: Double? {
|
||
downloadSamples.last
|
||
}
|
||
|
||
var latestUploadMbps: Double? {
|
||
uploadSamples.last
|
||
}
|
||
}
|
||
|
||
enum IPOverviewNetworkQualityPhase: Codable, Equatable, Sendable {
|
||
case initializing
|
||
case measuringDownload
|
||
case measuringUpload
|
||
case measuringLatency
|
||
|
||
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
||
switch self {
|
||
case .initializing:
|
||
return localization.string("speed.phase.initializing", defaultValue: "准备测速")
|
||
case .measuringDownload:
|
||
return localization.string("speed.phase.download", defaultValue: "测量下载")
|
||
case .measuringUpload:
|
||
return localization.string("speed.phase.upload", defaultValue: "测量上传")
|
||
case .measuringLatency:
|
||
return localization.string("speed.phase.latency", defaultValue: "测量延迟")
|
||
}
|
||
}
|
||
}
|
||
|
||
struct IPOverviewNetworkQualityMeasurement: Codable, Equatable, Sendable {
|
||
let baseRTTMilliseconds: Double?
|
||
let downloadThroughputBitsPerSecond: Double?
|
||
let uploadThroughputBitsPerSecond: Double?
|
||
let downloadResponsivenessRPM: Double?
|
||
let uploadResponsivenessRPM: Double?
|
||
let downloadPhaseDuration: TimeInterval?
|
||
let uploadPhaseDuration: TimeInterval?
|
||
let interfaceName: String?
|
||
let testEndpoint: String?
|
||
let startDate: String?
|
||
let endDate: String?
|
||
|
||
var downloadMbps: Double? {
|
||
downloadThroughputBitsPerSecond.map { $0 / 1_000_000 }
|
||
}
|
||
|
||
var uploadMbps: Double? {
|
||
uploadThroughputBitsPerSecond.map { $0 / 1_000_000 }
|
||
}
|
||
|
||
var totalPhaseDuration: TimeInterval? {
|
||
let durations = [downloadPhaseDuration, uploadPhaseDuration].compactMap(\.self)
|
||
guard !durations.isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
return durations.reduce(0, +)
|
||
}
|
||
|
||
var quality: IPOverviewNetworkQualityGrade {
|
||
IPOverviewNetworkQualityGrade.evaluate(
|
||
baseRTTMilliseconds: baseRTTMilliseconds,
|
||
downloadMbps: downloadMbps,
|
||
uploadMbps: uploadMbps
|
||
)
|
||
}
|
||
}
|
||
|
||
enum IPOverviewNetworkQualityGrade: Sendable {
|
||
case excellent
|
||
case good
|
||
case fair
|
||
case poor
|
||
case unknown
|
||
|
||
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
||
switch self {
|
||
case .excellent:
|
||
return localization.string("speed.grade.excellent", defaultValue: "优秀")
|
||
case .good:
|
||
return localization.string("speed.grade.good", defaultValue: "良好")
|
||
case .fair:
|
||
return localization.string("speed.grade.fair", defaultValue: "一般")
|
||
case .poor:
|
||
return localization.string("speed.grade.poor", defaultValue: "偏慢")
|
||
case .unknown:
|
||
return localization.string("common.unknown", defaultValue: "未知")
|
||
}
|
||
}
|
||
|
||
static func evaluate(
|
||
baseRTTMilliseconds: Double?,
|
||
downloadMbps: Double?,
|
||
uploadMbps: Double?
|
||
) -> IPOverviewNetworkQualityGrade {
|
||
guard baseRTTMilliseconds != nil || downloadMbps != nil || uploadMbps != nil else {
|
||
return .unknown
|
||
}
|
||
|
||
let latencyScore: Int
|
||
switch baseRTTMilliseconds {
|
||
case let value? where value <= 50:
|
||
latencyScore = 3
|
||
case let value? where value <= 100:
|
||
latencyScore = 2
|
||
case let value? where value <= 200:
|
||
latencyScore = 1
|
||
case .some:
|
||
latencyScore = 0
|
||
case nil:
|
||
latencyScore = 1
|
||
}
|
||
|
||
let downloadScore: Int
|
||
switch downloadMbps {
|
||
case let value? where value >= 100:
|
||
downloadScore = 3
|
||
case let value? where value >= 25:
|
||
downloadScore = 2
|
||
case let value? where value >= 5:
|
||
downloadScore = 1
|
||
case .some:
|
||
downloadScore = 0
|
||
case nil:
|
||
downloadScore = 1
|
||
}
|
||
|
||
let uploadScore: Int
|
||
switch uploadMbps {
|
||
case let value? where value >= 40:
|
||
uploadScore = 3
|
||
case let value? where value >= 10:
|
||
uploadScore = 2
|
||
case let value? where value >= 2:
|
||
uploadScore = 1
|
||
case .some:
|
||
uploadScore = 0
|
||
case nil:
|
||
uploadScore = 1
|
||
}
|
||
|
||
switch min(latencyScore, downloadScore, uploadScore) {
|
||
case 3:
|
||
return .excellent
|
||
case 2:
|
||
return .good
|
||
case 1:
|
||
return .fair
|
||
default:
|
||
return .poor
|
||
}
|
||
}
|
||
}
|
||
|
||
struct IPOverviewLeakTestResult: Identifiable, Codable, Equatable, Sendable {
|
||
enum Status: Codable, Equatable, Sendable {
|
||
case waiting
|
||
case checking
|
||
case success(IPOverviewLeakEndpoint)
|
||
case failure(String)
|
||
}
|
||
|
||
let id: String
|
||
let name: String
|
||
let status: Status
|
||
|
||
var endpoint: IPOverviewLeakEndpoint? {
|
||
guard case .success(let endpoint) = status else {
|
||
return nil
|
||
}
|
||
|
||
return endpoint
|
||
}
|
||
|
||
var isWaiting: Bool {
|
||
guard case .waiting = status else {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
var isChecking: Bool {
|
||
guard case .checking = status else {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
var isFailure: Bool {
|
||
guard case .failure = status else {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
}
|
||
|
||
struct IPOverviewLeakEndpoint: Codable, Equatable, Sendable {
|
||
let ip: String
|
||
let natType: String?
|
||
let country: String?
|
||
let countryCode: String?
|
||
let organization: String?
|
||
|
||
var countryDisplayText: String? {
|
||
guard let country, !country.isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
if let countryCode, let flag = IPOverviewFormatter.flagEmoji(countryCode: countryCode) {
|
||
return "\(flag) \(country)"
|
||
}
|
||
|
||
return country
|
||
}
|
||
}
|
||
|
||
private extension IPOverviewGeoInfo {
|
||
var regionKey: String? {
|
||
IPOverviewRegionKey.countryCodeOrName(countryCode: countryCode, country: country)
|
||
}
|
||
}
|
||
|
||
private extension IPOverviewLeakEndpoint {
|
||
var regionKey: String? {
|
||
IPOverviewRegionKey.countryCodeOrName(countryCode: countryCode, country: country)
|
||
}
|
||
}
|
||
|
||
private enum IPOverviewRegionKey {
|
||
static func countryCodeOrName(countryCode: String?, country: String?) -> String? {
|
||
if let code = countryCode?
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.uppercased(),
|
||
!code.isEmpty {
|
||
return "code:\(code)"
|
||
}
|
||
|
||
if let country = country?
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.lowercased(),
|
||
!country.isEmpty {
|
||
return "country:\(country)"
|
||
}
|
||
|
||
return nil
|
||
}
|
||
}
|
||
|
||
enum IPOverviewLeakAssessmentKind: Equatable, Sendable {
|
||
case webRTC
|
||
case dns
|
||
}
|
||
|
||
enum IPOverviewLeakAssessmentState: Equatable, Sendable {
|
||
case waiting
|
||
case checking
|
||
case clear
|
||
case warning
|
||
case unknown
|
||
}
|
||
|
||
enum IPOverviewLeakAssessmentReason: Equatable, Sendable {
|
||
case waiting
|
||
case checking
|
||
case noPublicIP
|
||
case noDNSEndpoint
|
||
case webRTCMatchesPublicIP
|
||
case webRTCNoVisibleEndpoint
|
||
case webRTCDifferentIP
|
||
case dnsMatchesEgressRegion
|
||
case dnsDifferentEgressRegion
|
||
case dnsObservedWithoutBaselineRegion
|
||
}
|
||
|
||
struct IPOverviewLeakAssessment: Equatable, Sendable {
|
||
let kind: IPOverviewLeakAssessmentKind
|
||
let state: IPOverviewLeakAssessmentState
|
||
let reason: IPOverviewLeakAssessmentReason
|
||
let totalCount: Int
|
||
let observedCount: Int
|
||
let failureCount: Int
|
||
let issueEndpoint: IPOverviewLeakEndpoint?
|
||
|
||
static func evaluate(
|
||
kind: IPOverviewLeakAssessmentKind,
|
||
results: [IPOverviewLeakTestResult],
|
||
snapshot: IPOverviewSnapshot,
|
||
isRunning: Bool
|
||
) -> IPOverviewLeakAssessment {
|
||
let endpoints = results.compactMap(\.endpoint)
|
||
let failureCount = results.filter(\.isFailure).count
|
||
let hasChecking = isRunning || results.contains(where: \.isChecking)
|
||
let isWaitingOnly = !results.isEmpty && results.allSatisfy(\.isWaiting)
|
||
|
||
switch kind {
|
||
case .webRTC:
|
||
return evaluateWebRTC(
|
||
results: results,
|
||
endpoints: endpoints,
|
||
snapshot: snapshot,
|
||
isWaitingOnly: isWaitingOnly,
|
||
hasChecking: hasChecking,
|
||
failureCount: failureCount
|
||
)
|
||
case .dns:
|
||
return evaluateDNS(
|
||
results: results,
|
||
endpoints: endpoints,
|
||
snapshot: snapshot,
|
||
isWaitingOnly: isWaitingOnly,
|
||
hasChecking: hasChecking,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
}
|
||
|
||
private static func evaluateWebRTC(
|
||
results: [IPOverviewLeakTestResult],
|
||
endpoints: [IPOverviewLeakEndpoint],
|
||
snapshot: IPOverviewSnapshot,
|
||
isWaitingOnly: Bool,
|
||
hasChecking: Bool,
|
||
failureCount: Int
|
||
) -> IPOverviewLeakAssessment {
|
||
let publicIPs = snapshot.publicIPSet
|
||
|
||
if !endpoints.isEmpty, !publicIPs.isEmpty,
|
||
let issue = endpoints.first(where: { !publicIPs.contains($0.ip) }) {
|
||
return assessment(
|
||
kind: .webRTC,
|
||
state: .warning,
|
||
reason: .webRTCDifferentIP,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount,
|
||
issueEndpoint: issue
|
||
)
|
||
}
|
||
|
||
if hasChecking {
|
||
return assessment(
|
||
kind: .webRTC,
|
||
state: .checking,
|
||
reason: .checking,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
if isWaitingOnly {
|
||
return assessment(
|
||
kind: .webRTC,
|
||
state: .waiting,
|
||
reason: .waiting,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
if endpoints.isEmpty {
|
||
return assessment(
|
||
kind: .webRTC,
|
||
state: .clear,
|
||
reason: .webRTCNoVisibleEndpoint,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
if publicIPs.isEmpty {
|
||
return assessment(
|
||
kind: .webRTC,
|
||
state: .unknown,
|
||
reason: .noPublicIP,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
return assessment(
|
||
kind: .webRTC,
|
||
state: .clear,
|
||
reason: .webRTCMatchesPublicIP,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
private static func evaluateDNS(
|
||
results: [IPOverviewLeakTestResult],
|
||
endpoints: [IPOverviewLeakEndpoint],
|
||
snapshot: IPOverviewSnapshot,
|
||
isWaitingOnly: Bool,
|
||
hasChecking: Bool,
|
||
failureCount: Int
|
||
) -> IPOverviewLeakAssessment {
|
||
let baselineRegions = snapshot.publicRegionKeys
|
||
let observedRegions = endpoints.compactMap(\.regionKey)
|
||
|
||
if !baselineRegions.isEmpty, !observedRegions.isEmpty,
|
||
let issue = endpoints.first(where: { endpoint in
|
||
guard let regionKey = endpoint.regionKey else {
|
||
return false
|
||
}
|
||
return !baselineRegions.contains(regionKey)
|
||
}) {
|
||
return assessment(
|
||
kind: .dns,
|
||
state: .warning,
|
||
reason: .dnsDifferentEgressRegion,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount,
|
||
issueEndpoint: issue
|
||
)
|
||
}
|
||
|
||
if hasChecking {
|
||
return assessment(
|
||
kind: .dns,
|
||
state: .checking,
|
||
reason: .checking,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
if isWaitingOnly {
|
||
return assessment(
|
||
kind: .dns,
|
||
state: .waiting,
|
||
reason: .waiting,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
if endpoints.isEmpty {
|
||
return assessment(
|
||
kind: .dns,
|
||
state: .unknown,
|
||
reason: .noDNSEndpoint,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
if baselineRegions.isEmpty || observedRegions.isEmpty {
|
||
return assessment(
|
||
kind: .dns,
|
||
state: .unknown,
|
||
reason: .dnsObservedWithoutBaselineRegion,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
return assessment(
|
||
kind: .dns,
|
||
state: .clear,
|
||
reason: .dnsMatchesEgressRegion,
|
||
results: results,
|
||
observedCount: endpoints.count,
|
||
failureCount: failureCount
|
||
)
|
||
}
|
||
|
||
private static func assessment(
|
||
kind: IPOverviewLeakAssessmentKind,
|
||
state: IPOverviewLeakAssessmentState,
|
||
reason: IPOverviewLeakAssessmentReason,
|
||
results: [IPOverviewLeakTestResult],
|
||
observedCount: Int,
|
||
failureCount: Int,
|
||
issueEndpoint: IPOverviewLeakEndpoint? = nil
|
||
) -> IPOverviewLeakAssessment {
|
||
IPOverviewLeakAssessment(
|
||
kind: kind,
|
||
state: state,
|
||
reason: reason,
|
||
totalCount: results.count,
|
||
observedCount: observedCount,
|
||
failureCount: failureCount,
|
||
issueEndpoint: issueEndpoint
|
||
)
|
||
}
|
||
}
|
||
|
||
struct IPOverviewWebRTCTarget: Identifiable, Equatable, Sendable {
|
||
let id: String
|
||
let name: String
|
||
let host: String
|
||
let port: UInt16
|
||
|
||
var displayAddress: String {
|
||
"\(host):\(port)"
|
||
}
|
||
|
||
static let defaults: [IPOverviewWebRTCTarget] = [
|
||
IPOverviewWebRTCTarget(id: "google", name: "Google", host: "stun.l.google.com", port: 19302),
|
||
IPOverviewWebRTCTarget(id: "blackberry", name: "BlackBerry", host: "stun.voip.blackberry.com", port: 3478),
|
||
IPOverviewWebRTCTarget(id: "twilio", name: "Twilio", host: "global.stun.twilio.com", port: 3478),
|
||
IPOverviewWebRTCTarget(id: "cloudflare", name: "Cloudflare", host: "stun.cloudflare.com", port: 3478)
|
||
]
|
||
}
|
||
|
||
struct IPOverviewSnapshot: Codable, Equatable, Sendable {
|
||
var domesticIPv4: IPOverviewPublicIPResult?
|
||
var domesticIPv6: IPOverviewPublicIPResult?
|
||
var internationalIPv4: IPOverviewPublicIPResult?
|
||
var internationalIPv6: IPOverviewPublicIPResult?
|
||
var localAddresses: [IPOverviewLocalAddress]
|
||
var geoInfoByIP: [String: IPOverviewGeoInfo]
|
||
var sourceResults: [IPOverviewSourceResult]
|
||
var lastUpdated: Date?
|
||
var errorMessage: String?
|
||
var isRefreshing: Bool
|
||
|
||
static let empty = IPOverviewSnapshot(
|
||
domesticIPv4: nil,
|
||
domesticIPv6: nil,
|
||
internationalIPv4: nil,
|
||
internationalIPv6: nil,
|
||
localAddresses: [],
|
||
geoInfoByIP: [:],
|
||
sourceResults: [],
|
||
lastUpdated: nil,
|
||
errorMessage: nil,
|
||
isRefreshing: false
|
||
)
|
||
|
||
init(
|
||
domesticIPv4: IPOverviewPublicIPResult? = nil,
|
||
domesticIPv6: IPOverviewPublicIPResult? = nil,
|
||
internationalIPv4: IPOverviewPublicIPResult? = nil,
|
||
internationalIPv6: IPOverviewPublicIPResult? = nil,
|
||
localAddresses: [IPOverviewLocalAddress],
|
||
geoInfoByIP: [String: IPOverviewGeoInfo],
|
||
sourceResults: [IPOverviewSourceResult],
|
||
lastUpdated: Date?,
|
||
errorMessage: String?,
|
||
isRefreshing: Bool
|
||
) {
|
||
self.domesticIPv4 = domesticIPv4
|
||
self.domesticIPv6 = domesticIPv6
|
||
self.internationalIPv4 = internationalIPv4
|
||
self.internationalIPv6 = internationalIPv6
|
||
self.localAddresses = localAddresses
|
||
self.geoInfoByIP = geoInfoByIP
|
||
self.sourceResults = sourceResults
|
||
self.lastUpdated = lastUpdated
|
||
self.errorMessage = errorMessage
|
||
self.isRefreshing = isRefreshing
|
||
}
|
||
|
||
init(
|
||
publicIPv4: IPOverviewPublicIPResult?,
|
||
publicIPv6: IPOverviewPublicIPResult?,
|
||
localAddresses: [IPOverviewLocalAddress],
|
||
geoInfoByIP: [String: IPOverviewGeoInfo],
|
||
sourceResults: [IPOverviewSourceResult],
|
||
lastUpdated: Date?,
|
||
errorMessage: String?,
|
||
isRefreshing: Bool
|
||
) {
|
||
self.init(
|
||
internationalIPv4: publicIPv4,
|
||
internationalIPv6: publicIPv6,
|
||
localAddresses: localAddresses,
|
||
geoInfoByIP: geoInfoByIP,
|
||
sourceResults: sourceResults,
|
||
lastUpdated: lastUpdated,
|
||
errorMessage: errorMessage,
|
||
isRefreshing: isRefreshing
|
||
)
|
||
}
|
||
|
||
var publicIPv4: IPOverviewPublicIPResult? {
|
||
internationalIPv4
|
||
}
|
||
|
||
var publicIPv6: IPOverviewPublicIPResult? {
|
||
internationalIPv6
|
||
}
|
||
|
||
var preferredPublicIP: IPOverviewPublicIPResult? {
|
||
internationalIPv4 ?? internationalIPv6 ?? domesticIPv4 ?? domesticIPv6
|
||
}
|
||
|
||
var preferredGeoInfo: IPOverviewGeoInfo? {
|
||
guard let ip = preferredPublicIP?.ip else {
|
||
return nil
|
||
}
|
||
|
||
return geoInfoByIP[ip]
|
||
}
|
||
|
||
var publicIPSet: Set<String> {
|
||
Set([internationalIPv4?.ip, internationalIPv6?.ip].compactMap { $0 })
|
||
}
|
||
|
||
var publicRegionKeys: Set<String> {
|
||
Set(publicIPSet.compactMap { geoInfoByIP[$0]?.regionKey })
|
||
}
|
||
|
||
var reportText: String {
|
||
reportText()
|
||
}
|
||
|
||
func reportText(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
||
var lines: [String] = []
|
||
lines.append(localization.string("report.title", defaultValue: "IP 检测结果"))
|
||
if let lastUpdated {
|
||
lines.append(localization.format(
|
||
"report.updatedAt",
|
||
defaultValue: "更新时间:%@",
|
||
IPOverviewFormatter.dateTime(lastUpdated)
|
||
))
|
||
}
|
||
let notDetected = localization.string("common.notDetected", defaultValue: "未检测到")
|
||
lines.append(localization.format("report.domesticIPv4", defaultValue: "国内出口 IPv4:%@", domesticIPv4?.ip ?? notDetected))
|
||
lines.append(localization.format("report.domesticIPv6", defaultValue: "国内出口 IPv6:%@", domesticIPv6?.ip ?? notDetected))
|
||
lines.append(localization.format("report.publicIPv4", defaultValue: "国际出口 IPv4:%@", internationalIPv4?.ip ?? notDetected))
|
||
lines.append(localization.format("report.publicIPv6", defaultValue: "国际出口 IPv6:%@", internationalIPv6?.ip ?? notDetected))
|
||
|
||
if !localAddresses.isEmpty {
|
||
lines.append("")
|
||
lines.append(localization.string("report.localAddresses", defaultValue: "本地地址:"))
|
||
for address in localAddresses {
|
||
lines.append(localization.format(
|
||
"report.localAddress",
|
||
defaultValue: "- %@ %@:%@",
|
||
address.interfaceName,
|
||
address.family.rawValue,
|
||
address.address
|
||
))
|
||
}
|
||
}
|
||
|
||
if !geoInfoByIP.isEmpty {
|
||
lines.append("")
|
||
lines.append(localization.string("report.geo", defaultValue: "归属地:"))
|
||
for info in geoInfoByIP.values.sorted(by: { $0.ip < $1.ip }) {
|
||
lines.append("- \(info.ip)")
|
||
if let country = info.countryDisplayText {
|
||
lines.append(localization.format("report.region", defaultValue: " 地区:%@", country))
|
||
}
|
||
if let region = info.region, !region.isEmpty {
|
||
lines.append(localization.format("report.province", defaultValue: " 省份:%@", region))
|
||
}
|
||
if let city = info.city, !city.isEmpty {
|
||
lines.append(localization.format("report.city", defaultValue: " 城市:%@", city))
|
||
}
|
||
if let network = info.networkText {
|
||
lines.append(localization.format("report.network", defaultValue: " 网络:%@", network))
|
||
}
|
||
if let timezone = info.timezone, !timezone.isEmpty {
|
||
lines.append(localization.format("report.timezone", defaultValue: " 时区:%@", timezone))
|
||
}
|
||
lines.append(localization.format("report.source", defaultValue: " 来源:%@", info.source))
|
||
}
|
||
}
|
||
|
||
if !sourceResults.isEmpty {
|
||
lines.append("")
|
||
lines.append(localization.string("report.sources", defaultValue: "检测源:"))
|
||
for result in sourceResults {
|
||
switch result.status {
|
||
case .success(let ip):
|
||
lines.append(localization.format(
|
||
"report.source.success",
|
||
defaultValue: "- %@ %@:%@",
|
||
result.route.title(localization: localization),
|
||
result.source,
|
||
ip
|
||
))
|
||
case .failure(let message):
|
||
lines.append(localization.format(
|
||
"report.source.failure",
|
||
defaultValue: "- %@ %@:失败(%@)",
|
||
result.route.title(localization: localization),
|
||
result.source,
|
||
message
|
||
))
|
||
}
|
||
}
|
||
}
|
||
|
||
if let errorMessage, !errorMessage.isEmpty {
|
||
lines.append("")
|
||
lines.append(localization.format("report.error", defaultValue: "错误:%@", errorMessage))
|
||
}
|
||
|
||
return lines.joined(separator: "\n")
|
||
}
|
||
}
|
||
|
||
enum IPOverviewFormatter {
|
||
static func dateTime(_ date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = PluginRuntimeLocalization.locale
|
||
formatter.dateStyle = .short
|
||
formatter.timeStyle = .medium
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
static func flagEmoji(countryCode: String) -> String? {
|
||
let code = countryCode
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.uppercased()
|
||
|
||
guard code.count == 2 else {
|
||
return nil
|
||
}
|
||
|
||
var scalars = String.UnicodeScalarView()
|
||
for scalar in code.unicodeScalars {
|
||
guard let regionalIndicator = UnicodeScalar(127397 + scalar.value) else {
|
||
return nil
|
||
}
|
||
scalars.append(regionalIndicator)
|
||
}
|
||
|
||
return String(scalars)
|
||
}
|
||
}
|
||
|
||
enum IPOverviewSensitiveValueMask {
|
||
static func maskedIP(_ value: String) -> String {
|
||
if value.contains(":") {
|
||
return maskedIPv6(value)
|
||
}
|
||
|
||
return maskedIPv4(value)
|
||
}
|
||
|
||
private static func maskedIPv4(_ value: String) -> String {
|
||
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
|
||
guard parts.count == 4 else {
|
||
return "••••"
|
||
}
|
||
|
||
return "\(parts[0]).\(parts[1]).•••.•••"
|
||
}
|
||
|
||
private static func maskedIPv6(_ value: String) -> String {
|
||
let visibleParts = value
|
||
.split(separator: ":", omittingEmptySubsequences: true)
|
||
.prefix(2)
|
||
guard !visibleParts.isEmpty else {
|
||
return "••••:••••"
|
||
}
|
||
|
||
return "\(visibleParts.joined(separator: ":")):••••:••••"
|
||
}
|
||
}
|
||
|
||
extension String {
|
||
var nilIfEmpty: String? {
|
||
isEmpty ? nil : self
|
||
}
|
||
}
|