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
+16 -3
View File
@@ -7,6 +7,8 @@ struct BugReportView: View {
@State private var isReproducible = true
@State private var isRunning = false
@State private var showCopiedAlert = false
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
var body: some View {
VStack(spacing: 0) {
@@ -85,9 +87,19 @@ struct BugReportView: View {
private func generateReport() {
isRunning = true
Task {
//
_ = await setup.runVerificationTest()
let testLog = setup.testLog
let testLog: String
if runtimeMode.mode == .thirdParty {
do {
let response = try await thirdPartyProxy.query()
let active = response.success && response.latitude != nil && response.longitude != nil
testLog = "第三方代理测试模式:模块连接成功;已保存坐标=\(active ? "" : "")"
} catch {
testLog = "第三方代理测试模式:模块连接失败;\(error.localizedDescription)"
}
} else {
_ = await setup.runVerificationTest()
testLog = setup.testLog
}
//
let appVersion: String = {
@@ -102,6 +114,7 @@ struct BugReportView: View {
### 环境信息
App 版本: \(appVersion)
系统版本: iOS \(systemVersion)
运行模式: \(runtimeMode.mode.displayName)
可复现环境: \(isReproducible ? "" : "")
### 问题描述
-8
View File
@@ -1,8 +0,0 @@
import Foundation
import UIKit
enum CertificateInstaller {
static func open(url: URL, completion: @escaping (Bool) -> Void) {
UIApplication.shared.open(url, options: [:], completionHandler: completion)
}
}
+52 -4
View File
@@ -2,10 +2,12 @@ import SwiftUI
struct ContentView: View {
@StateObject private var setup = SetupCoordinator()
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@State private var phase: AppPhase = .splash
@State private var verifiedDuringInitialSetup = false
@AppStorage("setupCompleted") private var setupCompleted = false
enum AppPhase { case splash, map }
enum AppPhase { case splash, setup, map }
var body: some View {
Group {
@@ -15,8 +17,13 @@ struct ContentView: View {
Image(systemName: "location.fill")
.font(.system(size: 48)).foregroundStyle(.blue)
ProgressView()
Text("正在初始化地图与本地代理…").font(.subheadline).foregroundStyle(.secondary)
Text(runtimeMode.hasSelectedMode && runtimeMode.mode == .localWiFi
? "正在初始化地图与本地代理…"
: "正在初始化地图…")
.font(.subheadline).foregroundStyle(.secondary)
}
case .setup:
FirstSetupView(setup: setup, onComplete: finishInitialSetup)
case .map:
NavigationView {
MapHomeView(setup: setup)
@@ -34,7 +41,35 @@ struct ContentView: View {
@MainActor
private func bootstrap() async {
await setup.prepareLocalServices()
guard runtimeMode.hasSelectedMode else {
ProxyManager.shared.stop()
RuntimeLogger.info("APP", "Startup", "尚未选择运行模式,跳过本地 CA 和代理初始化")
setup.requestModeSelection()
phase = .setup
return
}
let launchMode = runtimeMode.mode
guard setupCompleted else {
if launchMode == .localWiFi {
await setup.prepareLocalServices()
setup.requestSetup()
} else {
ProxyManager.shared.stop()
BackgroundKeepAlive.shared.stop()
setup.requestThirdPartySetup()
}
phase = .setup
return
}
if launchMode == .localWiFi {
await setup.prepareLocalServices()
} else {
ProxyManager.shared.stop()
setup.completeSetup()
RuntimeLogger.info("APP", "Startup", "第三方代理测试模式:跳过本地 CA、代理和环境检测")
}
do {
try CoordinateStorageMigration.migrateIfNeeded(favorites: FavoriteLocationStore())
} catch {
@@ -83,7 +118,12 @@ struct ContentView: View {
}
guard !Task.isCancelled else { return }
if setupCompleted {
if launchMode == .thirdParty {
setup.completeSetup()
} else if verifiedDuringInitialSetup {
verifiedDuringInitialSetup = false
setup.completeSetup()
} else if setupCompleted {
let result = await setup.runVerificationTest()
setup.applyVerificationResult(result)
} else {
@@ -92,4 +132,12 @@ struct ContentView: View {
RuntimeLogger.info("APP", "Startup", "启动门禁全部完成,现在创建 MapHomeView")
phase = .map
}
private func finishInitialSetup() {
verifiedDuringInitialSetup = runtimeMode.mode == .localWiFi
setupCompleted = true
setup.completeSetup()
phase = .splash
Task { await bootstrap() }
}
}
+44 -10
View File
@@ -7,6 +7,8 @@ struct RuntimeLogsView: View {
let testFavorite: FavoriteLocation
@Environment(\.dismiss) private var dismiss
@ObservedObject private var proxy = ProxyManager.shared
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@State private var entries: [RuntimeLogEntry] = []
@State private var isTesting = false
@State private var testResult = ""
@@ -97,10 +99,14 @@ struct RuntimeLogsView: View {
Button {
isTesting = true; testResult = ""; testLogCopied = false
Task {
let result = await setup.runVerificationTest()
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
if !result.isSuccess { testResult += ",查看下方日志" }
testMessage = setup.testLog
if runtimeMode.mode == .thirdParty {
await runThirdPartyConnectionTest()
} else {
let result = await setup.runVerificationTest()
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
if !result.isSuccess { testResult += ",查看下方日志" }
testMessage = setup.testLog
}
isTesting = false; refresh()
}
} label: {
@@ -110,7 +116,7 @@ struct RuntimeLogsView: View {
} else {
Image(systemName: "play.fill").font(.system(size: 13, weight: .bold))
}
Text(isTesting ? "正在测" : "虚拟定位测试").font(.subheadline.weight(.semibold))
Text(isTesting ? "正在测…" : "环境检测").font(.subheadline.weight(.semibold))
Spacer()
Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).opacity(0.5)
}
@@ -125,7 +131,9 @@ struct RuntimeLogsView: View {
.fill(isTesting ? Color.gray : Color.blue)
)
.disabled(isTesting || actions.state.isBusy)
Text("依次执行:代理 → 证书 → WiFi 代理 → 坐标写入 → 数据改写验证。")
Text(runtimeMode.mode == .thirdParty
? "检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。"
: "依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。")
.font(.caption).foregroundStyle(.secondary)
if !testMessage.isEmpty {
VStack(alignment: .leading, spacing: 6) {
@@ -170,10 +178,12 @@ struct RuntimeLogsView: View {
}.frame(maxHeight: 180)
}
}
HStack(spacing: 14) {
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
}.font(.caption).foregroundStyle(.secondary)
if runtimeMode.mode == .localWiFi {
HStack(spacing: 14) {
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
}.font(.caption).foregroundStyle(.secondary)
}
if !testResult.isEmpty {
Text(testResult).font(.footnote.weight(.medium))
.foregroundStyle(testResult.contains("通过") ? .green : .red)
@@ -224,4 +234,28 @@ struct RuntimeLogsView: View {
}
private func refresh() { entries = RuntimeLogStore.loadAll() }
@MainActor
private func runThirdPartyConnectionTest() async {
do {
let response = try await thirdPartyProxy.query()
let active = response.success && response.latitude != nil && response.longitude != nil
testResult = active ? "第三方模块连接通过,已有坐标" : "第三方模块连接通过,暂无坐标"
testMessage = """
======== 第三方代理连接检测 ========
模式: 测试模式
请求: wloc-settings/save?action=query
拦截响应: 有效 JSON
已保存坐标: \(active ? "" : "")
"""
} catch {
testResult = "第三方模块连接失败"
testMessage = """
======== 第三方代理连接检测 ========
模式: 测试模式
请求: wloc-settings/save?action=query
结果: \(error.localizedDescription)
"""
}
}
}
+352 -6
View File
@@ -1,13 +1,21 @@
import SwiftUI
enum SetupStep: Int, CaseIterable {
case mode
case proxy
case cert
case thirdPartyClient
case thirdPartyImport
case thirdPartyTest
var title: String {
switch self {
case .mode: return "选择模式"
case .proxy: return "配置 Wi-Fi 代理"
case .cert: return "初始化 CA 证书"
case .thirdPartyClient: return "选择客户端"
case .thirdPartyImport: return "导入配置"
case .thirdPartyTest: return "连接检测"
}
}
}
@@ -22,9 +30,16 @@ struct FirstSetupView: View {
@State private var trustedDone = false
@State private var result: VerificationResult?
@State private var isVerifying = false
@State private var isPreparingMode = false
@State private var manualHint = ""
@State private var setupActionError = ""
@State private var showDiagnostics = false
@StateObject private var diagnosticActions = LocationActionCoordinator()
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
@State private var copiedSubscriptionURL = false
@State private var copiedMITMHostname = false
init(setup: SetupCoordinator, onComplete: @escaping () -> Void) {
self.setup = setup
@@ -44,15 +59,25 @@ struct FirstSetupView: View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch step {
case .mode: modeStep
case .proxy: proxyStep
case .cert: certificateStep
case .thirdPartyClient: thirdPartyClientStep
case .thirdPartyImport: thirdPartyImportStep
case .thirdPartyTest: thirdPartyTestStep
}
if let result { resultView(result) }
}
.padding(20)
}
Divider()
primaryAction
VStack(spacing: 10) {
primaryAction
if step != .mode {
Button("上一步") { returnToPreviousStep() }
.disabled(isVerifying || thirdPartyProxy.isRequesting)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
@@ -73,19 +98,28 @@ struct FirstSetupView: View {
)) {
Button("知道了", role: .cancel) {}
} message: { Text(manualHint) }
.alert("操作失败", isPresented: Binding(
get: { !setupActionError.isEmpty },
set: { if !$0 { setupActionError = "" } }
)) {
Button("查看诊断日志") { showDiagnostics = true }
Button("知道了", role: .cancel) {}
} message: {
Text(setupActionError)
}
}
}
private var progress: some View {
HStack(spacing: 8) {
ForEach(SetupStep.allCases, id: \.rawValue) { value in
ForEach(visibleSteps, id: \.rawValue) { value in
HStack(spacing: 6) {
Circle()
.fill(value.rawValue <= step.rawValue ? Color.blue : Color.gray.opacity(0.3))
.frame(width: 10, height: 10)
Text(value.title).font(.caption).foregroundStyle(.secondary)
}
if value != SetupStep.allCases.last {
if value != visibleSteps.last {
Rectangle().fill(Color.gray.opacity(0.3)).frame(width: 28, height: 2)
}
}
@@ -93,6 +127,94 @@ struct FirstSetupView: View {
.padding(.vertical, 16)
}
private var visibleSteps: [SetupStep] {
switch step {
case .mode:
return [.mode]
case .proxy, .cert:
return [.mode, .proxy, .cert]
case .thirdPartyClient, .thirdPartyImport, .thirdPartyTest:
return [.mode, .thirdPartyClient, .thirdPartyImport, .thirdPartyTest]
}
}
private var modeStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("选择运行模式")
.font(.title2.bold())
Text("后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。")
.font(.footnote)
.foregroundStyle(.secondary)
modeCard(
title: "APP模式",
icon: "iphone.and.arrow.forward",
badges: ["仅 Wi-Fi", "无外部依赖"],
description: "App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。",
tint: .blue
) {
selectMode(.localWiFi)
}
.disabled(isPreparingMode)
modeCard(
title: "第三方代理模式",
icon: "network.badge.shield.half.filled",
badges: ["Wi-Fi + 4G/5G", "测试模式"],
description: "App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。",
tint: .orange
) {
selectMode(.thirdParty)
}
.disabled(isPreparingMode)
if isPreparingMode {
HStack(spacing: 8) {
ProgressView()
Text("正在准备 APP模式本地服务…")
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
}
private func modeCard(
title: String,
icon: String,
badges: [String],
description: String,
tint: Color,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 12) {
Label(title, systemImage: icon)
.font(.headline)
.foregroundStyle(tint)
HStack(spacing: 6) {
ForEach(badges, id: \.self) { badge in
Text(badge)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(tint.opacity(0.12), in: Capsule())
}
}
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(tint.opacity(0.25)))
}
.buttonStyle(.plain)
}
private var proxyStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("先配置 Wi-Fi 系统代理", systemImage: "wifi")) {
@@ -123,7 +245,14 @@ struct FirstSetupView: View {
actionTitle: "去下载",
actionIcon: "arrow.down.circle.fill",
complete: downloadedDone,
action: { Task { await setup.proxy.openCertificateDownload() } },
action: {
Task {
let opened = await setup.proxy.openCertificateDownload()
if !opened {
setupActionError = setup.proxy.error ?? "无法打开证书下载页面,请查看诊断日志"
}
}
},
markComplete: { downloadedDone = true }
)
certificateCard(
@@ -149,6 +278,171 @@ struct FirstSetupView: View {
}
}
private var thirdPartyClientStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("选择第三方代理客户端", systemImage: "app.badge.checkmark")) {
VStack(spacing: 0) {
ForEach(ThirdPartyProxyClient.allCases) { client in
Button {
thirdPartyClient.select(client)
} label: {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(client.name).foregroundStyle(.primary)
Text(client.verificationText)
.font(.caption2)
.foregroundStyle(client == .shadowrocket ? .green : .orange)
}
Spacer()
Image(systemName: thirdPartyClient.selectedClient == client ? "checkmark.circle.fill" : "circle")
.foregroundStyle(thirdPartyClient.selectedClient == client ? .blue : .secondary)
}
.padding(.vertical, 10)
}
.buttonStyle(.plain)
if client != ThirdPartyProxyClient.allCases.last { Divider() }
}
}
}
Text("Egern 直接使用 Surge 模块。Stash 直接订阅 .stoverride,不需要 Script Hub 转换。除 Shadowrocket 外,当前仅提供配置,尚未完成真机验证。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var thirdPartyImportStep: some View {
let client = thirdPartyClient.selectedClient
return VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("第 1 步:导入 \(client.name) 模块", systemImage: "square.and.arrow.down")) {
VStack(alignment: .leading, spacing: 12) {
Text(importInstructions(for: client))
.font(.caption)
.foregroundStyle(.secondary)
Button {
UIPasteboard.general.string = client.subscriptionURL.absoluteString
copiedSubscriptionURL = true
} label: {
Label(copiedSubscriptionURL ? "已复制订阅地址" : "复制订阅地址", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
openThirdPartyClient(client)
} label: {
Label("打开 \(client.name)", systemImage: "arrow.up.forward.app")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
if client == .shadowrocket {
shadowrocketHTTPSDecryptionGuide
} else {
Text("请复制订阅地址,在客户端的模块、重写或覆写订阅入口中添加。证书、MITM、VPN 和代理连接请按第三方客户端自己的流程配置。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
private var shadowrocketHTTPSDecryptionGuide: some View {
GroupBox(label: Label("第 2 步:配置 HTTPS 解密", systemImage: "lock.open")) {
VStack(alignment: .leading, spacing: 12) {
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
Button {
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostname
copiedMITMHostname = true
} label: {
Label(copiedMITMHostname ? "已复制 gs-loc.apple.com" : "复制解密域名", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
instructionRow(5, "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。")
Button {
openThirdPartyClient(.shadowrocket)
} label: {
Label("打开 Shadowrocket 继续配置", systemImage: "arrow.up.forward.app")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Text("App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private func instructionRow(_ number: Int, _ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("\(number)")
.font(.caption2.bold())
.foregroundStyle(.white)
.frame(width: 20, height: 20)
.background(Color.blue, in: Circle())
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func importInstructions(for client: ThirdPartyProxyClient) -> String {
if client == .shadowrocket {
return "先复制订阅地址。然后打开 Shadowrocket,进入“配置 → 模块”,点击右上角“+”,粘贴订阅地址并完成导入。导入完成后,模块配置由 Shadowrocket 保存,不依赖本 App 持续运行。"
}
return "先复制订阅地址,然后打开 \(client.name),在模块、重写或覆写订阅入口中粘贴并导入。配置由 \(client.name) 保存,不依赖本 App 持续运行。"
}
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
guard let url = client.launchURL else { return }
UIApplication.shared.open(url, options: [:]) { opened in
guard !opened else { return }
Task { @MainActor in
manualHint = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
}
}
}
private var thirdPartyTestStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("检测配置接口", systemImage: "network")) {
Text("请先在第三方客户端中启用刚导入的配置,并按客户端要求完成 MITM、证书和代理/VPN 连接。App 只调用 WLOC 查询接口确认模块能否正常响应,不检查或管理第三方证书。")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
}
private func returnToPreviousStep() {
result = nil
setupActionError = ""
switch step {
case .mode:
break
case .proxy, .thirdPartyClient:
step = .mode
case .cert:
step = .proxy
case .thirdPartyImport:
step = .thirdPartyClient
case .thirdPartyTest:
step = .thirdPartyImport
}
}
private func certificateCard(
title: String,
icon: String,
@@ -212,7 +506,9 @@ struct FirstSetupView: View {
@ViewBuilder
private var primaryAction: some View {
if step == .proxy {
if step == .mode {
EmptyView()
} else if step == .proxy {
Button {
verifyAfterProxyConfirmation()
} label: {
@@ -220,7 +516,7 @@ struct FirstSetupView: View {
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying)
} else {
} else if step == .cert {
Button {
verifyAfterCertificateConfirmation()
} label: {
@@ -228,6 +524,56 @@ struct FirstSetupView: View {
}
.buttonStyle(.borderedProminent)
.disabled(!certificateStepsComplete || isVerifying)
} else if step == .thirdPartyClient {
Button("下一步:导入配置") { step = .thirdPartyImport }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
} else if step == .thirdPartyImport {
Button("我已导入,下一步") { step = .thirdPartyTest }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
} else {
Button {
verifyThirdPartyConnection()
} label: {
actionLabel("检测接口连接")
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying || thirdPartyProxy.isRequesting)
}
}
private func selectMode(_ mode: ProxyRuntimeMode) {
guard !isPreparingMode else { return }
runtimeMode.setMode(mode)
result = nil
switch mode {
case .localWiFi:
isPreparingMode = true
Task { @MainActor in
await setup.prepareLocalServices()
isPreparingMode = false
step = .proxy
}
case .thirdParty:
setup.proxy.stop()
BackgroundKeepAlive.shared.stop()
step = .thirdPartyClient
}
}
private func verifyThirdPartyConnection() {
guard !isVerifying else { return }
isVerifying = true
result = nil
Task { @MainActor in
defer { isVerifying = false }
do {
_ = try await thirdPartyProxy.query()
onComplete()
} catch {
setupActionError = error.localizedDescription
}
}
}
+5 -1
View File
@@ -103,7 +103,11 @@ final class LocationActionCoordinator: ObservableObject {
enabled: true,
accuracy: favorite.accuracy
)
RuntimeLogger.info("APP", "坐标转换", "已向代理写入 WGS-84 坐标", details: [
RuntimeLogger.info("APP", "坐标转换", "设置虚拟定位坐标", details: [
"WLOC写入标准": CoordinateConverter.MapCoordinateSystem.wgs84.diagnosticName,
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"目标所在区域": CoordinateConverter.usesGCJ02ServiceArea(lat: wgs.latitude, lon: wgs.longitude) ? "国内转换区域" : "国外非转换区域",
"取值字段": "coordinatePair.wgs84",
"accuracy": String(favorite.accuracy)
])
state = .idle
+265 -55
View File
@@ -25,11 +25,33 @@ private struct RealtimeLocationRequestContext {
let showFailureAlert: Bool
}
private enum RealtimeCoordinateSource: Equatable {
case mapKitBluePoint
case coreLocation
@MainActor
var coordinateSystem: CoordinateConverter.MapCoordinateSystem {
switch self {
case .mapKitBluePoint: return CoordinateConverter.currentMapCoordinateSystem
case .coreLocation: return .wgs84
}
}
var diagnosticName: String {
switch self {
case .mapKitBluePoint: return "MapKit地图蓝点"
case .coreLocation: return "CLLocationManager"
}
}
}
struct MapHomeView: View {
@ObservedObject var setup: SetupCoordinator
@StateObject private var favorites: FavoriteLocationStore
@StateObject private var actions = LocationActionCoordinator()
@ObservedObject private var proxy = ProxyManager.shared
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@StateObject private var realtime = RealtimeLocationManager.shared
@StateObject private var mapState: MapLocationState
@ObservedObject private var net = NetworkMonitor.shared
@@ -63,6 +85,8 @@ struct MapHomeView: View {
// C
@State private var activeSpoofLat: Double?
@State private var activeSpoofLon: Double?
@State private var lastSpoofDiagnosisSystem: CoordinateConverter.MapCoordinateSystem?
@State private var hasLoggedSpoofDiagnosis = false
init(setup: SetupCoordinator) {
self.setup = setup
@@ -115,7 +139,8 @@ struct MapHomeView: View {
initialName: initialName
))
if let settings = WlocSettingsStore.load(), settings.enabled {
if ProxyRuntimeModeStore.shared.mode == .localWiFi,
let settings = WlocSettingsStore.load(), settings.enabled {
_spoofState = State(initialValue: .active)
_activeSpoofLat = State(initialValue: settings.latitude)
_activeSpoofLon = State(initialValue: settings.longitude)
@@ -145,7 +170,7 @@ struct MapHomeView: View {
zoomMeters: mapState.viewportMeters
)
favorites.select(nil)
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
scheduleGeocode(pair: pair, revision: revision)
},
onViewportChanged: { distance in
mapState.updateViewport(distanceMeters: distance)
@@ -161,7 +186,7 @@ struct MapHomeView: View {
coordinatePair: pair,
zoomMeters: mapState.viewportMeters
)
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
scheduleGeocode(pair: pair, revision: revision)
},
onUserZoomChanged: { distance in
ViewportStore.save(distance)
@@ -228,7 +253,7 @@ struct MapHomeView: View {
}
}
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind)
TipSheetView(kind: kind, runtimeMode: runtimeMode.mode)
}
.alert("无法直接跳转", isPresented: Binding(
get: { !manualHint.isEmpty },
@@ -240,7 +265,11 @@ struct MapHomeView: View {
startMapRuntimeOnce()
// ContentView performs the startup environment test before this map
// view is constructed. Do not immediately run it again here.
registerWiFiChangeObserver()
if runtimeMode.mode == .localWiFi {
registerWiFiChangeObserver()
} else {
refreshThirdPartyState()
}
}
.onDisappear {
if let token = wifiChangeObserverToken {
@@ -252,11 +281,31 @@ struct MapHomeView: View {
wifiVerificationID = nil
}
.onChange(of: proxy.isRunning) { running in
if !running && spoofState == .active {
if runtimeMode.mode == .localWiFi, !running && spoofState == .active {
spoofState = .idle
actions.clear()
}
}
.onChange(of: runtimeMode.mode) { mode in
locationOperationTask?.cancel()
locationOperationTask = nil
locationOperationID &+= 1
if let token = wifiChangeObserverToken {
net.removeWiFiChangeObserver(token)
wifiChangeObserverToken = nil
}
wifiVerificationTask?.cancel()
wifiVerificationTask = nil
activeSpoofLat = nil
activeSpoofLon = nil
if mode == .localWiFi {
spoofState = actions.virtualLocationEnabled ? .active : .idle
registerWiFiChangeObserver()
} else {
spoofState = .idle
refreshThirdPartyState()
}
}
.sheet(isPresented: $showEnableTip) { enableTipSheet }
.sheet(isPresented: $showDisableTip) { disableTipSheet }
.alert("定位失败", isPresented: $showLocationAlert) {
@@ -388,13 +437,13 @@ struct MapHomeView: View {
Spacer()
//
Button {
if actions.virtualLocationEnabled {
if spoofState == .active {
activeTip = .activation
} else {
activeTip = .deactivation
}
} label: {
Text(actions.virtualLocationEnabled ? "无法生效?" : "无法取消?")
Text(spoofState == .active ? "无法生效?" : "无法取消?")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
@@ -409,6 +458,11 @@ struct MapHomeView: View {
return
}
let snapshot = currentSelectionFavorite
RuntimeLogger.info("APP", "坐标转换", "保存当前选点为收藏", details: [
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"输入字段": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"持久化字段": "国际标准(WGS-84)+国内标准(GCJ-02)"
])
let favorite = favorites.save(
name: snapshot.name,
mapCoordinate: mapState.selection.coordinate,
@@ -491,6 +545,13 @@ struct MapHomeView: View {
}
private var buttonTitle: String {
if runtimeMode.mode == .thirdParty {
switch spoofState {
case .idle: return "同步到第三方代理"
case .verifying: return "检测并同步中…"
case .active: return "停止第三方虚拟定位"
}
}
switch spoofState {
case .idle: return "开始虚拟定位"
case .verifying: return "验证环境中…"
@@ -519,6 +580,7 @@ struct MapHomeView: View {
private func beginLocationOperation() {
guard spoofState != .verifying, locationOperationTask == nil else { return }
let wasActive = spoofState == .active
locationOperationID &+= 1
let operationID = locationOperationID
let selectionRevision = mapState.selection.revision
@@ -526,6 +588,39 @@ struct MapHomeView: View {
spoofState = .verifying
locationOperationTask = Task { @MainActor in
if runtimeMode.mode == .thirdParty {
do {
let response = try await thirdPartyProxy.save(target)
guard !Task.isCancelled,
operationID == locationOperationID,
runtimeMode.mode == .thirdParty else {
return
}
// The remote write has already succeeded. If the user moved
// the map meanwhile, keep this target active and let
// needsSwitchButton offer syncing the newer selection.
spoofState = .active
activeSpoofLat = response.latitude
activeSpoofLon = response.longitude
RuntimeLogger.info("APP", "定位", "第三方代理坐标同步成功", details: [
"坐标标准": "WGS-84",
"客户端模式": "测试模式",
"选点期间发生变化": String(selectionRevision != mapState.selection.revision)
])
presentSuccessfulOperationTip(.activation)
} catch {
guard operationID == locationOperationID else { return }
// A failed replacement does not clear the coordinate that
// was already persisted inside the third-party client.
spoofState = wasActive ? .active : .idle
manualHint = error.localizedDescription
}
if operationID == locationOperationID {
locationOperationTask = nil
}
return
}
let result = await setup.runVerificationTest()
guard !Task.isCancelled,
operationID == locationOperationID,
@@ -543,6 +638,8 @@ struct MapHomeView: View {
if applied {
activeSpoofLat = target.latitude
activeSpoofLon = target.longitude
lastSpoofDiagnosisSystem = nil
hasLoggedSpoofDiagnosis = false
}
RuntimeLogger.info("APP", "定位", "验证结果", details: [
"success": "true",
@@ -550,12 +647,7 @@ struct MapHomeView: View {
"spoofState": String(describing: spoofState)
])
if applied {
let count = tipPreferences.recordSuccessfulOperation(.activation)
RuntimeLogger.info("APP", "提醒", "累计开启虚拟定位次数", details: [
"次数": String(count),
"可显示不再提醒": String(tipPreferences.canSuppress(.activation))
])
showEnableTip = tipPreferences.shouldPresentAutomaticTip(.activation)
presentSuccessfulOperationTip(.activation)
}
} else {
spoofState = actions.virtualLocationEnabled ? .active : .idle
@@ -579,16 +671,48 @@ struct MapHomeView: View {
locationOperationTask?.cancel()
locationOperationTask = nil
locationOperationID &+= 1
if runtimeMode.mode == .thirdParty {
spoofState = .verifying
locationOperationTask = Task { @MainActor in
do {
try await thirdPartyProxy.clear()
spoofState = .idle
activeSpoofLat = nil
activeSpoofLon = nil
presentSuccessfulOperationTip(.deactivation)
} catch {
spoofState = .active
manualHint = error.localizedDescription
}
locationOperationTask = nil
}
return
}
actions.clear()
spoofState = .idle
activeSpoofLat = nil
activeSpoofLon = nil
let count = tipPreferences.recordSuccessfulOperation(.deactivation)
RuntimeLogger.info("APP", "提醒", "累计关闭虚拟定位次数", details: [
lastSpoofDiagnosisSystem = nil
hasLoggedSpoofDiagnosis = false
presentSuccessfulOperationTip(.deactivation)
}
private func presentSuccessfulOperationTip(_ kind: VirtualLocationTipKind) {
let count = tipPreferences.recordSuccessfulOperation(kind)
let operationName = kind == .activation ? "开启" : "关闭"
RuntimeLogger.info("APP", "提醒", "累计\(operationName)虚拟定位次数", details: [
"次数": String(count),
"可显示不再提醒": String(tipPreferences.canSuppress(.deactivation))
"运行模式": runtimeMode.mode.displayName,
"可显示不再提醒": String(tipPreferences.canSuppress(kind))
])
showDisableTip = tipPreferences.shouldPresentAutomaticTip(.deactivation)
guard tipPreferences.shouldPresentAutomaticTip(kind) else { return }
switch kind {
case .activation:
showEnableTip = true
case .deactivation:
showDisableTip = true
}
}
@@ -647,10 +771,7 @@ struct MapHomeView: View {
mapCoordinate: mapState.selection.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
)
scheduleGeocode(
coordinate: pair.wgs84.coordinate,
revision: mapState.selection.revision
)
scheduleGeocode(pair: pair, revision: mapState.selection.revision)
}
private func reprojectMapSelection(for change: CoordinateConverter.MapCoordinateSystemChange) {
@@ -669,12 +790,45 @@ struct MapHomeView: View {
}
private func registerWiFiChangeObserver() {
guard runtimeMode.mode == .localWiFi else { return }
guard wifiChangeObserverToken == nil else { return }
wifiChangeObserverToken = net.observeWiFiChanges { [self] reason in
handleWiFiChange(reason: reason)
}
}
private func refreshThirdPartyState() {
guard runtimeMode.mode == .thirdParty,
locationOperationTask == nil else { return }
locationOperationTask = Task { @MainActor in
do {
let response = try await thirdPartyProxy.query()
if response.success,
let latitude = response.latitude,
let longitude = response.longitude {
activeSpoofLat = latitude
activeSpoofLon = longitude
spoofState = .active
} else if response.error?.contains("无已保存") == true {
activeSpoofLat = nil
activeSpoofLon = nil
spoofState = .idle
} else {
spoofState = .idle
RuntimeLogger.warning("APP", "ThirdPartyProxy", "第三方代理查询返回失败", details: [
"错误": response.error ?? "未知错误"
])
}
} catch {
spoofState = .idle
RuntimeLogger.warning("APP", "ThirdPartyProxy", "启动后第三方代理状态查询失败", details: [
"错误": error.localizedDescription
])
}
locationOperationTask = nil
}
}
private func handleWiFiChange(reason: WiFiChangeReason) {
RuntimeLogger.info("APP", "WiFi", "检测到 Wi-Fi 网络变化", details: [
"原因": reason.rawValue,
@@ -694,9 +848,9 @@ struct MapHomeView: View {
wifiVerificationID = nil
}
}
let stabilizationNanoseconds: UInt64 = 5_000_000_000
let stabilizationNanoseconds: UInt64 = 3_000_000_000
RuntimeLogger.info("APP", "WiFi", "等待 Wi-Fi 连接稳定后检测", details: [
"等待秒数": "5",
"等待秒数": "3",
"事件原因": reason.rawValue
])
do {
@@ -776,7 +930,12 @@ struct MapHomeView: View {
"intentID": String(intent.id),
"来源": "MapLocationState.realtimeLocation"
])
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
acceptRealtimeLocation(
loc.coordinate,
intent: intent,
source: .mapKitBluePoint,
sourceDescription: "MapKit蓝点缓存"
)
return
}
RuntimeLogger.info("APP", "实时定位", "MapKit 蓝点尚不可用,启动 CLLocationManager 兜底", details: [
@@ -790,16 +949,16 @@ struct MapHomeView: View {
}
private func handleNativeRealtimeLocation(_ location: CLLocation) {
RealtimeLocationTrace.log("主页收到 MapKit 蓝点回调", location: location, details: [
"存在待处理请求": String(realtimeRequestContext != nil),
"当前选点revision": String(mapState.selection.revision)
])
mapState.updateRealtimeLocation(location)
logSpoofCoordinateDiagnosisIfNeeded(location)
guard let context = realtimeRequestContext else {
RuntimeLogger.debug("APP", "实时定位", "蓝点已缓存;当前没有待处理的实时定位意图")
return
}
RealtimeLocationTrace.log("主页收到待处理请求所需的 MapKit 蓝点回调", location: location, details: [
"当前选点revision": String(mapState.selection.revision)
])
// MKMapView's MKUserLocation is the visible blue point. When it arrives,
// fulfill the pending intent from that exact sample and cancel the slower
// CLLocationManager fallback so the camera and dot cannot disagree.
@@ -809,7 +968,42 @@ struct MapHomeView: View {
"intentID": String(context.intent.id),
"原兜底来源": context.source
])
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "蓝点(途中)→\(context.source)")
acceptRealtimeLocation(
location.coordinate,
intent: context.intent,
source: .mapKitBluePoint,
sourceDescription: "蓝点(途中)→\(context.source)"
)
}
private func logSpoofCoordinateDiagnosisIfNeeded(_ location: CLLocation) {
guard spoofState == .active,
let latitude = activeSpoofLat,
let longitude = activeSpoofLon else { return }
let targetPair = CoordinateConverter.coordinatePair(
lat: latitude,
lon: longitude,
mapCoordinateSystem: .wgs84
)
let diagnosis = CoordinateConverter.diagnoseRepresentation(
sample: location.coordinate,
pair: targetPair,
maximumDistance: max(1_000, location.horizontalAccuracy * 4),
minimumSeparation: max(30, location.horizontalAccuracy)
)
let shouldLog = !hasLoggedSpoofDiagnosis
|| diagnosis.inferredSystem != lastSpoofDiagnosisSystem
guard shouldLog else { return }
hasLoggedSpoofDiagnosis = true
lastSpoofDiagnosisSystem = diagnosis.inferredSystem
RuntimeLogger.info("APP", "坐标转换", "虚拟定位开启后的 MapKit 蓝点标准判定", details: [
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"WLOC目标标准": CoordinateConverter.MapCoordinateSystem.wgs84.diagnosticName,
"蓝点回调更接近": diagnosis.inferredName,
"蓝点距WGS目标米": String(format: "%.1f", diagnosis.distanceToWGS84),
"蓝点距GCJ目标米": String(format: "%.1f", diagnosis.distanceToGCJ02),
"日志策略": "每次开启首次或判定变化"
])
}
private func startRealtimeLocationRequest(
@@ -869,40 +1063,46 @@ struct MapHomeView: View {
])
return
}
acceptRealtimeLocation(coordinate, intent: context.intent, source: context.source)
acceptRealtimeLocation(
coordinate,
intent: context.intent,
source: .coreLocation,
sourceDescription: context.source
)
}
}
private func acceptRealtimeLocation(
_ coordinate: CLLocationCoordinate2D,
intent: RealtimeLocationIntent,
source: String
source: RealtimeCoordinateSource,
sourceDescription: String
) {
let currentViewport = mapState.viewportMeters
let previousMapCoordinateSystem = CoordinateConverter.currentMapCoordinateSystem
let usesDomesticStandard = CoordinateConverter.usesGCJ02ServiceArea(
lat: coordinate.latitude,
lon: coordinate.longitude
)
let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(coordinate)
let sourceCoordinateSystem = source.coordinateSystem
let mapCoordinateSystemChange = source == .coreLocation
? CoordinateConverter.correctMapCoordinateSystemUsingRealtime(coordinate)
: nil
if let change = mapCoordinateSystemChange {
reprojectMapSelection(for: change)
}
let pair = CoordinateConverter.coordinatePair(
lat: coordinate.latitude,
lon: coordinate.longitude,
mapCoordinateSystem: .wgs84
mapCoordinateSystem: sourceCoordinateSystem
)
let accepted = mapState.acceptRealtimeLocation(
pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
intent: intent
)
RuntimeLogger.info("APP", "实时定位", "实时定位坐标完成标准判断并提交到地图", details: [
"来源": source,
"来源": sourceDescription,
"来源类型": source.diagnosticName,
"输入坐标标准": sourceCoordinateSystem.diagnosticName,
"intentID": String(intent.id),
"intent选点revision": String(intent.selectionRevision),
"当前选点revision": String(mapState.selection.revision),
"服务区域使用GCJ": String(usesDomesticStandard),
"修正前地图标准": previousMapCoordinateSystem.rawValue,
"修正后地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"地图标准发生修正": String(mapCoordinateSystemChange != nil),
@@ -910,8 +1110,9 @@ struct MapHomeView: View {
"显示坐标字段": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"持久化字段": "WGS-84+GCJ-02"
])
RealtimeLocationTrace.coordinate("原始实时定位坐标(按 WGS-84", coordinate: coordinate, details: [
"来源": source
RealtimeLocationTrace.coordinate("原始实时定位坐标", coordinate: coordinate, details: [
"来源": sourceDescription,
"输入坐标标准": sourceCoordinateSystem.diagnosticName
])
RealtimeLocationTrace.coordinate("地图实际显示坐标", coordinate: pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem), details: [
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
@@ -919,13 +1120,13 @@ struct MapHomeView: View {
guard accepted else { return }
//
mapState.focusSelection(distanceMeters: currentViewport)
// Core Location is WGS-84; persist both forms once and render the active form.
// Persist both forms once from the explicitly typed input boundary.
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: currentViewport)
favorites.select(nil)
scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision)
scheduleGeocode(pair: pair, revision: mapState.selection.revision)
}
private func scheduleGeocode(coordinate: CLLocationCoordinate2D, revision: UInt64) {
private func scheduleGeocode(pair: CoordinatePair, revision: UInt64) {
geocodeDebounceTask?.cancel()
reverseGeocodeTask?.cancel()
geocodeDebounceTask = Task { @MainActor in
@@ -935,13 +1136,15 @@ struct MapHomeView: View {
return
}
guard !Task.isCancelled, mapState.selection.revision == revision else { return }
reverseGeocode(coordinate, revision: revision)
reverseGeocode(pair, revision: revision)
}
}
private func reverseGeocode(_ coordinate: CLLocationCoordinate2D, revision: UInt64) {
private func reverseGeocode(_ pair: CoordinatePair, revision: UInt64) {
reverseGeocodeTask?.cancel()
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let wgsCoordinate = pair.wgs84.coordinate
let mapCoordinate = pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
let location = CLLocation(latitude: wgsCoordinate.latitude, longitude: wgsCoordinate.longitude)
reverseGeocodeTask = Task { @MainActor in
let retryDelays: [UInt64] = [0, 800_000_000, 1_600_000_000]
var lastError: Error?
@@ -958,7 +1161,7 @@ struct MapHomeView: View {
// MKLocalSearch CLGeocoder try await
async let clPlacemarks = CLGeocoder().reverseGeocodeLocation(location)
let mkRequest = MKLocalSearch.Request()
mkRequest.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 400, longitudinalMeters: 400)
mkRequest.region = MKCoordinateRegion(center: mapCoordinate, latitudinalMeters: 400, longitudinalMeters: 400)
let mkResponse = try? await MKLocalSearch(request: mkRequest).start()
let placemarks = try await clPlacemarks
@@ -1077,6 +1280,11 @@ struct MapHomeView: View {
geocodeDebounceTask?.cancel()
reverseGeocodeTask?.cancel()
favorites.select(favorite.id)
RuntimeLogger.info("APP", "坐标转换", "点击收藏点并回显到地图", details: [
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"地图取值字段": CoordinateConverter.currentMapCoordinateSystem == .gcj02 ? "coordinatePair.gcj02" : "coordinatePair.wgs84",
"保存数据包含": "国际标准(WGS-84)+国内标准(GCJ-02)"
])
mapState.selectFavorite(
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
id: favorite.id,
@@ -1092,7 +1300,7 @@ struct MapHomeView: View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ActivationTipContent(dismiss: {})
ActivationTipContent(runtimeMode: runtimeMode.mode, dismiss: {})
}.padding(16)
}
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
@@ -1117,7 +1325,7 @@ struct MapHomeView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.buttonStyle(.borderedProminent)
.tint(.blue)
} else {
Button { showEnableTip = false } label: {
@@ -1138,8 +1346,10 @@ struct MapHomeView: View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
DeactivationTipContent(dismiss: {})
RemoveProxyTipContent(dismiss: {})
DeactivationTipContent(runtimeMode: runtimeMode.mode, dismiss: {})
if runtimeMode.mode == .localWiFi {
RemoveProxyTipContent(dismiss: {})
}
}.padding(16)
}
.navigationTitle("虚拟定位已关闭").navigationBarTitleDisplayMode(.inline)
@@ -1164,7 +1374,7 @@ struct MapHomeView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.buttonStyle(.borderedProminent)
.tint(.blue)
} else {
Button { showDisableTip = false } label: {
+19 -8
View File
@@ -239,7 +239,7 @@ struct MapViewRepresentable: UIViewRepresentable {
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard let location = userLocation.location else {
guard let location = visibleUserLocationSample(userLocation) else {
RuntimeLogger.warning("APP", "实时定位", "MapKit 蓝点更新但 location 为空", details: [
"来源": "MKMapView.didUpdate"
])
@@ -257,9 +257,6 @@ struct MapViewRepresentable: UIViewRepresentable {
], level: .warning)
return
}
RealtimeLocationTrace.log("收到 MapKit 可见蓝点样本", location: location, details: [
"来源": "MKMapView.didUpdate"
])
forwardRealtimeLocation(location)
}
@@ -291,12 +288,9 @@ struct MapViewRepresentable: UIViewRepresentable {
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
updatePinPosition(on: mapView)
// delegate mapState.realtimeLocation nil
if let ul = mapView.userLocation.location,
if let ul = visibleUserLocationSample(mapView.userLocation),
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
if lastForwardedRealtimeTimestamp.map({ ul.timestamp > $0 }) ?? true {
RealtimeLocationTrace.log("区域变化后同步到较新的 MapKit 蓝点样本", location: ul, details: [
"来源": "MKMapView.regionDidChange"
])
forwardRealtimeLocation(ul)
}
}
@@ -327,6 +321,23 @@ struct MapViewRepresentable: UIViewRepresentable {
parent.onRealtimeLocationChanged(location)
}
/// `MKUserLocation.coordinate` is the coordinate of the blue-point
/// annotation in the current map representation. Keep the native
/// accuracy/timestamp metadata, but do not replace it with the raw
/// Core Location coordinate carried by `location.coordinate`.
private func visibleUserLocationSample(_ userLocation: MKUserLocation) -> CLLocation? {
guard let native = userLocation.location else { return nil }
return CLLocation(
coordinate: userLocation.coordinate,
altitude: native.altitude,
horizontalAccuracy: native.horizontalAccuracy,
verticalAccuracy: native.verticalAccuracy,
course: native.course,
speed: native.speed,
timestamp: native.timestamp
)
}
private func visibleVerticalDistance(in map: MKMapView) -> CLLocationDistance {
let centerX = map.bounds.midX
let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map)
+19 -3
View File
@@ -119,15 +119,31 @@ final class ProxyManager: ObservableObject {
return (Double(r.r0), Double(r.r1), r.r2 != 0)
}
func openCertificateDownload() async {
@discardableResult
func openCertificateDownload() async -> Bool {
do {
if !isRunning { try await start() }
// CA
guard let url = URL(string: "http://127.0.0.1:8888/cert") else { return }
await MainActor.run { UIApplication.shared.open(url) }
guard let url = URL(string: "http://127.0.0.1:8888/cert") else {
error = "证书下载地址无效"
return false
}
let opened = await withCheckedContinuation { continuation in
UIApplication.shared.open(url, options: [:]) { accepted in
continuation.resume(returning: accepted)
}
}
guard opened else {
error = "系统无法打开证书下载页面,请稍后重试"
RuntimeLogger.warning("APP", "Certificate", "系统拒绝打开证书下载地址")
return false
}
error = nil
return true
} catch {
self.error = "启动代理失败: \(error.localizedDescription)"
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
return false
}
}
+247 -21
View File
@@ -4,26 +4,71 @@ struct SettingsView: View {
@ObservedObject var setup: SetupCoordinator
@ObservedObject var actions: LocationActionCoordinator
@ObservedObject private var proxy = ProxyManager.shared
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
@Environment(\.dismiss) private var dismiss
@State private var activeTip: TipKind?
@State private var proxyOperationError = ""
@State private var proxyOperationAlertTitle = "代理操作失败"
@State private var modeOperationRunning = false
@State private var copiedClient: ThirdPartyProxyClient?
var body: some View {
Form {
Section("状态") {
HStack {
Label("代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
Spacer()
Toggle("", isOn: proxyBinding).labelsHidden()
.tint(.blue)
Section("运行模式") {
Picker("模式", selection: runtimeModeBinding) {
ForEach(ProxyRuntimeMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
HStack {
Label("虚拟定位", systemImage: actions.virtualLocationEnabled ? "location.fill" : "location.slash")
Spacer()
Text(actions.virtualLocationEnabled ? "已开启" : "已关闭").foregroundStyle(.secondary)
.pickerStyle(.inline)
.disabled(modeOperationRunning || actions.state.isBusy || thirdPartyProxy.isRequesting)
if runtimeMode.mode == .thirdParty {
Label("测试模式:仅 Shadowrocket 当前可测试", systemImage: "testtube.2")
.font(.footnote)
.foregroundStyle(.orange)
}
}
Section("说明") {
Section("状态") {
if runtimeMode.mode == .localWiFi {
HStack {
Label("本机代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
Spacer()
Toggle("", isOn: proxyBinding).labelsHidden()
.tint(.blue)
.disabled(actions.state.isBusy)
}
} else {
HStack {
Label("第三方模块", systemImage: thirdPartyStatusIcon)
Spacer()
Text(thirdPartyStatusText).foregroundStyle(.secondary)
}
Button {
detectThirdPartyConnection()
} label: {
if thirdPartyProxy.isRequesting {
HStack { ProgressView(); Text("正在检测…") }
} else {
Label("检测连接", systemImage: "network")
}
}
.disabled(thirdPartyProxy.isRequesting)
}
HStack {
Label("虚拟定位", systemImage: virtualLocationIsActive ? "location.fill" : "location.slash")
Spacer()
Text(virtualLocationStatusText).foregroundStyle(.secondary)
}
}
if runtimeMode.mode == .thirdParty {
thirdPartyConfigurationSection
} else {
Section("说明") {
Button {
activeTip = .activation
} label: {
@@ -39,23 +84,22 @@ struct SettingsView: View {
} label: {
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
}
}
}
Section("工作原理") {
Text("""
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
""")
Text(workflowDescription)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("应用") {
Button {
setup.requestSetup()
} label: {
Label("进入引导页", systemImage: "arrow.clockwise.circle")
if runtimeMode.mode == .localWiFi {
Button {
setup.requestSetup()
} label: {
Label("进入引导页", systemImage: "arrow.clockwise.circle")
}
}
valueRow("版本", value: versionText)
}
@@ -100,6 +144,14 @@ struct SettingsView: View {
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind)
}
.alert(proxyOperationAlertTitle, isPresented: Binding(
get: { !proxyOperationError.isEmpty },
set: { if !$0 { proxyOperationError = "" } }
)) {
Button("知道了", role: .cancel) {}
} message: {
Text(proxyOperationError)
}
}
private func valueRow(_ title: String, value: String) -> some View {
@@ -116,11 +168,185 @@ struct SettingsView: View {
Binding(get: { proxy.isRunning }, set: { on in
Task {
if on {
do { try await proxy.start() } catch { proxy.error = error.localizedDescription }
do {
try await proxy.start()
} catch {
proxy.error = error.localizedDescription
proxyOperationAlertTitle = "代理操作失败"
proxyOperationError = error.localizedDescription
}
} else {
if actions.virtualLocationEnabled {
actions.clear()
RuntimeLogger.info("APP", "Settings", "关闭代理前已同步关闭虚拟定位")
}
proxy.stop()
}
}
})
}
private var runtimeModeBinding: Binding<ProxyRuntimeMode> {
Binding(
get: { runtimeMode.mode },
set: { newMode in switchRuntimeMode(to: newMode) }
)
}
@ViewBuilder
private var thirdPartyConfigurationSection: some View {
Section("第三方代理配置") {
Picker("客户端", selection: Binding(
get: { thirdPartyClient.selectedClient },
set: { thirdPartyClient.select($0) }
)) {
ForEach(ThirdPartyProxyClient.allCases) { client in
Text(client.name).tag(client)
}
}
HStack {
Text("验证状态")
Spacer()
Text(thirdPartyClient.selectedClient.verificationText)
.font(.footnote)
.foregroundStyle(thirdPartyClient.selectedClient == .shadowrocket ? .green : .orange)
}
Button {
UIPasteboard.general.string = thirdPartyClient.selectedClient.subscriptionURL.absoluteString
copiedClient = thirdPartyClient.selectedClient
} label: {
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制订阅链接" : "复制订阅链接", systemImage: "doc.on.doc")
}
Button {
openThirdPartyClient(thirdPartyClient.selectedClient)
} label: {
Label("打开 \(thirdPartyClient.selectedClient.name)", systemImage: "arrow.up.forward.app")
}
Button {
setup.requestThirdPartySetup()
dismiss()
} label: {
Label("重新打开配置引导", systemImage: "arrow.clockwise.circle")
}
if thirdPartyClient.selectedClient == .egern {
Text("Egern 直接使用 Surge 的 .sgmodule 模块。")
.font(.footnote).foregroundStyle(.secondary)
} else if thirdPartyClient.selectedClient == .stash {
Text("Stash 直接订阅 .stoverride,不要通过 Script Hub 转换。")
.font(.footnote).foregroundStyle(.secondary)
}
Text("复制链接后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
.font(.footnote).foregroundStyle(.secondary)
}
}
private var thirdPartyStatusIcon: String {
switch thirdPartyProxy.connectionState {
case .unknown: return "questionmark.circle"
case .connected: return "checkmark.circle.fill"
case .failed: return "xmark.circle.fill"
}
}
private var thirdPartyStatusText: String {
switch thirdPartyProxy.connectionState {
case .unknown: return "未检测"
case .connected(let active): return active ? "已连接,有坐标" : "已连接,无坐标"
case .failed: return "连接失败"
}
}
private var virtualLocationStatusText: String {
if runtimeMode.mode == .localWiFi {
return actions.virtualLocationEnabled ? "已开启" : "已关闭"
}
if case .connected(let active) = thirdPartyProxy.connectionState {
return active ? "第三方已保存" : "未保存"
}
return "未知"
}
private var workflowDescription: String {
if runtimeMode.mode == .thirdParty {
return "App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。"
}
return """
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
"""
}
private func switchRuntimeMode(to newMode: ProxyRuntimeMode) {
guard newMode != runtimeMode.mode, !modeOperationRunning else { return }
modeOperationRunning = true
Task { @MainActor in
defer { modeOperationRunning = false }
switch newMode {
case .thirdParty:
if actions.virtualLocationEnabled { actions.clear() }
proxy.stop()
setup.completeSetup()
runtimeMode.setMode(.thirdParty)
setup.requestThirdPartySetup()
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "已切换到第三方代理模式。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,并按引导导入第三方配置。"
case .localWiFi:
do {
try await thirdPartyProxy.clear()
} catch {
RuntimeLogger.warning("APP", "Mode", "切换 APP 模式前无法清除第三方坐标", details: [
"错误": error.localizedDescription
])
}
runtimeMode.setMode(.localWiFi)
await setup.prepareLocalServices()
setup.requestSetup()
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "已切换到 APP 模式。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
}
}
}
private func detectThirdPartyConnection() {
Task { @MainActor in
do {
let response = try await thirdPartyProxy.query()
if !response.success, response.error?.contains("无已保存") != true {
proxyOperationAlertTitle = "检测失败"
proxyOperationError = response.error ?? "第三方代理模块返回失败"
}
} catch {
proxyOperationAlertTitle = "检测失败"
proxyOperationError = error.localizedDescription
}
}
}
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
guard let url = client.launchURL else { return }
UIApplication.shared.open(url, options: [:]) { opened in
guard !opened else { return }
Task { @MainActor in
proxyOperationAlertTitle = "无法打开客户端"
proxyOperationError = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
}
}
}
private var virtualLocationIsActive: Bool {
if runtimeMode.mode == .localWiFi {
return actions.virtualLocationEnabled
}
if case .connected(let active) = thirdPartyProxy.connectionState {
return active
}
return false
}
}
+8
View File
@@ -59,6 +59,14 @@ final class SetupCoordinator: ObservableObject {
func sceneDidBecomeActive() {}
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
func completeSetup() { needsSetup = false }
func requestModeSelection() {
setupStep = .mode
needsSetup = true
}
func requestThirdPartySetup() {
setupStep = .thirdPartyClient
needsSetup = true
}
func requestSetup() {
setupStep = .proxy
needsSetup = true
+14 -4
View File
@@ -11,6 +11,7 @@ enum TipKind: String, Identifiable {
struct TipSheetView: View {
let kind: TipKind
var runtimeMode: ProxyRuntimeMode = .localWiFi
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -18,8 +19,8 @@ struct TipSheetView: View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
switch kind {
case .activation: ActivationTipContent(dismiss: { dismiss() })
case .deactivation: DeactivationTipContent(dismiss: { dismiss() })
case .activation: ActivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
case .deactivation: DeactivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
@@ -65,15 +66,19 @@ private struct TipCloseButton: View {
// MARK: -
struct ActivationTipContent: View {
var runtimeMode: ProxyRuntimeMode = .localWiFi
let dismiss: () -> Void
var body: some View {
GroupBox(label: Label("让虚拟定位生效", systemImage: "checklist")) {
VStack(alignment: .leading, spacing: 10) {
if runtimeMode == .thirdParty {
step(0, "确认第三方代理已开启", "保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。")
}
step(1, "开启飞行模式", "从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。")
step(2, "关闭 WiFi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。")
step(4, "打开 WiFi,启动虚拟定位", "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
step(4, "打开 WiFi,启动虚拟定位", runtimeMode == .thirdParty ? "从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" : "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。")
}.padding(.vertical, 4)
@@ -117,6 +122,7 @@ struct ActivationTipContent: View {
// MARK: -
struct DeactivationTipContent: View {
var runtimeMode: ProxyRuntimeMode = .localWiFi
let dismiss: () -> Void
var body: some View {
@@ -125,7 +131,11 @@ struct DeactivationTipContent: View {
step(1, "开启飞行模式", "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。")
step(2, "关闭 WiFi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
systemStep(4, "打开 WiFi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
if runtimeMode == .thirdParty {
step(4, "确认坐标已清除", "App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。")
} else {
systemStep(4, "打开 WiFi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
}
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。")
}.padding(.vertical, 4)