mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
feat: add third-party proxy mode and improve onboarding
This commit is contained in:
+16
-3
@@ -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 ? "是" : "否")
|
||||
|
||||
### 问题描述
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, "关闭 Wi‑Fi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。")
|
||||
systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。")
|
||||
step(4, "打开 Wi‑Fi,启动虚拟定位", "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
|
||||
step(4, "打开 Wi‑Fi,启动虚拟定位", 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, "关闭 Wi‑Fi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
|
||||
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
|
||||
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
|
||||
if runtimeMode == .thirdParty {
|
||||
step(4, "确认坐标已清除", "App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。")
|
||||
} else {
|
||||
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
|
||||
}
|
||||
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
|
||||
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。")
|
||||
}.padding(.vertical, 4)
|
||||
|
||||
Reference in New Issue
Block a user