fix: 优化启动引导、坐标处理和日志管理

This commit is contained in:
xweiba
2026-08-06 16:23:09 +08:00
parent 0e8e83fd33
commit e86909cc2f
30 changed files with 2296 additions and 857 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ struct BugReportView: View {
isRunning = true
Task {
// 跑测试
_ = await setup.runVerificationTest(testLat: 22.543099, testLon: 113.934576)
_ = await setup.runVerificationTest()
let testLog = setup.testLog
// 获取版本信息
+63 -17
View File
@@ -2,8 +2,6 @@ import SwiftUI
struct ContentView: View {
@StateObject private var setup = SetupCoordinator()
@ObservedObject private var net = NetworkMonitor.shared
@State private var showSetup = false
@State private var phase: AppPhase = .splash
@AppStorage("setupCompleted") private var setupCompleted = false
@@ -17,33 +15,81 @@ struct ContentView: View {
Image(systemName: "location.fill")
.font(.system(size: 48)).foregroundStyle(.blue)
ProgressView()
Text("正在启动…").font(.subheadline).foregroundStyle(.secondary)
Text("正在初始化地图与本地代理…").font(.subheadline).foregroundStyle(.secondary)
}
case .map:
NavigationView {
MapHomeView(setup: setup)
}
.fullScreenCover(isPresented: $showSetup) {
.fullScreenCover(isPresented: $setup.needsSetup) {
FirstSetupView(setup: setup, onComplete: {
setupCompleted = true
setup.completeSetup()
showSetup = false
})
}
}
}
.task {
if !setup.proxy.isRunning {
do {
try await setup.proxy.start()
} catch {
RuntimeLogger.error("APP", "Startup", "代理启动失败,将在设置检测中重试", error: error)
}
}
phase = .map
if !setupCompleted { showSetup = true }
// Tile probing is a best-effort display refinement and must never block first render.
await CoordinateConverter.detectTileByFixedGeocode(force: true)
.task { await bootstrap() }
}
@MainActor
private func bootstrap() async {
await setup.prepareLocalServices()
do {
try CoordinateStorageMigration.migrateIfNeeded(favorites: FavoriteLocationStore())
} catch {
RuntimeLogger.error("APP", "Startup", "旧坐标数据迁移失败,将在下次启动重试", error: error)
}
// MapHomeView is intentionally constructed only after this required
// coordinate-system gate resolves, so cached pins are never replayed
// into an unknown Apple Maps coordinate system.
let mapCoordinateSystem = await CoordinateConverter.resolveInitialMapCoordinateSystem()
guard !Task.isCancelled else { return }
RuntimeLogger.info("APP", "Startup", "地图坐标标准初始化完成,开始后续启动流程", details: [
"地图标准": mapCoordinateSystem.rawValue,
"使用兜底": String(CoordinateConverter.initialMapCoordinateSystemUsedFallback)
])
// Resolve the first map center before constructing MapHomeView. This
// prevents a Shenzhen/cache frame followed by a second realtime frame.
if LastCoordinateStore.load() == nil {
RuntimeLogger.info("APP", "Startup", "没有持久化图钉,地图创建前请求实时定位")
if let realtime = await RealtimeLocationManager.shared.requestLocation() {
let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(realtime)
let pair = CoordinateConverter.coordinatePair(
lat: realtime.latitude,
lon: realtime.longitude,
mapCoordinateSystem: .wgs84
)
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: 1_000)
RuntimeLogger.info("APP", "Startup", "已使用实时定位准备唯一初始地图状态", details: [
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"修正兜底标准": String(mapCoordinateSystemChange != nil),
"缩放米": "1000"
])
RealtimeLocationTrace.coordinate(
"地图创建前取得的初始实时位置(WGS-84)",
coordinate: realtime
)
} else {
RuntimeLogger.warning("APP", "Startup", "地图创建前无法取得实时定位,唯一初始位置使用深圳", details: [
"地图标准": mapCoordinateSystem.rawValue,
"缩放米": "1000"
])
}
} else {
RuntimeLogger.info("APP", "Startup", "已找到持久化图钉,直接准备唯一初始地图状态")
}
guard !Task.isCancelled else { return }
if setupCompleted {
let result = await setup.runVerificationTest()
setup.applyVerificationResult(result)
} else {
setup.requestSetup()
}
RuntimeLogger.info("APP", "Startup", "启动门禁全部完成,现在创建 MapHomeView")
phase = .map
}
}
+7 -1
View File
@@ -51,6 +51,12 @@ struct RuntimeLogsView: View {
}
}
.navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
Text("日志自动清理,仅保留近 3 天")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } }
ToolbarItem(placement: .navigationBarTrailing) {
@@ -91,7 +97,7 @@ struct RuntimeLogsView: View {
Button {
isTesting = true; testResult = ""; testLogCopied = false
Task {
let result = await setup.runVerificationTest(testLat: testFavorite.latitude, testLon: testFavorite.longitude)
let result = await setup.runVerificationTest()
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
if !result.isSuccess { testResult += ",查看下方日志" }
testMessage = setup.testLog
+247 -252
View File
@@ -1,12 +1,13 @@
import SwiftUI
enum SetupStep: Int, CaseIterable {
case cert = 0, proxy = 1, verify = 2
case proxy
case cert
var title: String {
switch self {
case .proxy: return "配置 Wi-Fi 代理"
case .cert: return "初始化 CA 证书"
case .proxy: return "初始化代理"
case .verify: return "环境检测"
}
}
}
@@ -15,283 +16,277 @@ struct FirstSetupView: View {
@ObservedObject var setup: SetupCoordinator
let onComplete: () -> Void
@State private var step: SetupStep = .cert
@State private var step: SetupStep
@State private var downloadedDone = false
@State private var installedDone = false
@State private var trustedDone = false
@State private var proxyDone = false
@State private var testPassed: VerificationResult? = nil
@State private var testMessage = ""
@State private var isLoading = false
@State private var result: VerificationResult?
@State private var isVerifying = false
@State private var manualHint = ""
@State private var showDiagnostics = false
@StateObject private var diagnosticActions = LocationActionCoordinator()
private var certDone: Bool { downloadedDone && installedDone && trustedDone }
init(setup: SetupCoordinator, onComplete: @escaping () -> Void) {
self.setup = setup
self.onComplete = onComplete
_step = State(initialValue: setup.setupStep)
}
private var certificateStepsComplete: Bool { downloadedDone && installedDone && trustedDone }
private var diagnosticFavorite: FavoriteLocation {
FavoriteLocation(name: "诊断位置", latitude: 22.544577, longitude: 113.94114, accuracy: 25)
}
var body: some View {
VStack(spacing: 0) {
// 顶部大步骤进度
HStack(spacing: 6) {
ForEach(SetupStep.allCases, id: \.rawValue) { s in
VStack(spacing: 4) {
Circle()
.fill(s.rawValue < step.rawValue ? Color.green
: s.rawValue == step.rawValue ? Color.blue
: Color.gray.opacity(0.3))
.frame(width: 10, height: 10)
Text(s.title).font(.caption2).foregroundStyle(.secondary)
}
if s.rawValue < SetupStep.allCases.count - 1 {
Rectangle().fill(s.rawValue < step.rawValue ? Color.green : Color.gray.opacity(0.3))
.frame(height: 2).frame(maxWidth: 30)
}
}
}
.padding(.top, 18)
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch step {
case .cert: certStepView
case .proxy: proxyStepView
case .verify: verifyStepView
}
}
.padding(20)
}
// 底部导航
Divider()
HStack {
if step.rawValue > 0 {
Button("上一步") { step = SetupStep(rawValue: step.rawValue - 1)! }
.buttonStyle(.bordered)
}
Spacer()
if step == .verify && testPassed?.isSuccess == true {
Button("完成,进入主页") { onComplete() }.buttonStyle(.borderedProminent)
} else if step == .cert && certDone {
Button("下一步") { step = .proxy }.buttonStyle(.borderedProminent)
} else if step == .proxy && proxyDone {
Button("下一步") { step = .verify }.buttonStyle(.borderedProminent)
}
}
.padding(.horizontal, 20).padding(.vertical, 10)
}
.alert("无法直接跳转", isPresented: Binding(
get: { !manualHint.isEmpty },
set: { if !$0 { manualHint = "" } }
)) {
Button("知道了", role: .cancel) {}
} message: { Text(manualHint) }
}
// MARK: - 第 1 步:初始化 CA 证书(三个步骤平铺)
private var certStepView: some View {
VStack(alignment: .leading, spacing: 20) {
GroupBox(label: Label("第 1 步:下载证书", systemImage: "arrow.down.circle")) {
VStack(alignment: .leading, spacing: 12) {
Color.clear.frame(height: 0).padding(.top, 2)
Text("虚拟定位需要通过自签 CA 证书来解密和改写定位请求。点击下方按钮,Safari 会打开下载页面。Safari 弹出「此网站正尝试下载一个配置描述文件」时,点「允许」。")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 10) {
Button {
Task { await setup.proxy.openCertificateDownload() }
} label: {
Label("去下载", systemImage: "arrow.down.circle.fill").frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).tint(.blue)
Button {
downloadedDone = true
} label: {
Label(downloadedDone ? "已完成 ✓" : "已完成", systemImage: downloadedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
}.buttonStyle(.bordered).tint(downloadedDone ? .green : .secondary)
}
}
}
GroupBox(label: Label("第 2 步:安装证书", systemImage: "square.and.arrow.down")) {
VStack(alignment: .leading, spacing: 12) {
Color.clear.frame(height: 0).padding(.top, 2)
Text("下载完成后打开系统「设置」:\n\n1. 如果顶部显示了「已下载描述文件」,点进去安装\n2. 如果没显示:进入「通用 → VPN与设备管理」,找到 WLOC CA 证书点击安装\n\n安装时系统会要求输入锁屏密码,确认后点右上角「安装」即可。")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 10) {
Button { openSettings(.general) } label: {
Label("去安装", systemImage: "gearshape").frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).tint(.blue)
Button { installedDone = true } label: {
Label(installedDone ? "已完成 ✓" : "已完成", systemImage: installedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
}.buttonStyle(.bordered).tint(installedDone ? .green : .secondary)
}
}
}
GroupBox(label: Label("第 3 步:信任证书", systemImage: "shield.checkered")) {
VStack(alignment: .leading, spacing: 12) {
Color.clear.frame(height: 0).padding(.top, 2)
Text("证书安装后还需要开启信任,否则系统会拦截代理的 HTTPS 请求:\n\n1. 打开「设置 → 通用 → 关于本机」\n2. 滑到底部找到「证书信任设置」\n3. 找到 WLOC CA,打开旁边的开关\n4. 弹出的警告中点「继续」\n\n⚠️ 每次重装 App 都需要重新下载安装证书。如果检测时报 TLS 错误,说明证书过期或不匹配,请删除旧证书(设置 → 通用 → VPN与设备管理)后重新安装。")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 10) {
Button { openSettings(.general) } label: {
Label("去信任", systemImage: "shield.checkered").frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).tint(.blue)
Button { trustedDone = true } label: {
Label(trustedDone ? "已完成 ✓" : "已完成", systemImage: trustedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
}.buttonStyle(.bordered).tint(trustedDone ? .green : .secondary)
}
}
}
}
}
// MARK: - 第 2 步:初始化代理
private var proxyStepView: some View {
VStack(alignment: .leading, spacing: 20) {
GroupBox(label: Label("配置 WiFi 系统代理", systemImage: "wifi")) {
VStack(alignment: .leading, spacing: 12) {
Color.clear.frame(height: 0).padding(.top, 2)
Text("要让系统定位请求经过本地代理,需要在 WiFi 设置中手动配置:\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前连接的 WiFi 右侧 (i) 图标\n3. 滑到底部找到「HTTP 代理」,选择「手动」\n4. 服务器填入 127.0.0.1,端口填入 8888\n5. 点右上角「存储」\n\n⚠️ 只有连接的这个 WiFi 会走代理,蜂窝数据不受影响。换 WiFi 后需要重新配置。")
.font(.caption).foregroundStyle(.secondary)
}
}
HStack(spacing: 12) {
Button {
UIPasteboard.general.string = "127.0.0.1:8888"
} label: {
Label("复制地址", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}.buttonStyle(.bordered).tint(.blue)
Button {
openSettings(.wifi)
} label: {
Label("去设置", systemImage: "wifi")
.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).tint(.blue)
}
Button {
proxyDone = true
} label: {
Label(proxyDone ? "已完成 ✓" : "已完成", systemImage: proxyDone ? "checkmark.circle.fill" : "circle")
.frame(maxWidth: .infinity)
}.buttonStyle(.bordered).tint(proxyDone ? .green : .secondary)
}
}
// MARK: - 第 3 步:环境检测
private var verifyStepView: some View {
VStack(alignment: .leading, spacing: 20) {
GroupBox(label: Label("检测整个流程", systemImage: "checklist")) {
VStack(alignment: .leading, spacing: 12) {
Color.clear.frame(height: 0).padding(.top, 2)
Text("点击「开始检测」后依次检查代理运行、证书信任与 WiFi 代理配置、坐标写入、数据改写。全部通过后「完成,进入主页」按钮才会亮起。")
.font(.caption).foregroundStyle(.secondary)
}
}
if let result = testPassed, result.isSuccess {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green).font(.title3)
Text("全部检测通过").font(.subheadline.weight(.semibold)).foregroundStyle(.green)
}.padding(12).background(Color.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
} else if let result = testPassed {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: "xmark.circle.fill").foregroundStyle(.red).font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("测试未通过").font(.subheadline.weight(.semibold)).foregroundStyle(.red)
Text(failureSummary(result)).font(.caption).foregroundStyle(.secondary)
}
}
if let step = failureRemedyStep(result) {
Button {
withAnimation(.none) { self.step = step }
testPassed = nil
testMessage = ""
} label: {
Label("去处理", systemImage: "arrow.right.circle.fill")
.font(.subheadline.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
}
.padding(12)
.background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
}
if !testMessage.isEmpty {
NavigationView {
VStack(spacing: 0) {
progress
ScrollView {
Text(testMessage)
.font(.caption.monospaced()).textSelection(.enabled)
.padding(10).frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
}.frame(maxHeight: 280)
VStack(alignment: .leading, spacing: 20) {
switch step {
case .proxy: proxyStep
case .cert: certificateStep
}
if let result { resultView(result) }
}
.padding(20)
}
Divider()
primaryAction
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.navigationTitle("开始使用")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showDiagnostics) {
NavigationView {
RuntimeLogsView(
setup: setup,
actions: diagnosticActions,
testFavorite: diagnosticFavorite
)
}
}
.alert("无法直接跳转", isPresented: Binding(
get: { !manualHint.isEmpty },
set: { if !$0 { manualHint = "" } }
)) {
Button("知道了", role: .cancel) {}
} message: { Text(manualHint) }
}
}
private var progress: some View {
HStack(spacing: 8) {
ForEach(SetupStep.allCases, 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 {
Rectangle().fill(Color.gray.opacity(0.3)).frame(width: 28, height: 2)
}
}
}
.padding(.vertical, 16)
}
private var proxyStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("先配置 Wi-Fi 系统代理", systemImage: "wifi")) {
Text("在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。完成后点击下方「我已配置,开始检测」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
HStack(spacing: 12) {
Button { UIPasteboard.general.string = "127.0.0.1:8888" } label: {
Label("复制地址", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button { openSettings(.wifi) } label: {
Label("打开 Wi-Fi 设置", systemImage: "gearshape").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
}
}
private var certificateStep: some View {
VStack(alignment: .leading, spacing: 20) {
certificateCard(
title: "第 1 步:下载证书",
icon: "arrow.down.circle",
description: "下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。Safari 出现配置描述文件下载提示时,选择「允许」。",
actionTitle: "去下载",
actionIcon: "arrow.down.circle.fill",
complete: downloadedDone,
action: { Task { await setup.proxy.openCertificateDownload() } },
markComplete: { downloadedDone = true }
)
certificateCard(
title: "第 2 步:安装证书",
icon: "square.and.arrow.down",
description: "下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 WLOC CA 并完成安装。",
actionTitle: "去安装",
actionIcon: "gearshape",
complete: installedDone,
action: { openSettings(.general) },
markComplete: { installedDone = true }
)
certificateCard(
title: "第 3 步:信任证书",
icon: "shield.checkered",
description: "安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 WLOC CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。",
actionTitle: "去信任",
actionIcon: "shield.checkered",
complete: trustedDone,
action: { openSettings(.general) },
markComplete: { trustedDone = true }
)
}
}
private func certificateCard(
title: String,
icon: String,
description: String,
actionTitle: String,
actionIcon: String,
complete: Bool,
action: @escaping () -> Void,
markComplete: @escaping () -> Void
) -> some View {
GroupBox(label: Label(title, systemImage: icon)) {
VStack(alignment: .leading, spacing: 12) {
Color.clear.frame(height: 0).padding(.top, 2)
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 10) {
Button(action: action) {
Label(actionTitle, systemImage: actionIcon).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
Button(action: markComplete) {
Label(
complete ? "已完成 ✓" : "已完成",
systemImage: complete ? "checkmark.circle.fill" : "circle"
)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(complete ? .green : .secondary)
}
}
}
}
@ViewBuilder
private func resultView(_ result: VerificationResult) -> some View {
let success = result.isSuccess
VStack(alignment: .leading, spacing: 10) {
Label(success ? "环境检测通过" : failureSummary(result), systemImage: success ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(success ? .green : .red)
.font(.subheadline.weight(.semibold))
if !success {
Text(setup.testLog).font(.caption.monospaced()).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(8)
Button {
showDiagnostics = true
} label: {
Label("查看诊断日志", systemImage: "doc.text.magnifyingglass")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
.padding(12)
.background((success ? Color.green : Color.red).opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private var primaryAction: some View {
if step == .proxy {
Button {
isLoading = true; testMessage = ""
Task {
testPassed = await setup.runVerificationTest()
testMessage = setup.testLog
isLoading = false
}
verifyAfterProxyConfirmation()
} label: {
HStack {
if isLoading { ProgressView().tint(.white).controlSize(.small) }
Text(buttonText).frame(maxWidth: .infinity)
}
actionLabel("我已配置,开始检测")
}
.buttonStyle(.borderedProminent)
.tint(buttonTint)
.disabled(isLoading)
.disabled(isVerifying)
} else {
Button {
UIPasteboard.general.string = testMessage
verifyAfterCertificateConfirmation()
} label: {
Label("复制检测日志", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
}.buttonStyle(.bordered).disabled(testMessage.isEmpty)
actionLabel("确认完成,重新检测")
}
.buttonStyle(.borderedProminent)
.disabled(!certificateStepsComplete || isVerifying)
}
}
private var buttonText: String {
guard let result = testPassed else { return "开始检测" }
return result.isSuccess ? "重新检测" : "⚠️ 重新测试"
private func actionLabel(_ title: String) -> some View {
HStack {
if isVerifying { ProgressView().tint(.white).controlSize(.small) }
Text(title).frame(maxWidth: .infinity)
}
}
private var buttonTint: Color {
guard let result = testPassed else { return .blue }
return result.isSuccess ? .blue : .red
private func verifyAfterProxyConfirmation() {
runVerification { result in
if result.isSuccess {
onComplete()
} else if result == .certNotTrusted {
step = .cert
} else {
step = .proxy
}
}
}
private func verifyAfterCertificateConfirmation() {
runVerification { result in
if result.isSuccess {
onComplete()
} else if result != .certNotTrusted {
step = .proxy
}
}
}
private func runVerification(completion: @escaping (VerificationResult) -> Void) {
guard !isVerifying else { return }
isVerifying = true
result = nil
Task {
let verification = await setup.runVerificationTest()
setup.applyVerificationResult(verification)
guard !Task.isCancelled else { return }
result = verification
isVerifying = false
completion(verification)
}
}
private func failureSummary(_ result: VerificationResult) -> String {
switch result {
case .proxyNotRunning: return "代理未能启动,请检查代理状态"
case .verificationInProgress: return "已有检测正在进行,请稍候"
case .verificationSuperseded: return "检测期间位置已更新,本次结果已取消,请重新检测"
case .certNotTrusted: return "CA 证书未安装或未信任,请重新安装证书并开启信任"
case .wifiProxyNotConfigured: return "WiFi 代理未配置,请在系统设置中配置 127.0.0.1:8888"
case .coordinateWriteFailed: return "坐标写入失败,请重试"
case .patchFailed: return "坐标改写验证失败,可能是证书过期或不匹配"
case .success: return ""
case .certNotTrusted: return "证书尚未安装或信任"
case .wifiProxyNotConfigured: return "Wi-Fi 代理未正确设置"
case .proxyNotRunning: return "本地代理未能启动"
case .verificationInProgress: return "检测仍在进行"
case .verificationSuperseded: return "检测结果已过期"
case .coordinateWriteFailed: return "坐标写入失败"
case .patchFailed: return "定位改写检测失败"
case .success: return "环境检测通过"
}
}
private func failureRemedyStep(_ result: VerificationResult) -> SetupStep? {
switch result {
case .certNotTrusted: return .cert
case .wifiProxyNotConfigured: return .proxy
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded,
.coordinateWriteFailed, .patchFailed: return nil
case .success: return nil
}
}
@MainActor
private func openSettings(_ destination: SystemSettingsDestination) {
SystemSettingsNavigator.open(destination) { fallbackHint in
+46 -13
View File
@@ -1,15 +1,47 @@
import Foundation
@MainActor
protocol LocationActionProxying: AnyObject {
var isRunning: Bool { get }
func start() async throws
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64
}
extension ProxyManager: LocationActionProxying {}
@MainActor
protocol LocationActionSettingsStoring: AnyObject {
func load() -> WlocSettings?
func save(_ settings: WlocSettings)
func clear()
}
@MainActor
final class DeviceWlocSettingsStorage: LocationActionSettingsStoring {
func load() -> WlocSettings? { WlocSettingsStore.load() }
func save(_ settings: WlocSettings) { WlocSettingsStore.save(settings) }
func clear() { WlocSettingsStore.clear() }
}
@MainActor
final class LocationActionCoordinator: ObservableObject {
@Published private(set) var state: LocationActionState = .idle
@Published private(set) var virtualLocationEnabled = false
@Published private(set) var message = ""
private let proxy = ProxyManager.shared
private let proxy: any LocationActionProxying
private let settings: any LocationActionSettingsStoring
init() {
self.virtualLocationEnabled = WlocSettingsStore.load()?.enabled == true
self.proxy = ProxyManager.shared
self.settings = DeviceWlocSettingsStorage()
self.virtualLocationEnabled = settings.load()?.enabled == true
}
init(proxy: any LocationActionProxying, settings: any LocationActionSettingsStoring) {
self.proxy = proxy
self.settings = settings
self.virtualLocationEnabled = settings.load()?.enabled == true
}
func apply(_ favorite: FavoriteLocation) async -> Bool {
@@ -41,8 +73,8 @@ final class LocationActionCoordinator: ObservableObject {
func clear() {
guard !state.isBusy else { return }
proxy.setCoords(lat: 0, lon: 0, enabled: false)
WlocSettingsStore.clear()
_ = proxy.setCoords(lat: 0, lon: 0, enabled: false, accuracy: 25)
settings.clear()
state = .idle
virtualLocationEnabled = false
message = "已恢复真实定位"
@@ -56,17 +88,18 @@ final class LocationActionCoordinator: ObservableObject {
}
private func commit(_ favorite: FavoriteLocation) -> Bool {
// 统一转为 WGS-84 存储(地图取点可能为当前瓦片坐标系)
let wgs = CoordinateConverter.toStored(lat: favorite.latitude, lon: favorite.longitude)
WlocSettingsStore.save(WlocSettings(
longitude: wgs.lon,
latitude: wgs.lat,
// WLOC 合约固定使用持久化的 WGS-84 值,不依赖当前地图地图坐标标准。
let wgs = favorite.coordinatePair.wgs84
let value = WlocSettings(
longitude: wgs.longitude,
latitude: wgs.latitude,
accuracy: favorite.accuracy,
enabled: true
))
proxy.setCoords(
lat: wgs.lat,
lon: wgs.lon,
)
settings.save(value)
_ = proxy.setCoords(
lat: wgs.latitude,
lon: wgs.longitude,
enabled: true,
accuracy: favorite.accuracy
)
+355 -137
View File
@@ -27,7 +27,7 @@ private struct RealtimeLocationRequestContext {
struct MapHomeView: View {
@ObservedObject var setup: SetupCoordinator
@StateObject private var favorites = FavoriteLocationStore()
@StateObject private var favorites: FavoriteLocationStore
@StateObject private var actions = LocationActionCoordinator()
@ObservedObject private var proxy = ProxyManager.shared
@StateObject private var realtime = RealtimeLocationManager.shared
@@ -39,13 +39,13 @@ struct MapHomeView: View {
@State private var isSearching = false
@State private var searchRequestID: UInt64 = 0
@State private var searchError = ""
@State private var mapDidInitialize = false
@State private var mapRuntimeDidStart = false
@State private var activeSheet: HomeSheet?
@State private var showEnableTip = false
@State private var showDisableTip = false
@State private var activeTip: TipKind?
@State private var manualHint = ""
@AppStorage("activationTipDisabled") private var activationTipDisabled = false
private let tipPreferences = VirtualLocationTipPreferences()
@State private var editingFavorite: FavoriteLocation?
@State private var editName = ""
@State private var reverseGeocodeTask: Task<Void, Never>?
@@ -56,8 +56,6 @@ struct MapHomeView: View {
@State private var wifiChangeObserverToken: UUID?
@State private var wifiVerificationTask: Task<Void, Never>?
@State private var wifiVerificationID: UUID?
@State private var tileProbeTask: Task<Void, Never>?
@State private var tileProbeID: UUID?
@State private var copyConfirmed = false
@State private var spoofState: SpoofState = .idle
@State private var locationOperationTask: Task<Void, Never>?
@@ -68,29 +66,67 @@ struct MapHomeView: View {
init(setup: SetupCoordinator) {
self.setup = setup
let favoriteStore = FavoriteLocationStore()
_favorites = StateObject(wrappedValue: favoriteStore)
let savedCoord = LastCoordinateStore.load()
let initialZoom = ViewportStore.loadOrDefault()
let initialZoom = savedCoord?.zoomMeters ?? ViewportStore.loadOrDefault()
let selectedFavorite = favoriteStore.selectedFavorite
let initialCoord: CLLocationCoordinate2D
let initialSource: MapSelectionSource
let initialName: String?
if let saved = savedCoord {
let display = CoordinateConverter.toDisplay(lat: saved.coordinate.latitude, lon: saved.coordinate.longitude)
initialCoord = CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon)
initialCoord = saved.coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
if let selectedFavorite,
selectedFavorite.coordinatePair.matchesWGS84(
latitude: saved.coordinatePair.wgs84.latitude,
longitude: saved.coordinatePair.wgs84.longitude
) {
initialSource = .favorite(selectedFavorite.id)
initialName = selectedFavorite.name
} else {
initialSource = .initial
initialName = nil
}
} else if let selectedFavorite {
initialCoord = selectedFavorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
initialSource = .favorite(selectedFavorite.id)
initialName = selectedFavorite.name
} else {
initialCoord = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114)
// The fallback location is defined as WGS-84 and rendered in the
// coordinate system resolved by the startup gate.
initialCoord = CoordinateConverter.coordinatePair(
lat: 22.544577,
lon: 113.94114,
mapCoordinateSystem: .wgs84
).coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
initialSource = .initial
initialName = nil
}
RuntimeLogger.info("APP", "地图", "初始化", details: [
"zoom": String(initialZoom),
"有缓存": String(savedCoord != nil)
"有缓存": String(savedCoord != nil),
"初始来源": String(describing: initialSource),
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
])
_mapState = StateObject(wrappedValue: MapLocationState(
initialCoordinate: initialCoord,
initialViewportMeters: initialZoom
initialViewportMeters: initialZoom,
initialSource: initialSource,
initialName: initialName
))
if let settings = WlocSettingsStore.load(), settings.enabled {
_spoofState = State(initialValue: .active)
_activeSpoofLat = State(initialValue: settings.latitude)
_activeSpoofLon = State(initialValue: settings.longitude)
}
}
var body: some View {
ZStack {
MapViewRepresentable(
selection: mapState.selection,
initialViewportMeters: mapState.viewportMeters,
cameraCommand: mapState.cameraCommand,
onRealtimeLocationChanged: { location in
handleNativeRealtimeLocation(location)
@@ -100,10 +136,16 @@ struct MapHomeView: View {
let previousRevision = mapState.selection.revision
let revision = mapState.selectUserMapCenter(coordinate)
guard revision != previousRevision else { return }
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
let pair = CoordinatePair(
mapCoordinate: coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
)
LastCoordinateStore.save(
coordinatePair: pair,
zoomMeters: mapState.viewportMeters
)
favorites.select(nil)
scheduleGeocode(coordinate: coordinate, revision: revision)
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
},
onViewportChanged: { distance in
mapState.updateViewport(distanceMeters: distance)
@@ -111,12 +153,19 @@ struct MapHomeView: View {
onMapTap: { coordinate in
favorites.select(nil)
let revision = mapState.selectMapTap(coordinate)
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
scheduleGeocode(coordinate: coordinate, revision: revision)
let pair = CoordinatePair(
mapCoordinate: coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
)
LastCoordinateStore.save(
coordinatePair: pair,
zoomMeters: mapState.viewportMeters
)
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
},
onUserZoomChanged: { distance in
ViewportStore.save(distance)
LastCoordinateStore.updateZoom(distance)
},
onZoomIn: { mapState.zoom(by: 0.5) },
onZoomOut: { mapState.zoom(by: 2) }
@@ -188,9 +237,9 @@ struct MapHomeView: View {
Button("知道了", role: .cancel) {}
} message: { Text(manualHint) }
.onAppear {
initializeMap()
Task { await setup.refreshTrust() }
startTileProbe()
startMapRuntimeOnce()
// ContentView performs the startup environment test before this map
// view is constructed. Do not immediately run it again here.
registerWiFiChangeObserver()
}
.onDisappear {
@@ -201,14 +250,6 @@ struct MapHomeView: View {
wifiVerificationTask?.cancel()
wifiVerificationTask = nil
wifiVerificationID = nil
tileProbeTask?.cancel()
tileProbeTask = nil
tileProbeID = nil
}
.onChange(of: net.isAirplaneMode) { airplane in
if !airplane {
Task { await setup.refreshTrust() }
}
}
.onChange(of: proxy.isRunning) { running in
if !running && spoofState == .active {
@@ -350,7 +391,7 @@ struct MapHomeView: View {
if actions.virtualLocationEnabled {
activeTip = .activation
} else {
showDisableTip = true
activeTip = .deactivation
}
} label: {
Text(actions.virtualLocationEnabled ? "无法生效?" : "无法取消?")
@@ -368,16 +409,14 @@ struct MapHomeView: View {
return
}
let snapshot = currentSelectionFavorite
let wgs = CoordinateConverter.toStored(lat: snapshot.latitude, lon: snapshot.longitude)
let favorite = favorites.save(
name: snapshot.name,
latitude: wgs.lat,
longitude: wgs.lon,
mapCoordinate: mapState.selection.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem,
accuracy: snapshot.accuracy
)
let display = CoordinateConverter.toDisplay(lat: favorite.latitude, lon: favorite.longitude)
mapState.selectFavorite(
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
id: favorite.id,
name: favorite.name
)
@@ -445,8 +484,10 @@ struct MapHomeView: View {
guard spoofState == .active,
let sLat = activeSpoofLat,
let sLon = activeSpoofLon else { return false }
return abs(sLat - mapState.selection.coordinate.latitude) > 0.0001
|| abs(sLon - mapState.selection.coordinate.longitude) > 0.0001
return !currentSelectionFavorite.coordinatePair.matchesWGS84(
latitude: sLat,
longitude: sLon
)
}
private var buttonTitle: String {
@@ -485,7 +526,7 @@ struct MapHomeView: View {
spoofState = .verifying
locationOperationTask = Task { @MainActor in
let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude)
let result = await setup.runVerificationTest()
guard !Task.isCancelled,
operationID == locationOperationID,
selectionRevision == mapState.selection.revision else {
@@ -508,8 +549,13 @@ struct MapHomeView: View {
"applied": String(applied),
"spoofState": String(describing: spoofState)
])
if applied && !activationTipDisabled {
showEnableTip = true
if applied {
let count = tipPreferences.recordSuccessfulOperation(.activation)
RuntimeLogger.info("APP", "提醒", "累计开启虚拟定位次数", details: [
"次数": String(count),
"可显示不再提醒": String(tipPreferences.canSuppress(.activation))
])
showEnableTip = tipPreferences.shouldPresentAutomaticTip(.activation)
}
} else {
spoofState = actions.virtualLocationEnabled ? .active : .idle
@@ -517,7 +563,11 @@ struct MapHomeView: View {
"result": result.id,
"spoofState": String(describing: spoofState)
])
if let tip = result.tipKind {
if result == .certNotTrusted {
RuntimeLogger.warning("APP", "定位", "开启前检测发现证书异常,进入证书安装引导")
activeTip = nil
setup.applyVerificationResult(result)
} else if let tip = result.tipKind {
activeTip = tip
}
}
@@ -533,7 +583,12 @@ struct MapHomeView: View {
spoofState = .idle
activeSpoofLat = nil
activeSpoofLon = nil
showDisableTip = true
let count = tipPreferences.recordSuccessfulOperation(.deactivation)
RuntimeLogger.info("APP", "提醒", "累计关闭虚拟定位次数", details: [
"次数": String(count),
"可显示不再提醒": String(tipPreferences.canSuppress(.deactivation))
])
showDisableTip = tipPreferences.shouldPresentAutomaticTip(.deactivation)
}
@@ -574,69 +629,40 @@ struct MapHomeView: View {
mapState.selection.coordinate.latitude,
mapState.selection.coordinate.longitude
),
latitude: mapState.selection.coordinate.latitude,
longitude: mapState.selection.coordinate.longitude,
coordinatePair: .init(
mapCoordinate: mapState.selection.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
),
accuracy: 25
)
}
private var testFavorite: FavoriteLocation { currentSelectionFavorite }
private func initializeMap() {
guard !mapDidInitialize else { return }
mapDidInitialize = true
if let selected = favorites.selectedFavorite {
let display = CoordinateConverter.toDisplay(lat: selected.latitude, lon: selected.longitude)
mapState.selectFavorite(
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
id: selected.id,
name: selected.name
private func startMapRuntimeOnce() {
guard !mapRuntimeDidStart else { return }
mapRuntimeDidStart = true
let pair = LastCoordinateStore.load()?.coordinatePair
?? CoordinatePair(
mapCoordinate: mapState.selection.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
)
LastCoordinateStore.save(lat: selected.latitude, lon: selected.longitude)
} else {
mapState.focusSelection(distanceMeters: mapState.viewportMeters)
scheduleGeocode(
coordinate: mapState.selection.coordinate,
revision: mapState.selection.revision
)
}
// 启动坐标获取优先级:缓存 > 实时定位 > 深圳兜底(init 时已给兜底)
let savedCoord = LastCoordinateStore.load()
if savedCoord == nil {
requestRealtimeLocation()
}
}
private func startTileProbe(force: Bool = false) {
guard tileProbeTask == nil else { return }
let probeID = UUID()
tileProbeID = probeID
tileProbeTask = Task { @MainActor in
defer {
if tileProbeID == probeID {
tileProbeTask = nil
tileProbeID = nil
}
}
guard let change = await CoordinateConverter.detectTileByFixedGeocode(force: force),
!Task.isCancelled else { return }
reprojectMapSelection(for: change)
}
}
private func reprojectMapSelection(for change: CoordinateConverter.TileTypeChange) {
// CLLocationManager samples are WGS-84 already. Other map interactions
// are display coordinates and must be converted through WGS-84 once.
guard mapState.selection.source != .realtime else { return }
let coordinate = CoordinateConverter.reprojectDisplayCoordinate(
mapState.selection.coordinate,
from: change.previous,
to: change.current
scheduleGeocode(
coordinate: pair.wgs84.coordinate,
revision: mapState.selection.revision
)
mapState.reprojectSelectionForTileChange(coordinate)
RuntimeLogger.info("APP", "坐标转换", "瓦片类型切换后已重投影当前选点", details: [
}
private func reprojectMapSelection(for change: CoordinateConverter.MapCoordinateSystemChange) {
// Every current selection is persisted as a complete coordinate pair at
// its input boundary. Replaying the matching stored representation
// avoids a second GCJ/WGS conversion and its accumulated offset.
guard let stored = LastCoordinateStore.load() else {
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准切换时未找到当前选点缓存")
return
}
mapState.reprojectSelectionForMapCoordinateSystemChange(stored.coordinate(for: change.current))
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准切换后已使用缓存坐标对回显当前选点", details: [
"from": change.previous.rawValue,
"to": change.current.rawValue
])
@@ -644,16 +670,20 @@ struct MapHomeView: View {
private func registerWiFiChangeObserver() {
guard wifiChangeObserverToken == nil else { return }
wifiChangeObserverToken = net.observeWiFiChanges { [self] in
handleWiFiChange()
wifiChangeObserverToken = net.observeWiFiChanges { [self] reason in
handleWiFiChange(reason: reason)
}
}
private func handleWiFiChange() {
private func handleWiFiChange(reason: WiFiChangeReason) {
RuntimeLogger.info("APP", "WiFi", "检测到 Wi-Fi 网络变化", details: [
"原因": reason.rawValue,
"虚拟定位已开启": String(spoofState == .active)
])
guard spoofState == .active else { return }
if wifiVerificationTask != nil {
RuntimeLogger.info("APP", "WiFi", "网络仍在变化,重新计算环境检测等待时间")
}
wifiVerificationTask?.cancel()
let verificationID = UUID()
wifiVerificationID = verificationID
@@ -664,34 +694,94 @@ struct MapHomeView: View {
wifiVerificationID = nil
}
}
let stabilizationNanoseconds: UInt64 = 5_000_000_000
RuntimeLogger.info("APP", "WiFi", "等待 Wi-Fi 连接稳定后检测", details: [
"等待秒数": "5",
"事件原因": reason.rawValue
])
do {
try await Task.sleep(nanoseconds: 3_000_000_000)
try await Task.sleep(nanoseconds: stabilizationNanoseconds)
} catch {
RuntimeLogger.debug("APP", "WiFi", "延时检测已被更新的网络事件取消")
return
}
guard !Task.isCancelled, spoofState == .active else { return }
RuntimeLogger.info("APP", "WiFi", "开始重新验证")
let target = currentSelectionFavorite
let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude)
guard !Task.isCancelled, spoofState == .active else { return }
RuntimeLogger.info("APP", "WiFi", "重新验证完成", details: [
"success": String(result.isSuccess),
"tipKind": result.tipKind?.rawValue ?? "nil"
])
if !result.isSuccess, let tip = result.tipKind {
activeTip = tip
guard net.isSatisfied, net.isWiFiEnabled else {
RuntimeLogger.warning("APP", "WiFi", "稳定等待结束后仍未连接 Wi-Fi,提示检查代理", details: [
"网络可用": String(net.isSatisfied),
"Wi-Fi接口": String(net.isWiFiEnabled)
])
activeTip = .proxySetup
return
}
// A manual diagnostics request can occupy the verifier for its full
// eight-second URL timeout. Wait long enough to run this check after
// it finishes instead of silently dropping the Wi-Fi-change check.
let maximumAttempts = 11
for attempt in 1...maximumAttempts {
RuntimeLogger.info("APP", "WiFi", "开始后台环境检测", details: [
"尝试": "\(attempt)/\(maximumAttempts)",
"SSID可读取": String(net.currentSSID != nil)
])
let result = await setup.runVerificationTest()
guard !Task.isCancelled, spoofState == .active else { return }
if result == .verificationInProgress, attempt < maximumAttempts {
RuntimeLogger.info("APP", "WiFi", "已有环境检测运行,1 秒后重试", details: [
"尝试": "\(attempt)/\(maximumAttempts)"
])
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
return
}
continue
}
if result == .certNotTrusted {
RuntimeLogger.warning("APP", "WiFi", "后台环境检测发现证书异常,进入证书安装引导")
activeTip = nil
setup.applyVerificationResult(result)
return
}
let tip = result.wifiChangeReminderTipKind
RuntimeLogger.info("APP", "WiFi", "后台环境检测完成", details: [
"结果": result.id,
"success": String(result.isSuccess),
"提示": tip?.rawValue ?? "无"
])
if !result.isSuccess, let tip {
activeTip = tip
} else if result == .verificationInProgress {
RuntimeLogger.warning("APP", "WiFi", "环境检测连续被占用,本次不重复弹窗")
}
return
}
}
}
private func requestRealtimeLocation() {
let intent = mapState.beginRealtimeIntent()
startTileProbe()
RuntimeLogger.info("APP", "实时定位", "用户点击实时定位", details: [
"intentID": String(intent.id),
"选点revision": String(intent.selectionRevision),
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"蓝点缓存存在": String(mapState.realtimeLocation != nil),
"CLLocationManager请求中": String(realtime.isRequesting)
])
// 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新)
if let loc = mapState.realtimeLocation {
RealtimeLocationTrace.log("实时定位按钮直接使用 MapKit 蓝点缓存", location: loc, details: [
"intentID": String(intent.id),
"来源": "MapLocationState.realtimeLocation"
])
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
return
}
RuntimeLogger.info("APP", "实时定位", "MapKit 蓝点尚不可用,启动 CLLocationManager 兜底", details: [
"intentID": String(intent.id)
])
startRealtimeLocationRequest(
source: "CLLocationManager",
showFailureAlert: true,
@@ -700,14 +790,25 @@ 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)
guard let context = realtimeRequestContext else { return }
guard let context = realtimeRequestContext else {
RuntimeLogger.debug("APP", "实时定位", "蓝点已缓存;当前没有待处理的实时定位意图")
return
}
// 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.
realtimeRequestContext = nil
realtimeRequestTask?.cancel()
RuntimeLogger.info("APP", "实时定位", "MapKit 蓝点抢先完成请求,取消 CLLocationManager 兜底", details: [
"intentID": String(context.intent.id),
"原兜底来源": context.source
])
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "蓝点(途中)→\(context.source)")
}
@@ -722,16 +823,34 @@ struct MapHomeView: View {
source: source,
showFailureAlert: showFailureAlert
)
RuntimeLogger.info("APP", "实时定位", "登记实时定位请求上下文", details: [
"intentID": String(intent.id),
"选点revision": String(intent.selectionRevision),
"来源": source,
"失败时提示": String(showFailureAlert),
"任务已存在": String(realtimeRequestTask != nil),
"manager请求中": String(realtime.isRequesting)
])
// A button tap during the startup request retargets that same in-flight
// Core Location request to the newer intent instead of being ignored.
guard realtimeRequestTask == nil, !realtime.isRequesting else { return }
guard realtimeRequestTask == nil, !realtime.isRequesting else {
RuntimeLogger.info("APP", "实时定位", "复用进行中的 CLLocationManager 请求并更新意图上下文", details: [
"intentID": String(intent.id)
])
return
}
realtimeRequestTask = Task { @MainActor in
defer {
realtimeRequestTask = nil
realtimeRequestContext = nil
}
guard let coordinate = await realtime.requestLocation() else {
RuntimeLogger.warning("APP", "实时定位", "CLLocationManager 兜底未返回坐标", details: [
"授权状态rawValue": String(realtime.authorizationStatus.rawValue),
"任务已取消": String(Task.isCancelled),
"上下文存在": String(realtimeRequestContext != nil)
])
guard let context = realtimeRequestContext,
context.showFailureAlert,
!Task.isCancelled,
@@ -740,7 +859,16 @@ struct MapHomeView: View {
showLocationAlert = true
return
}
guard !Task.isCancelled, let context = realtimeRequestContext else { return }
RealtimeLocationTrace.coordinate("主页收到 CLLocationManager 兜底坐标", coordinate: coordinate, details: [
"任务已取消": String(Task.isCancelled)
])
guard !Task.isCancelled, let context = realtimeRequestContext else {
RuntimeLogger.info("APP", "实时定位", "丢弃 CLLocationManager 结果:任务已取消或蓝点已抢先完成", details: [
"任务已取消": String(Task.isCancelled),
"上下文存在": String(realtimeRequestContext != nil)
])
return
}
acceptRealtimeLocation(coordinate, intent: context.intent, source: context.source)
}
}
@@ -751,15 +879,48 @@ struct MapHomeView: View {
source: String
) {
let currentViewport = mapState.viewportMeters
let accepted = mapState.acceptRealtimeLocation(coordinate, intent: intent)
RuntimeLogger.info("APP", "地图", "\(source)返回", details: [
"accepted": String(accepted)
let previousMapCoordinateSystem = CoordinateConverter.currentMapCoordinateSystem
let usesDomesticStandard = CoordinateConverter.usesGCJ02ServiceArea(
lat: coordinate.latitude,
lon: coordinate.longitude
)
let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(coordinate)
if let change = mapCoordinateSystemChange {
reprojectMapSelection(for: change)
}
let pair = CoordinateConverter.coordinatePair(
lat: coordinate.latitude,
lon: coordinate.longitude,
mapCoordinateSystem: .wgs84
)
let accepted = mapState.acceptRealtimeLocation(
pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
intent: intent
)
RuntimeLogger.info("APP", "实时定位", "实时定位坐标完成标准判断并提交到地图", details: [
"来源": source,
"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),
"accepted": String(accepted),
"显示坐标字段": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"持久化字段": "WGS-84+GCJ-02"
])
RealtimeLocationTrace.coordinate("原始实时定位坐标(按 WGS-84)", coordinate: coordinate, details: [
"来源": source
])
RealtimeLocationTrace.coordinate("地图实际显示坐标", coordinate: pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem), details: [
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
])
guard accepted else { return }
// 用点击时的缩放级别居中,不改变缩放
mapState.focusSelection(distanceMeters: currentViewport)
// 蓝点坐标已是 WGS-84,直接存,不需要 toStored
LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude)
// Core Location is WGS-84; persist both forms once and render the active form.
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: currentViewport)
favorites.select(nil)
scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision)
}
@@ -898,8 +1059,11 @@ struct MapHomeView: View {
reverseGeocodeTask?.cancel()
favorites.select(nil)
mapState.selectSearchResult(result.coordinate, name: result.name)
let wgs = CoordinateConverter.toStored(lat: result.coordinate.latitude, lon: result.coordinate.longitude)
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
LastCoordinateStore.save(
mapCoordinate: result.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem,
zoomMeters: mapState.viewportMeters
)
searchText = result.name
searchResults = []
searchError = ""
@@ -913,13 +1077,15 @@ struct MapHomeView: View {
geocodeDebounceTask?.cancel()
reverseGeocodeTask?.cancel()
favorites.select(favorite.id)
let display = CoordinateConverter.toDisplay(lat: favorite.latitude, lon: favorite.longitude)
mapState.selectFavorite(
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
id: favorite.id,
name: favorite.name
)
LastCoordinateStore.save(lat: favorite.latitude, lon: favorite.longitude)
LastCoordinateStore.save(
coordinatePair: favorite.coordinatePair,
zoomMeters: mapState.viewportMeters
)
}
private var enableTipSheet: some View {
@@ -931,16 +1097,38 @@ struct MapHomeView: View {
}
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Button { showEnableTip = false } label: {
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
}.buttonStyle(.borderedProminent).tint(.blue)
Button(role: .destructive) {
activationTipDisabled = true
showEnableTip = false
} label: {
Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity)
}.buttonStyle(.bordered)
HStack(spacing: 12) {
if tipPreferences.canSuppress(.activation) {
Button {
tipPreferences.suppress(.activation)
showEnableTip = false
} label: {
Label("不再提醒", systemImage: "bell.slash.fill")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
Button { showEnableTip = false } label: {
Text("知道了")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.tint(.blue)
} else {
Button { showEnableTip = false } label: {
Text("知道了")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}.padding(.horizontal, 16).padding(.bottom, 8)
}
}
@@ -956,9 +1144,39 @@ struct MapHomeView: View {
}
.navigationTitle("虚拟定位已关闭").navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
Button { showDisableTip = false } label: {
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
}.buttonStyle(.borderedProminent).tint(.blue).padding(.horizontal, 16).padding(.bottom, 8)
HStack(spacing: 12) {
if tipPreferences.canSuppress(.deactivation) {
Button {
tipPreferences.suppress(.deactivation)
showDisableTip = false
} label: {
Label("不再提醒", systemImage: "bell.slash.fill")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
Button { showDisableTip = false } label: {
Text("知道了")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.tint(.blue)
} else {
Button { showDisableTip = false } label: {
Text("知道了")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}.padding(.horizontal, 16).padding(.bottom, 8)
}
}
}
+125 -37
View File
@@ -131,12 +131,17 @@ final class MapLocationState: ObservableObject {
private var nextRealtimeIntentID: UInt64 = 0
private var latestRealtimeIntentID: UInt64 = 0
init(initialCoordinate: CLLocationCoordinate2D, initialViewportMeters: CLLocationDistance = 1_000) {
init(
initialCoordinate: CLLocationCoordinate2D,
initialViewportMeters: CLLocationDistance = 1_000,
initialSource: MapSelectionSource = .initial,
initialName: String? = nil
) {
viewportMeters = initialViewportMeters
selection = MapSelection(
coordinate: initialCoordinate,
source: .initial,
explicitName: nil,
source: initialSource,
explicitName: initialName?.nonEmpty,
revision: nextSelectionRevision
)
}
@@ -233,7 +238,7 @@ final class MapLocationState: ObservableObject {
/// Updates only the map representation of the current physical selection.
/// This preserves revision/source so an in-flight user action is not invalidated.
func reprojectSelectionForTileChange(_ coordinate: CLLocationCoordinate2D) {
func reprojectSelectionForMapCoordinateSystemChange(_ coordinate: CLLocationCoordinate2D) {
guard CLLocationCoordinate2DIsValid(coordinate),
!selection.coordinate.isApproximatelyEqual(to: coordinate) else { return }
selection = MapSelection(
@@ -302,49 +307,132 @@ extension CLLocationCoordinate2D {
// MARK: - 持久化存储
struct LastCoordinate: Codable, Equatable {
let coordinatePair: CoordinatePair
let zoomMeters: CLLocationDistance
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
coordinatePair.coordinate(for: mapCoordinateSystem)
}
}
enum LastCoordinateStore {
private struct StoredPosition: Codable {
let coordinatePair: CoordinatePair
let zoomMeters: CLLocationDistance
}
private static let positionKey = "lastMapPositionV1"
private static let legacyLatKey = "lastMapLat"
private static let legacyLonKey = "lastMapLon"
static func save(
mapCoordinate: CLLocationCoordinate2D,
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
zoomMeters: CLLocationDistance,
defaults: UserDefaults = AppGroup.defaults
) {
save(
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
zoomMeters: zoomMeters,
defaults: defaults
)
}
static func save(
coordinatePair: CoordinatePair,
zoomMeters: CLLocationDistance,
defaults: UserDefaults = AppGroup.defaults
) {
do {
let value = StoredPosition(coordinatePair: coordinatePair, zoomMeters: max(50, zoomMeters))
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
ViewportStore.save(value.zoomMeters, defaults: defaults)
} catch {
RuntimeLogger.error("APP", "地图", "保存当前图钉失败", error: error)
}
}
static func load(defaults: UserDefaults = AppGroup.defaults) -> LastCoordinate? {
if let data = defaults.data(forKey: positionKey),
let value = try? JSONDecoder().decode(StoredPosition.self, from: data) {
return LastCoordinate(coordinatePair: value.coordinatePair, zoomMeters: value.zoomMeters)
}
let legacyLatitude = defaults.double(forKey: legacyLatKey)
let legacyLongitude = defaults.double(forKey: legacyLonKey)
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else {
return nil
}
return LastCoordinate(
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
zoomMeters: ViewportStore.load(defaults: defaults) ?? 1_000
)
}
static func updateZoom(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
guard let current = load(defaults: defaults) else { return }
save(coordinatePair: current.coordinatePair, zoomMeters: meters, defaults: defaults)
}
static func migrateLegacyCoordinate(
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) throws {
guard defaults.data(forKey: positionKey) == nil else { return }
let legacyLatitude = legacyDefaults.double(forKey: legacyLatKey)
let legacyLongitude = legacyDefaults.double(forKey: legacyLonKey)
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else { return }
let value = StoredPosition(
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
zoomMeters: ViewportStore.load(defaults: legacyDefaults) ?? 1_000
)
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
}
}
enum ViewportStore {
private static let key = "mapViewportMeters"
static func save(_ meters: CLLocationDistance) {
UserDefaults.standard.set(meters, forKey: key)
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(meters)])
static func save(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
let value = max(50, meters)
defaults.set(value, forKey: key)
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(value)])
}
/// 取持久化缩放值;未存过返回 nil
static func load() -> CLLocationDistance? {
let v = UserDefaults.standard.double(forKey: key)
let result = v > 0 ? v : nil
RuntimeLogger.info("APP", "缩放", "读取缩放", details: ["zoom": result.map { String($0) } ?? "nil"])
return result
static func load(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance? {
let value = defaults.double(forKey: key)
return value > 0 ? value : nil
}
/// 取持久化缩放值,取不到返回默认 1km 并立即存储
static func loadOrDefault() -> CLLocationDistance {
if let v = load() { return v }
static func loadOrDefault(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance {
if let value = load(defaults: defaults) { return value }
let fallback: CLLocationDistance = 1_000
save(fallback)
RuntimeLogger.info("APP", "缩放", "使用默认缩放 1km")
save(fallback, defaults: defaults)
return fallback
}
}
struct LastCoordinate {
let latitude: Double
let longitude: Double
var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) }
var isValid: Bool { CLLocationCoordinate2DIsValid(coordinate) && (latitude != 0 || longitude != 0) }
}
enum CoordinateStorageMigration {
private static let versionKey = "coordinateStorageMigrationVersion"
static let currentVersion = 1
enum LastCoordinateStore {
private static let latKey = "lastMapLat"
private static let lonKey = "lastMapLon"
static func save(lat: Double, lon: Double) {
UserDefaults.standard.set(lat, forKey: latKey)
UserDefaults.standard.set(lon, forKey: lonKey)
}
/// 取持久化坐标,未存过或无效返回 nil
static func load() -> LastCoordinate? {
let c = LastCoordinate(
latitude: UserDefaults.standard.double(forKey: latKey),
longitude: UserDefaults.standard.double(forKey: lonKey)
static func migrateIfNeeded(
favorites: FavoriteLocationStore,
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) throws {
guard defaults.integer(forKey: versionKey) < currentVersion else { return }
// Pre-migration map pin and viewport values lived in UserDefaults.standard;
// favorites already lived in App Group defaults.
try LastCoordinateStore.migrateLegacyCoordinate(
defaults: defaults,
legacyDefaults: legacyDefaults
)
return c.isValid ? c : nil
try favorites.migrateLegacyCoordinates()
defaults.set(currentVersion, forKey: versionKey)
RuntimeLogger.info("APP", "坐标转换", "旧坐标数据迁移完成")
}
}
+40 -8
View File
@@ -32,6 +32,7 @@ enum MapZoomMath {
struct MapViewRepresentable: UIViewRepresentable {
let selection: MapSelection
let initialViewportMeters: CLLocationDistance
let cameraCommand: MapCameraCommand?
let onRealtimeLocationChanged: (CLLocation) -> Void
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
@@ -47,9 +48,11 @@ struct MapViewRepresentable: UIViewRepresentable {
let map = MKMapView()
map.delegate = context.coordinator
map.showsUserLocation = true
let initialDistance = ViewportStore.loadOrDefault()
let initialDistance = max(50, initialViewportMeters)
RuntimeLogger.info("APP", "地图", "makeUIView", details: [
"zoom": String(initialDistance)
"zoom": String(initialDistance),
"初始坐标来源": String(describing: selection.source),
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
])
map.setRegion(
MKCoordinateRegion(
@@ -102,7 +105,7 @@ struct MapViewRepresentable: UIViewRepresentable {
zoomLabel.textAlignment = .center
zoomLabel.adjustsFontSizeToFitWidth = true
zoomLabel.minimumScaleFactor = 0.7
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: ViewportStore.loadOrDefault())
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: initialDistance)
zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true
context.coordinator.zoomLabel = zoomLabel
@@ -160,6 +163,7 @@ struct MapViewRepresentable: UIViewRepresentable {
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
private var userDotDiameter: CGFloat = 20
private var keyboardObserverTokens: [NSObjectProtocol] = []
private var lastForwardedRealtimeTimestamp: Date?
deinit {
keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver)
@@ -235,10 +239,28 @@ struct MapViewRepresentable: UIViewRepresentable {
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard let location = userLocation.location,
CLLocationCoordinate2DIsValid(location.coordinate),
location.horizontalAccuracy >= 0 else { return }
parent.onRealtimeLocationChanged(location)
guard let location = userLocation.location else {
RuntimeLogger.warning("APP", "实时定位", "MapKit 蓝点更新但 location 为空", details: [
"来源": "MKMapView.didUpdate"
])
return
}
guard CLLocationCoordinate2DIsValid(location.coordinate) else {
RealtimeLocationTrace.log("拒绝 MapKit 蓝点样本:坐标无效", location: location, details: [
"来源": "MKMapView.didUpdate"
], level: .warning)
return
}
guard location.horizontalAccuracy >= 0 else {
RealtimeLocationTrace.log("拒绝 MapKit 蓝点样本:水平精度无效", location: location, details: [
"来源": "MKMapView.didUpdate"
], level: .warning)
return
}
RealtimeLocationTrace.log("收到 MapKit 可见蓝点样本", location: location, details: [
"来源": "MKMapView.didUpdate"
])
forwardRealtimeLocation(location)
}
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
@@ -271,7 +293,12 @@ struct MapViewRepresentable: UIViewRepresentable {
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
if let ul = mapView.userLocation.location,
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
parent.onRealtimeLocationChanged(ul)
if lastForwardedRealtimeTimestamp.map({ ul.timestamp > $0 }) ?? true {
RealtimeLocationTrace.log("区域变化后同步到较新的 MapKit 蓝点样本", location: ul, details: [
"来源": "MKMapView.regionDidChange"
])
forwardRealtimeLocation(ul)
}
}
let userZoomed: Bool
@@ -295,6 +322,11 @@ struct MapViewRepresentable: UIViewRepresentable {
regionChangeWasUserDriven = false
}
private func forwardRealtimeLocation(_ location: CLLocation) {
lastForwardedRealtimeTimestamp = location.timestamp
parent.onRealtimeLocationChanged(location)
}
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)
+110 -7
View File
@@ -92,24 +92,43 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
func requestLocation() async -> CLLocationCoordinate2D? {
guard activeRequest == nil else {
RuntimeLogger.warning("APP", "定位", "忽略重复实时定位请求")
RuntimeLogger.warning("APP", "实时定位", "忽略重复 CLLocationManager 请求", details: [
"活动requestID": activeRequest.map { String($0.id) } ?? "nil",
"活动阶段": activeRequest.map { phaseName($0.phase) } ?? "nil"
])
return nil
}
authorizationStatus = driver.authorizationStatus
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求入口", details: [
"授权状态": authorizationName(authorizationStatus),
"内存缓存存在": String(location != nil),
"系统缓存存在": String(driver.location != nil)
])
guard authorizationStatus != .denied, authorizationStatus != .restricted else {
RuntimeLogger.warning("APP", "实时定位", "授权状态不允许定位", details: [
"授权状态": authorizationName(authorizationStatus)
])
return nil
}
if let cached = freshestCachedLocation() {
location = cached
RealtimeLocationTrace.log("使用 CLLocationManager 新鲜缓存", location: cached, details: [
"来源": "manager-memory-or-system",
"缓存上限秒": String(Int(cacheMaxAge))
])
return cached.coordinate
}
nextRequestID &+= 1
let requestID = nextRequestID
isRequesting = true
RuntimeLogger.info("APP", "定位", "请求实时定位…", details: ["requestID": String(requestID)])
RuntimeLogger.info("APP", "实时定位", "创建 CLLocationManager 请求", details: [
"requestID": String(requestID),
"授权状态": authorizationName(authorizationStatus),
"初始阶段": "awaitingAuthorization"
])
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
@@ -121,6 +140,9 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
)
if authorizationStatus == .notDetermined {
RuntimeLogger.info("APP", "实时定位", "请求前台定位授权", details: [
"requestID": String(requestID)
])
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
driver.requestWhenInUseAuthorization()
} else {
@@ -148,6 +170,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
MainActor.assumeIsolated {
authorizationStatus = driver.authorizationStatus
RuntimeLogger.info("APP", "实时定位", "定位授权状态变化", details: [
"授权状态": authorizationName(authorizationStatus),
"requestID": activeRequest.map { String($0.id) } ?? "nil",
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
])
switch authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
if let request = activeRequest, request.phase == .awaitingAuthorization {
@@ -165,6 +192,27 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
MainActor.assumeIsolated {
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 返回样本批次", details: [
"样本数": String(locations.count),
"requestID": activeRequest.map { String($0.id) } ?? "nil",
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
])
for (index, candidate) in locations.enumerated() {
let valid = Self.isValid(candidate)
let freshEnough = activeRequest?.startedAt.map {
candidate.timestamp >= $0.addingTimeInterval(-1)
} ?? false
RealtimeLocationTrace.log(
valid ? "检查 CLLocationManager 样本" : "拒绝 CLLocationManager 样本:坐标或精度无效",
location: candidate,
details: [
"批次索引": String(index),
"基础校验通过": String(valid),
"满足当前请求时间窗": String(freshEnough)
],
level: valid ? .info : .warning
)
}
let validLocations = locations.filter(Self.isValid)
if let latestValid = validLocations.last {
location = latestValid
@@ -176,8 +224,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
let latest = validLocations.last(where: {
$0.timestamp >= startedAt.addingTimeInterval(-1)
}) else {
if let request = activeRequest, request.phase != .awaitingAuthorization {
RuntimeLogger.warning("APP", "实时定位", "本批次没有可完成当前请求的样本", details: [
"requestID": String(request.id),
"阶段": phaseName(request.phase),
"有效样本数": String(validLocations.count)
])
}
return
}
RealtimeLocationTrace.log("接受 CLLocationManager 样本并完成请求", location: latest, details: [
"requestID": String(request.id),
"阶段": phaseName(request.phase)
])
finishRequest(id: request.id, coordinate: latest.coordinate)
}
}
@@ -206,8 +265,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
}
private func freshestCachedLocation(now: Date = Date()) -> CLLocation? {
[location, driver.location]
.compactMap { $0 }
let candidates = [location, driver.location].compactMap { $0 }
for candidate in candidates {
let valid = Self.isValid(candidate)
let age = abs(candidate.timestamp.timeIntervalSince(now))
if !valid || age > cacheMaxAge {
RealtimeLocationTrace.log("跳过 CLLocationManager 缓存样本", location: candidate, details: [
"基础校验通过": String(valid),
"缓存时效通过": String(age <= cacheMaxAge),
"缓存上限秒": String(Int(cacheMaxAge))
], level: .warning)
}
}
return candidates
.filter(Self.isValid)
.filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge }
.max(by: { $0.timestamp < $1.timestamp })
@@ -253,6 +323,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
request.phase = .oneShot
request.startedAt = Date()
activeRequest = request
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 单次定位", details: [
"requestID": String(requestID),
"超时毫秒": String(oneShotTimeoutNanoseconds / 1_000_000)
])
scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds)
driver.requestLocation()
}
@@ -263,6 +337,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
request.phase == .oneShot else { return }
request.phase = .continuousFallback
activeRequest = request
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 持续定位兜底", details: [
"requestID": String(requestID),
"超时毫秒": String(fallbackTimeoutNanoseconds / 1_000_000)
])
driver.startUpdatingLocation()
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
}
@@ -281,12 +359,37 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
isRequesting = false
if coordinate != nil {
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
"requestID": String(requestID)
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求完成", details: [
"requestID": String(requestID),
"最终阶段": phaseName(request.phase),
"有坐标": "true"
])
} else {
RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)])
RuntimeLogger.warning("APP", "实时定位", "CLLocationManager 请求结束但没有坐标", details: [
"requestID": String(requestID),
"最终阶段": phaseName(request.phase),
"有坐标": "false"
])
}
request.continuation.resume(returning: coordinate)
}
private func phaseName(_ phase: RequestPhase) -> String {
switch phase {
case .awaitingAuthorization: return "awaitingAuthorization"
case .oneShot: return "oneShot"
case .continuousFallback: return "continuousFallback"
}
}
private func authorizationName(_ status: CLAuthorizationStatus) -> String {
switch status {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .authorizedAlways: return "authorizedAlways"
case .authorizedWhenInUse: return "authorizedWhenInUse"
@unknown default: return "unknown(\(status.rawValue))"
}
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ struct SettingsView: View {
Section("应用") {
Button {
setup.needsSetup = true
setup.requestSetup()
} label: {
Label("进入引导页", systemImage: "arrow.clockwise.circle")
}
+54 -56
View File
@@ -9,6 +9,7 @@ final class SetupCoordinator: ObservableObject {
@Published var testLog = ""
// 启动检测失败才弹引导页;检测通过则保持 false
@Published var needsSetup = false
@Published private(set) var setupStep: SetupStep = .proxy
let certificateStore = CertificateAuthorityStore()
let proxy = ProxyManager.shared
@@ -24,68 +25,48 @@ final class SetupCoordinator: ObservableObject {
var canModify: Bool { proxy.isRunning && trustState == .trusted }
func refreshTrust() async {
trustState = .checking
message = "正在检测…"
testLog = ""
defer { needsSetup = !canModify }
/// Local services are prepared before the setup UI so the environment test
/// can distinguish Wi-Fi proxy configuration from CA trust failures.
func prepareLocalServices() async {
do {
_ = try certificateStore.ensure()
if !proxy.isRunning { try await proxy.start() }
// 强制走代理 POST 到 Apple 定位接口:TLS 成功 = CA 信任
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 5
config.timeoutIntervalForResource = 8
config.connectionProxyDictionary = [
kCFNetworkProxiesHTTPEnable as String: true,
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
kCFNetworkProxiesHTTPPort as String: 8888,
]
// This probe verifies proxy reachability and TLS trust only; it does not validate a selected coordinate.
let req = makeWlocRequest()
let (_, resp) = try await URLSession(configuration: config).data(for: req)
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
// 400 = Apple 拒绝了测试请求体,但 TLS 握手成功 = 证书已信任
if status == 0 {
trustState = .unavailable
message = "代理链路异常,未收到响应"
return
}
trustState = .trusted
message = "✓ 定位环境正常(返回 \(status))"
} catch {
message = "本地代理初始化失败:\(error.localizedDescription)"
RuntimeLogger.error("APP", "Startup", "本地服务初始化失败", error: error)
}
}
func applyVerificationResult(_ result: VerificationResult) {
switch result {
case .success:
trustState = .trusted
needsSetup = false
message = "✓ 定位环境正常"
case .certNotTrusted:
trustState = .unavailable
let ns = error as NSError
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
message = "CA 证书未信任,请去「设置→通用→关于→证书信任设置」开启或重新安装"
} else if ns.domain == NSURLErrorDomain && ns.code == -1001 {
message = "检测超时,请检查代理是否正常"
} else if ns.domain == NSURLErrorDomain && ns.code == -1200 {
message = "代理未启动或无法连接"
} else {
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
}
setupStep = .cert
needsSetup = true
message = "CA 证书未安装或未信任"
default:
trustState = .unavailable
setupStep = .proxy
needsSetup = true
message = "Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888"
}
}
func sceneDidBecomeActive() {}
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
func completeSetup() { needsSetup = false }
func requestSetup() { needsSetup = true }
func requestSetup() {
setupStep = .proxy
needsSetup = true
}
// MARK: - Step-by-step verification test
private func makeWlocRequest() -> URLRequest {
var req = URLRequest(url: URL(string: "https://gs-loc.apple.com/clls/wloc")!)
req.httpMethod = "POST"
req.httpBody = CoreBridge.testWlocRequestData()
req.setValue("application/x-protobuf", forHTTPHeaderField: "Content-Type")
req.setValue("wloc/1.0", forHTTPHeaderField: "User-Agent")
req.setValue("application/x-protobuf", forHTTPHeaderField: "Accept")
return req
}
func runVerificationTest(testLat: Double = 22.543099, testLon: Double = 113.934576) async -> VerificationResult {
func runVerificationTest() async -> VerificationResult {
guard !isVerificationRunning else { return .verificationInProgress }
isVerificationRunning = true
defer { isVerificationRunning = false }
@@ -137,20 +118,15 @@ final class SetupCoordinator: ObservableObject {
if body == verifyToken {
log(" ✓ 证书已信任,WiFi 代理已配置")
} else {
log(" ✗ 响应不匹配 (HTTP \(statusCode)),WiFi 代理未配置")
log(" 收到: \(body.prefix(100))")
log(" ✗ 响应不匹配: HTTP \(statusCode), \(data.count) bytes,WiFi 代理未配置")
return .wifiProxyNotConfigured
}
} catch {
let ns = error as NSError
let msg = error.localizedDescription
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)")
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
log(" TLS 握手被拒,CA 证书未信任")
return .certNotTrusted
}
if msg.contains("TLS") {
log(" 包含 TLS → 证书问题")
if isCertificateTrustError(nsError: ns, message: msg) {
log(" TLS/证书校验失败,CA 证书未信任")
return .certNotTrusted
}
return .wifiProxyNotConfigured
@@ -162,6 +138,28 @@ final class SetupCoordinator: ObservableObject {
return .success
}
/// Classifies TLS trust failures without relying on localized error text alone.
private func isCertificateTrustError(nsError: NSError, message: String) -> Bool {
if nsError.domain == NSURLErrorDomain {
let trustErrorCodes: Set<Int> = [
-1200, // secure connection failed
-1201, // server certificate has bad date
-1202, // server certificate untrusted
-1203, // server certificate has unknown root
-1204, // server certificate not yet valid
-1205, // client certificate rejected
-1206, // client certificate required
]
return trustErrorCodes.contains(nsError.code)
}
let normalized = message.lowercased()
return normalized.contains("tls")
|| normalized.contains("ssl")
|| normalized.contains("certificate")
|| normalized.contains("证书")
}
/// 拉取 Go 代理的详细日志(CONNECT/请求/上游响应/改写结果)到 testLog
private func collectProxyLogs(since date: Date, to log: (String) -> Void) {
CoreBridge.flushLogs(category: "Proxy")
-20
View File
@@ -4,7 +4,6 @@ enum TipKind: String, Identifiable {
case activation = "生效说明"
case deactivation = "失效说明"
case removeProxy = "关闭 WiFi 代理"
case certificate = "证书问题"
case proxySetup = "配置代理"
case rewriteFailed = "改写失败"
var id: String { rawValue }
@@ -22,7 +21,6 @@ struct TipSheetView: View {
case .activation: ActivationTipContent(dismiss: { dismiss() })
case .deactivation: DeactivationTipContent(dismiss: { dismiss() })
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
case .certificate: CertificateTipContent(dismiss: { dismiss() })
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
}
@@ -185,24 +183,6 @@ struct RemoveProxyTipContent: View {
}
}
// MARK: - 证书问题
struct CertificateTipContent: View {
let dismiss: () -> Void
var body: some View {
GroupBox(label: Label("证书未安装或未信任", systemImage: "lock.shield")) {
VStack(alignment: .leading, spacing: 8) {
Text("代理的 HTTPS 请求被系统拦截了,原因是 CA 证书未完成安装或信任。\n\n请依次检查:\n1. 打开「设置 → 通用 → VPN与设备管理」,确认 WLOC CA 证书已安装。如果没有,请删除旧证书后回到 App 重新下载安装\n2. 打开「设置 → 通用 → 关于本机 → 证书信任设置」,找到 WLOC CA 开启开关\n\n⚠️ 每次重装 App 都需要重新下载安装证书。如报 TLS 错误,请删除旧证书后重装。")
.font(.caption).foregroundStyle(.primary)
Button { openSettings(.general) } label: {
Label("去设置", systemImage: "arrow.up.right.square").font(.caption)
}.buttonStyle(.bordered).tint(.blue)
}.padding(.vertical, 4)
}
}
}
// MARK: - WiFi 代理配置
struct ProxySetupTipContent: View {