Files
location-spoofer/Shared/ThirdPartyProxyManager.swift
T

265 lines
9.8 KiB
Swift

import Foundation
struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
let success: Bool
let longitude: Double?
let latitude: Double?
let accuracy: Int?
let error: String?
}
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)
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)"
}
}
}
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")!
@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 {
let wgs84 = favorite.coordinatePair.wgs84
let response = try await perform(action: .save(
latitude: wgs84.latitude,
longitude: wgs84.longitude,
accuracy: favorite.accuracy
))
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 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)
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):
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))
]
}
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"
}
}
var subscriptionURL: URL {
let url: String
switch self {
case .surge, .egern:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule"
case .quantumultX:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf"
case .loon:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx"
case .stash:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride"
case .shadowrocket:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/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)
}
}