mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
fix: 优化启动引导、坐标处理和日志管理
This commit is contained in:
@@ -86,7 +86,7 @@ struct BugReportView: View {
|
|||||||
isRunning = true
|
isRunning = true
|
||||||
Task {
|
Task {
|
||||||
// 跑测试
|
// 跑测试
|
||||||
_ = await setup.runVerificationTest(testLat: 22.543099, testLon: 113.934576)
|
_ = await setup.runVerificationTest()
|
||||||
let testLog = setup.testLog
|
let testLog = setup.testLog
|
||||||
|
|
||||||
// 获取版本信息
|
// 获取版本信息
|
||||||
|
|||||||
+63
-17
@@ -2,8 +2,6 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var setup = SetupCoordinator()
|
@StateObject private var setup = SetupCoordinator()
|
||||||
@ObservedObject private var net = NetworkMonitor.shared
|
|
||||||
@State private var showSetup = false
|
|
||||||
@State private var phase: AppPhase = .splash
|
@State private var phase: AppPhase = .splash
|
||||||
@AppStorage("setupCompleted") private var setupCompleted = false
|
@AppStorage("setupCompleted") private var setupCompleted = false
|
||||||
|
|
||||||
@@ -17,33 +15,81 @@ struct ContentView: View {
|
|||||||
Image(systemName: "location.fill")
|
Image(systemName: "location.fill")
|
||||||
.font(.system(size: 48)).foregroundStyle(.blue)
|
.font(.system(size: 48)).foregroundStyle(.blue)
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text("正在启动…").font(.subheadline).foregroundStyle(.secondary)
|
Text("正在初始化地图与本地代理…").font(.subheadline).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
case .map:
|
case .map:
|
||||||
NavigationView {
|
NavigationView {
|
||||||
MapHomeView(setup: setup)
|
MapHomeView(setup: setup)
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showSetup) {
|
.fullScreenCover(isPresented: $setup.needsSetup) {
|
||||||
FirstSetupView(setup: setup, onComplete: {
|
FirstSetupView(setup: setup, onComplete: {
|
||||||
setupCompleted = true
|
setupCompleted = true
|
||||||
setup.completeSetup()
|
setup.completeSetup()
|
||||||
showSetup = false
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task { await bootstrap() }
|
||||||
if !setup.proxy.isRunning {
|
}
|
||||||
do {
|
|
||||||
try await setup.proxy.start()
|
@MainActor
|
||||||
} catch {
|
private func bootstrap() async {
|
||||||
RuntimeLogger.error("APP", "Startup", "代理启动失败,将在设置检测中重试", error: error)
|
await setup.prepareLocalServices()
|
||||||
}
|
do {
|
||||||
}
|
try CoordinateStorageMigration.migrateIfNeeded(favorites: FavoriteLocationStore())
|
||||||
phase = .map
|
} catch {
|
||||||
if !setupCompleted { showSetup = true }
|
RuntimeLogger.error("APP", "Startup", "旧坐标数据迁移失败,将在下次启动重试", error: error)
|
||||||
// Tile probing is a best-effort display refinement and must never block first render.
|
|
||||||
await CoordinateConverter.detectTileByFixedGeocode(force: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ struct RuntimeLogsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline)
|
.navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline)
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
Text("日志自动清理,仅保留近 3 天")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } }
|
ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } }
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
@@ -91,7 +97,7 @@ struct RuntimeLogsView: View {
|
|||||||
Button {
|
Button {
|
||||||
isTesting = true; testResult = ""; testLogCopied = false
|
isTesting = true; testResult = ""; testLogCopied = false
|
||||||
Task {
|
Task {
|
||||||
let result = await setup.runVerificationTest(testLat: testFavorite.latitude, testLon: testFavorite.longitude)
|
let result = await setup.runVerificationTest()
|
||||||
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
|
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
|
||||||
if !result.isSuccess { testResult += ",查看下方日志" }
|
if !result.isSuccess { testResult += ",查看下方日志" }
|
||||||
testMessage = setup.testLog
|
testMessage = setup.testLog
|
||||||
|
|||||||
+247
-252
@@ -1,12 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum SetupStep: Int, CaseIterable {
|
enum SetupStep: Int, CaseIterable {
|
||||||
case cert = 0, proxy = 1, verify = 2
|
case proxy
|
||||||
|
case cert
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .proxy: return "配置 Wi-Fi 代理"
|
||||||
case .cert: return "初始化 CA 证书"
|
case .cert: return "初始化 CA 证书"
|
||||||
case .proxy: return "初始化代理"
|
|
||||||
case .verify: return "环境检测"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,283 +16,277 @@ struct FirstSetupView: View {
|
|||||||
@ObservedObject var setup: SetupCoordinator
|
@ObservedObject var setup: SetupCoordinator
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
|
|
||||||
@State private var step: SetupStep = .cert
|
@State private var step: SetupStep
|
||||||
@State private var downloadedDone = false
|
@State private var downloadedDone = false
|
||||||
@State private var installedDone = false
|
@State private var installedDone = false
|
||||||
@State private var trustedDone = false
|
@State private var trustedDone = false
|
||||||
@State private var proxyDone = false
|
@State private var result: VerificationResult?
|
||||||
@State private var testPassed: VerificationResult? = nil
|
@State private var isVerifying = false
|
||||||
@State private var testMessage = ""
|
|
||||||
@State private var isLoading = false
|
|
||||||
@State private var manualHint = ""
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
NavigationView {
|
||||||
// 顶部大步骤进度
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 6) {
|
progress
|
||||||
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 {
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
Text(testMessage)
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
.font(.caption.monospaced()).textSelection(.enabled)
|
switch step {
|
||||||
.padding(10).frame(maxWidth: .infinity, alignment: .leading)
|
case .proxy: proxyStep
|
||||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
case .cert: certificateStep
|
||||||
}.frame(maxHeight: 280)
|
}
|
||||||
|
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 {
|
Button {
|
||||||
isLoading = true; testMessage = ""
|
verifyAfterProxyConfirmation()
|
||||||
Task {
|
|
||||||
testPassed = await setup.runVerificationTest()
|
|
||||||
testMessage = setup.testLog
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
actionLabel("我已配置,开始检测")
|
||||||
if isLoading { ProgressView().tint(.white).controlSize(.small) }
|
|
||||||
Text(buttonText).frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(buttonTint)
|
.disabled(isVerifying)
|
||||||
.disabled(isLoading)
|
} else {
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
UIPasteboard.general.string = testMessage
|
verifyAfterCertificateConfirmation()
|
||||||
} label: {
|
} label: {
|
||||||
Label("复制检测日志", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
|
actionLabel("确认完成,重新检测")
|
||||||
}.buttonStyle(.bordered).disabled(testMessage.isEmpty)
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!certificateStepsComplete || isVerifying)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var buttonText: String {
|
private func actionLabel(_ title: String) -> some View {
|
||||||
guard let result = testPassed else { return "开始检测" }
|
HStack {
|
||||||
return result.isSuccess ? "重新检测" : "⚠️ 重新测试"
|
if isVerifying { ProgressView().tint(.white).controlSize(.small) }
|
||||||
|
Text(title).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var buttonTint: Color {
|
private func verifyAfterProxyConfirmation() {
|
||||||
guard let result = testPassed else { return .blue }
|
runVerification { result in
|
||||||
return result.isSuccess ? .blue : .red
|
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 {
|
private func failureSummary(_ result: VerificationResult) -> String {
|
||||||
switch result {
|
switch result {
|
||||||
case .proxyNotRunning: return "代理未能启动,请检查代理状态"
|
case .certNotTrusted: return "证书尚未安装或信任"
|
||||||
case .verificationInProgress: return "已有检测正在进行,请稍候"
|
case .wifiProxyNotConfigured: return "Wi-Fi 代理未正确设置"
|
||||||
case .verificationSuperseded: return "检测期间位置已更新,本次结果已取消,请重新检测"
|
case .proxyNotRunning: return "本地代理未能启动"
|
||||||
case .certNotTrusted: return "CA 证书未安装或未信任,请重新安装证书并开启信任"
|
case .verificationInProgress: return "检测仍在进行"
|
||||||
case .wifiProxyNotConfigured: return "WiFi 代理未配置,请在系统设置中配置 127.0.0.1:8888"
|
case .verificationSuperseded: return "检测结果已过期"
|
||||||
case .coordinateWriteFailed: return "坐标写入失败,请重试"
|
case .coordinateWriteFailed: return "坐标写入失败"
|
||||||
case .patchFailed: return "坐标改写验证失败,可能是证书过期或不匹配"
|
case .patchFailed: return "定位改写检测失败"
|
||||||
case .success: 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
|
@MainActor
|
||||||
private func openSettings(_ destination: SystemSettingsDestination) {
|
private func openSettings(_ destination: SystemSettingsDestination) {
|
||||||
SystemSettingsNavigator.open(destination) { fallbackHint in
|
SystemSettingsNavigator.open(destination) { fallbackHint in
|
||||||
|
|||||||
@@ -1,15 +1,47 @@
|
|||||||
import Foundation
|
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
|
@MainActor
|
||||||
final class LocationActionCoordinator: ObservableObject {
|
final class LocationActionCoordinator: ObservableObject {
|
||||||
@Published private(set) var state: LocationActionState = .idle
|
@Published private(set) var state: LocationActionState = .idle
|
||||||
@Published private(set) var virtualLocationEnabled = false
|
@Published private(set) var virtualLocationEnabled = false
|
||||||
@Published private(set) var message = ""
|
@Published private(set) var message = ""
|
||||||
|
|
||||||
private let proxy = ProxyManager.shared
|
private let proxy: any LocationActionProxying
|
||||||
|
private let settings: any LocationActionSettingsStoring
|
||||||
|
|
||||||
init() {
|
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 {
|
func apply(_ favorite: FavoriteLocation) async -> Bool {
|
||||||
@@ -41,8 +73,8 @@ final class LocationActionCoordinator: ObservableObject {
|
|||||||
|
|
||||||
func clear() {
|
func clear() {
|
||||||
guard !state.isBusy else { return }
|
guard !state.isBusy else { return }
|
||||||
proxy.setCoords(lat: 0, lon: 0, enabled: false)
|
_ = proxy.setCoords(lat: 0, lon: 0, enabled: false, accuracy: 25)
|
||||||
WlocSettingsStore.clear()
|
settings.clear()
|
||||||
state = .idle
|
state = .idle
|
||||||
virtualLocationEnabled = false
|
virtualLocationEnabled = false
|
||||||
message = "已恢复真实定位"
|
message = "已恢复真实定位"
|
||||||
@@ -56,17 +88,18 @@ final class LocationActionCoordinator: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func commit(_ favorite: FavoriteLocation) -> Bool {
|
private func commit(_ favorite: FavoriteLocation) -> Bool {
|
||||||
// 统一转为 WGS-84 存储(地图取点可能为当前瓦片坐标系)
|
// WLOC 合约固定使用持久化的 WGS-84 值,不依赖当前地图地图坐标标准。
|
||||||
let wgs = CoordinateConverter.toStored(lat: favorite.latitude, lon: favorite.longitude)
|
let wgs = favorite.coordinatePair.wgs84
|
||||||
WlocSettingsStore.save(WlocSettings(
|
let value = WlocSettings(
|
||||||
longitude: wgs.lon,
|
longitude: wgs.longitude,
|
||||||
latitude: wgs.lat,
|
latitude: wgs.latitude,
|
||||||
accuracy: favorite.accuracy,
|
accuracy: favorite.accuracy,
|
||||||
enabled: true
|
enabled: true
|
||||||
))
|
)
|
||||||
proxy.setCoords(
|
settings.save(value)
|
||||||
lat: wgs.lat,
|
_ = proxy.setCoords(
|
||||||
lon: wgs.lon,
|
lat: wgs.latitude,
|
||||||
|
lon: wgs.longitude,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accuracy: favorite.accuracy
|
accuracy: favorite.accuracy
|
||||||
)
|
)
|
||||||
|
|||||||
+355
-137
@@ -27,7 +27,7 @@ private struct RealtimeLocationRequestContext {
|
|||||||
|
|
||||||
struct MapHomeView: View {
|
struct MapHomeView: View {
|
||||||
@ObservedObject var setup: SetupCoordinator
|
@ObservedObject var setup: SetupCoordinator
|
||||||
@StateObject private var favorites = FavoriteLocationStore()
|
@StateObject private var favorites: FavoriteLocationStore
|
||||||
@StateObject private var actions = LocationActionCoordinator()
|
@StateObject private var actions = LocationActionCoordinator()
|
||||||
@ObservedObject private var proxy = ProxyManager.shared
|
@ObservedObject private var proxy = ProxyManager.shared
|
||||||
@StateObject private var realtime = RealtimeLocationManager.shared
|
@StateObject private var realtime = RealtimeLocationManager.shared
|
||||||
@@ -39,13 +39,13 @@ struct MapHomeView: View {
|
|||||||
@State private var isSearching = false
|
@State private var isSearching = false
|
||||||
@State private var searchRequestID: UInt64 = 0
|
@State private var searchRequestID: UInt64 = 0
|
||||||
@State private var searchError = ""
|
@State private var searchError = ""
|
||||||
@State private var mapDidInitialize = false
|
@State private var mapRuntimeDidStart = false
|
||||||
@State private var activeSheet: HomeSheet?
|
@State private var activeSheet: HomeSheet?
|
||||||
@State private var showEnableTip = false
|
@State private var showEnableTip = false
|
||||||
@State private var showDisableTip = false
|
@State private var showDisableTip = false
|
||||||
@State private var activeTip: TipKind?
|
@State private var activeTip: TipKind?
|
||||||
@State private var manualHint = ""
|
@State private var manualHint = ""
|
||||||
@AppStorage("activationTipDisabled") private var activationTipDisabled = false
|
private let tipPreferences = VirtualLocationTipPreferences()
|
||||||
@State private var editingFavorite: FavoriteLocation?
|
@State private var editingFavorite: FavoriteLocation?
|
||||||
@State private var editName = ""
|
@State private var editName = ""
|
||||||
@State private var reverseGeocodeTask: Task<Void, Never>?
|
@State private var reverseGeocodeTask: Task<Void, Never>?
|
||||||
@@ -56,8 +56,6 @@ struct MapHomeView: View {
|
|||||||
@State private var wifiChangeObserverToken: UUID?
|
@State private var wifiChangeObserverToken: UUID?
|
||||||
@State private var wifiVerificationTask: Task<Void, Never>?
|
@State private var wifiVerificationTask: Task<Void, Never>?
|
||||||
@State private var wifiVerificationID: UUID?
|
@State private var wifiVerificationID: UUID?
|
||||||
@State private var tileProbeTask: Task<Void, Never>?
|
|
||||||
@State private var tileProbeID: UUID?
|
|
||||||
@State private var copyConfirmed = false
|
@State private var copyConfirmed = false
|
||||||
@State private var spoofState: SpoofState = .idle
|
@State private var spoofState: SpoofState = .idle
|
||||||
@State private var locationOperationTask: Task<Void, Never>?
|
@State private var locationOperationTask: Task<Void, Never>?
|
||||||
@@ -68,29 +66,67 @@ struct MapHomeView: View {
|
|||||||
|
|
||||||
init(setup: SetupCoordinator) {
|
init(setup: SetupCoordinator) {
|
||||||
self.setup = setup
|
self.setup = setup
|
||||||
|
let favoriteStore = FavoriteLocationStore()
|
||||||
|
_favorites = StateObject(wrappedValue: favoriteStore)
|
||||||
let savedCoord = LastCoordinateStore.load()
|
let savedCoord = LastCoordinateStore.load()
|
||||||
let initialZoom = ViewportStore.loadOrDefault()
|
let initialZoom = savedCoord?.zoomMeters ?? ViewportStore.loadOrDefault()
|
||||||
|
let selectedFavorite = favoriteStore.selectedFavorite
|
||||||
let initialCoord: CLLocationCoordinate2D
|
let initialCoord: CLLocationCoordinate2D
|
||||||
|
let initialSource: MapSelectionSource
|
||||||
|
let initialName: String?
|
||||||
if let saved = savedCoord {
|
if let saved = savedCoord {
|
||||||
let display = CoordinateConverter.toDisplay(lat: saved.coordinate.latitude, lon: saved.coordinate.longitude)
|
initialCoord = saved.coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
|
||||||
initialCoord = CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon)
|
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 {
|
} 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: [
|
RuntimeLogger.info("APP", "地图", "初始化", details: [
|
||||||
"zoom": String(initialZoom),
|
"zoom": String(initialZoom),
|
||||||
"有缓存": String(savedCoord != nil)
|
"有缓存": String(savedCoord != nil),
|
||||||
|
"初始来源": String(describing: initialSource),
|
||||||
|
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
|
||||||
])
|
])
|
||||||
_mapState = StateObject(wrappedValue: MapLocationState(
|
_mapState = StateObject(wrappedValue: MapLocationState(
|
||||||
initialCoordinate: initialCoord,
|
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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
MapViewRepresentable(
|
MapViewRepresentable(
|
||||||
selection: mapState.selection,
|
selection: mapState.selection,
|
||||||
|
initialViewportMeters: mapState.viewportMeters,
|
||||||
cameraCommand: mapState.cameraCommand,
|
cameraCommand: mapState.cameraCommand,
|
||||||
onRealtimeLocationChanged: { location in
|
onRealtimeLocationChanged: { location in
|
||||||
handleNativeRealtimeLocation(location)
|
handleNativeRealtimeLocation(location)
|
||||||
@@ -100,10 +136,16 @@ struct MapHomeView: View {
|
|||||||
let previousRevision = mapState.selection.revision
|
let previousRevision = mapState.selection.revision
|
||||||
let revision = mapState.selectUserMapCenter(coordinate)
|
let revision = mapState.selectUserMapCenter(coordinate)
|
||||||
guard revision != previousRevision else { return }
|
guard revision != previousRevision else { return }
|
||||||
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
let pair = CoordinatePair(
|
||||||
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
mapCoordinate: coordinate,
|
||||||
|
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
|
||||||
|
)
|
||||||
|
LastCoordinateStore.save(
|
||||||
|
coordinatePair: pair,
|
||||||
|
zoomMeters: mapState.viewportMeters
|
||||||
|
)
|
||||||
favorites.select(nil)
|
favorites.select(nil)
|
||||||
scheduleGeocode(coordinate: coordinate, revision: revision)
|
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
|
||||||
},
|
},
|
||||||
onViewportChanged: { distance in
|
onViewportChanged: { distance in
|
||||||
mapState.updateViewport(distanceMeters: distance)
|
mapState.updateViewport(distanceMeters: distance)
|
||||||
@@ -111,12 +153,19 @@ struct MapHomeView: View {
|
|||||||
onMapTap: { coordinate in
|
onMapTap: { coordinate in
|
||||||
favorites.select(nil)
|
favorites.select(nil)
|
||||||
let revision = mapState.selectMapTap(coordinate)
|
let revision = mapState.selectMapTap(coordinate)
|
||||||
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
let pair = CoordinatePair(
|
||||||
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
mapCoordinate: coordinate,
|
||||||
scheduleGeocode(coordinate: coordinate, revision: revision)
|
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
|
||||||
|
)
|
||||||
|
LastCoordinateStore.save(
|
||||||
|
coordinatePair: pair,
|
||||||
|
zoomMeters: mapState.viewportMeters
|
||||||
|
)
|
||||||
|
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
|
||||||
},
|
},
|
||||||
onUserZoomChanged: { distance in
|
onUserZoomChanged: { distance in
|
||||||
ViewportStore.save(distance)
|
ViewportStore.save(distance)
|
||||||
|
LastCoordinateStore.updateZoom(distance)
|
||||||
},
|
},
|
||||||
onZoomIn: { mapState.zoom(by: 0.5) },
|
onZoomIn: { mapState.zoom(by: 0.5) },
|
||||||
onZoomOut: { mapState.zoom(by: 2) }
|
onZoomOut: { mapState.zoom(by: 2) }
|
||||||
@@ -188,9 +237,9 @@ struct MapHomeView: View {
|
|||||||
Button("知道了", role: .cancel) {}
|
Button("知道了", role: .cancel) {}
|
||||||
} message: { Text(manualHint) }
|
} message: { Text(manualHint) }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
initializeMap()
|
startMapRuntimeOnce()
|
||||||
Task { await setup.refreshTrust() }
|
// ContentView performs the startup environment test before this map
|
||||||
startTileProbe()
|
// view is constructed. Do not immediately run it again here.
|
||||||
registerWiFiChangeObserver()
|
registerWiFiChangeObserver()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@@ -201,14 +250,6 @@ struct MapHomeView: View {
|
|||||||
wifiVerificationTask?.cancel()
|
wifiVerificationTask?.cancel()
|
||||||
wifiVerificationTask = nil
|
wifiVerificationTask = nil
|
||||||
wifiVerificationID = 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
|
.onChange(of: proxy.isRunning) { running in
|
||||||
if !running && spoofState == .active {
|
if !running && spoofState == .active {
|
||||||
@@ -350,7 +391,7 @@ struct MapHomeView: View {
|
|||||||
if actions.virtualLocationEnabled {
|
if actions.virtualLocationEnabled {
|
||||||
activeTip = .activation
|
activeTip = .activation
|
||||||
} else {
|
} else {
|
||||||
showDisableTip = true
|
activeTip = .deactivation
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(actions.virtualLocationEnabled ? "无法生效?" : "无法取消?")
|
Text(actions.virtualLocationEnabled ? "无法生效?" : "无法取消?")
|
||||||
@@ -368,16 +409,14 @@ struct MapHomeView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let snapshot = currentSelectionFavorite
|
let snapshot = currentSelectionFavorite
|
||||||
let wgs = CoordinateConverter.toStored(lat: snapshot.latitude, lon: snapshot.longitude)
|
|
||||||
let favorite = favorites.save(
|
let favorite = favorites.save(
|
||||||
name: snapshot.name,
|
name: snapshot.name,
|
||||||
latitude: wgs.lat,
|
mapCoordinate: mapState.selection.coordinate,
|
||||||
longitude: wgs.lon,
|
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem,
|
||||||
accuracy: snapshot.accuracy
|
accuracy: snapshot.accuracy
|
||||||
)
|
)
|
||||||
let display = CoordinateConverter.toDisplay(lat: favorite.latitude, lon: favorite.longitude)
|
|
||||||
mapState.selectFavorite(
|
mapState.selectFavorite(
|
||||||
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
|
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
|
||||||
id: favorite.id,
|
id: favorite.id,
|
||||||
name: favorite.name
|
name: favorite.name
|
||||||
)
|
)
|
||||||
@@ -445,8 +484,10 @@ struct MapHomeView: View {
|
|||||||
guard spoofState == .active,
|
guard spoofState == .active,
|
||||||
let sLat = activeSpoofLat,
|
let sLat = activeSpoofLat,
|
||||||
let sLon = activeSpoofLon else { return false }
|
let sLon = activeSpoofLon else { return false }
|
||||||
return abs(sLat - mapState.selection.coordinate.latitude) > 0.0001
|
return !currentSelectionFavorite.coordinatePair.matchesWGS84(
|
||||||
|| abs(sLon - mapState.selection.coordinate.longitude) > 0.0001
|
latitude: sLat,
|
||||||
|
longitude: sLon
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var buttonTitle: String {
|
private var buttonTitle: String {
|
||||||
@@ -485,7 +526,7 @@ struct MapHomeView: View {
|
|||||||
spoofState = .verifying
|
spoofState = .verifying
|
||||||
|
|
||||||
locationOperationTask = Task { @MainActor in
|
locationOperationTask = Task { @MainActor in
|
||||||
let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude)
|
let result = await setup.runVerificationTest()
|
||||||
guard !Task.isCancelled,
|
guard !Task.isCancelled,
|
||||||
operationID == locationOperationID,
|
operationID == locationOperationID,
|
||||||
selectionRevision == mapState.selection.revision else {
|
selectionRevision == mapState.selection.revision else {
|
||||||
@@ -508,8 +549,13 @@ struct MapHomeView: View {
|
|||||||
"applied": String(applied),
|
"applied": String(applied),
|
||||||
"spoofState": String(describing: spoofState)
|
"spoofState": String(describing: spoofState)
|
||||||
])
|
])
|
||||||
if applied && !activationTipDisabled {
|
if applied {
|
||||||
showEnableTip = true
|
let count = tipPreferences.recordSuccessfulOperation(.activation)
|
||||||
|
RuntimeLogger.info("APP", "提醒", "累计开启虚拟定位次数", details: [
|
||||||
|
"次数": String(count),
|
||||||
|
"可显示不再提醒": String(tipPreferences.canSuppress(.activation))
|
||||||
|
])
|
||||||
|
showEnableTip = tipPreferences.shouldPresentAutomaticTip(.activation)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
spoofState = actions.virtualLocationEnabled ? .active : .idle
|
spoofState = actions.virtualLocationEnabled ? .active : .idle
|
||||||
@@ -517,7 +563,11 @@ struct MapHomeView: View {
|
|||||||
"result": result.id,
|
"result": result.id,
|
||||||
"spoofState": String(describing: spoofState)
|
"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
|
activeTip = tip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -533,7 +583,12 @@ struct MapHomeView: View {
|
|||||||
spoofState = .idle
|
spoofState = .idle
|
||||||
activeSpoofLat = nil
|
activeSpoofLat = nil
|
||||||
activeSpoofLon = 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.latitude,
|
||||||
mapState.selection.coordinate.longitude
|
mapState.selection.coordinate.longitude
|
||||||
),
|
),
|
||||||
latitude: mapState.selection.coordinate.latitude,
|
coordinatePair: .init(
|
||||||
longitude: mapState.selection.coordinate.longitude,
|
mapCoordinate: mapState.selection.coordinate,
|
||||||
|
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
|
||||||
|
),
|
||||||
accuracy: 25
|
accuracy: 25
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var testFavorite: FavoriteLocation { currentSelectionFavorite }
|
private var testFavorite: FavoriteLocation { currentSelectionFavorite }
|
||||||
|
|
||||||
private func initializeMap() {
|
private func startMapRuntimeOnce() {
|
||||||
guard !mapDidInitialize else { return }
|
guard !mapRuntimeDidStart else { return }
|
||||||
mapDidInitialize = true
|
mapRuntimeDidStart = true
|
||||||
|
let pair = LastCoordinateStore.load()?.coordinatePair
|
||||||
if let selected = favorites.selectedFavorite {
|
?? CoordinatePair(
|
||||||
let display = CoordinateConverter.toDisplay(lat: selected.latitude, lon: selected.longitude)
|
mapCoordinate: mapState.selection.coordinate,
|
||||||
mapState.selectFavorite(
|
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
|
||||||
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
|
|
||||||
id: selected.id,
|
|
||||||
name: selected.name
|
|
||||||
)
|
)
|
||||||
LastCoordinateStore.save(lat: selected.latitude, lon: selected.longitude)
|
scheduleGeocode(
|
||||||
} else {
|
coordinate: pair.wgs84.coordinate,
|
||||||
mapState.focusSelection(distanceMeters: mapState.viewportMeters)
|
revision: mapState.selection.revision
|
||||||
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
|
|
||||||
)
|
)
|
||||||
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,
|
"from": change.previous.rawValue,
|
||||||
"to": change.current.rawValue
|
"to": change.current.rawValue
|
||||||
])
|
])
|
||||||
@@ -644,16 +670,20 @@ struct MapHomeView: View {
|
|||||||
|
|
||||||
private func registerWiFiChangeObserver() {
|
private func registerWiFiChangeObserver() {
|
||||||
guard wifiChangeObserverToken == nil else { return }
|
guard wifiChangeObserverToken == nil else { return }
|
||||||
wifiChangeObserverToken = net.observeWiFiChanges { [self] in
|
wifiChangeObserverToken = net.observeWiFiChanges { [self] reason in
|
||||||
handleWiFiChange()
|
handleWiFiChange(reason: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleWiFiChange() {
|
private func handleWiFiChange(reason: WiFiChangeReason) {
|
||||||
RuntimeLogger.info("APP", "WiFi", "检测到 Wi-Fi 网络变化", details: [
|
RuntimeLogger.info("APP", "WiFi", "检测到 Wi-Fi 网络变化", details: [
|
||||||
|
"原因": reason.rawValue,
|
||||||
"虚拟定位已开启": String(spoofState == .active)
|
"虚拟定位已开启": String(spoofState == .active)
|
||||||
])
|
])
|
||||||
guard spoofState == .active else { return }
|
guard spoofState == .active else { return }
|
||||||
|
if wifiVerificationTask != nil {
|
||||||
|
RuntimeLogger.info("APP", "WiFi", "网络仍在变化,重新计算环境检测等待时间")
|
||||||
|
}
|
||||||
wifiVerificationTask?.cancel()
|
wifiVerificationTask?.cancel()
|
||||||
let verificationID = UUID()
|
let verificationID = UUID()
|
||||||
wifiVerificationID = verificationID
|
wifiVerificationID = verificationID
|
||||||
@@ -664,34 +694,94 @@ struct MapHomeView: View {
|
|||||||
wifiVerificationID = nil
|
wifiVerificationID = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let stabilizationNanoseconds: UInt64 = 5_000_000_000
|
||||||
|
RuntimeLogger.info("APP", "WiFi", "等待 Wi-Fi 连接稳定后检测", details: [
|
||||||
|
"等待秒数": "5",
|
||||||
|
"事件原因": reason.rawValue
|
||||||
|
])
|
||||||
do {
|
do {
|
||||||
try await Task.sleep(nanoseconds: 3_000_000_000)
|
try await Task.sleep(nanoseconds: stabilizationNanoseconds)
|
||||||
} catch {
|
} catch {
|
||||||
|
RuntimeLogger.debug("APP", "WiFi", "延时检测已被更新的网络事件取消")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !Task.isCancelled, spoofState == .active else { return }
|
guard !Task.isCancelled, spoofState == .active else { return }
|
||||||
RuntimeLogger.info("APP", "WiFi", "开始重新验证")
|
guard net.isSatisfied, net.isWiFiEnabled else {
|
||||||
let target = currentSelectionFavorite
|
RuntimeLogger.warning("APP", "WiFi", "稳定等待结束后仍未连接 Wi-Fi,提示检查代理", details: [
|
||||||
let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude)
|
"网络可用": String(net.isSatisfied),
|
||||||
guard !Task.isCancelled, spoofState == .active else { return }
|
"Wi-Fi接口": String(net.isWiFiEnabled)
|
||||||
RuntimeLogger.info("APP", "WiFi", "重新验证完成", details: [
|
])
|
||||||
"success": String(result.isSuccess),
|
activeTip = .proxySetup
|
||||||
"tipKind": result.tipKind?.rawValue ?? "nil"
|
return
|
||||||
])
|
}
|
||||||
if !result.isSuccess, let tip = result.tipKind {
|
|
||||||
activeTip = tip
|
// 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() {
|
private func requestRealtimeLocation() {
|
||||||
let intent = mapState.beginRealtimeIntent()
|
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 只在位置变化时才更新)
|
// 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新)
|
||||||
if let loc = mapState.realtimeLocation {
|
if let loc = mapState.realtimeLocation {
|
||||||
|
RealtimeLocationTrace.log("实时定位按钮直接使用 MapKit 蓝点缓存", location: loc, details: [
|
||||||
|
"intentID": String(intent.id),
|
||||||
|
"来源": "MapLocationState.realtimeLocation"
|
||||||
|
])
|
||||||
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
|
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
RuntimeLogger.info("APP", "实时定位", "MapKit 蓝点尚不可用,启动 CLLocationManager 兜底", details: [
|
||||||
|
"intentID": String(intent.id)
|
||||||
|
])
|
||||||
startRealtimeLocationRequest(
|
startRealtimeLocationRequest(
|
||||||
source: "CLLocationManager",
|
source: "CLLocationManager",
|
||||||
showFailureAlert: true,
|
showFailureAlert: true,
|
||||||
@@ -700,14 +790,25 @@ struct MapHomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleNativeRealtimeLocation(_ location: CLLocation) {
|
private func handleNativeRealtimeLocation(_ location: CLLocation) {
|
||||||
|
RealtimeLocationTrace.log("主页收到 MapKit 蓝点回调", location: location, details: [
|
||||||
|
"存在待处理请求": String(realtimeRequestContext != nil),
|
||||||
|
"当前选点revision": String(mapState.selection.revision)
|
||||||
|
])
|
||||||
mapState.updateRealtimeLocation(location)
|
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,
|
// MKMapView's MKUserLocation is the visible blue point. When it arrives,
|
||||||
// fulfill the pending intent from that exact sample and cancel the slower
|
// fulfill the pending intent from that exact sample and cancel the slower
|
||||||
// CLLocationManager fallback so the camera and dot cannot disagree.
|
// CLLocationManager fallback so the camera and dot cannot disagree.
|
||||||
realtimeRequestContext = nil
|
realtimeRequestContext = nil
|
||||||
realtimeRequestTask?.cancel()
|
realtimeRequestTask?.cancel()
|
||||||
|
RuntimeLogger.info("APP", "实时定位", "MapKit 蓝点抢先完成请求,取消 CLLocationManager 兜底", details: [
|
||||||
|
"intentID": String(context.intent.id),
|
||||||
|
"原兜底来源": context.source
|
||||||
|
])
|
||||||
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "蓝点(途中)→\(context.source)")
|
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "蓝点(途中)→\(context.source)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,16 +823,34 @@ struct MapHomeView: View {
|
|||||||
source: source,
|
source: source,
|
||||||
showFailureAlert: showFailureAlert
|
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
|
// A button tap during the startup request retargets that same in-flight
|
||||||
// Core Location request to the newer intent instead of being ignored.
|
// 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
|
realtimeRequestTask = Task { @MainActor in
|
||||||
defer {
|
defer {
|
||||||
realtimeRequestTask = nil
|
realtimeRequestTask = nil
|
||||||
realtimeRequestContext = nil
|
realtimeRequestContext = nil
|
||||||
}
|
}
|
||||||
guard let coordinate = await realtime.requestLocation() else {
|
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,
|
guard let context = realtimeRequestContext,
|
||||||
context.showFailureAlert,
|
context.showFailureAlert,
|
||||||
!Task.isCancelled,
|
!Task.isCancelled,
|
||||||
@@ -740,7 +859,16 @@ struct MapHomeView: View {
|
|||||||
showLocationAlert = true
|
showLocationAlert = true
|
||||||
return
|
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)
|
acceptRealtimeLocation(coordinate, intent: context.intent, source: context.source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -751,15 +879,48 @@ struct MapHomeView: View {
|
|||||||
source: String
|
source: String
|
||||||
) {
|
) {
|
||||||
let currentViewport = mapState.viewportMeters
|
let currentViewport = mapState.viewportMeters
|
||||||
let accepted = mapState.acceptRealtimeLocation(coordinate, intent: intent)
|
let previousMapCoordinateSystem = CoordinateConverter.currentMapCoordinateSystem
|
||||||
RuntimeLogger.info("APP", "地图", "\(source)返回", details: [
|
let usesDomesticStandard = CoordinateConverter.usesGCJ02ServiceArea(
|
||||||
"accepted": String(accepted)
|
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 }
|
guard accepted else { return }
|
||||||
// 用点击时的缩放级别居中,不改变缩放
|
// 用点击时的缩放级别居中,不改变缩放
|
||||||
mapState.focusSelection(distanceMeters: currentViewport)
|
mapState.focusSelection(distanceMeters: currentViewport)
|
||||||
// 蓝点坐标已是 WGS-84,直接存,不需要 toStored
|
// Core Location is WGS-84; persist both forms once and render the active form.
|
||||||
LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude)
|
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: currentViewport)
|
||||||
favorites.select(nil)
|
favorites.select(nil)
|
||||||
scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision)
|
scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision)
|
||||||
}
|
}
|
||||||
@@ -898,8 +1059,11 @@ struct MapHomeView: View {
|
|||||||
reverseGeocodeTask?.cancel()
|
reverseGeocodeTask?.cancel()
|
||||||
favorites.select(nil)
|
favorites.select(nil)
|
||||||
mapState.selectSearchResult(result.coordinate, name: result.name)
|
mapState.selectSearchResult(result.coordinate, name: result.name)
|
||||||
let wgs = CoordinateConverter.toStored(lat: result.coordinate.latitude, lon: result.coordinate.longitude)
|
LastCoordinateStore.save(
|
||||||
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
mapCoordinate: result.coordinate,
|
||||||
|
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem,
|
||||||
|
zoomMeters: mapState.viewportMeters
|
||||||
|
)
|
||||||
searchText = result.name
|
searchText = result.name
|
||||||
searchResults = []
|
searchResults = []
|
||||||
searchError = ""
|
searchError = ""
|
||||||
@@ -913,13 +1077,15 @@ struct MapHomeView: View {
|
|||||||
geocodeDebounceTask?.cancel()
|
geocodeDebounceTask?.cancel()
|
||||||
reverseGeocodeTask?.cancel()
|
reverseGeocodeTask?.cancel()
|
||||||
favorites.select(favorite.id)
|
favorites.select(favorite.id)
|
||||||
let display = CoordinateConverter.toDisplay(lat: favorite.latitude, lon: favorite.longitude)
|
|
||||||
mapState.selectFavorite(
|
mapState.selectFavorite(
|
||||||
CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon),
|
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
|
||||||
id: favorite.id,
|
id: favorite.id,
|
||||||
name: favorite.name
|
name: favorite.name
|
||||||
)
|
)
|
||||||
LastCoordinateStore.save(lat: favorite.latitude, lon: favorite.longitude)
|
LastCoordinateStore.save(
|
||||||
|
coordinatePair: favorite.coordinatePair,
|
||||||
|
zoomMeters: mapState.viewportMeters
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var enableTipSheet: some View {
|
private var enableTipSheet: some View {
|
||||||
@@ -931,16 +1097,38 @@ struct MapHomeView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
|
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
VStack(spacing: 8) {
|
HStack(spacing: 12) {
|
||||||
Button { showEnableTip = false } label: {
|
if tipPreferences.canSuppress(.activation) {
|
||||||
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
Button {
|
||||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
tipPreferences.suppress(.activation)
|
||||||
Button(role: .destructive) {
|
showEnableTip = false
|
||||||
activationTipDisabled = true
|
} label: {
|
||||||
showEnableTip = false
|
Label("不再提醒", systemImage: "bell.slash.fill")
|
||||||
} label: {
|
.font(.body.weight(.semibold))
|
||||||
Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}.buttonStyle(.bordered)
|
.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)
|
}.padding(.horizontal, 16).padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,9 +1144,39 @@ struct MapHomeView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("虚拟定位已关闭").navigationBarTitleDisplayMode(.inline)
|
.navigationTitle("虚拟定位已关闭").navigationBarTitleDisplayMode(.inline)
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
Button { showDisableTip = false } label: {
|
HStack(spacing: 12) {
|
||||||
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
if tipPreferences.canSuppress(.deactivation) {
|
||||||
}.buttonStyle(.borderedProminent).tint(.blue).padding(.horizontal, 16).padding(.bottom, 8)
|
Button {
|
||||||
|
tipPreferences.suppress(.deactivation)
|
||||||
|
showDisableTip = false
|
||||||
|
} label: {
|
||||||
|
Label("不再提醒", systemImage: "bell.slash.fill")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.orange)
|
||||||
|
|
||||||
|
Button { showDisableTip = false } label: {
|
||||||
|
Text("知道了")
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.blue)
|
||||||
|
} else {
|
||||||
|
Button { showDisableTip = false } label: {
|
||||||
|
Text("知道了")
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 16).padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-37
@@ -131,12 +131,17 @@ final class MapLocationState: ObservableObject {
|
|||||||
private var nextRealtimeIntentID: UInt64 = 0
|
private var nextRealtimeIntentID: UInt64 = 0
|
||||||
private var latestRealtimeIntentID: 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
|
viewportMeters = initialViewportMeters
|
||||||
selection = MapSelection(
|
selection = MapSelection(
|
||||||
coordinate: initialCoordinate,
|
coordinate: initialCoordinate,
|
||||||
source: .initial,
|
source: initialSource,
|
||||||
explicitName: nil,
|
explicitName: initialName?.nonEmpty,
|
||||||
revision: nextSelectionRevision
|
revision: nextSelectionRevision
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -233,7 +238,7 @@ final class MapLocationState: ObservableObject {
|
|||||||
|
|
||||||
/// Updates only the map representation of the current physical selection.
|
/// Updates only the map representation of the current physical selection.
|
||||||
/// This preserves revision/source so an in-flight user action is not invalidated.
|
/// This preserves revision/source so an in-flight user action is not invalidated.
|
||||||
func reprojectSelectionForTileChange(_ coordinate: CLLocationCoordinate2D) {
|
func reprojectSelectionForMapCoordinateSystemChange(_ coordinate: CLLocationCoordinate2D) {
|
||||||
guard CLLocationCoordinate2DIsValid(coordinate),
|
guard CLLocationCoordinate2DIsValid(coordinate),
|
||||||
!selection.coordinate.isApproximatelyEqual(to: coordinate) else { return }
|
!selection.coordinate.isApproximatelyEqual(to: coordinate) else { return }
|
||||||
selection = MapSelection(
|
selection = MapSelection(
|
||||||
@@ -302,49 +307,132 @@ extension CLLocationCoordinate2D {
|
|||||||
|
|
||||||
// MARK: - 持久化存储
|
// 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 {
|
enum ViewportStore {
|
||||||
private static let key = "mapViewportMeters"
|
private static let key = "mapViewportMeters"
|
||||||
static func save(_ meters: CLLocationDistance) {
|
|
||||||
UserDefaults.standard.set(meters, forKey: key)
|
static func save(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
|
||||||
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(meters)])
|
let value = max(50, meters)
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(value)])
|
||||||
}
|
}
|
||||||
/// 取持久化缩放值;未存过返回 nil
|
|
||||||
static func load() -> CLLocationDistance? {
|
static func load(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance? {
|
||||||
let v = UserDefaults.standard.double(forKey: key)
|
let value = defaults.double(forKey: key)
|
||||||
let result = v > 0 ? v : nil
|
return value > 0 ? value : nil
|
||||||
RuntimeLogger.info("APP", "缩放", "读取缩放", details: ["zoom": result.map { String($0) } ?? "nil"])
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
/// 取持久化缩放值,取不到返回默认 1km 并立即存储
|
|
||||||
static func loadOrDefault() -> CLLocationDistance {
|
static func loadOrDefault(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance {
|
||||||
if let v = load() { return v }
|
if let value = load(defaults: defaults) { return value }
|
||||||
let fallback: CLLocationDistance = 1_000
|
let fallback: CLLocationDistance = 1_000
|
||||||
save(fallback)
|
save(fallback, defaults: defaults)
|
||||||
RuntimeLogger.info("APP", "缩放", "使用默认缩放 1km")
|
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LastCoordinate {
|
enum CoordinateStorageMigration {
|
||||||
let latitude: Double
|
private static let versionKey = "coordinateStorageMigrationVersion"
|
||||||
let longitude: Double
|
static let currentVersion = 1
|
||||||
var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) }
|
|
||||||
var isValid: Bool { CLLocationCoordinate2DIsValid(coordinate) && (latitude != 0 || longitude != 0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LastCoordinateStore {
|
static func migrateIfNeeded(
|
||||||
private static let latKey = "lastMapLat"
|
favorites: FavoriteLocationStore,
|
||||||
private static let lonKey = "lastMapLon"
|
defaults: UserDefaults = AppGroup.defaults,
|
||||||
static func save(lat: Double, lon: Double) {
|
legacyDefaults: UserDefaults = .standard
|
||||||
UserDefaults.standard.set(lat, forKey: latKey)
|
) throws {
|
||||||
UserDefaults.standard.set(lon, forKey: lonKey)
|
guard defaults.integer(forKey: versionKey) < currentVersion else { return }
|
||||||
}
|
// Pre-migration map pin and viewport values lived in UserDefaults.standard;
|
||||||
/// 取持久化坐标,未存过或无效返回 nil
|
// favorites already lived in App Group defaults.
|
||||||
static func load() -> LastCoordinate? {
|
try LastCoordinateStore.migrateLegacyCoordinate(
|
||||||
let c = LastCoordinate(
|
defaults: defaults,
|
||||||
latitude: UserDefaults.standard.double(forKey: latKey),
|
legacyDefaults: legacyDefaults
|
||||||
longitude: UserDefaults.standard.double(forKey: lonKey)
|
|
||||||
)
|
)
|
||||||
return c.isValid ? c : nil
|
try favorites.migrateLegacyCoordinates()
|
||||||
|
defaults.set(currentVersion, forKey: versionKey)
|
||||||
|
RuntimeLogger.info("APP", "坐标转换", "旧坐标数据迁移完成")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ enum MapZoomMath {
|
|||||||
|
|
||||||
struct MapViewRepresentable: UIViewRepresentable {
|
struct MapViewRepresentable: UIViewRepresentable {
|
||||||
let selection: MapSelection
|
let selection: MapSelection
|
||||||
|
let initialViewportMeters: CLLocationDistance
|
||||||
let cameraCommand: MapCameraCommand?
|
let cameraCommand: MapCameraCommand?
|
||||||
let onRealtimeLocationChanged: (CLLocation) -> Void
|
let onRealtimeLocationChanged: (CLLocation) -> Void
|
||||||
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
|
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
|
||||||
@@ -47,9 +48,11 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
let map = MKMapView()
|
let map = MKMapView()
|
||||||
map.delegate = context.coordinator
|
map.delegate = context.coordinator
|
||||||
map.showsUserLocation = true
|
map.showsUserLocation = true
|
||||||
let initialDistance = ViewportStore.loadOrDefault()
|
let initialDistance = max(50, initialViewportMeters)
|
||||||
RuntimeLogger.info("APP", "地图", "makeUIView", details: [
|
RuntimeLogger.info("APP", "地图", "makeUIView", details: [
|
||||||
"zoom": String(initialDistance)
|
"zoom": String(initialDistance),
|
||||||
|
"初始坐标来源": String(describing: selection.source),
|
||||||
|
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
|
||||||
])
|
])
|
||||||
map.setRegion(
|
map.setRegion(
|
||||||
MKCoordinateRegion(
|
MKCoordinateRegion(
|
||||||
@@ -102,7 +105,7 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
zoomLabel.textAlignment = .center
|
zoomLabel.textAlignment = .center
|
||||||
zoomLabel.adjustsFontSizeToFitWidth = true
|
zoomLabel.adjustsFontSizeToFitWidth = true
|
||||||
zoomLabel.minimumScaleFactor = 0.7
|
zoomLabel.minimumScaleFactor = 0.7
|
||||||
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: ViewportStore.loadOrDefault())
|
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: initialDistance)
|
||||||
zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
||||||
context.coordinator.zoomLabel = zoomLabel
|
context.coordinator.zoomLabel = zoomLabel
|
||||||
|
|
||||||
@@ -160,6 +163,7 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
|
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
|
||||||
private var userDotDiameter: CGFloat = 20
|
private var userDotDiameter: CGFloat = 20
|
||||||
private var keyboardObserverTokens: [NSObjectProtocol] = []
|
private var keyboardObserverTokens: [NSObjectProtocol] = []
|
||||||
|
private var lastForwardedRealtimeTimestamp: Date?
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver)
|
keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver)
|
||||||
@@ -235,10 +239,28 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||||
guard let location = userLocation.location,
|
guard let location = userLocation.location else {
|
||||||
CLLocationCoordinate2DIsValid(location.coordinate),
|
RuntimeLogger.warning("APP", "实时定位", "MapKit 蓝点更新但 location 为空", details: [
|
||||||
location.horizontalAccuracy >= 0 else { return }
|
"来源": "MKMapView.didUpdate"
|
||||||
parent.onRealtimeLocationChanged(location)
|
])
|
||||||
|
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) {
|
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
|
||||||
@@ -271,7 +293,12 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
|
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
|
||||||
if let ul = mapView.userLocation.location,
|
if let ul = mapView.userLocation.location,
|
||||||
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
|
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
|
let userZoomed: Bool
|
||||||
@@ -295,6 +322,11 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
regionChangeWasUserDriven = false
|
regionChangeWasUserDriven = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func forwardRealtimeLocation(_ location: CLLocation) {
|
||||||
|
lastForwardedRealtimeTimestamp = location.timestamp
|
||||||
|
parent.onRealtimeLocationChanged(location)
|
||||||
|
}
|
||||||
|
|
||||||
private func visibleVerticalDistance(in map: MKMapView) -> CLLocationDistance {
|
private func visibleVerticalDistance(in map: MKMapView) -> CLLocationDistance {
|
||||||
let centerX = map.bounds.midX
|
let centerX = map.bounds.midX
|
||||||
let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map)
|
let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map)
|
||||||
|
|||||||
@@ -92,24 +92,43 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
|
|
||||||
func requestLocation() async -> CLLocationCoordinate2D? {
|
func requestLocation() async -> CLLocationCoordinate2D? {
|
||||||
guard activeRequest == nil else {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
authorizationStatus = driver.authorizationStatus
|
authorizationStatus = driver.authorizationStatus
|
||||||
|
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求入口", details: [
|
||||||
|
"授权状态": authorizationName(authorizationStatus),
|
||||||
|
"内存缓存存在": String(location != nil),
|
||||||
|
"系统缓存存在": String(driver.location != nil)
|
||||||
|
])
|
||||||
guard authorizationStatus != .denied, authorizationStatus != .restricted else {
|
guard authorizationStatus != .denied, authorizationStatus != .restricted else {
|
||||||
|
RuntimeLogger.warning("APP", "实时定位", "授权状态不允许定位", details: [
|
||||||
|
"授权状态": authorizationName(authorizationStatus)
|
||||||
|
])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let cached = freshestCachedLocation() {
|
if let cached = freshestCachedLocation() {
|
||||||
location = cached
|
location = cached
|
||||||
|
RealtimeLocationTrace.log("使用 CLLocationManager 新鲜缓存", location: cached, details: [
|
||||||
|
"来源": "manager-memory-or-system",
|
||||||
|
"缓存上限秒": String(Int(cacheMaxAge))
|
||||||
|
])
|
||||||
return cached.coordinate
|
return cached.coordinate
|
||||||
}
|
}
|
||||||
|
|
||||||
nextRequestID &+= 1
|
nextRequestID &+= 1
|
||||||
let requestID = nextRequestID
|
let requestID = nextRequestID
|
||||||
isRequesting = true
|
isRequesting = true
|
||||||
RuntimeLogger.info("APP", "定位", "请求实时定位…", details: ["requestID": String(requestID)])
|
RuntimeLogger.info("APP", "实时定位", "创建 CLLocationManager 请求", details: [
|
||||||
|
"requestID": String(requestID),
|
||||||
|
"授权状态": authorizationName(authorizationStatus),
|
||||||
|
"初始阶段": "awaitingAuthorization"
|
||||||
|
])
|
||||||
|
|
||||||
return await withTaskCancellationHandler {
|
return await withTaskCancellationHandler {
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
@@ -121,6 +140,9 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
)
|
)
|
||||||
|
|
||||||
if authorizationStatus == .notDetermined {
|
if authorizationStatus == .notDetermined {
|
||||||
|
RuntimeLogger.info("APP", "实时定位", "请求前台定位授权", details: [
|
||||||
|
"requestID": String(requestID)
|
||||||
|
])
|
||||||
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
||||||
driver.requestWhenInUseAuthorization()
|
driver.requestWhenInUseAuthorization()
|
||||||
} else {
|
} else {
|
||||||
@@ -148,6 +170,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
authorizationStatus = driver.authorizationStatus
|
authorizationStatus = driver.authorizationStatus
|
||||||
|
RuntimeLogger.info("APP", "实时定位", "定位授权状态变化", details: [
|
||||||
|
"授权状态": authorizationName(authorizationStatus),
|
||||||
|
"requestID": activeRequest.map { String($0.id) } ?? "nil",
|
||||||
|
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
|
||||||
|
])
|
||||||
switch authorizationStatus {
|
switch authorizationStatus {
|
||||||
case .authorizedAlways, .authorizedWhenInUse:
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
if let request = activeRequest, request.phase == .awaitingAuthorization {
|
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]) {
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
MainActor.assumeIsolated {
|
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)
|
let validLocations = locations.filter(Self.isValid)
|
||||||
if let latestValid = validLocations.last {
|
if let latestValid = validLocations.last {
|
||||||
location = latestValid
|
location = latestValid
|
||||||
@@ -176,8 +224,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
let latest = validLocations.last(where: {
|
let latest = validLocations.last(where: {
|
||||||
$0.timestamp >= startedAt.addingTimeInterval(-1)
|
$0.timestamp >= startedAt.addingTimeInterval(-1)
|
||||||
}) else {
|
}) else {
|
||||||
|
if let request = activeRequest, request.phase != .awaitingAuthorization {
|
||||||
|
RuntimeLogger.warning("APP", "实时定位", "本批次没有可完成当前请求的样本", details: [
|
||||||
|
"requestID": String(request.id),
|
||||||
|
"阶段": phaseName(request.phase),
|
||||||
|
"有效样本数": String(validLocations.count)
|
||||||
|
])
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
RealtimeLocationTrace.log("接受 CLLocationManager 样本并完成请求", location: latest, details: [
|
||||||
|
"requestID": String(request.id),
|
||||||
|
"阶段": phaseName(request.phase)
|
||||||
|
])
|
||||||
finishRequest(id: request.id, coordinate: latest.coordinate)
|
finishRequest(id: request.id, coordinate: latest.coordinate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,8 +265,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func freshestCachedLocation(now: Date = Date()) -> CLLocation? {
|
private func freshestCachedLocation(now: Date = Date()) -> CLLocation? {
|
||||||
[location, driver.location]
|
let candidates = [location, driver.location].compactMap { $0 }
|
||||||
.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(Self.isValid)
|
||||||
.filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge }
|
.filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge }
|
||||||
.max(by: { $0.timestamp < $1.timestamp })
|
.max(by: { $0.timestamp < $1.timestamp })
|
||||||
@@ -253,6 +323,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
request.phase = .oneShot
|
request.phase = .oneShot
|
||||||
request.startedAt = Date()
|
request.startedAt = Date()
|
||||||
activeRequest = request
|
activeRequest = request
|
||||||
|
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 单次定位", details: [
|
||||||
|
"requestID": String(requestID),
|
||||||
|
"超时毫秒": String(oneShotTimeoutNanoseconds / 1_000_000)
|
||||||
|
])
|
||||||
scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds)
|
scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds)
|
||||||
driver.requestLocation()
|
driver.requestLocation()
|
||||||
}
|
}
|
||||||
@@ -263,6 +337,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
request.phase == .oneShot else { return }
|
request.phase == .oneShot else { return }
|
||||||
request.phase = .continuousFallback
|
request.phase = .continuousFallback
|
||||||
activeRequest = request
|
activeRequest = request
|
||||||
|
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 持续定位兜底", details: [
|
||||||
|
"requestID": String(requestID),
|
||||||
|
"超时毫秒": String(fallbackTimeoutNanoseconds / 1_000_000)
|
||||||
|
])
|
||||||
driver.startUpdatingLocation()
|
driver.startUpdatingLocation()
|
||||||
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
||||||
}
|
}
|
||||||
@@ -281,12 +359,37 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
isRequesting = false
|
isRequesting = false
|
||||||
|
|
||||||
if coordinate != nil {
|
if coordinate != nil {
|
||||||
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
|
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求完成", details: [
|
||||||
"requestID": String(requestID)
|
"requestID": String(requestID),
|
||||||
|
"最终阶段": phaseName(request.phase),
|
||||||
|
"有坐标": "true"
|
||||||
])
|
])
|
||||||
} else {
|
} 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)
|
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))"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Section("应用") {
|
Section("应用") {
|
||||||
Button {
|
Button {
|
||||||
setup.needsSetup = true
|
setup.requestSetup()
|
||||||
} label: {
|
} label: {
|
||||||
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-56
@@ -9,6 +9,7 @@ final class SetupCoordinator: ObservableObject {
|
|||||||
@Published var testLog = ""
|
@Published var testLog = ""
|
||||||
// 启动检测失败才弹引导页;检测通过则保持 false
|
// 启动检测失败才弹引导页;检测通过则保持 false
|
||||||
@Published var needsSetup = false
|
@Published var needsSetup = false
|
||||||
|
@Published private(set) var setupStep: SetupStep = .proxy
|
||||||
|
|
||||||
let certificateStore = CertificateAuthorityStore()
|
let certificateStore = CertificateAuthorityStore()
|
||||||
let proxy = ProxyManager.shared
|
let proxy = ProxyManager.shared
|
||||||
@@ -24,68 +25,48 @@ final class SetupCoordinator: ObservableObject {
|
|||||||
|
|
||||||
var canModify: Bool { proxy.isRunning && trustState == .trusted }
|
var canModify: Bool { proxy.isRunning && trustState == .trusted }
|
||||||
|
|
||||||
func refreshTrust() async {
|
/// Local services are prepared before the setup UI so the environment test
|
||||||
trustState = .checking
|
/// can distinguish Wi-Fi proxy configuration from CA trust failures.
|
||||||
message = "正在检测…"
|
func prepareLocalServices() async {
|
||||||
testLog = ""
|
|
||||||
defer { needsSetup = !canModify }
|
|
||||||
do {
|
do {
|
||||||
_ = try certificateStore.ensure()
|
_ = try certificateStore.ensure()
|
||||||
if !proxy.isRunning { try await proxy.start() }
|
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 {
|
} 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
|
trustState = .unavailable
|
||||||
let ns = error as NSError
|
setupStep = .cert
|
||||||
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
|
needsSetup = true
|
||||||
message = "CA 证书未信任,请去「设置→通用→关于→证书信任设置」开启或重新安装"
|
message = "CA 证书未安装或未信任"
|
||||||
} else if ns.domain == NSURLErrorDomain && ns.code == -1001 {
|
default:
|
||||||
message = "检测超时,请检查代理是否正常"
|
trustState = .unavailable
|
||||||
} else if ns.domain == NSURLErrorDomain && ns.code == -1200 {
|
setupStep = .proxy
|
||||||
message = "代理未启动或无法连接"
|
needsSetup = true
|
||||||
} else {
|
message = "Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888"
|
||||||
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidBecomeActive() {}
|
func sceneDidBecomeActive() {}
|
||||||
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
|
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
|
||||||
func completeSetup() { needsSetup = false }
|
func completeSetup() { needsSetup = false }
|
||||||
func requestSetup() { needsSetup = true }
|
func requestSetup() {
|
||||||
|
setupStep = .proxy
|
||||||
|
needsSetup = true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Step-by-step verification test
|
// MARK: - Step-by-step verification test
|
||||||
|
|
||||||
private func makeWlocRequest() -> URLRequest {
|
func runVerificationTest() async -> VerificationResult {
|
||||||
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 {
|
|
||||||
guard !isVerificationRunning else { return .verificationInProgress }
|
guard !isVerificationRunning else { return .verificationInProgress }
|
||||||
isVerificationRunning = true
|
isVerificationRunning = true
|
||||||
defer { isVerificationRunning = false }
|
defer { isVerificationRunning = false }
|
||||||
@@ -137,20 +118,15 @@ final class SetupCoordinator: ObservableObject {
|
|||||||
if body == verifyToken {
|
if body == verifyToken {
|
||||||
log(" ✓ 证书已信任,WiFi 代理已配置")
|
log(" ✓ 证书已信任,WiFi 代理已配置")
|
||||||
} else {
|
} else {
|
||||||
log(" ✗ 响应不匹配 (HTTP \(statusCode)),WiFi 代理未配置")
|
log(" ✗ 响应不匹配: HTTP \(statusCode), \(data.count) bytes,WiFi 代理未配置")
|
||||||
log(" 收到: \(body.prefix(100))")
|
|
||||||
return .wifiProxyNotConfigured
|
return .wifiProxyNotConfigured
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
let ns = error as NSError
|
let ns = error as NSError
|
||||||
let msg = error.localizedDescription
|
let msg = error.localizedDescription
|
||||||
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)")
|
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)")
|
||||||
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
|
if isCertificateTrustError(nsError: ns, message: msg) {
|
||||||
log(" TLS 握手被拒,CA 证书未信任")
|
log(" TLS/证书校验失败,CA 证书未信任")
|
||||||
return .certNotTrusted
|
|
||||||
}
|
|
||||||
if msg.contains("TLS") {
|
|
||||||
log(" 包含 TLS → 证书问题")
|
|
||||||
return .certNotTrusted
|
return .certNotTrusted
|
||||||
}
|
}
|
||||||
return .wifiProxyNotConfigured
|
return .wifiProxyNotConfigured
|
||||||
@@ -162,6 +138,28 @@ final class SetupCoordinator: ObservableObject {
|
|||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Classifies TLS trust failures without relying on localized error text alone.
|
||||||
|
private func isCertificateTrustError(nsError: NSError, message: String) -> Bool {
|
||||||
|
if nsError.domain == NSURLErrorDomain {
|
||||||
|
let trustErrorCodes: Set<Int> = [
|
||||||
|
-1200, // secure connection failed
|
||||||
|
-1201, // server certificate has bad date
|
||||||
|
-1202, // server certificate untrusted
|
||||||
|
-1203, // server certificate has unknown root
|
||||||
|
-1204, // server certificate not yet valid
|
||||||
|
-1205, // client certificate rejected
|
||||||
|
-1206, // client certificate required
|
||||||
|
]
|
||||||
|
return trustErrorCodes.contains(nsError.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = message.lowercased()
|
||||||
|
return normalized.contains("tls")
|
||||||
|
|| normalized.contains("ssl")
|
||||||
|
|| normalized.contains("certificate")
|
||||||
|
|| normalized.contains("证书")
|
||||||
|
}
|
||||||
|
|
||||||
/// 拉取 Go 代理的详细日志(CONNECT/请求/上游响应/改写结果)到 testLog
|
/// 拉取 Go 代理的详细日志(CONNECT/请求/上游响应/改写结果)到 testLog
|
||||||
private func collectProxyLogs(since date: Date, to log: (String) -> Void) {
|
private func collectProxyLogs(since date: Date, to log: (String) -> Void) {
|
||||||
CoreBridge.flushLogs(category: "Proxy")
|
CoreBridge.flushLogs(category: "Proxy")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ enum TipKind: String, Identifiable {
|
|||||||
case activation = "生效说明"
|
case activation = "生效说明"
|
||||||
case deactivation = "失效说明"
|
case deactivation = "失效说明"
|
||||||
case removeProxy = "关闭 WiFi 代理"
|
case removeProxy = "关闭 WiFi 代理"
|
||||||
case certificate = "证书问题"
|
|
||||||
case proxySetup = "配置代理"
|
case proxySetup = "配置代理"
|
||||||
case rewriteFailed = "改写失败"
|
case rewriteFailed = "改写失败"
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
@@ -22,7 +21,6 @@ struct TipSheetView: View {
|
|||||||
case .activation: ActivationTipContent(dismiss: { dismiss() })
|
case .activation: ActivationTipContent(dismiss: { dismiss() })
|
||||||
case .deactivation: DeactivationTipContent(dismiss: { dismiss() })
|
case .deactivation: DeactivationTipContent(dismiss: { dismiss() })
|
||||||
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
|
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
|
||||||
case .certificate: CertificateTipContent(dismiss: { dismiss() })
|
|
||||||
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
|
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
|
||||||
case .rewriteFailed: RewriteFailedTipContent(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 代理配置
|
// MARK: - WiFi 代理配置
|
||||||
|
|
||||||
struct ProxySetupTipContent: View {
|
struct ProxySetupTipContent: View {
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ func wloccore_generateca() (r0, r1 *C.char) {
|
|||||||
return C.CString(string(cert)), C.CString(string(key))
|
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
|
//export wloccore_startproxy
|
||||||
func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t {
|
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 {
|
if certData == nil || keyData == nil {
|
||||||
|
|||||||
+3
-1
@@ -145,7 +145,9 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
|||||||
logEvent("CONNECT " + host + " -> MITM (verify)")
|
logEvent("CONNECT " + host + " -> MITM (verify)")
|
||||||
return mitmAction, host
|
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
|
return goproxy.OkConnect, host
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,3 +43,76 @@ enum WlocSettingsStore {
|
|||||||
save(WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false))
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,46 +1,178 @@
|
|||||||
import Foundation
|
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 {
|
final class CertificateAuthorityStore {
|
||||||
private let directory: URL
|
private let directory: URL
|
||||||
private let generator: () throws -> CertificateAuthority
|
private let generator: () throws -> CertificateAuthority
|
||||||
|
private let validator: (CertificateAuthority) -> Bool
|
||||||
|
private let keychain: CertificateAuthorityKeychain
|
||||||
private let certificateURL: URL
|
private let certificateURL: URL
|
||||||
private let keyURL: 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.directory = directory
|
||||||
|
self.keychain = keychain
|
||||||
self.generator = generator
|
self.generator = generator
|
||||||
|
self.validator = validator
|
||||||
self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
|
self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
|
||||||
self.keyURL = directory.appendingPathComponent("ca-key.pem")
|
self.keyURL = directory.appendingPathComponent("ca-key.pem")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensure() throws -> CertificateAuthority {
|
func ensure() throws -> CertificateAuthority {
|
||||||
if let current = try load() {
|
if let authority = try loadValidKeychainAuthority() {
|
||||||
RuntimeLogger.debug("SHARED", "Certificate.store", "读取已有 CA 文件", details: ["directory": directory.path])
|
removeLegacyFilesBestEffort()
|
||||||
return current
|
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()
|
let authority = try generator()
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
guard validator(authority) else { throw CertificateAuthorityStoreError.invalidAuthority }
|
||||||
guard let certificateData = authority.certPEM.data(using: .utf8),
|
try keychain.save(authority)
|
||||||
let keyData = authority.keyPEM.data(using: .utf8) else {
|
removeLegacyFilesBestEffort()
|
||||||
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
RuntimeLogger.info("SHARED", "Certificate.store", "已生成并保存设备专属 CA")
|
||||||
}
|
|
||||||
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])
|
|
||||||
return authority
|
return authority
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() throws -> CertificateAuthority? {
|
func load() throws -> CertificateAuthority? {
|
||||||
guard FileManager.default.fileExists(atPath: certificateURL.path), FileManager.default.fileExists(atPath: keyURL.path) else { return nil }
|
try loadValidKeychainAuthority()
|
||||||
return CertificateAuthority(certPEM: try String(contentsOf: certificateURL, encoding: .utf8), keyPEM: try String(contentsOf: keyURL, encoding: .utf8))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyCompleteProtection(to url: URL) {
|
private func loadValidKeychainAuthority() throws -> CertificateAuthority? {
|
||||||
#if os(iOS)
|
guard let authority = try keychain.load() else { return nil }
|
||||||
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path)
|
guard validator(authority) else {
|
||||||
#endif
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+180
-121
@@ -2,121 +2,199 @@ import Foundation
|
|||||||
import CoreLocation
|
import CoreLocation
|
||||||
import MapKit
|
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 坐标转换。
|
/// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。
|
||||||
///
|
///
|
||||||
/// MKMapView 根据实时定位动态切换瓦片源:中国境内用高德 GCJ-02,境外用 Apple WGS-84。
|
/// MapKit does not expose a public API for its active coordinate reference system.
|
||||||
/// App 内部统一以 WGS-84 存储,仅在地图交互时按当前瓦片类型双向转换。
|
/// 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 {
|
enum CoordinateConverter {
|
||||||
// 椭球参数 (Krasovsky 1940)
|
// 椭球参数 (Krasovsky 1940)
|
||||||
private static let a = 6378245.0
|
private static let a = 6378245.0
|
||||||
private static let ee = 0.00669342162296594323
|
private static let ee = 0.00669342162296594323
|
||||||
|
|
||||||
/// 坐标类型
|
/// 坐标类型
|
||||||
enum CoordType: String {
|
enum MapCoordinateSystem: String {
|
||||||
case gcj02 = "GCJ-02"
|
case gcj02 = "GCJ-02"
|
||||||
case wgs84 = "WGS-84"
|
case wgs84 = "WGS-84"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 全局瓦片类型
|
// MARK: - 地图坐标标准
|
||||||
|
|
||||||
struct TileTypeChange: Equatable {
|
struct MapCoordinateSystemChange: Equatable {
|
||||||
let previous: CoordType
|
let previous: MapCoordinateSystem
|
||||||
let current: CoordType
|
let current: MapCoordinateSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前地图瓦片坐标系。探测不可用时使用国内 GCJ-02 作为兜底。
|
/// 当前 Apple 地图坐标标准。检测不可用时使用国内 GCJ-02 作为兜底。
|
||||||
@MainActor static var currentTileType = CoordType.gcj02
|
@MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02
|
||||||
@MainActor private static var lastTileCheck: Date?
|
@MainActor private static var mapCoordinateSystemCheckPending = false
|
||||||
@MainActor private static var tileCheckPending = false
|
@MainActor private(set) static var initialMapCoordinateSystemUsedFallback = true
|
||||||
|
|
||||||
/// Uses one fixed, known reference result as a best-effort tile heuristic.
|
/// Startup gate: only request realtime location if the public MapKit probe
|
||||||
///
|
/// cannot resolve a coordinate type. This guarantees a finite answer before
|
||||||
/// MapKit does not expose a supported public API for its active tile CRS. A
|
/// persisted map positions are replayed.
|
||||||
/// timeout, empty response, or search error is therefore not evidence for
|
|
||||||
/// WGS-84: those cases deliberately fall back to the domestic GCJ-02 mode.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
static func resolveInitialMapCoordinateSystem() async -> MapCoordinateSystem {
|
||||||
static func detectTileByFixedGeocode(force: Bool = false) async -> TileTypeChange? {
|
guard !mapCoordinateSystemCheckPending else {
|
||||||
guard !tileCheckPending else {
|
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测已有请求进行中")
|
||||||
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(进行中)")
|
return currentMapCoordinateSystem
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if !force, let last = lastTileCheck, -last.timeIntervalSinceNow < 30 {
|
mapCoordinateSystemCheckPending = true
|
||||||
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(缓存\(Int(-last.timeIntervalSinceNow))s)")
|
defer { mapCoordinateSystemCheckPending = false }
|
||||||
return nil
|
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测开始", details: [
|
||||||
}
|
"锚点": "22.283819,114.158439",
|
||||||
|
"判定规则": "首条名称=林士街→GCJ-02,否则→WGS-84",
|
||||||
tileCheckPending = true
|
"缓存": "false"
|
||||||
defer { tileCheckPending = false }
|
|
||||||
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 发起查询", details: [
|
|
||||||
"force": String(force),
|
|
||||||
"当前瓦片": currentTileType.rawValue
|
|
||||||
])
|
])
|
||||||
|
|
||||||
let probeResult = await fixedGeocodeProbe()
|
let nextType: MapCoordinateSystem
|
||||||
guard !Task.isCancelled else { return nil }
|
switch await fixedAnchorCoordinateSystemProbe() {
|
||||||
lastTileCheck = Date()
|
|
||||||
|
|
||||||
let nextType: CoordType
|
|
||||||
switch probeResult {
|
|
||||||
case let .response(name, count):
|
case let .response(name, count):
|
||||||
nextType = name == "林士街" ? .gcj02 : .wgs84
|
nextType = name == "林士街" ? .gcj02 : .wgs84
|
||||||
RuntimeLogger.info("APP", "坐标转换", "瓦片检测完成 → \(nextType.rawValue)", details: [
|
initialMapCoordinateSystemUsedFallback = false
|
||||||
|
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [
|
||||||
|
"首条名称": name,
|
||||||
"结果数": String(count),
|
"结果数": 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:
|
case .cancelled:
|
||||||
return nil
|
initialMapCoordinateSystemUsedFallback = true
|
||||||
|
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测被取消,保留默认国内标准", details: [
|
||||||
|
"最终标准": currentMapCoordinateSystem.rawValue
|
||||||
|
])
|
||||||
|
return currentMapCoordinateSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
guard nextType != currentTileType else { return nil }
|
currentMapCoordinateSystem = nextType
|
||||||
let change = TileTypeChange(previous: currentTileType, current: nextType)
|
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准已确定,允许创建地图", details: [
|
||||||
currentTileType = nextType
|
"最终标准": 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
|
return change
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reprojects a map-display coordinate after the tile heuristic changes.
|
private static func fixedAnchorCoordinateSystemProbe() async -> MapCoordinateSystemProbeResult {
|
||||||
/// 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 {
|
|
||||||
let request = MKLocalSearch.Request()
|
let request = MKLocalSearch.Request()
|
||||||
request.naturalLanguageQuery = "22.283819, 114.158439"
|
request.naturalLanguageQuery = "22.283819, 114.158439"
|
||||||
let search = MKLocalSearch(request: request)
|
let search = MKLocalSearch(request: request)
|
||||||
let resolver = TileProbeResolver()
|
let resolver = MapCoordinateSystemProbeResolver()
|
||||||
|
|
||||||
return await withTaskCancellationHandler(operation: {
|
return await withTaskCancellationHandler(operation: {
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
let timeout = DispatchWorkItem {
|
let timeout = DispatchWorkItem {
|
||||||
search.cancel()
|
search.cancel()
|
||||||
resolver.resolve(.timedOut)
|
resolver.resolve(.timedOut(reason: "固定锚点查询超过5秒"))
|
||||||
}
|
}
|
||||||
resolver.install(continuation, timeout: timeout)
|
resolver.install(continuation, timeout: timeout)
|
||||||
guard !resolver.isResolved else { return }
|
guard !resolver.isResolved else { return }
|
||||||
search.start { response, error in
|
search.start { response, error in
|
||||||
guard error == nil else {
|
if let error {
|
||||||
resolver.resolve(.unavailable)
|
let nsError = error as NSError
|
||||||
|
resolver.resolve(.unavailable(
|
||||||
|
reason: "\(nsError.domain)(\(nsError.code)): \(nsError.localizedDescription)"
|
||||||
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resolver.resolve(.response(
|
let items = response?.mapItems ?? []
|
||||||
name: response?.mapItems.first?.name ?? "",
|
guard let first = items.first,
|
||||||
count: response?.mapItems.count ?? 0
|
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)
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout)
|
||||||
}
|
}
|
||||||
@@ -126,48 +204,30 @@ enum CoordinateConverter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 存取转换
|
/// Creates the complete persisted pair once at the map input boundary.
|
||||||
|
static func coordinatePair(lat: Double, lon: Double, mapCoordinateSystem: MapCoordinateSystem) -> CoordinatePair {
|
||||||
/// 地图坐标 → WGS-84 存储
|
let raw = CoordinatePair.Value(latitude: lat, longitude: lon)
|
||||||
@MainActor
|
switch mapCoordinateSystem {
|
||||||
static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
case .wgs84:
|
||||||
let stored = storedCoordinate(lat: lat, lon: lon, tileType: currentTileType)
|
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
|
||||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标已规范为 WGS-84", details: [
|
return CoordinatePair(
|
||||||
"转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon))
|
wgs84: raw,
|
||||||
])
|
gcj02: .init(latitude: gcj.lat, longitude: gcj.lon)
|
||||||
return stored
|
)
|
||||||
}
|
case .gcj02:
|
||||||
|
let wgs = gcj02ToWgs84(lat: lat, lon: lon)
|
||||||
/// WGS-84 存储 → 当前地图瓦片坐标系(显示用)
|
return CoordinatePair(
|
||||||
@MainActor
|
wgs84: .init(latitude: wgs.lat, longitude: wgs.lon),
|
||||||
static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
gcj02: raw
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return gcj02ToWgs84(lat: lat, lon: lon)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func displayCoordinate(
|
/// Legacy raw domestic map values were historically displayed as GCJ-02;
|
||||||
lat: Double,
|
/// overseas values were WGS-84 and stay identity coordinates.
|
||||||
lon: Double,
|
static func legacyCoordinatePair(lat: Double, lon: Double) -> CoordinatePair {
|
||||||
tileType: CoordType
|
let type: MapCoordinateSystem = usesGCJ02ServiceArea(lat: lat, lon: lon) ? .gcj02 : .wgs84
|
||||||
) -> (lat: Double, lon: Double) {
|
return coordinatePair(lat: lat, lon: lon, mapCoordinateSystem: type)
|
||||||
guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else {
|
|
||||||
return (lat, lon)
|
|
||||||
}
|
|
||||||
return wgs84ToGcj02(lat: lat, lon: lon)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 工具
|
// MARK: - 工具
|
||||||
@@ -205,9 +265,8 @@ enum CoordinateConverter {
|
|||||||
return (lat + d.lat, lon + d.lon)
|
return (lat + d.lat, lon + d.lon)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AMap documents GCJ-02 for mainland China, Hong Kong, Macao and Taiwan;
|
/// Keep the GCJ-02 service region explicit. Outside this region, conversion
|
||||||
/// its overseas world map uses WGS-84. Keep the bounds explicit so the
|
/// is identity so the domestic fallback can never shift an overseas value.
|
||||||
/// domestic fallback tile type never shifts an overseas coordinate.
|
|
||||||
static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool {
|
static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool {
|
||||||
let mainland = lat >= 0.8293 && lat <= 55.8271 && lon >= 72.004 && lon <= 137.8347
|
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
|
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 response(name: String, count: Int)
|
||||||
case unavailable
|
case unavailable(reason: String)
|
||||||
case timedOut
|
case timedOut(reason: String)
|
||||||
case cancelled
|
case cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class TileProbeResolver: @unchecked Sendable {
|
private final class MapCoordinateSystemProbeResolver: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var result: TileProbeResult?
|
private var result: MapCoordinateSystemProbeResult?
|
||||||
private var continuation: CheckedContinuation<TileProbeResult, Never>?
|
private var continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>?
|
||||||
private var timeout: DispatchWorkItem?
|
private var timeout: DispatchWorkItem?
|
||||||
|
|
||||||
var isResolved: Bool {
|
var isResolved: Bool {
|
||||||
@@ -269,7 +328,7 @@ private final class TileProbeResolver: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func install(
|
func install(
|
||||||
_ continuation: CheckedContinuation<TileProbeResult, Never>,
|
_ continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>,
|
||||||
timeout: DispatchWorkItem
|
timeout: DispatchWorkItem
|
||||||
) {
|
) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
@@ -284,7 +343,7 @@ private final class TileProbeResolver: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolve(_ nextResult: TileProbeResult) {
|
func resolve(_ nextResult: MapCoordinateSystemProbeResult) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
guard result == nil else {
|
guard result == nil else {
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
|
|||||||
+8
-15
@@ -19,6 +19,14 @@ enum CoreBridgeError: LocalizedError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum CoreBridge {
|
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 {
|
static func generateCertificateAuthority() throws -> CertificateAuthority {
|
||||||
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
|
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
|
||||||
let result = wloccore_generateca()
|
let result = wloccore_generateca()
|
||||||
@@ -41,21 +49,6 @@ enum CoreBridge {
|
|||||||
return String(cString: ptr)
|
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..<end], radix: 16) { data.append(b) }
|
|
||||||
idx = end
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func flushLogs(category: String) {
|
static func flushLogs(category: String) {
|
||||||
guard let pointer = wloccore_drainlogs() else { return }
|
guard let pointer = wloccore_drainlogs() else { return }
|
||||||
defer { free(pointer) }
|
defer { free(pointer) }
|
||||||
|
|||||||
@@ -1,21 +1,79 @@
|
|||||||
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct FavoriteLocation: Codable, Identifiable, Equatable {
|
struct FavoriteLocation: Codable, Identifiable, Equatable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
var name: String
|
var name: String
|
||||||
var latitude: Double
|
var coordinatePair: CoordinatePair
|
||||||
var longitude: Double
|
|
||||||
var accuracy: Int
|
var accuracy: Int
|
||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
|
private var wasDecodedFromLegacyCoordinates = false
|
||||||
|
|
||||||
init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double, accuracy: Int, createdAt: Date = Date()) {
|
/// WGS-84 compatibility accessor. WLOC consumers must use this value.
|
||||||
|
var latitude: Double { coordinatePair.wgs84.latitude }
|
||||||
|
var longitude: Double { coordinatePair.wgs84.longitude }
|
||||||
|
var isLegacyCoordinateRecord: Bool { wasDecodedFromLegacyCoordinates }
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String,
|
||||||
|
coordinatePair: CoordinatePair,
|
||||||
|
accuracy: Int,
|
||||||
|
createdAt: Date = Date()
|
||||||
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.latitude = latitude
|
self.coordinatePair = coordinatePair
|
||||||
self.longitude = longitude
|
|
||||||
self.accuracy = accuracy
|
self.accuracy = accuracy
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
accuracy: Int,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
coordinatePair: CoordinateConverter.coordinatePair(lat: latitude, lon: longitude, mapCoordinateSystem: mapCoordinateSystem),
|
||||||
|
accuracy: accuracy,
|
||||||
|
createdAt: createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, coordinatePair, accuracy, createdAt, latitude, longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(UUID.self, forKey: .id)
|
||||||
|
name = try container.decode(String.self, forKey: .name)
|
||||||
|
accuracy = try container.decode(Int.self, forKey: .accuracy)
|
||||||
|
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
|
if let pair = try container.decodeIfPresent(CoordinatePair.self, forKey: .coordinatePair) {
|
||||||
|
coordinatePair = pair
|
||||||
|
} else {
|
||||||
|
let latitude = try container.decode(Double.self, forKey: .latitude)
|
||||||
|
let longitude = try container.decode(Double.self, forKey: .longitude)
|
||||||
|
coordinatePair = CoordinateConverter.legacyCoordinatePair(lat: latitude, lon: longitude)
|
||||||
|
wasDecodedFromLegacyCoordinates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(coordinatePair, forKey: .coordinatePair)
|
||||||
|
try container.encode(accuracy, forKey: .accuracy)
|
||||||
|
try container.encode(createdAt, forKey: .createdAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MapConfiguration: Equatable {
|
struct MapConfiguration: Equatable {
|
||||||
@@ -52,18 +110,38 @@ final class FavoriteLocationStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func save(name: String, latitude: Double, longitude: Double, accuracy: Int) -> FavoriteLocation {
|
func save(
|
||||||
let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy)
|
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 {
|
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)
|
favorites.insert(favorite, at: 0)
|
||||||
select(favorite.id)
|
select(favorite.id)
|
||||||
persist()
|
persistIgnoringFailure()
|
||||||
return favorite
|
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?) {
|
func select(_ id: UUID?) {
|
||||||
selectedFavoriteID = id
|
selectedFavoriteID = id
|
||||||
defaults.set(id?.uuidString, forKey: Keys.selectedID)
|
defaults.set(id?.uuidString, forKey: Keys.selectedID)
|
||||||
@@ -72,7 +150,7 @@ final class FavoriteLocationStore: ObservableObject {
|
|||||||
func rename(_ id: UUID, to name: String) {
|
func rename(_ id: UUID, to name: String) {
|
||||||
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
|
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
|
||||||
favorites[idx].name = name
|
favorites[idx].name = name
|
||||||
persist()
|
persistIgnoringFailure()
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ favorite: FavoriteLocation) {
|
func delete(_ favorite: FavoriteLocation) {
|
||||||
@@ -80,11 +158,23 @@ final class FavoriteLocationStore: ObservableObject {
|
|||||||
if selectedFavoriteID == favorite.id {
|
if selectedFavoriteID == favorite.id {
|
||||||
select(favorites.first?.id)
|
select(favorites.first?.id)
|
||||||
}
|
}
|
||||||
persist()
|
persistIgnoringFailure()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persist() {
|
func migrateLegacyCoordinates() throws {
|
||||||
guard let data = try? JSONEncoder().encode(favorites) else { return }
|
guard favorites.contains(where: \.isLegacyCoordinateRecord) else { return }
|
||||||
defaults.set(data, forKey: Keys.favorites)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-14
@@ -2,6 +2,12 @@ import Network
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SystemConfiguration.CaptiveNetwork
|
import SystemConfiguration.CaptiveNetwork
|
||||||
|
|
||||||
|
enum WiFiChangeReason: String {
|
||||||
|
case reconnected = "Wi-Fi 恢复连接"
|
||||||
|
case interfaceChanged = "网络接口切换到 Wi-Fi"
|
||||||
|
case ssidChanged = "SSID 发生变化"
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NetworkMonitor: ObservableObject {
|
final class NetworkMonitor: ObservableObject {
|
||||||
static let shared = NetworkMonitor()
|
static let shared = NetworkMonitor()
|
||||||
@@ -10,26 +16,44 @@ final class NetworkMonitor: ObservableObject {
|
|||||||
@Published private(set) var isWiFiEnabled = true
|
@Published private(set) var isWiFiEnabled = true
|
||||||
@Published private(set) var currentSSID: String?
|
@Published private(set) var currentSSID: String?
|
||||||
|
|
||||||
/// WiFi 重连或 SSID 变化时触发的订阅。调用方必须在离开页面时移除订阅。
|
/// Wi-Fi 重连、接口切换或 SSID 变化时触发。调用方必须在离开页面时移除订阅。
|
||||||
private var wifiChangeHandlers: [UUID: @MainActor () -> Void] = [:]
|
private var wifiChangeHandlers: [UUID: @MainActor (WiFiChangeReason) -> Void] = [:]
|
||||||
|
|
||||||
private let monitor = NWPathMonitor()
|
private let monitor = NWPathMonitor()
|
||||||
private var ssidTimer: Timer?
|
private var ssidTimer: Timer?
|
||||||
private var wasSatisfied = true
|
private var wasSatisfied = true
|
||||||
|
private var wasWiFiEnabled = true
|
||||||
|
private var hasReceivedInitialPath = false
|
||||||
|
private var lastKnownSSID: String?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
let initialSSID = Self.fetchSSID()
|
||||||
|
currentSSID = initialSSID
|
||||||
|
lastKnownSSID = initialSSID
|
||||||
monitor.pathUpdateHandler = { [weak self] path in
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
let satisfied = path.status == .satisfied
|
let satisfied = path.status == .satisfied
|
||||||
let wifi = path.usesInterfaceType(.wifi)
|
let wifi = path.usesInterfaceType(.wifi)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// 网络恢复连接 → 触发检测
|
let reason: WiFiChangeReason?
|
||||||
let reconnected = satisfied && !self.wasSatisfied && wifi
|
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.wasSatisfied = satisfied
|
||||||
|
self.wasWiFiEnabled = wifi
|
||||||
self.isSatisfied = satisfied
|
self.isSatisfied = satisfied
|
||||||
self.isWiFiEnabled = wifi
|
self.isWiFiEnabled = wifi
|
||||||
if reconnected {
|
if let reason {
|
||||||
self.notifyWiFiChanged()
|
self.notifyWiFiChanged(reason: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,11 +61,9 @@ final class NetworkMonitor: ObservableObject {
|
|||||||
startSSIDPolling()
|
startSSIDPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAirplaneMode: Bool { !isSatisfied }
|
|
||||||
|
|
||||||
/// Registers a Wi-Fi-change observer and returns a token that must be removed.
|
/// Registers a Wi-Fi-change observer and returns a token that must be removed.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func observeWiFiChanges(_ handler: @escaping @MainActor () -> Void) -> UUID {
|
func observeWiFiChanges(_ handler: @escaping @MainActor (WiFiChangeReason) -> Void) -> UUID {
|
||||||
let token = UUID()
|
let token = UUID()
|
||||||
wifiChangeHandlers[token] = handler
|
wifiChangeHandlers[token] = handler
|
||||||
return token
|
return token
|
||||||
@@ -51,9 +73,9 @@ final class NetworkMonitor: ObservableObject {
|
|||||||
wifiChangeHandlers.removeValue(forKey: token)
|
wifiChangeHandlers.removeValue(forKey: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notifyWiFiChanged() {
|
private func notifyWiFiChanged(reason: WiFiChangeReason) {
|
||||||
for handler in wifiChangeHandlers.values {
|
for handler in wifiChangeHandlers.values {
|
||||||
handler()
|
handler(reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +84,16 @@ final class NetworkMonitor: ObservableObject {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let ssid = Self.fetchSSID()
|
let ssid = Self.fetchSSID()
|
||||||
if ssid != self.currentSSID, ssid != nil {
|
self.currentSSID = ssid
|
||||||
self.currentSSID = ssid
|
guard let ssid else { return }
|
||||||
self.notifyWiFiChanged()
|
guard let previousSSID = self.lastKnownSSID else {
|
||||||
|
// 首次取得 SSID 只是建立基线,不能当作用户切换了 Wi-Fi。
|
||||||
|
self.lastKnownSSID = ssid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ssid != previousSSID {
|
||||||
|
self.lastKnownSSID = ssid
|
||||||
|
self.notifyWiFiChanged(reason: .ssidChanged)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-4
@@ -1,3 +1,4 @@
|
|||||||
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct RuntimeLogEntry: Codable, Identifiable, Equatable {
|
struct RuntimeLogEntry: Codable, Identifiable, Equatable {
|
||||||
@@ -49,11 +50,17 @@ enum RuntimeLogStore {
|
|||||||
private static let decoder = JSONDecoder()
|
private static let decoder = JSONDecoder()
|
||||||
private static let encoder = JSONEncoder()
|
private static let encoder = JSONEncoder()
|
||||||
private static let maximumBytes: UInt64 = 1_500_000
|
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) {
|
static func append(_ entry: RuntimeLogEntry) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
do {
|
do {
|
||||||
|
let now = Date()
|
||||||
|
try pruneExpiredLogsIfNeeded(now: now)
|
||||||
|
guard entry.timestamp >= retentionCutoff(now: now) else { return }
|
||||||
let url = try logURL(for: entry.source)
|
let url = try logURL(for: entry.source)
|
||||||
try rotateIfNeeded(url)
|
try rotateIfNeeded(url)
|
||||||
var data = try encoder.encode(entry)
|
var data = try encoder.encode(entry)
|
||||||
@@ -75,7 +82,8 @@ enum RuntimeLogStore {
|
|||||||
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
|
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
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(
|
guard let urls = try? FileManager.default.contentsOfDirectory(
|
||||||
at: directory,
|
at: directory,
|
||||||
includingPropertiesForKeys: nil
|
includingPropertiesForKeys: nil
|
||||||
@@ -90,12 +98,12 @@ enum RuntimeLogStore {
|
|||||||
static func clearAll() {
|
static func clearAll() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
try? FileManager.default.removeItem(at: logDirectory)
|
||||||
try? FileManager.default.removeItem(at: directory)
|
lastPrunedAt = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func logURL(for source: String) throws -> URL {
|
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)
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
|
let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
|
||||||
.replacingOccurrences(of: "/", with: "-")
|
.replacingOccurrences(of: "/", with: "-")
|
||||||
@@ -103,6 +111,56 @@ enum RuntimeLogStore {
|
|||||||
return directory.appendingPathComponent("\(process)-\(safeSource).jsonl")
|
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 {
|
private static func rotateIfNeeded(_ url: URL) throws {
|
||||||
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
let size = attributes[.size] as? NSNumber,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,9 +31,22 @@ enum VerificationResult: Equatable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .success: return nil
|
case .success: return nil
|
||||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
||||||
case .certNotTrusted: return .certificate
|
case .certNotTrusted: return nil
|
||||||
case .wifiProxyNotConfigured: return .proxySetup
|
case .wifiProxyNotConfigured: return .proxySetup
|
||||||
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,139 @@ import XCTest
|
|||||||
@testable import PaopaoLocationSpoofer
|
@testable import PaopaoLocationSpoofer
|
||||||
|
|
||||||
final class CertificateAuthorityStoreTests: XCTestCase {
|
final class CertificateAuthorityStoreTests: XCTestCase {
|
||||||
func testEnsureCreatesOnceAndThenReusesExistingPair() throws {
|
private let validAuthority = CertificateAuthority(certPEM: "valid-cert", keyPEM: "valid-key")
|
||||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
func testEnsureCreatesOnceThenReusesValidKeychainPair() throws {
|
||||||
|
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||||
var generations = 0
|
var generations = 0
|
||||||
let store = CertificateAuthorityStore(directory: directory) {
|
let store = makeStore(keychain: keychain) {
|
||||||
generations += 1
|
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)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
import CoreLocation
|
||||||
@testable import PaopaoLocationSpoofer
|
@testable import PaopaoLocationSpoofer
|
||||||
|
|
||||||
final class FavoriteLocationStoreTests: XCTestCase {
|
final class FavoriteLocationStoreTests: XCTestCase {
|
||||||
@@ -7,14 +8,92 @@ final class FavoriteLocationStoreTests: XCTestCase {
|
|||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
let store = FavoriteLocationStore(defaults: defaults)
|
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(store.selectedFavoriteID, favorite.id)
|
||||||
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾")
|
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() {
|
func testMapConfigurationNeverRequestsRealUserLocation() {
|
||||||
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
|
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
|
||||||
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,130 +3,85 @@ import XCTest
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationActionCoordinatorTests: XCTestCase {
|
final class LocationActionCoordinatorTests: XCTestCase {
|
||||||
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async {
|
func testApplyStartsProxyAndWritesTheFavoriteWGS84Pair() async {
|
||||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
let proxy = FakeLocationActionProxy()
|
||||||
let coordinator = LocationActionCoordinator()
|
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)
|
let applied = await coordinator.apply(favorite)
|
||||||
// LocationActionCoordinator doesn't take injected deps — just verify state
|
|
||||||
XCTAssertTrue(applied)
|
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 {
|
func testClearWritesDisabledCoordinatesAndClearsSettings() {
|
||||||
let coordinator = LocationActionCoordinator()
|
let proxy = FakeLocationActionProxy(isRunning: true)
|
||||||
|
let settings = FakeLocationActionSettingsStore()
|
||||||
|
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||||
|
|
||||||
coordinator.clear()
|
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)
|
XCTAssertFalse(coordinator.virtualLocationEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBusyApplyRejectsASecondRequest() async {
|
func testApplyVerifiedRejectsInactiveProxyWithoutWritingCoordinates() {
|
||||||
let coordinator = LocationActionCoordinator()
|
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)
|
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||||
|
|
||||||
// The coordinator serializes on MainActor. A completed first request may
|
XCTAssertFalse(coordinator.applyVerified(favorite))
|
||||||
// legitimately make the next request a no-op rather than a concurrent rejection.
|
XCTAssertNil(proxy.lastCoordinates)
|
||||||
let firstApplied = await coordinator.apply(favorite)
|
XCTAssertNil(settings.saved)
|
||||||
let secondApplied = await coordinator.apply(favorite)
|
|
||||||
XCTAssertTrue(firstApplied)
|
|
||||||
XCTAssertTrue(secondApplied)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class FakeTrust {
|
private final class FakeLocationActionProxy: LocationActionProxying {
|
||||||
var canModify: Bool
|
struct Coordinates: Equatable {
|
||||||
let events: EventLog
|
let latitude: Double
|
||||||
|
let longitude: Double
|
||||||
init(canModify: Bool, events: EventLog) {
|
let enabled: Bool
|
||||||
self.canModify = canModify
|
let accuracy: Int
|
||||||
self.events = events
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshTrust() async {
|
var isRunning: Bool
|
||||||
events.append("trust.refresh")
|
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
|
@MainActor
|
||||||
private final class FakeProxy {
|
private final class FakeLocationActionSettingsStore: LocationActionSettingsStoring {
|
||||||
let activeForClear: Bool
|
private(set) var saved: WlocSettings?
|
||||||
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 }
|
|
||||||
|
|
||||||
func load() -> WlocSettings? { saved }
|
func load() -> WlocSettings? { saved }
|
||||||
|
func save(_ settings: WlocSettings) { saved = settings }
|
||||||
func save(_ settings: WlocSettings) {
|
func clear() { saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false) }
|
||||||
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<Void, Never>] = []
|
|
||||||
private var waitUntilBlockedContinuation: CheckedContinuation<Void, Never>?
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,20 +185,20 @@ final class MapLocationStateTests: XCTestCase {
|
|||||||
XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it")
|
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 state = MapLocationState(initialCoordinate: initial)
|
||||||
let favoriteID = UUID()
|
let favoriteID = UUID()
|
||||||
state.selectFavorite(.init(latitude: 22.55, longitude: 113.95), id: favoriteID, name: "测试收藏")
|
state.selectFavorite(.init(latitude: 22.55, longitude: 113.95), id: favoriteID, name: "测试收藏")
|
||||||
let revision = state.selection.revision
|
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.source, .favorite(favoriteID))
|
||||||
XCTAssertEqual(state.selection.explicitName, "测试收藏")
|
XCTAssertEqual(state.selection.explicitName, "测试收藏")
|
||||||
XCTAssertEqual(state.selection.revision, revision)
|
XCTAssertEqual(state.selection.revision, revision)
|
||||||
XCTAssertEqual(state.selection.coordinate.latitude, 22.54, accuracy: 0.000001)
|
XCTAssertEqual(state.selection.coordinate.latitude, 22.54, accuracy: 0.000001)
|
||||||
guard case let .focus(coordinate, distanceMeters) = state.cameraCommand?.kind else {
|
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(coordinate.latitude, 22.54, accuracy: 0.000001)
|
||||||
XCTAssertEqual(distanceMeters, state.viewportMeters)
|
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: 2_500), "2.5 km")
|
||||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,11 @@ SETUP="$ROOT/App/SetupCoordinator.swift"
|
|||||||
PROXY="$ROOT/App/ProxyManager.swift"
|
PROXY="$ROOT/App/ProxyManager.swift"
|
||||||
SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift"
|
SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift"
|
||||||
DIAGNOSTICS="$ROOT/App/DiagnosticsView.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"
|
test -f "$file" || fail "missing required refactor file: $file"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -23,6 +26,8 @@ done
|
|||||||
grep -q 'showsUserLocation = true' "$MAP_BRIDGE" || fail "MapKit native user location must be visible"
|
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 '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 '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 '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 '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"
|
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 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent"
|
||||||
! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed"
|
! 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 '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 '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 '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 '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 '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 copy button"
|
||||||
grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear 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 '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"
|
echo "PASS: map location state refactor contract"
|
||||||
|
|||||||
Reference in New Issue
Block a user