项目文件夹

文件
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

1089 行
33 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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
}
}