feat: add third-party proxy mode and improve onboarding

This commit is contained in:
xweiba
2026-08-06 19:20:25 +08:00
parent e86909cc2f
commit b52d72bef2
36 changed files with 1946 additions and 218 deletions
+12 -3
View File
@@ -29,19 +29,28 @@ final class BackgroundKeepAlive {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
RuntimeLogger.error("APP", "KeepAlive", "音频会话失败", error: error)
isActive = false
return
}
let eng = AVAudioEngine()
let player = AVAudioPlayerNode()
eng.attach(player)
let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)!
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3)!
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1),
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3) else {
RuntimeLogger.error("APP", "KeepAlive", "无法创建静音音频缓冲区")
isActive = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
return
}
buf.frameLength = 44100 * 3
eng.connect(player, to: eng.mainMixerNode, format: fmt)
eng.prepare()
do { try eng.start() } catch {
RuntimeLogger.error("APP", "KeepAlive", "引擎启动失败", error: error)
isActive = false; return
isActive = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
return
}
player.scheduleBuffer(buf, at: nil, options: .loops)
player.play()
+52
View File
@@ -59,6 +59,23 @@ enum CoordinateConverter {
enum MapCoordinateSystem: String {
case gcj02 = "GCJ-02"
case wgs84 = "WGS-84"
var diagnosticName: String {
switch self {
case .gcj02: return "国内标准(GCJ-02)"
case .wgs84: return "国际标准(WGS-84)"
}
}
}
struct CoordinateRepresentationDiagnosis: Equatable {
let inferredSystem: MapCoordinateSystem?
let distanceToWGS84: CLLocationDistance
let distanceToGCJ02: CLLocationDistance
var inferredName: String {
inferredSystem?.diagnosticName ?? "无法判定"
}
}
// MARK: - 地图坐标标准
@@ -243,6 +260,41 @@ enum CoordinateConverter {
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
/// Identifies which stored representation a runtime sample most closely
/// resembles. Overseas identity pairs and unrelated samples stay ambiguous.
static func diagnoseRepresentation(
sample: CLLocationCoordinate2D,
pair: CoordinatePair,
maximumDistance: CLLocationDistance = 1_000,
minimumSeparation: CLLocationDistance = 30
) -> CoordinateRepresentationDiagnosis {
let distanceToWGS84 = distance(
lat1: sample.latitude,
lon1: sample.longitude,
lat2: pair.wgs84.latitude,
lon2: pair.wgs84.longitude
)
let distanceToGCJ02 = distance(
lat1: sample.latitude,
lon1: sample.longitude,
lat2: pair.gcj02.latitude,
lon2: pair.gcj02.longitude
)
let nearestDistance = min(distanceToWGS84, distanceToGCJ02)
let separation = abs(distanceToWGS84 - distanceToGCJ02)
let inferredSystem: MapCoordinateSystem?
if nearestDistance > maximumDistance || separation < minimumSeparation {
inferredSystem = nil
} else {
inferredSystem = distanceToWGS84 < distanceToGCJ02 ? .wgs84 : .gcj02
}
return CoordinateRepresentationDiagnosis(
inferredSystem: inferredSystem,
distanceToWGS84: distanceToWGS84,
distanceToGCJ02: distanceToGCJ02
)
}
// MARK: - 核心转换
/// GCJ-02 → WGS-84(迭代法,精度优于 0.5 米)
+49
View File
@@ -0,0 +1,49 @@
import Foundation
enum ProxyRuntimeMode: String, CaseIterable, Codable, Identifiable {
case localWiFi
case thirdParty
var id: String { rawValue }
var displayName: String {
switch self {
case .localWiFi: return "APP模式"
case .thirdParty: return "第三方代理模式"
}
}
}
@MainActor
final class ProxyRuntimeModeStore: ObservableObject {
static let shared = ProxyRuntimeModeStore()
private enum Key {
static let runtimeMode = "proxyRuntimeMode"
static let hasSelectedRuntimeMode = "hasSelectedProxyRuntimeMode"
}
@Published private(set) var mode: ProxyRuntimeMode
@Published private(set) var hasSelectedMode: Bool
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
self.mode = defaults.string(forKey: Key.runtimeMode)
.flatMap(ProxyRuntimeMode.init(rawValue:)) ?? .localWiFi
self.hasSelectedMode = defaults.bool(forKey: Key.hasSelectedRuntimeMode)
}
func setMode(_ mode: ProxyRuntimeMode) {
let changed = self.mode != mode
self.mode = mode
hasSelectedMode = true
defaults.set(mode.rawValue, forKey: Key.runtimeMode)
defaults.set(true, forKey: Key.hasSelectedRuntimeMode)
if changed {
RuntimeLogger.info("APP", "Mode", "代理运行模式已切换", details: [
"模式": mode.displayName
])
}
}
}
+264
View File
@@ -0,0 +1,264 @@
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"
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
@Published private(set) var isRequesting = false
private let requester: any ThirdPartyProxyRequesting
private let endpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
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: endpoint, 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 ? "当前可测试" : "配置已提供,尚未验证"
}
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)
}
}