import Foundation struct ThirdPartyProxySettingsResponse: Decodable, Equatable { let success: Bool let longitude: Double? let latitude: Double? let accuracy: Int? let error: String? let motionSimulationEnabled: Bool? } struct ThirdPartyProxyVersionResponse: Decodable, Equatable { let success: Bool let moduleVersion: String let protocolVersion: Int let capabilities: Set static let requiredCapabilities: Set = [ "wifi", "cellTower", "arpc", "marker", "synthetic", "bare", "motionSimulation" ] var isCompatible: Bool { success && protocolVersion >= 1 && capabilities.isSuperset(of: Self.requiredCapabilities) } } enum ThirdPartyProxyConnectionState: Equatable { case unknown case connected(active: Bool) case failed(String) } enum ThirdPartyProxyError: LocalizedError, Equatable { case invalidResponse case moduleNotIntercepted case rejected(String) case coordinateMismatch case network(String) case moduleOutdated var errorDescription: String? { switch self { case .invalidResponse: return "第三方代理返回了无法识别的数据" case .moduleNotIntercepted: return "请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接" case .rejected(let message): return message case .coordinateMismatch: return "第三方代理保存的坐标与当前选点不一致" case .network(let message): return "第三方代理请求失败:\(message)" case .moduleOutdated: return "模块版本过低,请重新导入模块" } } } protocol ThirdPartyProxyRequesting { func data(for request: URLRequest) async throws -> (Data, URLResponse) } extension URLSession: ThirdPartyProxyRequesting {} @MainActor final class ThirdPartyProxyManager: ObservableObject { static let shared = ThirdPartyProxyManager() static let interceptionHostname = "gs-loc.apple.com" static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")! static let versionEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/version")! @Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown @Published private(set) var activeSettings: ThirdPartyProxySettingsResponse? @Published private(set) var isRequesting = false private let requester: any ThirdPartyProxyRequesting init(requester: (any ThirdPartyProxyRequesting)? = nil) { if let requester { self.requester = requester } else { let configuration = URLSessionConfiguration.ephemeral configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData configuration.urlCache = nil configuration.timeoutIntervalForRequest = 8 configuration.timeoutIntervalForResource = 10 self.requester = URLSession(configuration: configuration) } } func query() async throws -> ThirdPartyProxySettingsResponse { let response = try await perform(action: .query) if response.success, response.latitude != nil, response.longitude != nil { activeSettings = response connectionState = .connected(active: true) } else if response.error?.contains("无已保存") == true { activeSettings = nil connectionState = .connected(active: false) } else { let error = ThirdPartyProxyError.rejected(response.error ?? "第三方代理查询失败") connectionState = .failed(error.localizedDescription) throw error } return response } func save(_ favorite: FavoriteLocation) async throws -> ThirdPartyProxySettingsResponse { _ = try await validateVersion() let wgs84 = favorite.coordinatePair.wgs84 let response = try await perform(action: .save( latitude: wgs84.latitude, longitude: wgs84.longitude, accuracy: favorite.accuracy, motionEnabled: MotionSimulationStore.shared.isEnabled )) guard response.success else { throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标") } guard let latitude = response.latitude, let longitude = response.longitude, abs(latitude - wgs84.latitude) <= 0.000_001, abs(longitude - wgs84.longitude) <= 0.000_001 else { throw ThirdPartyProxyError.coordinateMismatch } activeSettings = response connectionState = .connected(active: true) RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理已保存 WGS-84 坐标", details: [ "坐标标准": "WGS-84", "取值字段": "coordinatePair.wgs84", "accuracy": String(favorite.accuracy) ]) return response } func updateMotionSimulation(_ enabled: Bool) async throws -> ThirdPartyProxySettingsResponse { guard let current = activeSettings, let latitude = current.latitude, let longitude = current.longitude else { throw ThirdPartyProxyError.rejected("第三方虚拟定位尚未开启") } _ = try await validateVersion() let response = try await perform(action: .save( latitude: latitude, longitude: longitude, accuracy: current.accuracy ?? 25, motionEnabled: enabled )) guard response.success else { throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝更新运动状态") } activeSettings = response return response } func validateVersion() async throws -> ThirdPartyProxyVersionResponse { guard !isRequesting else { throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行") } isRequesting = true defer { isRequesting = false } var request = URLRequest(url: Self.versionEndpoint) request.httpMethod = "GET" request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.timeoutInterval = 8 do { let (data, response) = try await requester.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200, let version = try? JSONDecoder().decode(ThirdPartyProxyVersionResponse.self, from: data), version.isCompatible else { throw ThirdPartyProxyError.moduleOutdated } RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方模块版本检测通过", details: [ "模块版本": version.moduleVersion, "协议版本": String(version.protocolVersion), "能力": version.capabilities.sorted().joined(separator: ",") ]) return version } catch let error as ThirdPartyProxyError { throw error } catch { throw ThirdPartyProxyError.network(error.localizedDescription) } } func clear() async throws { let response = try await perform(action: .clear) guard response.success else { throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理清除坐标失败") } activeSettings = nil connectionState = .connected(active: false) RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理坐标已清除") } private enum Action { case query case save(latitude: Double, longitude: Double, accuracy: Int, motionEnabled: Bool) case clear } private func perform(action: Action) async throws -> ThirdPartyProxySettingsResponse { guard !isRequesting else { throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行") } isRequesting = true defer { isRequesting = false } var components = URLComponents(url: Self.configurationEndpoint, resolvingAgainstBaseURL: false)! switch action { case .query: components.queryItems = [URLQueryItem(name: "action", value: "query")] case .clear: components.queryItems = [URLQueryItem(name: "action", value: "clear")] case .save(let latitude, let longitude, let accuracy, let motionEnabled): components.queryItems = [ URLQueryItem(name: "lon", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), longitude)), URLQueryItem(name: "lat", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), latitude)), URLQueryItem(name: "acc", value: String(accuracy)), URLQueryItem( name: "motion", value: motionEnabled ? "1" : "0" ) ] } guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse } var request = URLRequest(url: url) request.httpMethod = "GET" request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.timeoutInterval = 8 do { let (data, urlResponse) = try await requester.data(for: request) guard let http = urlResponse as? HTTPURLResponse, http.statusCode == 200 else { throw ThirdPartyProxyError.moduleNotIntercepted } guard let response = try? JSONDecoder().decode(ThirdPartyProxySettingsResponse.self, from: data) else { throw ThirdPartyProxyError.moduleNotIntercepted } return response } catch let error as ThirdPartyProxyError { connectionState = .failed(error.localizedDescription) RuntimeLogger.error("APP", "ThirdPartyProxy", "第三方代理请求失败", error: error) throw error } catch { let mapped = ThirdPartyProxyError.network(error.localizedDescription) connectionState = .failed(mapped.localizedDescription) RuntimeLogger.error("APP", "ThirdPartyProxy", "第三方代理请求失败", error: error) throw mapped } } } enum ThirdPartyProxyClient: String, CaseIterable, Identifiable { case shadowrocket case surge case quantumultX case loon case stash case egern var id: String { rawValue } var name: String { switch self { case .shadowrocket: return "Shadowrocket" case .surge: return "Surge" case .quantumultX: return "Quantumult X" case .loon: return "Loon" case .stash: return "Stash" case .egern: return "Egern" } } var verificationText: String? { self == .shadowrocket ? nil : "配置已提供,尚未验证" } var moduleFileName: String { switch self { case .shadowrocket: return "wloc.module" case .surge, .egern: return "wloc.sgmodule" case .quantumultX: return "wloc.conf" case .loon: return "wloc.lpx" case .stash: return "wloc.stoverride" } } @MainActor var subscriptionURL: URL { let url: String let directory = ThirdPartyModuleSourceStore.shared.useMirror ? "Resources/ThirdPartyProxyModules" : "ThirdParty/WlocScripts/modules/direct" let prefix = ThirdPartyModuleSourceStore.shared.useMirror ? "https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/" : "https://raw.githubusercontent.com/xweiba/location-spoofer/main/" switch self { case .surge, .egern: url = "\(prefix)\(directory)/wloc.sgmodule" case .quantumultX: url = "\(prefix)\(directory)/wloc.conf" case .loon: url = "\(prefix)\(directory)/wloc.lpx" case .stash: url = "\(prefix)\(directory)/wloc.stoverride" case .shadowrocket: url = "\(prefix)\(directory)/wloc.module" } return URL(string: url)! } var launchURL: URL? { switch self { case .shadowrocket: return URL(string: "shadowrocket://") case .surge: return URL(string: "surge://") case .quantumultX: return URL(string: "quantumult-x://") case .loon: return URL(string: "loon://") case .stash: return URL(string: "stash://") case .egern: return URL(string: "egern://") } } } @MainActor final class ThirdPartyProxyClientStore: ObservableObject { static let shared = ThirdPartyProxyClientStore() private enum Key { static let selectedClient = "selectedThirdPartyProxyClient" } @Published private(set) var selectedClient: ThirdPartyProxyClient private let defaults: UserDefaults init(defaults: UserDefaults = AppGroup.defaults) { self.defaults = defaults selectedClient = defaults.string(forKey: Key.selectedClient) .flatMap(ThirdPartyProxyClient.init(rawValue:)) ?? .shadowrocket } func select(_ client: ThirdPartyProxyClient) { selectedClient = client defaults.set(client.rawValue, forKey: Key.selectedClient) } }