import Darwin import Foundation import MacToolsPluginKit protocol IPOverviewProviding: Sendable { func collectSnapshot() async -> IPOverviewSnapshot func collectPublicIPSnapshot(preserving snapshot: IPOverviewSnapshot) async -> IPOverviewSnapshot } protocol IPOverviewHTTPClient: Sendable { func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) } extension URLSession: IPOverviewHTTPClient { func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { let (data, response) = try await data(for: request, delegate: nil) guard let httpResponse = response as? HTTPURLResponse else { throw IPOverviewServiceError.invalidResponse } return (data, httpResponse) } } enum IPOverviewServiceError: LocalizedError { case invalidResponse case badStatus(Int) case invalidPayload case invalidIP(String) var errorDescription: String? { localizedDescription() } func localizedDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { switch self { case .invalidResponse: return localization.string("service.error.invalidResponse", defaultValue: "响应无效") case .badStatus(let statusCode): return "HTTP \(statusCode)" case .invalidPayload: return localization.string("service.error.invalidPayload", defaultValue: "数据格式不正确") case .invalidIP(let value): return localization.format("service.error.invalidIP", defaultValue: "IP 无效:%@", value) } } } struct IPOverviewService: IPOverviewProviding { private let httpClient: any IPOverviewHTTPClient private let timeout: TimeInterval private let localization: PluginLocalization init( httpClient: any IPOverviewHTTPClient = URLSession.shared, timeout: TimeInterval = 4, localization: PluginLocalization = PluginLocalization(bundle: .main) ) { self.httpClient = httpClient self.timeout = timeout self.localization = localization } func collectSnapshot() async -> IPOverviewSnapshot { async let localAddresses = Self.localAddresses() async let domesticIPv4Results = fetchPublicIP(route: .domestic, family: .ipv4) async let domesticIPv6Results = fetchPublicIP(route: .domestic, family: .ipv6) async let internationalIPv4Results = fetchPublicIP(route: .international, family: .ipv4) async let internationalIPv6Results = fetchPublicIP(route: .international, family: .ipv6) let local = await localAddresses let domesticIPv4 = await domesticIPv4Results let domesticIPv6 = await domesticIPv6Results let internationalIPv4 = await internationalIPv4Results let internationalIPv6 = await internationalIPv6Results let sourceResults = domesticIPv4.results + domesticIPv6.results + internationalIPv4.results + internationalIPv6.results let domesticIPv4Result = domesticIPv4.best let domesticIPv6Result = domesticIPv6.best let internationalIPv4Result = internationalIPv4.best let internationalIPv6Result = internationalIPv6.best var geoInfoByIP: [String: IPOverviewGeoInfo] = [:] for result in [ domesticIPv4Result, domesticIPv6Result, internationalIPv4Result, internationalIPv6Result ].compactMap({ $0 }) { if let geoInfo = await fetchGeoInfo(ip: result.ip) { geoInfoByIP[result.ip] = geoInfo } } let errorMessage: String? if domesticIPv4Result == nil && domesticIPv6Result == nil && internationalIPv4Result == nil && internationalIPv6Result == nil { errorMessage = localization.string( "service.error.noPublicIP", defaultValue: "未能从外部检测源获取公网 IP" ) } else { errorMessage = nil } return IPOverviewSnapshot( domesticIPv4: domesticIPv4Result, domesticIPv6: domesticIPv6Result, internationalIPv4: internationalIPv4Result, internationalIPv6: internationalIPv6Result, localAddresses: local, geoInfoByIP: geoInfoByIP, sourceResults: sourceResults, lastUpdated: Date(), errorMessage: errorMessage, isRefreshing: false ) } func collectPublicIPSnapshot(preserving snapshot: IPOverviewSnapshot) async -> IPOverviewSnapshot { async let domesticIPv4Results = fetchPublicIP(route: .domestic, family: .ipv4) async let domesticIPv6Results = fetchPublicIP(route: .domestic, family: .ipv6) async let internationalIPv4Results = fetchPublicIP(route: .international, family: .ipv4) async let internationalIPv6Results = fetchPublicIP(route: .international, family: .ipv6) let domesticIPv4 = await domesticIPv4Results let domesticIPv6 = await domesticIPv6Results let internationalIPv4 = await internationalIPv4Results let internationalIPv6 = await internationalIPv6Results let sourceResults = domesticIPv4.results + domesticIPv6.results + internationalIPv4.results + internationalIPv6.results let domesticIPv4Result = domesticIPv4.best let domesticIPv6Result = domesticIPv6.best let internationalIPv4Result = internationalIPv4.best let internationalIPv6Result = internationalIPv6.best let publicIPs = Set([ domesticIPv4Result?.ip, domesticIPv6Result?.ip, internationalIPv4Result?.ip, internationalIPv6Result?.ip ].compactMap(\.self)) let errorMessage: String? if domesticIPv4Result == nil && domesticIPv6Result == nil && internationalIPv4Result == nil && internationalIPv6Result == nil { errorMessage = localization.string( "service.error.noPublicIP", defaultValue: "未能从外部检测源获取公网 IP" ) } else { errorMessage = nil } return IPOverviewSnapshot( domesticIPv4: domesticIPv4Result, domesticIPv6: domesticIPv6Result, internationalIPv4: internationalIPv4Result, internationalIPv6: internationalIPv6Result, localAddresses: snapshot.localAddresses, geoInfoByIP: snapshot.geoInfoByIP.filter { publicIPs.contains($0.key) }, sourceResults: sourceResults, lastUpdated: Date(), errorMessage: errorMessage, isRefreshing: false ) } private func fetchPublicIP( route: IPOverviewEgressRoute, family: IPOverviewAddressFamily ) async -> (best: IPOverviewPublicIPResult?, results: [IPOverviewSourceResult]) { let sources = IPOverviewPublicIPSource.sources(route: route, family: family) let orderByID = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.element.id, $0.offset) }) let unorderedResults = await withTaskGroup(of: IPOverviewSourceResult.self) { group in for source in sources { group.addTask { do { let ip = try await fetchIP(from: source) return IPOverviewSourceResult( id: source.id, family: family, route: route, source: source.name, status: .success(ip) ) } catch { let message = (error as? IPOverviewServiceError)? .localizedDescription(localization: localization) ?? error.localizedDescription return IPOverviewSourceResult( id: source.id, family: family, route: route, source: source.name, status: .failure(message) ) } } } var results: [IPOverviewSourceResult] = [] for await result in group { results.append(result) } return results } let results = unorderedResults.sorted { (orderByID[$0.id] ?? Int.max) < (orderByID[$1.id] ?? Int.max) } let best = results.lazy.compactMap { sourceResult -> IPOverviewPublicIPResult? in guard case .success(let ip) = sourceResult.status else { return nil } return IPOverviewPublicIPResult( family: family, route: route, ip: ip, source: sourceResult.source ) }.first return (best, results) } private func fetchIP(from source: IPOverviewPublicIPSource) async throws -> String { let (data, response) = try await fetch(source.url) guard (200..<300).contains(response.statusCode) else { throw IPOverviewServiceError.badStatus(response.statusCode) } let ip: String? switch source.parser { case .jsonIP: ip = IPOverviewParser.ipFromJSON(data) case .cloudflareTrace: ip = IPOverviewParser.ipFromCloudflareTrace(data) case .plainText: ip = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) case .bilibiliIPService: ip = IPOverviewParser.ipFromBilibiliIPService(data) case .cipText: ip = IPOverviewParser.ipFromCIPText(data) case .ipipText: ip = IPOverviewParser.ipFromIPIPText(data) } guard let ip else { throw IPOverviewServiceError.invalidPayload } guard Self.isValidIPAddress(ip, family: source.family) else { throw IPOverviewServiceError.invalidIP(ip) } return ip } private func fetchGeoInfo(ip: String) async -> IPOverviewGeoInfo? { for source in IPOverviewGeoSource.allCases { do { let (data, response) = try await fetch(source.url(ip: ip)) guard (200..<300).contains(response.statusCode) else { continue } switch source { case .ipwhois: if let info = IPOverviewParser.geoInfoFromIPWhois(data, ip: ip) { return info } case .ipapi: if let info = IPOverviewParser.geoInfoFromIPAPI(data, ip: ip) { return info } } } catch { continue } } return nil } private func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) { var request = URLRequest(url: url) request.timeoutInterval = timeout request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.setValue("MacTools IP Overview", forHTTPHeaderField: "User-Agent") return try await httpClient.data(for: request) } static func localAddresses() -> [IPOverviewLocalAddress] { var interfaceAddresses: UnsafeMutablePointer? guard getifaddrs(&interfaceAddresses) == 0, let firstAddress = interfaceAddresses else { return [] } defer { freeifaddrs(interfaceAddresses) } var addresses: [IPOverviewLocalAddress] = [] var pointer: UnsafeMutablePointer? = firstAddress while let currentPointer = pointer { defer { pointer = currentPointer.pointee.ifa_next } let name = String(cString: currentPointer.pointee.ifa_name) guard !isNoiseInterface(name), (currentPointer.pointee.ifa_flags & UInt32(IFF_UP)) != 0, let socketAddress = currentPointer.pointee.ifa_addr else { continue } let family: IPOverviewAddressFamily switch Int32(socketAddress.pointee.sa_family) { case AF_INET: family = .ipv4 case AF_INET6: family = .ipv6 default: continue } guard let address = numericAddress(from: socketAddress), isUsableLocalAddress(address, family: family) else { continue } addresses.append(IPOverviewLocalAddress( id: "\(name)-\(address)", interfaceName: name, address: address, family: family )) } return addresses.sorted { if $0.interfaceName == $1.interfaceName { return $0.address < $1.address } return $0.interfaceName < $1.interfaceName } } static func isValidIPAddress(_ value: String, family: IPOverviewAddressFamily? = nil) -> Bool { var ipv4 = in_addr() var ipv6 = in6_addr() switch family { case .ipv4: return inet_pton(AF_INET, value, &ipv4) == 1 case .ipv6: return inet_pton(AF_INET6, value, &ipv6) == 1 case nil: return inet_pton(AF_INET, value, &ipv4) == 1 || inet_pton(AF_INET6, value, &ipv6) == 1 } } private static func numericAddress(from pointer: UnsafePointer) -> String? { var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) let result = getnameinfo( pointer, socklen_t(pointer.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST ) guard result == 0 else { return nil } let nullIndex = host.firstIndex(of: 0) ?? host.endIndex let bytes = host[.. Bool { switch family { case .ipv4: return !address.hasPrefix("127.") && address != "0.0.0.0" case .ipv6: return !address.hasPrefix("::1") && !address.lowercased().hasPrefix("fe80") } } private static func isNoiseInterface(_ name: String) -> Bool { let lowercasedName = name.lowercased() let noisePrefixes = ["lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi"] return noisePrefixes.contains { lowercasedName.hasPrefix($0) } } } struct IPOverviewPublicIPSource: Sendable { enum Parser: Sendable { case jsonIP case cloudflareTrace case plainText case bilibiliIPService case cipText case ipipText } let id: String let name: String let route: IPOverviewEgressRoute let family: IPOverviewAddressFamily let url: URL let parser: Parser static func sources( route: IPOverviewEgressRoute, family: IPOverviewAddressFamily ) -> [IPOverviewPublicIPSource] { switch (route, family) { case (.domestic, .ipv4): return [ IPOverviewPublicIPSource( id: "bilibili-v4", name: "Bilibili IPv4", route: .domestic, family: .ipv4, url: URL(string: "https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr")!, parser: .bilibiliIPService ), IPOverviewPublicIPSource( id: "cip-v4", name: "cip.cc IPv4", route: .domestic, family: .ipv4, url: URL(string: "https://cip.cc")!, parser: .cipText ), IPOverviewPublicIPSource( id: "ipip-v4", name: "IPIP IPv4", route: .domestic, family: .ipv4, url: URL(string: "https://myip.ipip.net")!, parser: .ipipText ), IPOverviewPublicIPSource( id: "netart-v4", name: "NetArt IPv4", route: .domestic, family: .ipv4, url: URL(string: "https://ipv4.netart.cn")!, parser: .jsonIP ) ] case (.domestic, .ipv6): return [ IPOverviewPublicIPSource( id: "ddnspod-v6", name: "DNSPod IPv6", route: .domestic, family: .ipv6, url: URL(string: "https://ipv6.ddnspod.com")!, parser: .plainText ), IPOverviewPublicIPSource( id: "netart-v6", name: "NetArt IPv6", route: .domestic, family: .ipv6, url: URL(string: "https://ipv6.netart.cn")!, parser: .jsonIP ) ] case (.international, .ipv4): return [ IPOverviewPublicIPSource( id: "ipcheck-v4-json", name: "IPCheck.ing IPv4", route: .international, family: .ipv4, url: URL(string: "https://4.ipcheck.ing")!, parser: .jsonIP ), IPOverviewPublicIPSource( id: "ipcheck-v4-trace", name: "IPCheck.ing Trace IPv4", route: .international, family: .ipv4, url: URL(string: "https://4.ipcheck.ing/cdn-cgi/trace")!, parser: .cloudflareTrace ), IPOverviewPublicIPSource( id: "ipify-v4", name: "IPify IPv4", route: .international, family: .ipv4, url: URL(string: "https://api4.ipify.org?format=json")!, parser: .jsonIP ), IPOverviewPublicIPSource( id: "ifconfig-v4", name: "ifconfig.me IPv4", route: .international, family: .ipv4, url: URL(string: "https://ifconfig.me/ip")!, parser: .plainText ) ] case (.international, .ipv6): return [ IPOverviewPublicIPSource( id: "ipcheck-v6-json", name: "IPCheck.ing IPv6", route: .international, family: .ipv6, url: URL(string: "https://6.ipcheck.ing")!, parser: .jsonIP ), IPOverviewPublicIPSource( id: "ipcheck-v6-trace", name: "IPCheck.ing Trace IPv6", route: .international, family: .ipv6, url: URL(string: "https://6.ipcheck.ing/cdn-cgi/trace")!, parser: .cloudflareTrace ), IPOverviewPublicIPSource( id: "ipify-v6", name: "IPify IPv6", route: .international, family: .ipv6, url: URL(string: "https://api6.ipify.org?format=json")!, parser: .jsonIP ) ] } } } enum IPOverviewGeoSource: CaseIterable { case ipwhois case ipapi func url(ip: String) -> URL { switch self { case .ipwhois: return url(ip: ip, languageCode: IPOverviewLocale.geoLanguageCode()) case .ipapi: return URL(string: "https://ipapi.co/\(ip)/json/")! } } func url(ip: String, languageCode: String?) -> URL { switch self { case .ipwhois: var components = URLComponents(string: "https://ipwho.is/\(ip)")! if let languageCode { components.queryItems = [URLQueryItem(name: "lang", value: languageCode)] } return components.url! case .ipapi: return URL(string: "https://ipapi.co/\(ip)/json/")! } } } enum IPOverviewLocale { static func geoLanguageCode() -> String? { let preferredLocalization = PluginRuntimeLocalization.preferredLanguages.first ?? Locale.current.identifier return geoLanguageCode(preferredLocalization: preferredLocalization) } static func geoLanguageCode(preferredLocalization: String) -> String? { let normalizedLocalization = preferredLocalization .replacingOccurrences(of: "_", with: "-") .lowercased() if normalizedLocalization.hasPrefix("zh") { return "zh-CN" } if normalizedLocalization.hasPrefix("en") { return "en" } return nil } } enum IPOverviewParser { static func ipFromJSON(_ data: Data) -> String? { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let ip = object["ip"] as? String else { return nil } return ip.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } static func ipFromCloudflareTrace(_ data: Data) -> String? { guard let text = String(data: data, encoding: .utf8) else { return nil } return text .split(separator: "\n") .first { $0.hasPrefix("ip=") } .map { String($0.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) }? .nilIfEmpty } static func ipFromBilibiliIPService(_ data: Data) -> String? { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let data = object["data"] as? [String: Any], let ip = data["addr"] as? String else { return nil } return ip.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } static func ipFromCIPText(_ data: Data) -> String? { guard let text = String(data: data, encoding: .utf8) else { return nil } return text .split(separator: "\n") .lazy .compactMap { line -> String? in let parts = line.split(separator: ":", maxSplits: 1) guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "ip" else { return nil } return String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } .first } static func ipFromIPIPText(_ data: Data) -> String? { guard let text = String(data: data, encoding: .utf8) else { return nil } let pattern = #"(?:(?:当前\s*)?IP|Ip)\s*[::]\s*([0-9A-Fa-f:.]+)"# guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch( in: text, range: NSRange(text.startIndex.. IPOverviewGeoInfo? { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } if let success = object["success"] as? Bool, success == false { return nil } let connection = object["connection"] as? [String: Any] let timezone = object["timezone"] as? [String: Any] let asnValue = connection?["asn"].map { value in if let intValue = value as? Int { return "AS\(intValue)" } return "\(value)" } return IPOverviewGeoInfo( ip: ip, country: object["country"] as? String, countryCode: object["country_code"] as? String, region: object["region"] as? String, city: object["city"] as? String, isp: connection?["isp"] as? String, organization: connection?["org"] as? String, asn: asnValue, timezone: timezone?["id"] as? String, networkType: IPOverviewNetworkType.infer( organization: connection?["org"] as? String, isp: connection?["isp"] as? String, isHosting: nil ), isProxy: nil, isHosting: nil, source: "ipwho.is" ) } static func geoInfoFromIPAPI(_ data: Data, ip: String) -> IPOverviewGeoInfo? { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } if object["error"] as? Bool == true { return nil } let asn = object["asn"] as? String let organization = object["org"] as? String return IPOverviewGeoInfo( ip: ip, country: object["country_name"] as? String, countryCode: object["country_code"] as? String, region: object["region"] as? String, city: object["city"] as? String, isp: organization, organization: organization, asn: asn?.hasPrefix("AS") == true ? asn : asn.map { "AS\($0)" }, timezone: object["timezone"] as? String, networkType: IPOverviewNetworkType.infer( organization: organization, isp: organization, isHosting: nil ), isProxy: nil, isHosting: nil, source: "ipapi.co" ) } }