diff --git a/App/BugReportView.swift b/App/BugReportView.swift index c4663a3..23549ce 100644 --- a/App/BugReportView.swift +++ b/App/BugReportView.swift @@ -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 // 获取版本信息 diff --git a/App/ContentView.swift b/App/ContentView.swift index a1aafb9..f35ad2e 100644 --- a/App/ContentView.swift +++ b/App/ContentView.swift @@ -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 } } diff --git a/App/DiagnosticsView.swift b/App/DiagnosticsView.swift index 990eca8..72ae6b9 100644 --- a/App/DiagnosticsView.swift +++ b/App/DiagnosticsView.swift @@ -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 diff --git a/App/FirstSetupView.swift b/App/FirstSetupView.swift index ae35460..36c65c6 100644 --- a/App/FirstSetupView.swift +++ b/App/FirstSetupView.swift @@ -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 diff --git a/App/LocationActionCoordinator.swift b/App/LocationActionCoordinator.swift index fc44828..693d6cc 100644 --- a/App/LocationActionCoordinator.swift +++ b/App/LocationActionCoordinator.swift @@ -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 ) diff --git a/App/MapHomeView.swift b/App/MapHomeView.swift index cc105e1..116d56e 100644 --- a/App/MapHomeView.swift +++ b/App/MapHomeView.swift @@ -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? @@ -56,8 +56,6 @@ struct MapHomeView: View { @State private var wifiChangeObserverToken: UUID? @State private var wifiVerificationTask: Task? @State private var wifiVerificationID: UUID? - @State private var tileProbeTask: Task? - @State private var tileProbeID: UUID? @State private var copyConfirmed = false @State private var spoofState: SpoofState = .idle @State private var locationOperationTask: Task? @@ -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) } } } diff --git a/App/MapLocationState.swift b/App/MapLocationState.swift index 2336e71..043f902 100644 --- a/App/MapLocationState.swift +++ b/App/MapLocationState.swift @@ -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", "坐标转换", "旧坐标数据迁移完成") } } diff --git a/App/MapViewRepresentable.swift b/App/MapViewRepresentable.swift index 5aaad22..ad05107 100644 --- a/App/MapViewRepresentable.swift +++ b/App/MapViewRepresentable.swift @@ -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) diff --git a/App/RealtimeLocationManager.swift b/App/RealtimeLocationManager.swift index 737e3a4..c56ce15 100644 --- a/App/RealtimeLocationManager.swift +++ b/App/RealtimeLocationManager.swift @@ -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))" + } + } } diff --git a/App/SettingsView.swift b/App/SettingsView.swift index 24e29b7..a4634c1 100644 --- a/App/SettingsView.swift +++ b/App/SettingsView.swift @@ -53,7 +53,7 @@ struct SettingsView: View { Section("应用") { Button { - setup.needsSetup = true + setup.requestSetup() } label: { Label("进入引导页", systemImage: "arrow.clockwise.circle") } diff --git a/App/SetupCoordinator.swift b/App/SetupCoordinator.swift index aaf25f8..4b5ed8d 100644 --- a/App/SetupCoordinator.swift +++ b/App/SetupCoordinator.swift @@ -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 = [ + -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") diff --git a/App/TipViews.swift b/App/TipViews.swift index 2248c09..0901307 100644 --- a/App/TipViews.swift +++ b/App/TipViews.swift @@ -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 { diff --git a/Core/bridge.go b/Core/bridge.go index 1ad13eb..5d7c9e4 100644 --- a/Core/bridge.go +++ b/Core/bridge.go @@ -41,6 +41,18 @@ func wloccore_generateca() (r0, r1 *C.char) { return C.CString(string(cert)), C.CString(string(key)) } +//export wloccore_validateca +func wloccore_validateca(certData, keyData *C.char) C.int { + if certData == nil || keyData == nil { + return 0 + } + if _, err := parseCA([]byte(C.GoString(certData)), []byte(C.GoString(keyData))); err != nil { + logEvent("validateca failed: " + err.Error()) + return 0 + } + return 1 +} + //export wloccore_startproxy func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t { if certData == nil || keyData == nil { diff --git a/Core/proxy.go b/Core/proxy.go index b342682..fa6a87b 100644 --- a/Core/proxy.go +++ b/Core/proxy.go @@ -145,7 +145,9 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer { logEvent("CONNECT " + host + " -> MITM (verify)") return mitmAction, host } - logEvent("CONNECT " + host + " -> passthrough") + // Global Wi-Fi proxy mode sends all HTTPS CONNECT traffic here. Logging + // unrelated passthrough hosts creates high-volume noise and can disclose + // browsing destinations; diagnostics only retain WLOC and verify traffic. return goproxy.OkConnect, host }) } diff --git a/Shared/AppGroup.swift b/Shared/AppGroup.swift index 2a9df69..010e7bf 100644 --- a/Shared/AppGroup.swift +++ b/Shared/AppGroup.swift @@ -43,3 +43,76 @@ enum WlocSettingsStore { save(WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false)) } } + +enum VirtualLocationTipKind: Equatable { + case activation + case deactivation +} + +/// Owns the persistent counters and suppression flags for automatic operation tips. +/// Manual help sheets do not consult or mutate this store. +struct VirtualLocationTipPreferences { + static let minimumCountForSuppression = 3 + + private enum Key { + static let activationCount = "virtualLocationTip.activationCount" + static let deactivationCount = "virtualLocationTip.deactivationCount" + static let activationSuppressed = "virtualLocationTip.activationSuppressed" + static let deactivationSuppressed = "virtualLocationTip.deactivationSuppressed" + static let legacyActivationSuppressed = "activationTipDisabled" + } + + private let defaults: UserDefaults + private let legacyDefaults: UserDefaults + + init( + defaults: UserDefaults = AppGroup.defaults, + legacyDefaults: UserDefaults = .standard + ) { + self.defaults = defaults + self.legacyDefaults = legacyDefaults + } + + @discardableResult + func recordSuccessfulOperation(_ kind: VirtualLocationTipKind) -> Int { + let key = countKey(for: kind) + let next = defaults.integer(forKey: key) + 1 + defaults.set(next, forKey: key) + return next + } + + func shouldPresentAutomaticTip(_ kind: VirtualLocationTipKind) -> Bool { + !isSuppressed(kind) + } + + func canSuppress(_ kind: VirtualLocationTipKind) -> Bool { + defaults.integer(forKey: countKey(for: kind)) >= Self.minimumCountForSuppression + } + + func suppress(_ kind: VirtualLocationTipKind) { + guard canSuppress(kind) else { return } + defaults.set(true, forKey: suppressionKey(for: kind)) + } + + private func isSuppressed(_ kind: VirtualLocationTipKind) -> Bool { + if kind == .activation, + legacyDefaults.bool(forKey: Key.legacyActivationSuppressed) { + return true + } + return defaults.bool(forKey: suppressionKey(for: kind)) + } + + private func countKey(for kind: VirtualLocationTipKind) -> String { + switch kind { + case .activation: return Key.activationCount + case .deactivation: return Key.deactivationCount + } + } + + private func suppressionKey(for kind: VirtualLocationTipKind) -> String { + switch kind { + case .activation: return Key.activationSuppressed + case .deactivation: return Key.deactivationSuppressed + } + } +} diff --git a/Shared/CertificateAuthorityStore.swift b/Shared/CertificateAuthorityStore.swift index 36de1e8..6290da7 100644 --- a/Shared/CertificateAuthorityStore.swift +++ b/Shared/CertificateAuthorityStore.swift @@ -1,46 +1,178 @@ import Foundation +import Security + +protocol CertificateAuthorityKeychain { + func load() throws -> CertificateAuthority? + func save(_ authority: CertificateAuthority) throws + func remove() throws +} + +enum CertificateAuthorityStoreError: LocalizedError { + case invalidAuthority + case keychain(OSStatus) + + var errorDescription: String? { + switch self { + case .invalidAuthority: return "本地 CA 证书或私钥无效" + case let .keychain(status): return "无法写入设备钥匙串(\(status))" + } + } +} + +final class DeviceCertificateAuthorityKeychain: CertificateAuthorityKeychain { + private enum Item { + static let service = "com.paopaolabs.location-spoofer.certificate-authority" + static let certificateAccount = "root-ca-certificate" + static let keyAccount = "root-ca-private-key" + } + + func load() throws -> CertificateAuthority? { + guard let certPEM = try load(account: Item.certificateAccount), + let keyPEM = try load(account: Item.keyAccount) else { + return nil + } + return CertificateAuthority(certPEM: certPEM, keyPEM: keyPEM) + } + + func save(_ authority: CertificateAuthority) throws { + try remove() + do { + try save(authority.certPEM, account: Item.certificateAccount) + try save(authority.keyPEM, account: Item.keyAccount) + } catch { + try? remove() + throw error + } + } + + func remove() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Item.service, + kSecAttrSynchronizable as String: kCFBooleanFalse as Any, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CertificateAuthorityStoreError.keychain(status) + } + } + + private func load(account: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Item.service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kCFBooleanFalse as Any, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + throw CertificateAuthorityStoreError.keychain(status) + } + return value + } + + private func save(_ value: String, account: String) throws { + guard let data = value.data(using: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Item.service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrSynchronizable as String: kCFBooleanFalse as Any, + kSecValueData as String: data, + ] + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { throw CertificateAuthorityStoreError.keychain(status) } + } +} final class CertificateAuthorityStore { private let directory: URL private let generator: () throws -> CertificateAuthority + private let validator: (CertificateAuthority) -> Bool + private let keychain: CertificateAuthorityKeychain private let certificateURL: URL private let keyURL: URL - init(directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true), generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority) { + init( + directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true), + keychain: CertificateAuthorityKeychain = DeviceCertificateAuthorityKeychain(), + generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority, + validator: @escaping (CertificateAuthority) -> Bool = CoreBridge.isValidCertificateAuthority + ) { self.directory = directory + self.keychain = keychain self.generator = generator + self.validator = validator self.certificateURL = directory.appendingPathComponent("ca-cert.pem") self.keyURL = directory.appendingPathComponent("ca-key.pem") } func ensure() throws -> CertificateAuthority { - if let current = try load() { - RuntimeLogger.debug("SHARED", "Certificate.store", "读取已有 CA 文件", details: ["directory": directory.path]) - return current + if let authority = try loadValidKeychainAuthority() { + removeLegacyFilesBestEffort() + RuntimeLogger.debug("SHARED", "Certificate.store", "复用设备钥匙串中的 CA") + return authority } - RuntimeLogger.info("SHARED", "Certificate.store", "未找到 CA 文件,开始生成", details: ["directory": directory.path]) + + if let legacy = try loadLegacyAuthority(), validator(legacy) { + try keychain.save(legacy) + removeLegacyFilesBestEffort() + RuntimeLogger.info("SHARED", "Certificate.store", "旧 CA 已迁移到设备钥匙串") + return legacy + } + let authority = try generator() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - guard let certificateData = authority.certPEM.data(using: .utf8), - let keyData = authority.keyPEM.data(using: .utf8) else { - throw CocoaError(.fileWriteInapplicableStringEncoding) - } - try certificateData.write(to: certificateURL, options: .atomic) - try keyData.write(to: keyURL, options: .atomic) - applyCompleteProtection(to: certificateURL) - applyCompleteProtection(to: keyURL) - RuntimeLogger.info("SHARED", "Certificate.store", "CA 文件写入完成", details: ["directory": directory.path]) + guard validator(authority) else { throw CertificateAuthorityStoreError.invalidAuthority } + try keychain.save(authority) + removeLegacyFilesBestEffort() + RuntimeLogger.info("SHARED", "Certificate.store", "已生成并保存设备专属 CA") return authority } func load() throws -> CertificateAuthority? { - guard FileManager.default.fileExists(atPath: certificateURL.path), FileManager.default.fileExists(atPath: keyURL.path) else { return nil } - return CertificateAuthority(certPEM: try String(contentsOf: certificateURL, encoding: .utf8), keyPEM: try String(contentsOf: keyURL, encoding: .utf8)) + try loadValidKeychainAuthority() } - private func applyCompleteProtection(to url: URL) { - #if os(iOS) - try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path) - #endif + private func loadValidKeychainAuthority() throws -> CertificateAuthority? { + guard let authority = try keychain.load() else { return nil } + guard validator(authority) else { + RuntimeLogger.warning("SHARED", "Certificate.store", "钥匙串中的 CA 无效,准备回退") + try? keychain.remove() + return nil + } + return authority + } + + private func loadLegacyAuthority() throws -> CertificateAuthority? { + guard FileManager.default.fileExists(atPath: certificateURL.path), + FileManager.default.fileExists(atPath: keyURL.path) else { + return nil + } + return CertificateAuthority( + certPEM: try String(contentsOf: certificateURL, encoding: .utf8), + keyPEM: try String(contentsOf: keyURL, encoding: .utf8) + ) + } + + private func removeLegacyFilesBestEffort() { + let fileManager = FileManager.default + // Remove private material first. Each item is retried on later launches + // when a valid Keychain authority is available. + for url in [keyURL, certificateURL] where fileManager.fileExists(atPath: url.path) { + do { + try fileManager.removeItem(at: url) + } catch { + RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error) + } + } + try? fileManager.removeItem(at: directory) } } diff --git a/Shared/CoordinateConverter.swift b/Shared/CoordinateConverter.swift index f128c1b..4937882 100644 --- a/Shared/CoordinateConverter.swift +++ b/Shared/CoordinateConverter.swift @@ -2,121 +2,199 @@ import Foundation import CoreLocation import MapKit +struct CoordinatePair: Codable, Equatable { + static let currentConversionVersion = 1 + + struct Value: Codable, Equatable { + let latitude: Double + let longitude: Double + + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + } + + let wgs84: Value + let gcj02: Value + let conversionVersion: Int + + init(wgs84: Value, gcj02: Value, conversionVersion: Int = CoordinatePair.currentConversionVersion) { + self.wgs84 = wgs84 + self.gcj02 = gcj02 + self.conversionVersion = conversionVersion + } + + init(mapCoordinate: CLLocationCoordinate2D, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) { + self = CoordinateConverter.coordinatePair( + lat: mapCoordinate.latitude, + lon: mapCoordinate.longitude, + mapCoordinateSystem: mapCoordinateSystem + ) + } + + func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D { + switch mapCoordinateSystem { + case .wgs84: return wgs84.coordinate + case .gcj02: return gcj02.coordinate + } + } + + func matchesWGS84(latitude: Double, longitude: Double, tolerance: Double = 0.0001) -> Bool { + abs(wgs84.latitude - latitude) <= tolerance + && abs(wgs84.longitude - longitude) <= tolerance + } +} + /// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。 /// -/// MKMapView 根据实时定位动态切换瓦片源:中国境内用高德 GCJ-02,境外用 Apple WGS-84。 -/// App 内部统一以 WGS-84 存储,仅在地图交互时按当前瓦片类型双向转换。 +/// MapKit does not expose a public API for its active coordinate reference system. +/// The app resolves it with a bounded heuristic, then stores both WGS-84 and +/// GCJ-02 representations at each write boundary so replay does not convert again. enum CoordinateConverter { // 椭球参数 (Krasovsky 1940) private static let a = 6378245.0 private static let ee = 0.00669342162296594323 /// 坐标类型 - enum CoordType: String { + enum MapCoordinateSystem: String { case gcj02 = "GCJ-02" case wgs84 = "WGS-84" } - // MARK: - 全局瓦片类型 + // MARK: - 地图坐标标准 - struct TileTypeChange: Equatable { - let previous: CoordType - let current: CoordType + struct MapCoordinateSystemChange: Equatable { + let previous: MapCoordinateSystem + let current: MapCoordinateSystem } - /// 当前地图瓦片坐标系。探测不可用时使用国内 GCJ-02 作为兜底。 - @MainActor static var currentTileType = CoordType.gcj02 - @MainActor private static var lastTileCheck: Date? - @MainActor private static var tileCheckPending = false + /// 当前 Apple 地图坐标标准。检测不可用时使用国内 GCJ-02 作为兜底。 + @MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02 + @MainActor private static var mapCoordinateSystemCheckPending = false + @MainActor private(set) static var initialMapCoordinateSystemUsedFallback = true - /// Uses one fixed, known reference result as a best-effort tile heuristic. - /// - /// MapKit does not expose a supported public API for its active tile CRS. A - /// timeout, empty response, or search error is therefore not evidence for - /// WGS-84: those cases deliberately fall back to the domestic GCJ-02 mode. + /// Startup gate: only request realtime location if the public MapKit probe + /// cannot resolve a coordinate type. This guarantees a finite answer before + /// persisted map positions are replayed. @MainActor - @discardableResult - static func detectTileByFixedGeocode(force: Bool = false) async -> TileTypeChange? { - guard !tileCheckPending else { - RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(进行中)") - return nil + static func resolveInitialMapCoordinateSystem() async -> MapCoordinateSystem { + guard !mapCoordinateSystemCheckPending else { + RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测已有请求进行中") + return currentMapCoordinateSystem } - if !force, let last = lastTileCheck, -last.timeIntervalSinceNow < 30 { - RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(缓存\(Int(-last.timeIntervalSinceNow))s)") - return nil - } - - tileCheckPending = true - defer { tileCheckPending = false } - RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 发起查询", details: [ - "force": String(force), - "当前瓦片": currentTileType.rawValue + mapCoordinateSystemCheckPending = true + defer { mapCoordinateSystemCheckPending = false } + RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测开始", details: [ + "锚点": "22.283819,114.158439", + "判定规则": "首条名称=林士街→GCJ-02,否则→WGS-84", + "缓存": "false" ]) - let probeResult = await fixedGeocodeProbe() - guard !Task.isCancelled else { return nil } - lastTileCheck = Date() - - let nextType: CoordType - switch probeResult { + let nextType: MapCoordinateSystem + switch await fixedAnchorCoordinateSystemProbe() { case let .response(name, count): nextType = name == "林士街" ? .gcj02 : .wgs84 - RuntimeLogger.info("APP", "坐标转换", "瓦片检测完成 → \(nextType.rawValue)", details: [ + initialMapCoordinateSystemUsedFallback = false + RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [ + "首条名称": name, "结果数": String(count), - "命中锚点": String(nextType == .gcj02) + "命中林士街": String(name == "林士街"), + "最终标准": nextType.rawValue, + "结果来源": "固定锚点" + ]) + case .unavailable(let reason), .timedOut(let reason): + RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测不可用,开始实时定位兜底", details: [ + "原因": reason + ]) + let realtime = await RealtimeLocationManager.shared.requestLocation() + if let realtime, + CLLocationCoordinate2DIsValid(realtime), + !usesGCJ02ServiceArea(lat: realtime.latitude, lon: realtime.longitude) { + nextType = .wgs84 + } else { + nextType = .gcj02 + } + initialMapCoordinateSystemUsedFallback = true + RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测使用兜底结果", details: [ + "探测失败原因": reason, + "实时定位存在": String(realtime != nil), + "最终标准": nextType.rawValue, + "结果来源": realtime == nil ? "默认国内标准" : "实时定位服务区域" ]) - case .unavailable: - nextType = .gcj02 - RuntimeLogger.warning("APP", "坐标转换", "瓦片检测无结果,回退 GCJ-02") - case .timedOut: - nextType = .gcj02 - RuntimeLogger.warning("APP", "坐标转换", "瓦片检测超时,回退 GCJ-02") case .cancelled: - return nil + initialMapCoordinateSystemUsedFallback = true + RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测被取消,保留默认国内标准", details: [ + "最终标准": currentMapCoordinateSystem.rawValue + ]) + return currentMapCoordinateSystem } - guard nextType != currentTileType else { return nil } - let change = TileTypeChange(previous: currentTileType, current: nextType) - currentTileType = nextType + currentMapCoordinateSystem = nextType + RuntimeLogger.info("APP", "坐标转换", "地图坐标标准已确定,允许创建地图", details: [ + "最终标准": nextType.rawValue, + "使用兜底": String(initialMapCoordinateSystemUsedFallback), + "缓存": "false" + ]) + return nextType + } + + /// A user-requested realtime sample is WGS-84 and can correct a provisional + /// startup map coordinate system without altering persisted coordinate pairs. + @MainActor + static func correctMapCoordinateSystemUsingRealtime(_ coordinate: CLLocationCoordinate2D) -> MapCoordinateSystemChange? { + guard CLLocationCoordinate2DIsValid(coordinate) else { return nil } + guard initialMapCoordinateSystemUsedFallback else { + RuntimeLogger.info("APP", "坐标转换", "实时定位不覆盖固定锚点的明确检测结果", details: [ + "当前标准": currentMapCoordinateSystem.rawValue + ]) + return nil + } + let next: MapCoordinateSystem = usesGCJ02ServiceArea(lat: coordinate.latitude, lon: coordinate.longitude) ? .gcj02 : .wgs84 + guard next != currentMapCoordinateSystem else { + RuntimeLogger.info("APP", "坐标转换", "实时定位确认兜底地图坐标标准无需修正", details: [ + "当前标准": currentMapCoordinateSystem.rawValue + ]) + return nil + } + let change = MapCoordinateSystemChange(previous: currentMapCoordinateSystem, current: next) + currentMapCoordinateSystem = next + RuntimeLogger.warning("APP", "坐标转换", "实时定位修正启动兜底地图坐标标准", details: [ + "from": change.previous.rawValue, + "to": change.current.rawValue + ]) return change } - /// Reprojects a map-display coordinate after the tile heuristic changes. - /// The physical coordinate remains WGS-84 in between the two display modes. - static func reprojectDisplayCoordinate( - _ coordinate: CLLocationCoordinate2D, - from previous: CoordType, - to current: CoordType - ) -> CLLocationCoordinate2D { - guard previous != current else { return coordinate } - let stored = storedCoordinate(lat: coordinate.latitude, lon: coordinate.longitude, tileType: previous) - let display = displayCoordinate(lat: stored.lat, lon: stored.lon, tileType: current) - return CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon) - } - - private static func fixedGeocodeProbe() async -> TileProbeResult { + private static func fixedAnchorCoordinateSystemProbe() async -> MapCoordinateSystemProbeResult { let request = MKLocalSearch.Request() request.naturalLanguageQuery = "22.283819, 114.158439" let search = MKLocalSearch(request: request) - let resolver = TileProbeResolver() + let resolver = MapCoordinateSystemProbeResolver() return await withTaskCancellationHandler(operation: { await withCheckedContinuation { continuation in let timeout = DispatchWorkItem { search.cancel() - resolver.resolve(.timedOut) + resolver.resolve(.timedOut(reason: "固定锚点查询超过5秒")) } resolver.install(continuation, timeout: timeout) guard !resolver.isResolved else { return } search.start { response, error in - guard error == nil else { - resolver.resolve(.unavailable) + if let error { + let nsError = error as NSError + resolver.resolve(.unavailable( + reason: "\(nsError.domain)(\(nsError.code)): \(nsError.localizedDescription)" + )) return } - resolver.resolve(.response( - name: response?.mapItems.first?.name ?? "", - count: response?.mapItems.count ?? 0 - )) + let items = response?.mapItems ?? [] + guard let first = items.first, + let name = first.name?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty else { + resolver.resolve(.unavailable(reason: "固定锚点查询返回空结果")) + return + } + resolver.resolve(.response(name: name, count: items.count)) } DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout) } @@ -126,48 +204,30 @@ enum CoordinateConverter { }) } - // MARK: - 存取转换 - - /// 地图坐标 → WGS-84 存储 - @MainActor - static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) { - let stored = storedCoordinate(lat: lat, lon: lon, tileType: currentTileType) - RuntimeLogger.info("APP", "坐标转换", "地图坐标已规范为 WGS-84", details: [ - "转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon)) - ]) - return stored - } - - /// WGS-84 存储 → 当前地图瓦片坐标系(显示用) - @MainActor - static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) { - let display = displayCoordinate(lat: lat, lon: lon, tileType: currentTileType) - RuntimeLogger.info("APP", "坐标转换", "WGS-84 坐标已适配地图显示", details: [ - "转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon)) - ]) - return display - } - - private static func storedCoordinate( - lat: Double, - lon: Double, - tileType: CoordType - ) -> (lat: Double, lon: Double) { - guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else { - return (lat, lon) + /// Creates the complete persisted pair once at the map input boundary. + static func coordinatePair(lat: Double, lon: Double, mapCoordinateSystem: MapCoordinateSystem) -> CoordinatePair { + let raw = CoordinatePair.Value(latitude: lat, longitude: lon) + switch mapCoordinateSystem { + case .wgs84: + let gcj = wgs84ToGcj02(lat: lat, lon: lon) + return CoordinatePair( + wgs84: raw, + gcj02: .init(latitude: gcj.lat, longitude: gcj.lon) + ) + case .gcj02: + let wgs = gcj02ToWgs84(lat: lat, lon: lon) + return CoordinatePair( + wgs84: .init(latitude: wgs.lat, longitude: wgs.lon), + gcj02: raw + ) } - return gcj02ToWgs84(lat: lat, lon: lon) } - private static func displayCoordinate( - lat: Double, - lon: Double, - tileType: CoordType - ) -> (lat: Double, lon: Double) { - guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else { - return (lat, lon) - } - return wgs84ToGcj02(lat: lat, lon: lon) + /// Legacy raw domestic map values were historically displayed as GCJ-02; + /// overseas values were WGS-84 and stay identity coordinates. + static func legacyCoordinatePair(lat: Double, lon: Double) -> CoordinatePair { + let type: MapCoordinateSystem = usesGCJ02ServiceArea(lat: lat, lon: lon) ? .gcj02 : .wgs84 + return coordinatePair(lat: lat, lon: lon, mapCoordinateSystem: type) } // MARK: - 工具 @@ -205,9 +265,8 @@ enum CoordinateConverter { return (lat + d.lat, lon + d.lon) } - /// AMap documents GCJ-02 for mainland China, Hong Kong, Macao and Taiwan; - /// its overseas world map uses WGS-84. Keep the bounds explicit so the - /// domestic fallback tile type never shifts an overseas coordinate. + /// Keep the GCJ-02 service region explicit. Outside this region, conversion + /// is identity so the domestic fallback can never shift an overseas value. static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool { let mainland = lat >= 0.8293 && lat <= 55.8271 && lon >= 72.004 && lon <= 137.8347 let hongKong = lat >= 22.13 && lat <= 22.57 && lon >= 113.82 && lon <= 114.45 @@ -249,17 +308,17 @@ enum CoordinateConverter { } } -private enum TileProbeResult { +private enum MapCoordinateSystemProbeResult { case response(name: String, count: Int) - case unavailable - case timedOut + case unavailable(reason: String) + case timedOut(reason: String) case cancelled } -private final class TileProbeResolver: @unchecked Sendable { +private final class MapCoordinateSystemProbeResolver: @unchecked Sendable { private let lock = NSLock() - private var result: TileProbeResult? - private var continuation: CheckedContinuation? + private var result: MapCoordinateSystemProbeResult? + private var continuation: CheckedContinuation? private var timeout: DispatchWorkItem? var isResolved: Bool { @@ -269,7 +328,7 @@ private final class TileProbeResolver: @unchecked Sendable { } func install( - _ continuation: CheckedContinuation, + _ continuation: CheckedContinuation, timeout: DispatchWorkItem ) { lock.lock() @@ -284,7 +343,7 @@ private final class TileProbeResolver: @unchecked Sendable { lock.unlock() } - func resolve(_ nextResult: TileProbeResult) { + func resolve(_ nextResult: MapCoordinateSystemProbeResult) { lock.lock() guard result == nil else { lock.unlock() diff --git a/Shared/CoreBridge.swift b/Shared/CoreBridge.swift index 31b1325..ab19518 100644 --- a/Shared/CoreBridge.swift +++ b/Shared/CoreBridge.swift @@ -19,6 +19,14 @@ enum CoreBridgeError: LocalizedError { } enum CoreBridge { + static func isValidCertificateAuthority(_ authority: CertificateAuthority) -> Bool { + authority.certPEM.withCString { certificate in + authority.keyPEM.withCString { key in + wloccore_validateca(UnsafeMutablePointer(mutating: certificate), UnsafeMutablePointer(mutating: key)) != 0 + } + } + } + static func generateCertificateAuthority() throws -> CertificateAuthority { RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA") let result = wloccore_generateca() @@ -41,21 +49,6 @@ enum CoreBridge { return String(cString: ptr) } - /// 构造接近真实设备格式的 wloc 请求体(多个 WiFi AP + 蜂窝基站)。 - static func testWlocRequestData() -> Data { - guard let ptr = wloccore_testrequesthex() else { return Data([0x0a, 0x02, 0x08, 0x01]) } - defer { free(ptr) } - let hex = String(cString: ptr) - var data = Data() - var idx = hex.startIndex - while idx < hex.endIndex { - let end = hex.index(idx, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex - if let b = UInt8(hex[idx.. FavoriteLocation { - let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy) - // 去重:相同坐标删除旧数据,新数据插入顶部 + func save( + name: String, + mapCoordinate: CLLocationCoordinate2D, + mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem, + accuracy: Int + ) -> FavoriteLocation { + let favorite = FavoriteLocation( + name: name, + coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem), + accuracy: accuracy + ) favorites.removeAll { - abs($0.latitude - favorite.latitude) < 0.000001 && abs($0.longitude - favorite.longitude) < 0.000001 + abs($0.coordinatePair.wgs84.latitude - favorite.coordinatePair.wgs84.latitude) < 0.000001 + && abs($0.coordinatePair.wgs84.longitude - favorite.coordinatePair.wgs84.longitude) < 0.000001 } favorites.insert(favorite, at: 0) select(favorite.id) - persist() + persistIgnoringFailure() return favorite } + /// Compatibility entry point for callers that already own raw map values. + @discardableResult + func save(name: String, latitude: Double, longitude: Double, accuracy: Int, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02) -> FavoriteLocation { + save( + name: name, + mapCoordinate: .init(latitude: latitude, longitude: longitude), + mapCoordinateSystem: mapCoordinateSystem, + accuracy: accuracy + ) + } + func select(_ id: UUID?) { selectedFavoriteID = id defaults.set(id?.uuidString, forKey: Keys.selectedID) @@ -72,7 +150,7 @@ final class FavoriteLocationStore: ObservableObject { func rename(_ id: UUID, to name: String) { guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return } favorites[idx].name = name - persist() + persistIgnoringFailure() } func delete(_ favorite: FavoriteLocation) { @@ -80,11 +158,23 @@ final class FavoriteLocationStore: ObservableObject { if selectedFavoriteID == favorite.id { select(favorites.first?.id) } - persist() + persistIgnoringFailure() } - private func persist() { - guard let data = try? JSONEncoder().encode(favorites) else { return } - defaults.set(data, forKey: Keys.favorites) + func migrateLegacyCoordinates() throws { + guard favorites.contains(where: \.isLegacyCoordinateRecord) else { return } + try persist() + } + + private func persistIgnoringFailure() { + do { + try persist() + } catch { + RuntimeLogger.error("APP", "收藏", "保存收藏失败", error: error) + } + } + + private func persist() throws { + defaults.set(try JSONEncoder().encode(favorites), forKey: Keys.favorites) } } diff --git a/Shared/NetworkMonitor.swift b/Shared/NetworkMonitor.swift index b349551..7192473 100644 --- a/Shared/NetworkMonitor.swift +++ b/Shared/NetworkMonitor.swift @@ -2,6 +2,12 @@ import Network import Foundation import SystemConfiguration.CaptiveNetwork +enum WiFiChangeReason: String { + case reconnected = "Wi-Fi 恢复连接" + case interfaceChanged = "网络接口切换到 Wi-Fi" + case ssidChanged = "SSID 发生变化" +} + @MainActor final class NetworkMonitor: ObservableObject { static let shared = NetworkMonitor() @@ -10,26 +16,44 @@ final class NetworkMonitor: ObservableObject { @Published private(set) var isWiFiEnabled = true @Published private(set) var currentSSID: String? - /// WiFi 重连或 SSID 变化时触发的订阅。调用方必须在离开页面时移除订阅。 - private var wifiChangeHandlers: [UUID: @MainActor () -> Void] = [:] + /// Wi-Fi 重连、接口切换或 SSID 变化时触发。调用方必须在离开页面时移除订阅。 + private var wifiChangeHandlers: [UUID: @MainActor (WiFiChangeReason) -> Void] = [:] private let monitor = NWPathMonitor() private var ssidTimer: Timer? private var wasSatisfied = true + private var wasWiFiEnabled = true + private var hasReceivedInitialPath = false + private var lastKnownSSID: String? private init() { + let initialSSID = Self.fetchSSID() + currentSSID = initialSSID + lastKnownSSID = initialSSID monitor.pathUpdateHandler = { [weak self] path in let satisfied = path.status == .satisfied let wifi = path.usesInterfaceType(.wifi) Task { @MainActor in guard let self else { return } - // 网络恢复连接 → 触发检测 - let reconnected = satisfied && !self.wasSatisfied && wifi + let reason: WiFiChangeReason? + if !self.hasReceivedInitialPath { + // NWPathMonitor 的首次回调只是状态基线,不是网络切换。 + self.hasReceivedInitialPath = true + reason = nil + } else if satisfied && wifi && !self.wasSatisfied { + reason = .reconnected + } else if satisfied && wifi && !self.wasWiFiEnabled { + // 蜂窝网络和 Wi-Fi 都可能是 satisfied,不能只比较 status。 + reason = .interfaceChanged + } else { + reason = nil + } self.wasSatisfied = satisfied + self.wasWiFiEnabled = wifi self.isSatisfied = satisfied self.isWiFiEnabled = wifi - if reconnected { - self.notifyWiFiChanged() + if let reason { + self.notifyWiFiChanged(reason: reason) } } } @@ -37,11 +61,9 @@ final class NetworkMonitor: ObservableObject { startSSIDPolling() } - var isAirplaneMode: Bool { !isSatisfied } - /// Registers a Wi-Fi-change observer and returns a token that must be removed. @discardableResult - func observeWiFiChanges(_ handler: @escaping @MainActor () -> Void) -> UUID { + func observeWiFiChanges(_ handler: @escaping @MainActor (WiFiChangeReason) -> Void) -> UUID { let token = UUID() wifiChangeHandlers[token] = handler return token @@ -51,9 +73,9 @@ final class NetworkMonitor: ObservableObject { wifiChangeHandlers.removeValue(forKey: token) } - private func notifyWiFiChanged() { + private func notifyWiFiChanged(reason: WiFiChangeReason) { for handler in wifiChangeHandlers.values { - handler() + handler(reason) } } @@ -62,9 +84,16 @@ final class NetworkMonitor: ObservableObject { Task { @MainActor in guard let self else { return } let ssid = Self.fetchSSID() - if ssid != self.currentSSID, ssid != nil { - self.currentSSID = ssid - self.notifyWiFiChanged() + self.currentSSID = ssid + guard let ssid else { return } + guard let previousSSID = self.lastKnownSSID else { + // 首次取得 SSID 只是建立基线,不能当作用户切换了 Wi-Fi。 + self.lastKnownSSID = ssid + return + } + if ssid != previousSSID { + self.lastKnownSSID = ssid + self.notifyWiFiChanged(reason: .ssidChanged) } } } diff --git a/Shared/RuntimeLog.swift b/Shared/RuntimeLog.swift index eb6ba37..ab64fd7 100644 --- a/Shared/RuntimeLog.swift +++ b/Shared/RuntimeLog.swift @@ -1,3 +1,4 @@ +import CoreLocation import Foundation struct RuntimeLogEntry: Codable, Identifiable, Equatable { @@ -49,11 +50,17 @@ enum RuntimeLogStore { private static let decoder = JSONDecoder() private static let encoder = JSONEncoder() private static let maximumBytes: UInt64 = 1_500_000 + static let retentionInterval: TimeInterval = 3 * 24 * 60 * 60 + private static let pruningInterval: TimeInterval = 60 * 60 + private static var lastPrunedAt: Date? static func append(_ entry: RuntimeLogEntry) { lock.lock() defer { lock.unlock() } do { + let now = Date() + try pruneExpiredLogsIfNeeded(now: now) + guard entry.timestamp >= retentionCutoff(now: now) else { return } let url = try logURL(for: entry.source) try rotateIfNeeded(url) var data = try encoder.encode(entry) @@ -75,7 +82,8 @@ enum RuntimeLogStore { static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] { lock.lock() defer { lock.unlock() } - let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true) + let directory = logDirectory + try? pruneExpiredLogs(in: directory, now: Date()) guard let urls = try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: nil @@ -90,12 +98,12 @@ enum RuntimeLogStore { static func clearAll() { lock.lock() defer { lock.unlock() } - let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true) - try? FileManager.default.removeItem(at: directory) + try? FileManager.default.removeItem(at: logDirectory) + lastPrunedAt = nil } private static func logURL(for source: String) throws -> URL { - let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true) + let directory = logDirectory try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let process = (Bundle.main.bundleIdentifier ?? "unknown-process") .replacingOccurrences(of: "/", with: "-") @@ -103,6 +111,56 @@ enum RuntimeLogStore { return directory.appendingPathComponent("\(process)-\(safeSource).jsonl") } + private static var logDirectory: URL { + AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true) + } + + static func retentionCutoff(now: Date) -> Date { + now.addingTimeInterval(-retentionInterval) + } + + private static func pruneExpiredLogsIfNeeded(now: Date) throws { + if let lastPrunedAt, + now >= lastPrunedAt, + now.timeIntervalSince(lastPrunedAt) < pruningInterval { + return + } + try pruneExpiredLogs(in: logDirectory, now: now) + } + + private static func pruneExpiredLogs(in directory: URL, now: Date) throws { + guard FileManager.default.fileExists(atPath: directory.path) else { + lastPrunedAt = now + return + } + let urls = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "jsonl" } + + for url in urls { + let entries = readEntries(url) + let retained = retainedEntries(entries, now: now) + guard retained.count != entries.count else { continue } + guard !retained.isEmpty else { + try FileManager.default.removeItem(at: url) + continue + } + var data = Data() + for entry in retained { + data.append(try encoder.encode(entry)) + data.append(0x0A) + } + try data.write(to: url, options: .atomic) + } + lastPrunedAt = now + } + + static func retainedEntries(_ entries: [RuntimeLogEntry], now: Date) -> [RuntimeLogEntry] { + let cutoff = retentionCutoff(now: now) + return entries.filter { $0.timestamp >= cutoff } + } + private static func rotateIfNeeded(_ url: URL) throws { guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attributes[.size] as? NSNumber, @@ -162,3 +220,67 @@ enum RuntimeLogger { )) } } + +/// Realtime-location diagnostics keep precise coordinates out of the persisted, +/// exportable log. Exact values are printed only by DEBUG builds for local Xcode +/// debugging. +enum RealtimeLocationTrace { + static func log( + _ message: String, + location: CLLocation, + details: [String: String] = [:], + level: RuntimeLogEntry.Level = .info + ) { + var metadata = details + metadata["样本时间"] = ISO8601DateFormatter().string(from: location.timestamp) + metadata["样本年龄秒"] = format(Date().timeIntervalSince(location.timestamp)) + metadata["水平精度米"] = format(location.horizontalAccuracy) + metadata["垂直精度米"] = format(location.verticalAccuracy) + metadata["海拔米"] = format(location.altitude) + metadata["坐标有效"] = String(CLLocationCoordinate2DIsValid(location.coordinate)) + persist(level, message: message, details: metadata) + debugCoordinate(message, location: location, details: metadata) + } + + static func coordinate( + _ message: String, + coordinate: CLLocationCoordinate2D, + details: [String: String] = [:] + ) { + #if DEBUG + let suffix = details.sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + let latitude = String(format: "%.8f", coordinate.latitude) + let longitude = String(format: "%.8f", coordinate.longitude) + let metadata = suffix.isEmpty ? "" : " \(suffix)" + print("[RealtimeLocation] \(message) latitude=\(latitude) longitude=\(longitude)\(metadata)") + #endif + } + + private static func persist( + _ level: RuntimeLogEntry.Level, + message: String, + details: [String: String] + ) { + switch level { + case .debug: RuntimeLogger.debug("APP", "实时定位", message, details: details) + case .info: RuntimeLogger.info("APP", "实时定位", message, details: details) + case .warning: RuntimeLogger.warning("APP", "实时定位", message, details: details) + case .error: RuntimeLogger.error("APP", "实时定位", message, details: details) + } + } + + private static func debugCoordinate( + _ message: String, + location: CLLocation, + details: [String: String] + ) { + coordinate(message, coordinate: location.coordinate, details: details) + } + + private static func format(_ value: Double) -> String { + guard value.isFinite else { return String(value) } + return String(format: "%.3f", value) + } +} diff --git a/Shared/VerificationResult.swift b/Shared/VerificationResult.swift index c634654..6afe605 100644 --- a/Shared/VerificationResult.swift +++ b/Shared/VerificationResult.swift @@ -31,9 +31,22 @@ enum VerificationResult: Equatable, Identifiable { switch self { case .success: return nil case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil - case .certNotTrusted: return .certificate + case .certNotTrusted: return nil case .wifiProxyNotConfigured: return .proxySetup case .coordinateWriteFailed, .patchFailed: return .rewriteFailed } } + + /// Wi-Fi 变化后仍留在地图页显示的提醒。 + /// 证书失败必须进入完整证书安装引导,因此不在这里返回通用提示页。 + var wifiChangeReminderTipKind: TipKind? { + switch self { + case .proxyNotRunning: + return .proxySetup + case .certNotTrusted, .verificationInProgress, .verificationSuperseded: + return nil + default: + return tipKind + } + } } diff --git a/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift b/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift index 5a8b354..2aaf0b8 100644 --- a/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift +++ b/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift @@ -2,16 +2,139 @@ import XCTest @testable import PaopaoLocationSpoofer final class CertificateAuthorityStoreTests: XCTestCase { - func testEnsureCreatesOnceAndThenReusesExistingPair() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } + private let validAuthority = CertificateAuthority(certPEM: "valid-cert", keyPEM: "valid-key") + + func testEnsureCreatesOnceThenReusesValidKeychainPair() throws { + let keychain = InMemoryCertificateAuthorityKeychain() var generations = 0 - let store = CertificateAuthorityStore(directory: directory) { + let store = makeStore(keychain: keychain) { generations += 1 - return CertificateAuthority(certPEM: "cert", keyPEM: "key") + return self.validAuthority } - XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key")) - XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key")) + + XCTAssertEqual(try store.ensure(), validAuthority) + XCTAssertEqual(try store.ensure(), validAuthority) + XCTAssertEqual(keychain.stored, validAuthority) XCTAssertEqual(generations, 1) } + + func testEnsureMigratesValidLegacyPairThenDeletesLegacyFiles() throws { + let directory = try makeLegacyDirectory(authority: validAuthority) + defer { try? FileManager.default.removeItem(at: directory) } + let keychain = InMemoryCertificateAuthorityKeychain() + let store = makeStore(directory: directory, keychain: keychain) { + XCTFail("A valid legacy pair must be migrated instead of regenerated") + return self.validAuthority + } + + XCTAssertEqual(try store.ensure(), validAuthority) + XCTAssertEqual(keychain.stored, validAuthority) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path)) + } + + func testFailedKeychainMigrationPreservesLegacyFiles() throws { + let directory = try makeLegacyDirectory(authority: validAuthority) + defer { try? FileManager.default.removeItem(at: directory) } + let keychain = InMemoryCertificateAuthorityKeychain() + keychain.shouldFailSave = true + let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority } + + XCTAssertThrowsError(try store.ensure()) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path)) + } + + func testInvalidKeychainPairFallsBackToLegacyPair() throws { + let directory = try makeLegacyDirectory(authority: validAuthority) + defer { try? FileManager.default.removeItem(at: directory) } + let keychain = InMemoryCertificateAuthorityKeychain() + keychain.stored = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key") + let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority } + + XCTAssertEqual(try store.ensure(), validAuthority) + XCTAssertEqual(keychain.stored, validAuthority) + } + + func testValidKeychainPairRetriesCleanupOfLegacyFiles() throws { + let directory = try makeLegacyDirectory(authority: validAuthority) + defer { try? FileManager.default.removeItem(at: directory) } + let keychain = InMemoryCertificateAuthorityKeychain() + keychain.stored = validAuthority + let store = makeStore(directory: directory, keychain: keychain) { + XCTFail("A valid Keychain pair must not be regenerated") + return self.validAuthority + } + + XCTAssertEqual(try store.ensure(), validAuthority) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path)) + } + + func testInvalidLegacyFilesAreRemovedOnlyAfterReplacementIsPersisted() throws { + let invalidAuthority = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key") + let directory = try makeLegacyDirectory(authority: invalidAuthority) + defer { try? FileManager.default.removeItem(at: directory) } + let keychain = InMemoryCertificateAuthorityKeychain() + let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority } + + XCTAssertEqual(try store.ensure(), validAuthority) + XCTAssertEqual(keychain.stored, validAuthority) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path)) + } + + func testFailedReplacementPersistencePreservesInvalidLegacyFiles() throws { + let invalidAuthority = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key") + let directory = try makeLegacyDirectory(authority: invalidAuthority) + defer { try? FileManager.default.removeItem(at: directory) } + let keychain = InMemoryCertificateAuthorityKeychain() + keychain.shouldFailSave = true + let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority } + + XCTAssertThrowsError(try store.ensure()) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path)) + } + + private func makeStore( + directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString), + keychain: InMemoryCertificateAuthorityKeychain, + generator: @escaping () throws -> CertificateAuthority + ) -> CertificateAuthorityStore { + CertificateAuthorityStore( + directory: directory, + keychain: keychain, + generator: generator, + validator: { $0 == self.validAuthority } + ) + } + + private func makeLegacyDirectory(authority: CertificateAuthority) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try authority.certPEM.write(to: directory.appendingPathComponent("ca-cert.pem"), atomically: true, encoding: .utf8) + try authority.keyPEM.write(to: directory.appendingPathComponent("ca-key.pem"), atomically: true, encoding: .utf8) + return directory + } +} + +private enum CertificateAuthorityStoreTestError: Error { + case saveFailed +} + +private final class InMemoryCertificateAuthorityKeychain: CertificateAuthorityKeychain { + var stored: CertificateAuthority? + var shouldFailSave = false + + func load() throws -> CertificateAuthority? { stored } + + func save(_ authority: CertificateAuthority) throws { + guard !shouldFailSave else { throw CertificateAuthorityStoreTestError.saveFailed } + stored = authority + } + + func remove() throws { + stored = nil + } } diff --git a/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift b/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift index 86e2fe2..ff37717 100644 --- a/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift +++ b/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift @@ -1,4 +1,5 @@ import XCTest +import CoreLocation @testable import PaopaoLocationSpoofer final class FavoriteLocationStoreTests: XCTestCase { @@ -7,14 +8,92 @@ final class FavoriteLocationStoreTests: XCTestCase { let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let store = FavoriteLocationStore(defaults: defaults) - let favorite = store.save(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20) + let favorite = store.save( + name: "深圳湾", + mapCoordinate: .init(latitude: 22.494, longitude: 113.951), + mapCoordinateSystem: .gcj02, + accuracy: 20 + ) XCTAssertEqual(store.selectedFavoriteID, favorite.id) XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾") } + func testFavoriteStoresBothFormsAndSelectsMatchingPairWithoutReadConversion() { + let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let wgs = CLLocationCoordinate2D(latitude: 22.491_438, longitude: 113.945_702) + let favorite = FavoriteLocation( + name: "深圳湾", + coordinatePair: .init(mapCoordinate: wgs, mapCoordinateSystem: .wgs84), + accuracy: 20 + ) + + XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).latitude, wgs.latitude, accuracy: 0.000_000_1) + XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).longitude, wgs.longitude, accuracy: 0.000_000_1) + XCTAssertNotEqual(favorite.coordinatePair.gcj02.latitude, wgs.latitude) + XCTAssertNotEqual(favorite.coordinatePair.gcj02.longitude, wgs.longitude) + } + + func testLegacyFavoriteIsUpgradedAsDomesticGCJAndRewritten() throws { + let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let id = UUID() + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + let payload = LegacyFavoritePayload( + id: id, + name: "旧收藏", + latitude: 22.544_577, + longitude: 113.941_14, + accuracy: 25, + createdAt: createdAt + ) + defaults.set(try JSONEncoder().encode([payload]), forKey: "favorite_locations") + + let store = FavoriteLocationStore(defaults: defaults) + XCTAssertTrue(store.favorites[0].isLegacyCoordinateRecord) + XCTAssertEqual(store.favorites[0].coordinatePair.gcj02.latitude, payload.latitude, accuracy: 0.000_000_1) + XCTAssertNotEqual(store.favorites[0].coordinatePair.wgs84.longitude, payload.longitude) + + try store.migrateLegacyCoordinates() + let reloaded = FavoriteLocationStore(defaults: defaults) + XCTAssertEqual(reloaded.favorites[0].id, id) + XCTAssertEqual(reloaded.favorites[0].name, "旧收藏") + XCTAssertFalse(reloaded.favorites[0].isLegacyCoordinateRecord) + } + + func testOverseasPairUsesIdentityConversion() { + let eiffelTower = CoordinateConverter.coordinatePair(lat: 48.858_37, lon: 2.294_481, mapCoordinateSystem: .wgs84) + + XCTAssertEqual(eiffelTower.wgs84.latitude, eiffelTower.gcj02.latitude, accuracy: 0.000_000_1) + XCTAssertEqual(eiffelTower.wgs84.longitude, eiffelTower.gcj02.longitude, accuracy: 0.000_000_1) + } + + func testDomesticMapCoordinateMatchesPreviouslyActivatedWGS84Value() { + let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14) + let pair = CoordinatePair(mapCoordinate: gcj, mapCoordinateSystem: .gcj02) + + XCTAssertTrue(pair.matchesWGS84( + latitude: pair.wgs84.latitude, + longitude: pair.wgs84.longitude + )) + XCTAssertFalse(pair.matchesWGS84(latitude: gcj.latitude, longitude: gcj.longitude)) + } + func testMapConfigurationNeverRequestsRealUserLocation() { XCTAssertFalse(MapConfiguration.default.showsUserLocation) XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest) } + +} + +private struct LegacyFavoritePayload: Encodable { + let id: UUID + let name: String + let latitude: Double + let longitude: Double + let accuracy: Int + let createdAt: Date } diff --git a/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift b/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift index fb74793..28ee2e1 100644 --- a/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift +++ b/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift @@ -3,130 +3,85 @@ import XCTest @MainActor final class LocationActionCoordinatorTests: XCTestCase { - func testApplyChecksTrustThenConnectsAndSendsCoordinates() async { - let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20) - let coordinator = LocationActionCoordinator() + func testApplyStartsProxyAndWritesTheFavoriteWGS84Pair() async { + let proxy = FakeLocationActionProxy() + let settings = FakeLocationActionSettingsStore() + let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings) + let favorite = FavoriteLocation( + name: "深圳湾", + latitude: 22.494, + longitude: 113.951, + accuracy: 20, + mapCoordinateSystem: .gcj02 + ) let applied = await coordinator.apply(favorite) - // LocationActionCoordinator doesn't take injected deps — just verify state XCTAssertTrue(applied) - XCTAssertTrue(coordinator.virtualLocationEnabled) + XCTAssertTrue(proxy.isRunning) + XCTAssertEqual(proxy.lastCoordinates?.latitude, favorite.coordinatePair.wgs84.latitude) + XCTAssertEqual(proxy.lastCoordinates?.longitude, favorite.coordinatePair.wgs84.longitude) + XCTAssertEqual(settings.saved?.latitude, favorite.coordinatePair.wgs84.latitude) + XCTAssertEqual(settings.saved?.longitude, favorite.coordinatePair.wgs84.longitude) + XCTAssertTrue(settings.saved?.enabled == true) } - func testClearDoesNotConnectAnInactiveProxy() async { - let coordinator = LocationActionCoordinator() + func testClearWritesDisabledCoordinatesAndClearsSettings() { + let proxy = FakeLocationActionProxy(isRunning: true) + let settings = FakeLocationActionSettingsStore() + let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings) coordinator.clear() + + XCTAssertEqual(proxy.lastCoordinates?.latitude, 0) + XCTAssertEqual(proxy.lastCoordinates?.longitude, 0) + XCTAssertFalse(proxy.lastCoordinates?.enabled ?? true) + XCTAssertFalse(settings.saved?.enabled ?? true) XCTAssertFalse(coordinator.virtualLocationEnabled) } - func testBusyApplyRejectsASecondRequest() async { - let coordinator = LocationActionCoordinator() + func testApplyVerifiedRejectsInactiveProxyWithoutWritingCoordinates() { + let proxy = FakeLocationActionProxy() + let settings = FakeLocationActionSettingsStore() + let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings) let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20) - // The coordinator serializes on MainActor. A completed first request may - // legitimately make the next request a no-op rather than a concurrent rejection. - let firstApplied = await coordinator.apply(favorite) - let secondApplied = await coordinator.apply(favorite) - XCTAssertTrue(firstApplied) - XCTAssertTrue(secondApplied) + XCTAssertFalse(coordinator.applyVerified(favorite)) + XCTAssertNil(proxy.lastCoordinates) + XCTAssertNil(settings.saved) } } @MainActor -private final class FakeTrust { - var canModify: Bool - let events: EventLog - - init(canModify: Bool, events: EventLog) { - self.canModify = canModify - self.events = events +private final class FakeLocationActionProxy: LocationActionProxying { + struct Coordinates: Equatable { + let latitude: Double + let longitude: Double + let enabled: Bool + let accuracy: Int } - func refreshTrust() async { - events.append("trust.refresh") + var isRunning: Bool + private(set) var lastCoordinates: Coordinates? + + init(isRunning: Bool = false) { + self.isRunning = isRunning + } + + func start() async throws { + isRunning = true + } + + func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64 { + lastCoordinates = Coordinates(latitude: lat, longitude: lon, enabled: enabled, accuracy: accuracy) + return 1 } } @MainActor -private final class FakeProxy { - let activeForClear: Bool - let events: EventLog - let connectGate: AsyncGate? - - init(activeForClear: Bool, events: EventLog, connectGate: AsyncGate? = nil) { - self.activeForClear = activeForClear - self.events = events - self.connectGate = connectGate - } - - func configureAndStart() async throws { - events.append("proxy.connect") - await connectGate?.blockUntilOpened() - } - - func stopAndWait() async throws { - events.append("proxy.stop") - } - - func send(_ message: String) async throws -> String { - events.append("proxy.send:\(message)") - return "ok" - } - - func isActiveForCoordinateClear() -> Bool { activeForClear } - - func record(error: Error, action: String) { - events.append("proxy.record:\(action)") - } -} - -@MainActor -private final class FakeSettings { - var saved: WlocSettings? - let events: EventLog - - init(events: EventLog) { self.events = events } +private final class FakeLocationActionSettingsStore: LocationActionSettingsStoring { + private(set) var saved: WlocSettings? func load() -> WlocSettings? { saved } - - func save(_ settings: WlocSettings) { - saved = settings - events.append("settings.save.\(settings.enabled ? "enabled" : "disabled")") - } - - func clear() { - saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false) - events.append("settings.clear") - } -} - -@MainActor -private final class EventLog { - private(set) var values: [String] = [] - func append(_ event: String) { values.append(event) } -} - -private actor AsyncGate { - private var opened = false - private var blockedWaiters: [CheckedContinuation] = [] - private var waitUntilBlockedContinuation: CheckedContinuation? - - func blockUntilOpened() async { - guard !opened else { return } - waitUntilBlockedContinuation?.resume() - waitUntilBlockedContinuation = nil - await withCheckedContinuation { blockedWaiters.append($0) } - } - - func waitUntilBlocked() async { - guard !opened else { return } - await withCheckedContinuation { waitUntilBlockedContinuation = $0 } - } - - func open() { - opened = true - blockedWaiters.forEach { $0.resume() } - blockedWaiters.removeAll() - } + func save(_ settings: WlocSettings) { saved = settings } + func clear() { saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false) } } diff --git a/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift b/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift index 18f758b..750231f 100644 --- a/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift +++ b/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift @@ -185,20 +185,20 @@ final class MapLocationStateTests: XCTestCase { XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it") } - func testTileReprojectionPreservesSelectionIdentityAndIssuesFocus() { + func testMapCoordinateSystemReprojectionPreservesSelectionIdentityAndIssuesFocus() { let state = MapLocationState(initialCoordinate: initial) let favoriteID = UUID() state.selectFavorite(.init(latitude: 22.55, longitude: 113.95), id: favoriteID, name: "测试收藏") let revision = state.selection.revision - state.reprojectSelectionForTileChange(.init(latitude: 22.54, longitude: 113.94)) + state.reprojectSelectionForMapCoordinateSystemChange(.init(latitude: 22.54, longitude: 113.94)) XCTAssertEqual(state.selection.source, .favorite(favoriteID)) XCTAssertEqual(state.selection.explicitName, "测试收藏") XCTAssertEqual(state.selection.revision, revision) XCTAssertEqual(state.selection.coordinate.latitude, 22.54, accuracy: 0.000001) guard case let .focus(coordinate, distanceMeters) = state.cameraCommand?.kind else { - return XCTFail("Expected a focus command after tile reprojection") + return XCTFail("Expected a focus command after map coordinate-system reprojection") } XCTAssertEqual(coordinate.latitude, 22.54, accuracy: 0.000001) XCTAssertEqual(distanceMeters, state.viewportMeters) @@ -221,4 +221,71 @@ final class MapLocationStateTests: XCTestCase { XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km") XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 km") } + + func testLastCoordinateStoreKeepsBothFormsAndZoom() { + let suite = "MapLocationStateTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14) + + LastCoordinateStore.save(mapCoordinate: gcj, mapCoordinateSystem: .gcj02, zoomMeters: 1_250, defaults: defaults) + + guard let restored = LastCoordinateStore.load(defaults: defaults) else { + return XCTFail("Expected a persisted current map pin") + } + XCTAssertEqual(restored.coordinate(for: .gcj02).latitude, gcj.latitude, accuracy: 0.000_000_1) + XCTAssertEqual(restored.coordinate(for: .gcj02).longitude, gcj.longitude, accuracy: 0.000_000_1) + XCTAssertNotEqual(restored.coordinate(for: .wgs84).longitude, gcj.longitude) + XCTAssertEqual(restored.zoomMeters, 1_250) + } + + func testCoordinateMigrationUpgradesLegacyCurrentPinAndFavoritesBeforeSettingVersion() throws { + let suite = "MapLocationStateTests.\(UUID().uuidString)" + let legacySuite = "MapLocationStateTests.Legacy.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + let legacyDefaults = UserDefaults(suiteName: legacySuite)! + defer { defaults.removePersistentDomain(forName: suite) } + defer { legacyDefaults.removePersistentDomain(forName: legacySuite) } + let firstID = UUID() + let secondID = UUID() + let records = [ + CoordinateMigrationLegacyFavorite(id: firstID, name: "第一个", latitude: 22.544_577, longitude: 113.941_14, accuracy: 15, createdAt: .distantPast), + CoordinateMigrationLegacyFavorite(id: secondID, name: "海外", latitude: 48.858_37, longitude: 2.294_481, accuracy: 30, createdAt: .distantFuture), + ] + legacyDefaults.set(22.544_577, forKey: "lastMapLat") + legacyDefaults.set(113.941_14, forKey: "lastMapLon") + legacyDefaults.set(2_000.0, forKey: "mapViewportMeters") + defaults.set(try JSONEncoder().encode(records), forKey: "favorite_locations") + defaults.set(secondID.uuidString, forKey: "favorite_locations_selected_id") + + let favorites = FavoriteLocationStore(defaults: defaults) + try CoordinateStorageMigration.migrateIfNeeded( + favorites: favorites, + defaults: defaults, + legacyDefaults: legacyDefaults + ) + + XCTAssertEqual(defaults.integer(forKey: "coordinateStorageMigrationVersion"), CoordinateStorageMigration.currentVersion) + guard let current = LastCoordinateStore.load(defaults: defaults) else { + return XCTFail("Expected migrated current map pin") + } + XCTAssertEqual(current.coordinate(for: .gcj02).latitude, 22.544_577, accuracy: 0.000_000_1) + XCTAssertEqual(current.zoomMeters, 2_000) + + let reloaded = FavoriteLocationStore(defaults: defaults) + XCTAssertEqual(reloaded.favorites.map(\.id), [firstID, secondID]) + XCTAssertEqual(reloaded.favorites.map(\.name), ["第一个", "海外"]) + XCTAssertEqual(reloaded.selectedFavoriteID, secondID) + XCTAssertFalse(reloaded.favorites.contains(where: \.isLegacyCoordinateRecord)) + XCTAssertEqual(reloaded.favorites[1].coordinatePair.wgs84.latitude, reloaded.favorites[1].coordinatePair.gcj02.latitude, accuracy: 0.000_000_1) + } +} + +private struct CoordinateMigrationLegacyFavorite: Encodable { + let id: UUID + let name: String + let latitude: Double + let longitude: Double + let accuracy: Int + let createdAt: Date } diff --git a/Tests/PaopaoLocationSpooferTests/RuntimeLogStoreTests.swift b/Tests/PaopaoLocationSpooferTests/RuntimeLogStoreTests.swift new file mode 100644 index 0000000..cbe9f75 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/RuntimeLogStoreTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import PaopaoLocationSpoofer + +final class RuntimeLogStoreTests: XCTestCase { + func testRetentionCutoffIsExactlyThreeDays() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + XCTAssertEqual( + RuntimeLogStore.retentionCutoff(now: now), + now.addingTimeInterval(-3 * 24 * 60 * 60) + ) + } + + func testRetentionKeepsCutoffAndNewerEntriesOnly() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cutoff = RuntimeLogStore.retentionCutoff(now: now) + let expired = RuntimeLogEntry(timestamp: cutoff.addingTimeInterval(-0.001), source: "APP", level: .info, category: "Test", message: "expired") + let boundary = RuntimeLogEntry(timestamp: cutoff, source: "APP", level: .info, category: "Test", message: "boundary") + let recent = RuntimeLogEntry(timestamp: now, source: "CORE", level: .warning, category: "Proxy", message: "recent") + + XCTAssertEqual( + RuntimeLogStore.retainedEntries([expired, boundary, recent], now: now), + [boundary, recent] + ) + } +} diff --git a/Tests/PaopaoLocationSpooferTests/SetupCoordinatorTests.swift b/Tests/PaopaoLocationSpooferTests/SetupCoordinatorTests.swift new file mode 100644 index 0000000..12f0ee1 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/SetupCoordinatorTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import PaopaoLocationSpoofer + +@MainActor +final class SetupCoordinatorTests: XCTestCase { + func testSuccessfulVerificationDismissesSetup() { + let coordinator = SetupCoordinator() + coordinator.requestSetup() + + coordinator.applyVerificationResult(.success) + + XCTAssertEqual(coordinator.trustState, .trusted) + XCTAssertFalse(coordinator.needsSetup) + } + + func testCertificateFailureRoutesDirectlyToCertificateStep() { + let coordinator = SetupCoordinator() + + coordinator.applyVerificationResult(.certNotTrusted) + + XCTAssertEqual(coordinator.trustState, .unavailable) + XCTAssertTrue(coordinator.needsSetup) + XCTAssertEqual(coordinator.setupStep, .cert) + } + + func testProxyFailureRoutesBackToProxyStep() { + let coordinator = SetupCoordinator() + coordinator.applyVerificationResult(.certNotTrusted) + + coordinator.applyVerificationResult(.wifiProxyNotConfigured) + + XCTAssertTrue(coordinator.needsSetup) + XCTAssertEqual(coordinator.setupStep, .proxy) + } + + func testWiFiChangeMapsLocalProxyStartFailureToProxyReminder() { + XCTAssertEqual(VerificationResult.proxyNotRunning.wifiChangeReminderTipKind, .proxySetup) + } + + func testWiFiChangeDoesNotPresentFailureForConcurrentVerification() { + XCTAssertNil(VerificationResult.verificationInProgress.wifiChangeReminderTipKind) + } + + func testWiFiChangeCertificateFailureDoesNotUseGenericReminder() { + XCTAssertNil(VerificationResult.certNotTrusted.wifiChangeReminderTipKind) + XCTAssertNil(VerificationResult.certNotTrusted.tipKind) + } +} diff --git a/Tests/PaopaoLocationSpooferTests/VirtualLocationTipPreferencesTests.swift b/Tests/PaopaoLocationSpooferTests/VirtualLocationTipPreferencesTests.swift new file mode 100644 index 0000000..4be6f34 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/VirtualLocationTipPreferencesTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import PaopaoLocationSpoofer + +final class VirtualLocationTipPreferencesTests: XCTestCase { + private var suites: [String] = [] + + override func tearDown() { + for suite in suites { + UserDefaults.standard.removePersistentDomain(forName: suite) + } + suites.removeAll() + super.tearDown() + } + + func testSuppressionAppearsOnlyAfterThirdSuccessfulOperation() { + let defaults = makeDefaults() + let legacyDefaults = makeDefaults() + let preferences = VirtualLocationTipPreferences( + defaults: defaults, + legacyDefaults: legacyDefaults + ) + + XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 1) + XCTAssertFalse(preferences.canSuppress(.activation)) + XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 2) + XCTAssertFalse(preferences.canSuppress(.activation)) + XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 3) + XCTAssertTrue(preferences.canSuppress(.activation)) + } + + func testActivationAndDeactivationCountersAndSuppressionAreIndependent() { + let defaults = makeDefaults() + let preferences = VirtualLocationTipPreferences( + defaults: defaults, + legacyDefaults: makeDefaults() + ) + + for _ in 0..<3 { + preferences.recordSuccessfulOperation(.activation) + } + preferences.suppress(.activation) + + XCTAssertFalse(preferences.shouldPresentAutomaticTip(.activation)) + XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation)) + XCTAssertFalse(preferences.canSuppress(.deactivation)) + + for _ in 0..<3 { + preferences.recordSuccessfulOperation(.deactivation) + } + preferences.suppress(.deactivation) + XCTAssertFalse(preferences.shouldPresentAutomaticTip(.deactivation)) + } + + func testSuppressionBeforeThirdOperationIsIgnored() { + let preferences = VirtualLocationTipPreferences( + defaults: makeDefaults(), + legacyDefaults: makeDefaults() + ) + + preferences.recordSuccessfulOperation(.deactivation) + preferences.suppress(.deactivation) + + XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation)) + } + + func testLegacyActivationSuppressionRemainsEffective() { + let legacyDefaults = makeDefaults() + legacyDefaults.set(true, forKey: "activationTipDisabled") + let preferences = VirtualLocationTipPreferences( + defaults: makeDefaults(), + legacyDefaults: legacyDefaults + ) + + XCTAssertFalse(preferences.shouldPresentAutomaticTip(.activation)) + XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation)) + } + + private func makeDefaults() -> UserDefaults { + let suite = "VirtualLocationTipPreferencesTests.\(UUID().uuidString)" + suites.append(suite) + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } +} diff --git a/Tests/map_refactor_contract_test.sh b/Tests/map_refactor_contract_test.sh index 8572154..11339fb 100755 --- a/Tests/map_refactor_contract_test.sh +++ b/Tests/map_refactor_contract_test.sh @@ -12,8 +12,11 @@ SETUP="$ROOT/App/SetupCoordinator.swift" PROXY="$ROOT/App/ProxyManager.swift" SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift" DIAGNOSTICS="$ROOT/App/DiagnosticsView.swift" +CONTENT="$ROOT/App/ContentView.swift" +CONVERTER="$ROOT/Shared/CoordinateConverter.swift" +NETWORK_MONITOR="$ROOT/Shared/NetworkMonitor.swift" -for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS"; do +for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS" "$CONTENT" "$CONVERTER" "$NETWORK_MONITOR"; do test -f "$file" || fail "missing required refactor file: $file" done @@ -23,6 +26,8 @@ done grep -q 'showsUserLocation = true' "$MAP_BRIDGE" || fail "MapKit native user location must be visible" grep -q 'didUpdate userLocation' "$MAP_BRIDGE" || fail "MapKit native user location must feed realtime state" grep -q 'MapCameraCommand' "$MAP_BRIDGE" || fail "map bridge must consume MapCameraCommand" +grep -q 'let initialViewportMeters:' "$MAP_BRIDGE" || fail "map bridge must receive initial viewport from MapLocationState" +! grep -q 'ViewportStore.loadOrDefault()' "$MAP_BRIDGE" || fail "map bridge must not bypass MapLocationState for initial viewport" grep -q 'activeCameraCommandID' "$MAP_BRIDGE" || fail "programmatic map callbacks must be associated with the active camera command" grep -q 'UIPanGestureRecognizer' "$MAP_BRIDGE" || fail "map panning must be recognized explicitly" grep -q 'UIPinchGestureRecognizer' "$MAP_BRIDGE" || fail "pinch zoom must not be treated as a selected-center pan" @@ -41,13 +46,40 @@ grep -q 'var location: CLLocation?' "$REALTIME" || fail "Core Location driver mu grep -q 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent" ! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed" grep -q 'applyVerified' "$MAP_HOME" || fail "verified location commits must be synchronous after revision validation" -grep -q 'defer { needsSetup = !canModify }' "$SETUP" || fail "trust verification must always converge setup state" +grep -q 'func applyVerificationResult' "$SETUP" || fail "verification results must have one setup-state reducer" +grep -q 'case .success:' "$SETUP" || fail "successful verification must converge setup state" +grep -q 'case .certNotTrusted:' "$SETUP" || fail "certificate failure must converge setup state" +grep -q 'setupStep = .proxy' "$SETUP" || fail "proxy failure must converge setup state" grep -q 'realtimeRequestTask' "$MAP_HOME" || fail "realtime button requests must be synchronously serialized" grep -q 'RealtimeLocationRequestContext' "$MAP_HOME" || fail "a realtime button tap must retarget an in-flight startup request instead of being ignored" grep -q 'CLError.network' "$MAP_HOME" || fail "reverse geocoding network failures must use bounded retry" grep -q 'SystemSettingsNavigator' "$MAP_HOME" || fail "settings actions must use the shared navigator" grep -q '复制全部日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone copy button" grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear button" +grep -q '日志自动清理,仅保留近 3 天' "$DIAGNOSTICS" || fail "diagnostics must disclose the three-day retention policy" +grep -q 'retentionInterval: TimeInterval = 3 \* 24 \* 60 \* 60' "$ROOT/Shared/RuntimeLog.swift" || fail "runtime logs must retain only three days" +if grep -q 'logEvent("CONNECT " + host + " -> passthrough")' "$ROOT/Core/proxy.go"; then + fail "proxy diagnostics must not log unrelated passthrough CONNECT hosts" +fi grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing" +grep -q 'await CoordinateConverter.resolveInitialMapCoordinateSystem()' "$CONTENT" || fail "map type must resolve before MapHomeView construction" +grep -q 'phase = .map' "$CONTENT" || fail "ContentView must explicitly gate MapHomeView construction" +! grep -q 'startTileProbe' "$MAP_HOME" || fail "MapHomeView must not start a second fixed-anchor coordinate-system probe" +! grep -q 'initializeMap()' "$MAP_HOME" || fail "MapHomeView must not replay a second map initialization from onAppear" +grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime position must resolve before map construction" +! grep -q 'lastTileCheck' "$CONVERTER" || fail "map coordinate-system detection must not use a time cache" +! grep -q '跳过(缓存' "$CONVERTER" || fail "map coordinate-system detection must not skip using a cached result" +! grep -q '瓦片检测' "$CONVERTER" || fail "coordinate-system probe logs must not claim to inspect map tiles" +grep -q 'minimumCountForSuppression = 3' Shared/AppGroup.swift || fail "automatic tip suppression must require three successful operations" +grep -q 'activeTip = .deactivation' "$MAP_HOME" || fail "manual deactivation help must use the non-suppressible generic tip sheet" +grep -q 'stabilizationNanoseconds: UInt64 = 5_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait five seconds before environment verification" +grep -q 'result.wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi proxy failures must use the background reminder mapping" +grep -q 'if result == .certNotTrusted' "$MAP_HOME" || fail "Wi-Fi certificate failures must enter certificate setup" +grep -q 'setup.applyVerificationResult(result)' "$MAP_HOME" || fail "Wi-Fi certificate failures must use the setup routing reducer" +! grep -q 'case certificate' "$ROOT/App/TipViews.swift" || fail "generic certificate tip must not coexist with certificate setup" +! grep -q 'CertificateTipContent' "$ROOT/App/TipViews.swift" || fail "certificate failures must use the complete setup flow" +! grep -q 'onChange(of: net.isAirplaneMode)' "$MAP_HOME" || fail "airplane recovery must not race the Wi-Fi change verifier" +grep -q 'hasReceivedInitialPath' "$NETWORK_MONITOR" || fail "initial network path must not be reported as a Wi-Fi switch" +grep -q 'lastKnownSSID' "$NETWORK_MONITOR" || fail "SSID polling must preserve a baseline across temporary nil readings" echo "PASS: map location state refactor contract"