mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
release: PaopaoLocationSpoofer v1.0.0
- iOS 虚拟定位工具,基于本地 HTTP 代理 MITM 方案 - MapKit 原生地图体验,支持搜索、收藏、实时定位 - 完整的设置引导流程(证书安装、WiFi 代理配置、环境验证) - 支持 iOS 15+,SwiftUI 构建
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BugReportView: View {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var description = ""
|
||||
@State private var isReproducible = true
|
||||
@State private var isRunning = false
|
||||
@State private var showCopiedAlert = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// 说明
|
||||
Text("遇到问题时,在这里生成 Issue 报告。系统会运行一次诊断测试,将测试结果和你的描述一起复制到剪切板,然后跳转到 GitHub 提交。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
// 可复现环境
|
||||
Toggle(isOn: $isReproducible) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("可复现环境").font(.subheadline.weight(.medium))
|
||||
Text("当前设备上问题稳定复现,非偶发性。").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// 问题描述
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("问题描述").font(.subheadline.weight(.medium))
|
||||
TextEditor(text: $description)
|
||||
.font(.caption)
|
||||
.frame(minHeight: 120)
|
||||
.padding(6)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
Divider()
|
||||
// 底部按钮
|
||||
VStack(spacing: 8) {
|
||||
Button {
|
||||
generateReport()
|
||||
} label: {
|
||||
HStack {
|
||||
if isRunning {
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
Text(isRunning ? "正在生成报告…" : "生成 Issue 报告")
|
||||
.font(.body.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
.disabled(isRunning || description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.navigationTitle("报告 Issue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("已生成", isPresented: $showCopiedAlert) {
|
||||
Button("跳转到 GitHub") {
|
||||
dismiss()
|
||||
if let url = URL(string: "https://github.com/xweiba/location-spoofer/issues/new") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button("稍后再说", role: .cancel) {
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("Issue 报告已复制到剪切板。请在 GitHub Issues 页面粘贴并提交。")
|
||||
}
|
||||
}
|
||||
|
||||
private func generateReport() {
|
||||
isRunning = true
|
||||
Task {
|
||||
// 跑测试
|
||||
_ = await setup.runVerificationTest(testLat: 22.543099, testLon: 113.934576)
|
||||
let testLog = setup.testLog
|
||||
|
||||
// 获取版本信息
|
||||
let appVersion: String = {
|
||||
let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
|
||||
let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
|
||||
return "\(v) (\(b))"
|
||||
}()
|
||||
let systemVersion = UIDevice.current.systemVersion
|
||||
|
||||
// 拼接报告
|
||||
let report = """
|
||||
### 环境信息
|
||||
App 版本: \(appVersion)
|
||||
系统版本: iOS \(systemVersion)
|
||||
可复现环境: \(isReproducible ? "是" : "否")
|
||||
|
||||
### 问题描述
|
||||
\(description.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
### 诊断日志
|
||||
```
|
||||
\(testLog.isEmpty ? "(无诊断数据)" : testLog)
|
||||
```
|
||||
"""
|
||||
|
||||
// 复制到剪切板
|
||||
UIPasteboard.general.string = report
|
||||
isRunning = false
|
||||
|
||||
// 弹窗
|
||||
showCopiedAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum CertificateInstaller {
|
||||
static func open(url: URL, completion: @escaping (Bool) -> Void) {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: completion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var setup = SetupCoordinator()
|
||||
@ObservedObject private var net = NetworkMonitor.shared
|
||||
@State private var showSetup = false
|
||||
@State private var showEnableTip = false
|
||||
@AppStorage("setupCompleted") private var setupCompleted = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
MapHomeView(setup: setup)
|
||||
}
|
||||
.task {
|
||||
// 首次打开无标记:必须进引导页
|
||||
if !setupCompleted {
|
||||
showSetup = true
|
||||
return
|
||||
}
|
||||
await setup.refreshTrust()
|
||||
}
|
||||
.onChange(of: net.isAirplaneMode) { airplane in
|
||||
guard setupCompleted else { return }
|
||||
if airplane {
|
||||
showEnableTip = true
|
||||
} else {
|
||||
showEnableTip = false
|
||||
Task { await setup.refreshTrust() }
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showSetup) {
|
||||
FirstSetupView(setup: setup, onComplete: {
|
||||
setupCompleted = true
|
||||
setup.completeSetup()
|
||||
showSetup = false
|
||||
})
|
||||
}
|
||||
// 设置页「进入引导页」入口联动
|
||||
.onChange(of: setup.needsSetup) { needs in
|
||||
if needs { showSetup = true }
|
||||
}
|
||||
.sheet(isPresented: $showEnableTip) {
|
||||
NavigationView {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("飞行模式已开启")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Wi‑Fi 和蜂窝数据已关闭,虚拟定位无法生效。请关闭飞行模式后重试。")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("知道了") {
|
||||
showEnableTip = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(30)
|
||||
.navigationTitle("提示")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("完成") { showEnableTip = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct RuntimeLogsView: View {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
@ObservedObject var actions: LocationActionCoordinator
|
||||
let testFavorite: FavoriteLocation
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var proxy = ProxyManager.shared
|
||||
@State private var entries: [RuntimeLogEntry] = []
|
||||
@State private var isTesting = false
|
||||
@State private var testResult = ""
|
||||
@State private var testMessage = ""
|
||||
@State private var showClearConfirm = false
|
||||
@State private var copiedEntryID: UUID?
|
||||
@State private var copyLogsConfirmed = false
|
||||
@State private var testLogCopied = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
testPanel
|
||||
Divider()
|
||||
if entries.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
|
||||
Text("暂无运行日志").foregroundStyle(.secondary)
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(entries.reversed()) { entry in logRow(entry) }
|
||||
}.padding(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } }
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
UIPasteboard.general.string = entries.map(\.renderedText).joined(separator: "\n")
|
||||
copyLogsConfirmed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyLogsConfirmed = false }
|
||||
} label: {
|
||||
Image(systemName: copyLogsConfirmed ? "checkmark" : "doc.on.doc")
|
||||
}
|
||||
.disabled(entries.isEmpty)
|
||||
.accessibilityLabel("复制全部日志")
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(role: .destructive) {
|
||||
showClearConfirm = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.disabled(entries.isEmpty)
|
||||
.accessibilityLabel("清空全部日志")
|
||||
}
|
||||
}
|
||||
.confirmationDialog("清空所有运行日志?", isPresented: $showClearConfirm, titleVisibility: .visible) {
|
||||
Button("清空日志", role: .destructive) {
|
||||
RuntimeLogStore.clearAll()
|
||||
entries = []
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled { refresh(); try? await Task.sleep(nanoseconds: 750_000_000) }
|
||||
}
|
||||
}
|
||||
|
||||
private var testPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button {
|
||||
isTesting = true; testResult = ""; testLogCopied = false
|
||||
Task {
|
||||
let result = await setup.runVerificationTest(testLat: testFavorite.latitude, testLon: testFavorite.longitude)
|
||||
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
|
||||
if !result.isSuccess { testResult += ",查看下方日志" }
|
||||
testMessage = setup.testLog
|
||||
isTesting = false; refresh()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isTesting {
|
||||
ProgressView().tint(.white).controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "play.fill").font(.system(size: 13, weight: .bold))
|
||||
}
|
||||
Text(isTesting ? "正在测试…" : "虚拟定位测试").font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).opacity(0.5)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(isTesting ? Color.gray : Color.blue)
|
||||
)
|
||||
.disabled(isTesting || actions.state.isBusy)
|
||||
Text("依次执行:代理 → 证书 → WiFi 代理 → 坐标写入 → 数据改写验证。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !testMessage.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("测试日志").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button {
|
||||
UIPasteboard.general.string = testMessage
|
||||
testLogCopied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { testLogCopied = false }
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
if testLogCopied {
|
||||
Image(systemName: "checkmark").font(.system(size: 11, weight: .bold))
|
||||
Text("已复制").font(.system(size: 11))
|
||||
} else {
|
||||
Image(systemName: "doc.on.doc").font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(testLogCopied ? .green : .secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(testLogCopied ? Color.green.opacity(0.1) : Color.secondary.opacity(0.08), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { testMessage = "" }
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
ScrollView {
|
||||
Text(testMessage)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
}.frame(maxHeight: 180)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
|
||||
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
if !testResult.isEmpty {
|
||||
Text(testResult).font(.footnote.weight(.medium))
|
||||
.foregroundStyle(testResult.contains("通过") ? .green : .red)
|
||||
}
|
||||
}.padding(14).background(Color(.secondarySystemBackground))
|
||||
}
|
||||
|
||||
private func logRow(_ entry: RuntimeLogEntry) -> some View {
|
||||
HStack(alignment: .top, spacing: 9) {
|
||||
Image(systemName: entry.level == .error ? "xmark.octagon.fill" : entry.level == .warning ? "exclamationmark.triangle" : "info.circle")
|
||||
.foregroundStyle(entry.level == .error ? .red : entry.level == .warning ? .orange : .blue).frame(width: 18)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("\(entry.source) \(entry.category)").font(.caption.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
UIPasteboard.general.string = entry.renderedText
|
||||
copiedEntryID = entry.id
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
if copiedEntryID == entry.id { copiedEntryID = nil }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
if copiedEntryID == entry.id {
|
||||
Image(systemName: "checkmark").font(.system(size: 11, weight: .bold))
|
||||
Text("已复制").font(.system(size: 11))
|
||||
} else {
|
||||
Image(systemName: "doc.on.doc").font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(copiedEntryID == entry.id ? .green : .secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(copiedEntryID == entry.id ? Color.green.opacity(0.1) : Color.secondary.opacity(0.08), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Text(entry.message).font(.caption.monospaced()).textSelection(.enabled)
|
||||
if !entry.details.isEmpty {
|
||||
Text(entry.details.sorted(by: { $0.key < $1.key }).map { "\($0.key): \($0.value)" }.joined(separator: "\n"))
|
||||
.font(.caption2.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func refresh() { entries = RuntimeLogStore.loadAll() }
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SetupStep: Int, CaseIterable {
|
||||
case cert = 0, proxy = 1, verify = 2
|
||||
var title: String {
|
||||
switch self {
|
||||
case .cert: return "初始化 CA 证书"
|
||||
case .proxy: return "初始化代理"
|
||||
case .verify: return "环境检测"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FirstSetupView: View {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
let onComplete: () -> Void
|
||||
|
||||
@State private var step: SetupStep = .cert
|
||||
@State private var downloadedDone = false
|
||||
@State private var installedDone = false
|
||||
@State private var trustedDone = false
|
||||
@State private var proxyDone = false
|
||||
@State private var testPassed: VerificationResult? = nil
|
||||
@State private var testMessage = ""
|
||||
@State private var isLoading = false
|
||||
@State private var manualHint = ""
|
||||
|
||||
private var certDone: Bool { downloadedDone && installedDone && trustedDone }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 顶部大步骤进度
|
||||
HStack(spacing: 6) {
|
||||
ForEach(SetupStep.allCases, id: \.rawValue) { s in
|
||||
VStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(s.rawValue < step.rawValue ? Color.green
|
||||
: s.rawValue == step.rawValue ? Color.blue
|
||||
: Color.gray.opacity(0.3))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(s.title).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
if s.rawValue < SetupStep.allCases.count - 1 {
|
||||
Rectangle().fill(s.rawValue < step.rawValue ? Color.green : Color.gray.opacity(0.3))
|
||||
.frame(height: 2).frame(maxWidth: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 18)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch step {
|
||||
case .cert: certStepView
|
||||
case .proxy: proxyStepView
|
||||
case .verify: verifyStepView
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
// 底部导航
|
||||
Divider()
|
||||
HStack {
|
||||
if step.rawValue > 0 {
|
||||
Button("上一步") { step = SetupStep(rawValue: step.rawValue - 1)! }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Spacer()
|
||||
if step == .verify && testPassed?.isSuccess == true {
|
||||
Button("完成,进入主页") { onComplete() }.buttonStyle(.borderedProminent)
|
||||
} else if step == .cert && certDone {
|
||||
Button("下一步") { step = .proxy }.buttonStyle(.borderedProminent)
|
||||
} else if step == .proxy && proxyDone {
|
||||
Button("下一步") { step = .verify }.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.vertical, 10)
|
||||
}
|
||||
.alert("无法直接跳转", isPresented: Binding(
|
||||
get: { !manualHint.isEmpty },
|
||||
set: { if !$0 { manualHint = "" } }
|
||||
)) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: { Text(manualHint) }
|
||||
}
|
||||
|
||||
// MARK: - 第 1 步:初始化 CA 证书(三个步骤平铺)
|
||||
|
||||
private var certStepView: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox(label: Label("第 1 步:下载证书", systemImage: "arrow.down.circle")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("虚拟定位需要通过自签 CA 证书来解密和改写定位请求。点击下方按钮,Safari 会打开下载页面。Safari 弹出「此网站正尝试下载一个配置描述文件」时,点「允许」。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await setup.proxy.openCertificateDownload() }
|
||||
} label: {
|
||||
Label("去下载", systemImage: "arrow.down.circle.fill").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
Button {
|
||||
downloadedDone = true
|
||||
} label: {
|
||||
Label(downloadedDone ? "已完成 ✓" : "已完成", systemImage: downloadedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(downloadedDone ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupBox(label: Label("第 2 步:安装证书", systemImage: "square.and.arrow.down")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("下载完成后打开系统「设置」:\n\n1. 如果顶部显示了「已下载描述文件」,点进去安装\n2. 如果没显示:进入「通用 → VPN与设备管理」,找到 WLOC CA 证书点击安装\n\n安装时系统会要求输入锁屏密码,确认后点右上角「安装」即可。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Button { openSettings(.general) } label: {
|
||||
Label("去安装", systemImage: "gearshape").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
Button { installedDone = true } label: {
|
||||
Label(installedDone ? "已完成 ✓" : "已完成", systemImage: installedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(installedDone ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupBox(label: Label("第 3 步:信任证书", systemImage: "shield.checkered")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("证书安装后还需要开启信任,否则系统会拦截代理的 HTTPS 请求:\n\n1. 打开「设置 → 通用 → 关于本机」\n2. 滑到底部找到「证书信任设置」\n3. 找到 WLOC CA,打开旁边的开关\n4. 弹出的警告中点「继续」\n\n⚠️ 每次重装 App 都需要重新下载安装证书。如果检测时报 TLS 错误,说明证书过期或不匹配,请删除旧证书(设置 → 通用 → VPN与设备管理)后重新安装。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Button { openSettings(.general) } label: {
|
||||
Label("去信任", systemImage: "shield.checkered").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
Button { trustedDone = true } label: {
|
||||
Label(trustedDone ? "已完成 ✓" : "已完成", systemImage: trustedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(trustedDone ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 第 2 步:初始化代理
|
||||
|
||||
private var proxyStepView: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox(label: Label("配置 WiFi 系统代理", systemImage: "wifi")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("要让系统定位请求经过本地代理,需要在 WiFi 设置中手动配置:\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前连接的 WiFi 右侧 (i) 图标\n3. 滑到底部找到「HTTP 代理」,选择「手动」\n4. 服务器填入 127.0.0.1,端口填入 8888\n5. 点右上角「存储」\n\n⚠️ 只有连接的这个 WiFi 会走代理,蜂窝数据不受影响。换 WiFi 后需要重新配置。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
UIPasteboard.general.string = "127.0.0.1:8888"
|
||||
} label: {
|
||||
Label("复制地址", systemImage: "doc.on.doc")
|
||||
.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(.blue)
|
||||
Button {
|
||||
openSettings(.wifi)
|
||||
} label: {
|
||||
Label("去设置", systemImage: "wifi")
|
||||
.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
}
|
||||
|
||||
Button {
|
||||
proxyDone = true
|
||||
} label: {
|
||||
Label(proxyDone ? "已完成 ✓" : "已完成", systemImage: proxyDone ? "checkmark.circle.fill" : "circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(proxyDone ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 第 3 步:环境检测
|
||||
|
||||
private var verifyStepView: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox(label: Label("检测整个流程", systemImage: "checklist")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("点击「开始检测」后依次检查代理运行、证书信任与 WiFi 代理配置、坐标写入、数据改写。全部通过后「完成,进入主页」按钮才会亮起。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let result = testPassed, result.isSuccess {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green).font(.title3)
|
||||
Text("全部检测通过").font(.subheadline.weight(.semibold)).foregroundStyle(.green)
|
||||
}.padding(12).background(Color.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
} else if let result = testPassed {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.red).font(.title3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("测试未通过").font(.subheadline.weight(.semibold)).foregroundStyle(.red)
|
||||
Text(failureSummary(result)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let step = failureRemedyStep(result) {
|
||||
Button {
|
||||
withAnimation(.none) { self.step = step }
|
||||
testPassed = nil
|
||||
testMessage = ""
|
||||
} label: {
|
||||
Label("去处理", systemImage: "arrow.right.circle.fill")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
if !testMessage.isEmpty {
|
||||
ScrollView {
|
||||
Text(testMessage)
|
||||
.font(.caption.monospaced()).textSelection(.enabled)
|
||||
.padding(10).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
}.frame(maxHeight: 280)
|
||||
}
|
||||
|
||||
Button {
|
||||
isLoading = true; testMessage = ""
|
||||
Task {
|
||||
testPassed = await setup.runVerificationTest()
|
||||
testMessage = setup.testLog
|
||||
isLoading = false
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if isLoading { ProgressView().tint(.white).controlSize(.small) }
|
||||
Text(buttonText).frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(buttonTint)
|
||||
.disabled(isLoading)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = testMessage
|
||||
} label: {
|
||||
Label("复制检测日志", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).disabled(testMessage.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonText: String {
|
||||
guard let result = testPassed else { return "开始检测" }
|
||||
return result.isSuccess ? "重新检测" : "⚠️ 重新测试"
|
||||
}
|
||||
|
||||
private var buttonTint: Color {
|
||||
guard let result = testPassed else { return .blue }
|
||||
return result.isSuccess ? .blue : .red
|
||||
}
|
||||
|
||||
private func failureSummary(_ result: VerificationResult) -> String {
|
||||
switch result {
|
||||
case .proxyNotRunning: return "代理未能启动,请检查代理状态"
|
||||
case .verificationInProgress: return "已有检测正在进行,请稍候"
|
||||
case .verificationSuperseded: return "检测期间位置已更新,本次结果已取消,请重新检测"
|
||||
case .certNotTrusted: return "CA 证书未安装或未信任,请重新安装证书并开启信任"
|
||||
case .wifiProxyNotConfigured: return "WiFi 代理未配置,请在系统设置中配置 127.0.0.1:8888"
|
||||
case .coordinateWriteFailed: return "坐标写入失败,请重试"
|
||||
case .patchFailed: return "坐标改写验证失败,可能是证书过期或不匹配"
|
||||
case .success: return ""
|
||||
}
|
||||
}
|
||||
|
||||
private func failureRemedyStep(_ result: VerificationResult) -> SetupStep? {
|
||||
switch result {
|
||||
case .certNotTrusted: return .cert
|
||||
case .wifiProxyNotConfigured: return .proxy
|
||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded,
|
||||
.coordinateWriteFailed, .patchFailed: return nil
|
||||
case .success: return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private func openSettings(_ destination: SystemSettingsDestination) {
|
||||
SystemSettingsNavigator.open(destination) { fallbackHint in
|
||||
if let fallbackHint { manualHint = fallbackHint }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationActionCoordinator: ObservableObject {
|
||||
@Published private(set) var state: LocationActionState = .idle
|
||||
@Published private(set) var virtualLocationEnabled = false
|
||||
@Published private(set) var message = ""
|
||||
|
||||
private let proxy = ProxyManager.shared
|
||||
|
||||
init() {
|
||||
self.virtualLocationEnabled = WlocSettingsStore.load()?.enabled == true
|
||||
}
|
||||
|
||||
func apply(_ favorite: FavoriteLocation) async -> Bool {
|
||||
guard beginApply() else { return false }
|
||||
do {
|
||||
if !proxy.isRunning { try await proxy.start() }
|
||||
guard !Task.isCancelled else {
|
||||
finishCancelledApply()
|
||||
return false
|
||||
}
|
||||
return commit(favorite)
|
||||
} catch {
|
||||
failApply(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits a target after SetupCoordinator has completed verification.
|
||||
/// This method is synchronous on MainActor so selection revision validation
|
||||
/// and the final settings/proxy write cannot be interleaved by a newer map event.
|
||||
func applyVerified(_ favorite: FavoriteLocation) -> Bool {
|
||||
guard proxy.isRunning else {
|
||||
failApply(ProxyError.startFailed)
|
||||
return false
|
||||
}
|
||||
guard beginApply() else { return false }
|
||||
return commit(favorite)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
guard !state.isBusy else { return }
|
||||
proxy.setCoords(lat: 0, lon: 0, enabled: false)
|
||||
WlocSettingsStore.clear()
|
||||
state = .idle
|
||||
virtualLocationEnabled = false
|
||||
message = "已恢复真实定位"
|
||||
}
|
||||
|
||||
private func beginApply() -> Bool {
|
||||
guard !state.isBusy else { return false }
|
||||
state = .applyingLocation
|
||||
message = "启动代理…"
|
||||
return true
|
||||
}
|
||||
|
||||
private func commit(_ favorite: FavoriteLocation) -> Bool {
|
||||
// MKMapView 在中国地区使用高德瓦片(GCJ-02),返回的坐标是 GCJ-02。
|
||||
// 但 Apple wloc 定位服务使用 WGS-84,因此写入代理前需要转换为 WGS-84。
|
||||
let wgs = CoordinateConverter.gcj02ToWgs84(lat: favorite.latitude, lon: favorite.longitude)
|
||||
WlocSettingsStore.save(WlocSettings(
|
||||
longitude: wgs.lon,
|
||||
latitude: wgs.lat,
|
||||
accuracy: favorite.accuracy,
|
||||
enabled: true
|
||||
))
|
||||
proxy.setCoords(
|
||||
lat: wgs.lat,
|
||||
lon: wgs.lon,
|
||||
enabled: true,
|
||||
accuracy: favorite.accuracy
|
||||
)
|
||||
state = .idle
|
||||
virtualLocationEnabled = true
|
||||
message = "虚拟定位已开启"
|
||||
return true
|
||||
}
|
||||
|
||||
private func finishCancelledApply() {
|
||||
state = .idle
|
||||
message = "已取消位置更新"
|
||||
}
|
||||
|
||||
private func failApply(_ error: Error) {
|
||||
virtualLocationEnabled = false
|
||||
message = "启动失败"
|
||||
state = .failed(error.localizedDescription)
|
||||
RuntimeLogger.error("APP", "Location", "apply失败", error: error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import UIKit
|
||||
import CoreLocation
|
||||
|
||||
private struct SearchLocationResult: Identifiable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let subtitle: String
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
}
|
||||
|
||||
private enum HomeSheet: String, Identifiable {
|
||||
case settings, logs
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private enum SpoofState {
|
||||
case idle, verifying, active
|
||||
}
|
||||
|
||||
private struct RealtimeLocationRequestContext {
|
||||
let intent: RealtimeLocationIntent
|
||||
let source: String
|
||||
let showFailureAlert: Bool
|
||||
}
|
||||
|
||||
struct MapHomeView: View {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
@StateObject private var favorites = FavoriteLocationStore()
|
||||
@StateObject private var actions = LocationActionCoordinator()
|
||||
@ObservedObject private var proxy = ProxyManager.shared
|
||||
@StateObject private var realtime = RealtimeLocationManager.shared
|
||||
@StateObject private var mapState: MapLocationState
|
||||
@ObservedObject private var net = NetworkMonitor.shared
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var searchResults: [SearchLocationResult] = []
|
||||
@State private var isSearching = false
|
||||
@State private var searchRequestID: UInt64 = 0
|
||||
@State private var searchError = ""
|
||||
@State private var mapDidInitialize = false
|
||||
@State private var activeSheet: HomeSheet?
|
||||
@State private var showEnableTip = false
|
||||
@State private var showDisableTip = false
|
||||
@State private var activeTip: TipKind?
|
||||
@State private var manualHint = ""
|
||||
@AppStorage("activationTipCount") private var activationTipCount = 0
|
||||
@AppStorage("activationTipDisabled") private var activationTipDisabled = false
|
||||
@State private var editingFavorite: FavoriteLocation?
|
||||
@State private var editName = ""
|
||||
@State private var reverseGeocodeTask: Task<Void, Never>?
|
||||
@State private var geocodeDebounceTask: Task<Void, Never>?
|
||||
@State private var showLocationAlert = false
|
||||
@State private var realtimeRequestTask: Task<Void, Never>?
|
||||
@State private var realtimeRequestContext: RealtimeLocationRequestContext?
|
||||
@State private var copyConfirmed = false
|
||||
@State private var spoofState: SpoofState = .idle
|
||||
@State private var locationOperationTask: Task<Void, Never>?
|
||||
@State private var locationOperationID: UInt64 = 0
|
||||
// 激活时的坐标(本地存,绕过 C 桥接层精度丢失)
|
||||
@State private var activeSpoofLat: Double?
|
||||
@State private var activeSpoofLon: Double?
|
||||
|
||||
init(setup: SetupCoordinator) {
|
||||
self.setup = setup
|
||||
let savedCoord = LastCoordinateStore.load()
|
||||
let initialZoom = ViewportStore.loadOrDefault()
|
||||
// 坐标:缓存 → 先给兜底深圳(启动后会由 initializeMap 按优先级覆盖)
|
||||
let initialCoord: CLLocationCoordinate2D
|
||||
if let coord = savedCoord?.coordinate {
|
||||
initialCoord = coord
|
||||
} else {
|
||||
initialCoord = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114)
|
||||
}
|
||||
_mapState = StateObject(wrappedValue: MapLocationState(
|
||||
initialCoordinate: initialCoord,
|
||||
initialViewportMeters: initialZoom
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
MapViewRepresentable(
|
||||
selection: mapState.selection,
|
||||
cameraCommand: mapState.cameraCommand,
|
||||
onRealtimeLocationChanged: { location in
|
||||
handleNativeRealtimeLocation(location)
|
||||
},
|
||||
onUserCenterChanged: { coordinate, distance in
|
||||
mapState.updateViewport(distanceMeters: distance)
|
||||
let previousRevision = mapState.selection.revision
|
||||
let revision = mapState.selectUserMapCenter(coordinate)
|
||||
guard revision != previousRevision else { return }
|
||||
LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
favorites.select(nil)
|
||||
scheduleGeocode(coordinate: coordinate, revision: revision)
|
||||
},
|
||||
onViewportChanged: { distance in
|
||||
mapState.updateViewport(distanceMeters: distance)
|
||||
},
|
||||
onMapTap: { coordinate in
|
||||
favorites.select(nil)
|
||||
let revision = mapState.selectMapTap(coordinate)
|
||||
LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
scheduleGeocode(coordinate: coordinate, revision: revision)
|
||||
},
|
||||
onUserZoomChanged: { distance in
|
||||
ViewportStore.save(distance)
|
||||
}
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.overlay {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.font(.system(size: 38, weight: .semibold))
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, .red)
|
||||
.shadow(color: .black.opacity(0.28), radius: 5, y: 3)
|
||||
.offset(y: -19)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
zoomControls
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 130)
|
||||
.allowsHitTesting(true)
|
||||
.allowsHitTesting(true)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
topControls
|
||||
if !searchResults.isEmpty || !searchError.isEmpty { searchResultList }
|
||||
Spacer()
|
||||
// 右下角按钮
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
if let url = URL(string: "maps://app") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "map.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.frame(width: 44, height: 44)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.shadow(color: .black.opacity(0.2), radius: 6, y: 3)
|
||||
}
|
||||
Button {
|
||||
requestRealtimeLocation()
|
||||
} label: {
|
||||
if realtime.isRequesting {
|
||||
ProgressView()
|
||||
.frame(width: 44, height: 44)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.shadow(color: .black.opacity(0.2), radius: 6, y: 3)
|
||||
} else {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.frame(width: 44, height: 44)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.shadow(color: .black.opacity(0.2), radius: 6, y: 3)
|
||||
}
|
||||
}
|
||||
.disabled(realtimeRequestTask != nil || realtime.isRequesting)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 8)
|
||||
bottomControls
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.sheet(item: $activeSheet) { sheet in
|
||||
NavigationView {
|
||||
switch sheet {
|
||||
case .settings: SettingsView(setup: setup, actions: actions)
|
||||
case .logs: RuntimeLogsView(setup: setup, actions: actions, testFavorite: testFavorite)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $activeTip) { kind in
|
||||
TipSheetView(kind: kind)
|
||||
}
|
||||
.alert("无法直接跳转", isPresented: Binding(
|
||||
get: { !manualHint.isEmpty },
|
||||
set: { if !$0 { manualHint = "" } }
|
||||
)) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: { Text(manualHint) }
|
||||
.onAppear(perform: initializeMap)
|
||||
.onChange(of: net.isAirplaneMode) { airplane in
|
||||
if airplane {
|
||||
showEnableTip = true
|
||||
} else {
|
||||
showEnableTip = false
|
||||
Task { await setup.refreshTrust() }
|
||||
}
|
||||
}
|
||||
.onChange(of: proxy.isRunning) { running in
|
||||
if !running && spoofState == .active {
|
||||
spoofState = .idle
|
||||
actions.clear()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEnableTip) { enableTipSheet }
|
||||
.sheet(isPresented: $showDisableTip) { disableTipSheet }
|
||||
.alert("定位失败", isPresented: $showLocationAlert) {
|
||||
Button("打开设置") {
|
||||
openSettings(.locationServices)
|
||||
}
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text("无法获取当前定位,请检查定位服务是否已开启")
|
||||
}
|
||||
.alert("编辑收藏名称", isPresented: Binding(
|
||||
get: { editingFavorite != nil },
|
||||
set: { if !$0 { editingFavorite = nil } }
|
||||
)) {
|
||||
TextField("名称", text: $editName)
|
||||
Button("保存") {
|
||||
if let f = editingFavorite {
|
||||
let name = editName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let finalName = name.isEmpty ? f.name : name
|
||||
favorites.rename(f.id, to: finalName)
|
||||
mapState.updateExplicitName(finalName, forFavoriteID: f.id)
|
||||
}
|
||||
editingFavorite = nil
|
||||
}
|
||||
Button("取消", role: .cancel) { editingFavorite = nil }
|
||||
} message: { Text("修改收藏地点名称") }
|
||||
}
|
||||
|
||||
private var topControls: some View {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("搜索地点", text: $searchText)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled().submitLabel(.search).onSubmit(doSearch)
|
||||
if isSearching { ProgressView().controlSize(.small) }
|
||||
else if !searchText.isEmpty {
|
||||
Button {
|
||||
searchRequestID &+= 1
|
||||
isSearching = false
|
||||
searchText = ""
|
||||
searchResults = []
|
||||
searchError = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
Button(action: doSearch) { Image(systemName: "arrow.right.circle.fill").font(.title3) }
|
||||
.disabled(searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching)
|
||||
}
|
||||
.padding(.horizontal, 14).frame(height: 48)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
.shadow(color: .black.opacity(0.13), radius: 9, y: 4)
|
||||
Menu {
|
||||
Button { activeSheet = .logs } label: { Label("日志", systemImage: "list.bullet.rectangle") }
|
||||
Button { activeSheet = .settings } label: { Label("设置", systemImage: "gearshape") }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis").font(.system(size: 20, weight: .bold))
|
||||
.frame(width: 48, height: 48)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.shadow(color: .black.opacity(0.13), radius: 9, y: 4)
|
||||
.contentShape(Circle())
|
||||
}.accessibilityLabel("更多")
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索列表:动态高度,不写死
|
||||
private var searchResultList: some View {
|
||||
VStack(spacing: 0) {
|
||||
if !searchError.isEmpty {
|
||||
Text(searchError).font(.footnote).foregroundStyle(.red)
|
||||
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
|
||||
}
|
||||
ForEach(searchResults) { r in
|
||||
HStack(spacing: 8) {
|
||||
Button { selectSearchResult(r) } label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.foregroundStyle(.red)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(r.name).font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
if !r.subtitle.isEmpty { Text(r.subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}.buttonStyle(.plain)
|
||||
Button(role: .destructive) { deleteSearchResult(r) } label: {
|
||||
Image(systemName: "trash").frame(width: 36, height: 36).contentShape(Rectangle())
|
||||
}.buttonStyle(.plain).foregroundStyle(.red)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
if r.id != searchResults.last?.id { Divider().padding(.leading, 46) }
|
||||
}
|
||||
}
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.12), radius: 8, y: 4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
// 底部:当前选点 + 收藏 + 主控按钮
|
||||
private var bottomControls: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 当前选点
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(mapState.displayName ?? "当前选点").font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
Text(String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(copyConfirmed ? .green : .secondary)
|
||||
.onTapGesture {
|
||||
let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude)
|
||||
UIPasteboard.general.string = text
|
||||
RuntimeLogger.info("APP", "地图", "复制坐标: \(text)")
|
||||
copyConfirmed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false }
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if copyConfirmed {
|
||||
Text("已复制")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.green, in: Capsule())
|
||||
.offset(y: -24)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
// 帮助说明按钮
|
||||
Button {
|
||||
if actions.virtualLocationEnabled {
|
||||
activeTip = .activation
|
||||
} else {
|
||||
showDisableTip = true
|
||||
}
|
||||
} label: {
|
||||
Text(actions.virtualLocationEnabled ? "无法生效?" : "无法取消?")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.1), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// 收藏按钮
|
||||
Button {
|
||||
if favorites.selectedFavoriteID != nil {
|
||||
favorites.select(nil)
|
||||
return
|
||||
}
|
||||
let snapshot = currentSelectionFavorite
|
||||
let favorite = favorites.save(
|
||||
name: snapshot.name,
|
||||
latitude: snapshot.latitude,
|
||||
longitude: snapshot.longitude,
|
||||
accuracy: snapshot.accuracy
|
||||
)
|
||||
mapState.selectFavorite(
|
||||
CLLocationCoordinate2D(latitude: favorite.latitude, longitude: favorite.longitude),
|
||||
id: favorite.id,
|
||||
name: favorite.name
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: favorites.selectedFavoriteID != nil ? "star.fill" : "star")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.frame(width: 38, height: 38)
|
||||
.background((favorites.selectedFavoriteID != nil ? Color.yellow : Color.gray).opacity(0.18), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(favorites.selectedFavoriteID != nil ? .orange : .gray)
|
||||
.accessibilityLabel(favorites.selectedFavoriteID != nil ? "已收藏,点击取消收藏" : "收藏当前选点")
|
||||
}
|
||||
// 收藏
|
||||
if favorites.favorites.isEmpty {
|
||||
Text("搜索或点击地图选点后,保存为收藏。").font(.footnote).foregroundStyle(.secondary)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) { ForEach(favorites.favorites) { f in favoriteChip(f) } }.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
// 飞行模式
|
||||
if net.isAirplaneMode {
|
||||
HStack {
|
||||
Image(systemName: "airplane").foregroundStyle(.orange)
|
||||
Text("飞行模式已开启").font(.caption).foregroundStyle(.orange)
|
||||
Spacer()
|
||||
Button("查看说明") { activeTip = .activation }.font(.caption).buttonStyle(.bordered).tint(.orange)
|
||||
}
|
||||
}
|
||||
// 主控按钮(带动画)
|
||||
HStack(spacing: 10) {
|
||||
Button(action: handleMainButtonTap) {
|
||||
HStack(spacing: 6) {
|
||||
if spoofState == .verifying {
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
Text(spoofState == .active && needsSwitchButton ? "关闭" : buttonTitle)
|
||||
.font(.headline).lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: needsSwitchButton ? nil : .infinity)
|
||||
.frame(minWidth: needsSwitchButton ? 56 : nil)
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, needsSwitchButton ? 12 : 14)
|
||||
}
|
||||
.background(buttonColor, in: RoundedRectangle(cornerRadius: 14))
|
||||
.foregroundStyle(.white)
|
||||
.disabled(spoofState == .verifying)
|
||||
|
||||
if needsSwitchButton {
|
||||
Button {
|
||||
beginLocationOperation()
|
||||
} label: {
|
||||
Label("切换到此处", systemImage: "arrow.triangle.swap")
|
||||
.font(.body.weight(.medium)).lineLimit(1)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12).padding(.horizontal, 16)
|
||||
}
|
||||
.background(.blue, in: RoundedRectangle(cornerRadius: 14))
|
||||
.foregroundStyle(.white)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.7), value: needsSwitchButton)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(16)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.16), radius: 14, y: 6)
|
||||
}
|
||||
|
||||
|
||||
private var needsSwitchButton: Bool {
|
||||
guard spoofState == .active,
|
||||
let sLat = activeSpoofLat,
|
||||
let sLon = activeSpoofLon else { return false }
|
||||
return abs(sLat - mapState.selection.coordinate.latitude) > 0.0001
|
||||
|| abs(sLon - mapState.selection.coordinate.longitude) > 0.0001
|
||||
}
|
||||
|
||||
private var buttonTitle: String {
|
||||
switch spoofState {
|
||||
case .idle: return "开始虚拟定位"
|
||||
case .verifying: return "验证环境中…"
|
||||
case .active: return "停止虚拟定位"
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonColor: Color {
|
||||
switch spoofState {
|
||||
case .idle: return .blue
|
||||
case .verifying: return .gray
|
||||
case .active: return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMainButtonTap() {
|
||||
switch spoofState {
|
||||
case .idle:
|
||||
beginLocationOperation()
|
||||
case .active:
|
||||
stopSpoofing()
|
||||
case .verifying:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func beginLocationOperation() {
|
||||
guard spoofState != .verifying, locationOperationTask == nil else { return }
|
||||
locationOperationID &+= 1
|
||||
let operationID = locationOperationID
|
||||
let selectionRevision = mapState.selection.revision
|
||||
let target = currentSelectionFavorite
|
||||
spoofState = .verifying
|
||||
|
||||
locationOperationTask = Task { @MainActor in
|
||||
let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude)
|
||||
guard !Task.isCancelled,
|
||||
operationID == locationOperationID,
|
||||
selectionRevision == mapState.selection.revision else {
|
||||
if operationID == locationOperationID {
|
||||
spoofState = actions.virtualLocationEnabled ? .active : .idle
|
||||
locationOperationTask = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result.isSuccess {
|
||||
let applied = actions.applyVerified(target)
|
||||
spoofState = applied ? .active : .idle
|
||||
if applied {
|
||||
activeSpoofLat = target.latitude
|
||||
activeSpoofLon = target.longitude
|
||||
}
|
||||
if applied && !activationTipDisabled {
|
||||
showEnableTip = true
|
||||
activationTipCount += 1
|
||||
}
|
||||
} else {
|
||||
spoofState = actions.virtualLocationEnabled ? .active : .idle
|
||||
}
|
||||
locationOperationTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSpoofing() {
|
||||
locationOperationTask?.cancel()
|
||||
locationOperationTask = nil
|
||||
locationOperationID &+= 1
|
||||
actions.clear()
|
||||
spoofState = .idle
|
||||
activeSpoofLat = nil
|
||||
activeSpoofLon = nil
|
||||
showDisableTip = true
|
||||
}
|
||||
|
||||
|
||||
private func favoriteChip(_ f: FavoriteLocation) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Button { select(f) } label: {
|
||||
Label(f.name, systemImage: favorites.selectedFavoriteID == f.id ? "checkmark.circle.fill" : "mappin")
|
||||
.lineLimit(1).padding(.leading, 10).padding(.vertical, 8).padding(.trailing, 7).contentShape(Rectangle())
|
||||
}.buttonStyle(.plain)
|
||||
Divider().frame(height: 22)
|
||||
Button {
|
||||
editingFavorite = f
|
||||
editName = f.name
|
||||
} label: {
|
||||
Image(systemName: "pencil").font(.caption2).frame(width: 32, height: 36).contentShape(Rectangle())
|
||||
}.buttonStyle(.plain).foregroundStyle(.primary.opacity(0.55))
|
||||
Divider().frame(height: 22)
|
||||
Button(role: .destructive) { favorites.delete(f) } label: {
|
||||
Image(systemName: "trash").font(.caption.weight(.semibold)).frame(width: 36, height: 36).contentShape(Rectangle())
|
||||
}.buttonStyle(.plain).foregroundStyle(.red)
|
||||
}
|
||||
.background((favorites.selectedFavoriteID == f.id ? Color.red.opacity(0.14) : Color.secondary.opacity(0.12)), in: Capsule())
|
||||
.overlay(Capsule().stroke(favorites.selectedFavoriteID == f.id ? Color.red.opacity(0.7) : Color.clear))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func openSettings(_ destination: SystemSettingsDestination) {
|
||||
SystemSettingsNavigator.open(destination) { fallbackHint in
|
||||
if let fallbackHint { manualHint = fallbackHint }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var currentSelectionFavorite: FavoriteLocation {
|
||||
FavoriteLocation(
|
||||
name: mapState.displayName ?? String(
|
||||
format: "%.4f, %.4f",
|
||||
mapState.selection.coordinate.latitude,
|
||||
mapState.selection.coordinate.longitude
|
||||
),
|
||||
latitude: mapState.selection.coordinate.latitude,
|
||||
longitude: mapState.selection.coordinate.longitude,
|
||||
accuracy: 25
|
||||
)
|
||||
}
|
||||
|
||||
private var testFavorite: FavoriteLocation { currentSelectionFavorite }
|
||||
|
||||
private var zoomControls: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
mapState.zoom(by: 0.5)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.frame(width: 52, height: 52)
|
||||
}
|
||||
.accessibilityLabel("放大地图")
|
||||
|
||||
Divider().frame(width: 28)
|
||||
|
||||
Text(MapZoomMath.viewportScaleLabel(distanceMeters: mapState.viewportMeters))
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.frame(width: 54, height: 28)
|
||||
.accessibilityLabel("当前地图范围")
|
||||
.accessibilityValue(MapZoomMath.viewportScaleLabel(distanceMeters: mapState.viewportMeters))
|
||||
|
||||
Divider().frame(width: 28)
|
||||
|
||||
Button {
|
||||
mapState.zoom(by: 2)
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.frame(width: 52, height: 52)
|
||||
}
|
||||
.accessibilityLabel("缩小地图")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 7, y: 3)
|
||||
}
|
||||
|
||||
private func initializeMap() {
|
||||
guard !mapDidInitialize else { return }
|
||||
mapDidInitialize = true
|
||||
|
||||
if let selected = favorites.selectedFavorite {
|
||||
mapState.selectFavorite(
|
||||
CLLocationCoordinate2D(latitude: selected.latitude, longitude: selected.longitude),
|
||||
id: selected.id,
|
||||
name: selected.name
|
||||
)
|
||||
LastCoordinateStore.save(lat: selected.latitude, lon: selected.longitude)
|
||||
} else {
|
||||
mapState.focusSelection(distanceMeters: mapState.viewportMeters)
|
||||
scheduleGeocode(
|
||||
coordinate: mapState.selection.coordinate,
|
||||
revision: mapState.selection.revision
|
||||
)
|
||||
}
|
||||
|
||||
// 启动坐标获取优先级:缓存 > 实时定位 > 深圳兜底(init 时已给兜底)
|
||||
let savedCoord = LastCoordinateStore.load()
|
||||
if savedCoord == nil {
|
||||
requestRealtimeLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestRealtimeLocation() {
|
||||
let intent = mapState.beginRealtimeIntent()
|
||||
if let nativeLocation = mapState.realtimeLocation,
|
||||
abs(nativeLocation.timestamp.timeIntervalSinceNow) <= 30 {
|
||||
acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, source: "MapKit 实时位置")
|
||||
return
|
||||
}
|
||||
startRealtimeLocationRequest(
|
||||
source: "定位按钮兜底",
|
||||
showFailureAlert: true,
|
||||
intent: intent
|
||||
)
|
||||
}
|
||||
|
||||
private func handleNativeRealtimeLocation(_ location: CLLocation) {
|
||||
mapState.updateRealtimeLocation(location)
|
||||
guard let context = realtimeRequestContext else { return }
|
||||
|
||||
// MKMapView's MKUserLocation is the visible blue point. When it arrives,
|
||||
// fulfill the pending intent from that exact sample and cancel the slower
|
||||
// CLLocationManager fallback so the camera and dot cannot disagree.
|
||||
realtimeRequestContext = nil
|
||||
realtimeRequestTask?.cancel()
|
||||
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "MapKit \(context.source)")
|
||||
}
|
||||
|
||||
private func startRealtimeLocationRequest(
|
||||
source: String,
|
||||
showFailureAlert: Bool,
|
||||
intent suppliedIntent: RealtimeLocationIntent? = nil
|
||||
) {
|
||||
let intent = suppliedIntent ?? mapState.beginRealtimeIntent()
|
||||
realtimeRequestContext = RealtimeLocationRequestContext(
|
||||
intent: intent,
|
||||
source: source,
|
||||
showFailureAlert: showFailureAlert
|
||||
)
|
||||
|
||||
// A button tap during the startup request retargets that same in-flight
|
||||
// Core Location request to the newer intent instead of being ignored.
|
||||
guard realtimeRequestTask == nil, !realtime.isRequesting else { return }
|
||||
realtimeRequestTask = Task { @MainActor in
|
||||
defer {
|
||||
realtimeRequestTask = nil
|
||||
realtimeRequestContext = nil
|
||||
}
|
||||
guard let coordinate = await realtime.requestLocation() else {
|
||||
guard let context = realtimeRequestContext,
|
||||
context.showFailureAlert,
|
||||
!Task.isCancelled,
|
||||
mapState.selection.revision == context.intent.selectionRevision else { return }
|
||||
RuntimeLogger.info("APP", "地图", "定位失败 status=\(realtime.authorizationStatus.rawValue)")
|
||||
showLocationAlert = true
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled, let context = realtimeRequestContext else { return }
|
||||
acceptRealtimeLocation(coordinate, intent: context.intent, source: context.source)
|
||||
}
|
||||
}
|
||||
|
||||
private func acceptRealtimeLocation(
|
||||
_ coordinate: CLLocationCoordinate2D,
|
||||
intent: RealtimeLocationIntent,
|
||||
source: String
|
||||
) {
|
||||
let currentViewport = mapState.viewportMeters
|
||||
let accepted = mapState.acceptRealtimeLocation(coordinate, intent: intent)
|
||||
RuntimeLogger.info("APP", "地图", "\(source)返回", details: [
|
||||
"accepted": String(accepted),
|
||||
"lat": String(coordinate.latitude),
|
||||
"lon": String(coordinate.longitude)
|
||||
])
|
||||
guard accepted else { return }
|
||||
// 用点击时的缩放级别居中,不改变缩放
|
||||
mapState.focusSelection(distanceMeters: currentViewport)
|
||||
LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||
favorites.select(nil)
|
||||
scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision)
|
||||
}
|
||||
|
||||
private func scheduleGeocode(coordinate: CLLocationCoordinate2D, revision: UInt64) {
|
||||
geocodeDebounceTask?.cancel()
|
||||
reverseGeocodeTask?.cancel()
|
||||
geocodeDebounceTask = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 300_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled, mapState.selection.revision == revision else { return }
|
||||
reverseGeocode(coordinate, revision: revision)
|
||||
}
|
||||
}
|
||||
|
||||
private func reverseGeocode(_ coordinate: CLLocationCoordinate2D, revision: UInt64) {
|
||||
reverseGeocodeTask?.cancel()
|
||||
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||
reverseGeocodeTask = Task { @MainActor in
|
||||
let retryDelays: [UInt64] = [0, 800_000_000, 1_600_000_000]
|
||||
var lastError: Error?
|
||||
|
||||
for (attempt, delay) in retryDelays.enumerated() {
|
||||
if delay > 0 {
|
||||
do { try await Task.sleep(nanoseconds: delay) }
|
||||
catch { return }
|
||||
}
|
||||
guard !Task.isCancelled, mapState.selection.revision == revision else { return }
|
||||
|
||||
do {
|
||||
// 并⾏获取:CLGeocoder(地址结构化) + MKLocalSearch(地图显⽰名称)
|
||||
// MKLocalSearch 在无结果时抛错,不可与 CLGeocoder 共用 try await 导致互相影响
|
||||
async let clPlacemarks = CLGeocoder().reverseGeocodeLocation(location)
|
||||
let mkRequest = MKLocalSearch.Request()
|
||||
mkRequest.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 400, longitudinalMeters: 400)
|
||||
let mkResponse = try? await MKLocalSearch(request: mkRequest).start()
|
||||
|
||||
let placemarks = try await clPlacemarks
|
||||
guard !Task.isCancelled,
|
||||
mapState.selection.revision == revision,
|
||||
let placemark = placemarks.first else { return }
|
||||
|
||||
let mapItemName = mkResponse?.mapItems.first?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let mapItemPOI = mkResponse?.mapItems.first?.placemark.areasOfInterest?.first
|
||||
// 优先使⽤ MKLocalSearch 结果(与地图显⽰一致),CLGeocoder 作为 fallback
|
||||
let poi = { () -> String? in
|
||||
if let v = mapItemPOI?.trimmingCharacters(in: .whitespacesAndNewlines), !v.isEmpty { return v }
|
||||
if let v = mapItemName?.trimmingCharacters(in: .whitespacesAndNewlines), !v.isEmpty { return v }
|
||||
return placemark.areasOfInterest?.first ?? placemark.name
|
||||
}()
|
||||
let streetAddress = [placemark.thoroughfare, placemark.subThoroughfare]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
let descriptor = MapPlaceDescriptor(
|
||||
pointOfInterest: poi,
|
||||
streetAddress: streetAddress,
|
||||
road: placemark.thoroughfare,
|
||||
neighborhood: placemark.subLocality,
|
||||
district: placemark.subLocality ?? placemark.subAdministrativeArea,
|
||||
city: placemark.locality ?? placemark.subAdministrativeArea,
|
||||
province: placemark.administrativeArea,
|
||||
country: placemark.country
|
||||
)
|
||||
_ = mapState.acceptPlaceDescriptor(descriptor, selectionRevision: revision)
|
||||
return
|
||||
} catch {
|
||||
guard !Task.isCancelled, mapState.selection.revision == revision else { return }
|
||||
lastError = error
|
||||
let nsError = error as NSError
|
||||
let isNetworkError = nsError.domain == kCLErrorDomain
|
||||
&& nsError.code == CLError.network.rawValue
|
||||
guard isNetworkError, attempt < retryDelays.count - 1 else { break }
|
||||
RuntimeLogger.info("APP", "Geocode", "反向地理编码网络失败,准备重试", details: [
|
||||
"attempt": String(attempt + 1),
|
||||
"revision": String(revision)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
guard !Task.isCancelled,
|
||||
mapState.selection.revision == revision,
|
||||
let lastError else { return }
|
||||
RuntimeLogger.warning("APP", "Geocode", "反向地理编码失败", details: [
|
||||
"error": lastError.localizedDescription,
|
||||
"revision": String(revision)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func doSearch() {
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty, !isSearching else { return }
|
||||
searchRequestID &+= 1
|
||||
let requestID = searchRequestID
|
||||
isSearching = true
|
||||
searchError = ""
|
||||
let request = MKLocalSearch.Request()
|
||||
request.naturalLanguageQuery = query
|
||||
MKLocalSearch(request: request).start { response, error in
|
||||
DispatchQueue.main.async {
|
||||
guard requestID == searchRequestID else { return }
|
||||
isSearching = false
|
||||
if let error {
|
||||
searchResults = []
|
||||
searchError = error.localizedDescription
|
||||
return
|
||||
}
|
||||
searchResults = (response?.mapItems ?? []).prefix(6).map { item in
|
||||
SearchLocationResult(
|
||||
name: item.name ?? "未命名",
|
||||
subtitle: [item.placemark.locality, item.placemark.subLocality, item.placemark.thoroughfare]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " · "),
|
||||
coordinate: item.placemark.coordinate
|
||||
)
|
||||
}
|
||||
if searchResults.isEmpty { searchError = "没有找到相关地点" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectSearchResult(_ result: SearchLocationResult) {
|
||||
geocodeDebounceTask?.cancel()
|
||||
reverseGeocodeTask?.cancel()
|
||||
favorites.select(nil)
|
||||
mapState.selectSearchResult(result.coordinate, name: result.name)
|
||||
LastCoordinateStore.save(lat: result.coordinate.latitude, lon: result.coordinate.longitude)
|
||||
searchText = result.name
|
||||
searchResults = []
|
||||
searchError = ""
|
||||
}
|
||||
|
||||
private func deleteSearchResult(_ result: SearchLocationResult) {
|
||||
searchResults.removeAll { $0.id == result.id }
|
||||
}
|
||||
|
||||
private func select(_ favorite: FavoriteLocation) {
|
||||
geocodeDebounceTask?.cancel()
|
||||
reverseGeocodeTask?.cancel()
|
||||
favorites.select(favorite.id)
|
||||
mapState.selectFavorite(
|
||||
CLLocationCoordinate2D(latitude: favorite.latitude, longitude: favorite.longitude),
|
||||
id: favorite.id,
|
||||
name: favorite.name
|
||||
)
|
||||
LastCoordinateStore.save(lat: favorite.latitude, lon: favorite.longitude)
|
||||
}
|
||||
|
||||
private var enableTipSheet: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ActivationTipContent(dismiss: {})
|
||||
if activationTipCount >= 3 {
|
||||
Button(role: .destructive) {
|
||||
activationTipDisabled = true
|
||||
showEnableTip = false
|
||||
} label: {
|
||||
Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered)
|
||||
}
|
||||
}.padding(16)
|
||||
}
|
||||
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var disableTipSheet: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
DeactivationTipContent(dismiss: {})
|
||||
RemoveProxyTipContent(dismiss: {})
|
||||
}.padding(16)
|
||||
}
|
||||
.navigationTitle("虚拟定位已关闭").navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Button { showDisableTip = false } label: {
|
||||
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue).padding(.horizontal, 16).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import Combine
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct MapSelection: Equatable {
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
let source: MapSelectionSource
|
||||
let explicitName: String?
|
||||
let revision: UInt64
|
||||
|
||||
static func == (lhs: MapSelection, rhs: MapSelection) -> Bool {
|
||||
lhs.coordinate.isApproximatelyEqual(to: rhs.coordinate)
|
||||
&& lhs.source == rhs.source
|
||||
&& lhs.explicitName == rhs.explicitName
|
||||
&& lhs.revision == rhs.revision
|
||||
}
|
||||
}
|
||||
|
||||
enum MapSelectionSource: Equatable {
|
||||
case initial
|
||||
case userPan
|
||||
case mapTap
|
||||
case realtime
|
||||
case search
|
||||
case favorite(UUID)
|
||||
}
|
||||
|
||||
struct RealtimeLocationIntent: Equatable {
|
||||
let id: UInt64
|
||||
let selectionRevision: UInt64
|
||||
}
|
||||
|
||||
struct MapCameraCommand: Equatable, Identifiable {
|
||||
enum Kind: Equatable {
|
||||
case focus(coordinate: CLLocationCoordinate2D, distanceMeters: CLLocationDistance)
|
||||
case zoom(factor: Double)
|
||||
|
||||
var isZoom: Bool { if case .zoom = self { return true }; return false }
|
||||
|
||||
static func == (lhs: Kind, rhs: Kind) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.focus(lhsCoordinate, lhsDistance), .focus(rhsCoordinate, rhsDistance)):
|
||||
return lhsCoordinate.isApproximatelyEqual(to: rhsCoordinate)
|
||||
&& lhsDistance == rhsDistance
|
||||
case let (.zoom(lhsFactor), .zoom(rhsFactor)):
|
||||
return lhsFactor == rhsFactor
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id: UInt64
|
||||
let kind: Kind
|
||||
}
|
||||
|
||||
struct MapPlaceDescriptor: Equatable {
|
||||
var pointOfInterest: String?
|
||||
var streetAddress: String?
|
||||
var road: String?
|
||||
var neighborhood: String?
|
||||
var district: String?
|
||||
var city: String?
|
||||
var province: String?
|
||||
var country: String?
|
||||
|
||||
init(
|
||||
pointOfInterest: String? = nil,
|
||||
streetAddress: String? = nil,
|
||||
road: String? = nil,
|
||||
neighborhood: String? = nil,
|
||||
district: String? = nil,
|
||||
city: String? = nil,
|
||||
province: String? = nil,
|
||||
country: String? = nil
|
||||
) {
|
||||
self.pointOfInterest = pointOfInterest?.nonEmpty
|
||||
self.streetAddress = streetAddress?.nonEmpty
|
||||
self.road = road?.nonEmpty
|
||||
self.neighborhood = neighborhood?.nonEmpty
|
||||
self.district = district?.nonEmpty
|
||||
self.city = city?.nonEmpty
|
||||
self.province = province?.nonEmpty
|
||||
self.country = country?.nonEmpty
|
||||
}
|
||||
|
||||
func displayName(viewportMeters: CLLocationDistance) -> String? {
|
||||
switch viewportMeters {
|
||||
case ..<1_000:
|
||||
return firstAvailable(pointOfInterest, streetAddress, road, neighborhood, district, districtCity, cityProvince, country)
|
||||
case ..<5_000:
|
||||
return firstAvailable(road, streetAddress, neighborhood, district, districtCity, cityProvince, country)
|
||||
case ..<12_000:
|
||||
return firstAvailable(neighborhood, district, districtCity, road, city, cityProvince, country)
|
||||
case ..<100_000:
|
||||
return firstAvailable(districtCity, city, cityProvince, province, country, neighborhoodCity)
|
||||
default:
|
||||
return firstAvailable(cityProvince, provinceCountry, city, province, country, districtCity)
|
||||
}
|
||||
}
|
||||
|
||||
private var districtCity: String? { joinedDistinct(district, city) }
|
||||
private var neighborhoodCity: String? { joinedDistinct(neighborhood, city) }
|
||||
private var cityProvince: String? { joinedDistinct(city, province) }
|
||||
private var provinceCountry: String? { joinedDistinct(province, country) }
|
||||
|
||||
private func firstAvailable(_ values: String?...) -> String? {
|
||||
values.compactMap { $0?.nonEmpty }.first
|
||||
}
|
||||
|
||||
private func joinedDistinct(_ first: String?, _ second: String?) -> String? {
|
||||
let values = [first?.nonEmpty, second?.nonEmpty].compactMap { $0 }
|
||||
let unique = values.reduce(into: [String]()) { result, value in
|
||||
if !result.contains(value) { result.append(value) }
|
||||
}
|
||||
return unique.isEmpty ? nil : unique.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MapLocationState: ObservableObject {
|
||||
@Published private(set) var selection: MapSelection
|
||||
@Published private(set) var realtimeCoordinate: CLLocationCoordinate2D?
|
||||
@Published private(set) var realtimeLocation: CLLocation?
|
||||
@Published private(set) var cameraCommand: MapCameraCommand?
|
||||
@Published private(set) var viewportMeters: CLLocationDistance
|
||||
@Published private(set) var placeDescriptor: MapPlaceDescriptor?
|
||||
|
||||
private var nextSelectionRevision: UInt64 = 0
|
||||
private var nextCameraCommandID: UInt64 = 0
|
||||
private var nextRealtimeIntentID: UInt64 = 0
|
||||
private var latestRealtimeIntentID: UInt64 = 0
|
||||
|
||||
init(initialCoordinate: CLLocationCoordinate2D, initialViewportMeters: CLLocationDistance = 1_000) {
|
||||
viewportMeters = initialViewportMeters
|
||||
selection = MapSelection(
|
||||
coordinate: initialCoordinate,
|
||||
source: .initial,
|
||||
explicitName: nil,
|
||||
revision: nextSelectionRevision
|
||||
)
|
||||
}
|
||||
|
||||
var displayName: String? {
|
||||
selection.explicitName?.nonEmpty ?? placeDescriptor?.displayName(viewportMeters: viewportMeters)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectUserMapCenter(_ coordinate: CLLocationCoordinate2D) -> UInt64 {
|
||||
guard !selection.coordinate.isApproximatelyEqual(to: coordinate) else {
|
||||
return selection.revision
|
||||
}
|
||||
return replaceSelection(coordinate: coordinate, source: .userPan, explicitName: nil, focusDistance: nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectMapTap(_ coordinate: CLLocationCoordinate2D) -> UInt64 {
|
||||
replaceSelection(coordinate: coordinate, source: .mapTap, explicitName: nil, focusDistance: viewportMeters)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectSearchResult(
|
||||
_ coordinate: CLLocationCoordinate2D,
|
||||
name: String
|
||||
) -> UInt64 {
|
||||
replaceSelection(coordinate: coordinate, source: .search, explicitName: name, focusDistance: viewportMeters)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectFavorite(
|
||||
_ coordinate: CLLocationCoordinate2D,
|
||||
id: UUID,
|
||||
name: String
|
||||
) -> UInt64 {
|
||||
replaceSelection(coordinate: coordinate, source: .favorite(id), explicitName: name, focusDistance: viewportMeters)
|
||||
}
|
||||
|
||||
func beginRealtimeIntent() -> RealtimeLocationIntent {
|
||||
nextRealtimeIntentID &+= 1
|
||||
latestRealtimeIntentID = nextRealtimeIntentID
|
||||
return RealtimeLocationIntent(id: nextRealtimeIntentID, selectionRevision: selection.revision)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func acceptRealtimeLocation(
|
||||
_ coordinate: CLLocationCoordinate2D,
|
||||
intent: RealtimeLocationIntent
|
||||
) -> Bool {
|
||||
realtimeCoordinate = coordinate
|
||||
guard intent.id == latestRealtimeIntentID,
|
||||
intent.selectionRevision == selection.revision else {
|
||||
return false
|
||||
}
|
||||
_ = replaceSelection(
|
||||
coordinate: coordinate,
|
||||
source: .realtime,
|
||||
explicitName: nil,
|
||||
focusDistance: nil
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
func updateRealtimeLocation(_ location: CLLocation?) {
|
||||
guard let location else {
|
||||
realtimeLocation = nil
|
||||
realtimeCoordinate = nil
|
||||
return
|
||||
}
|
||||
guard CLLocationCoordinate2DIsValid(location.coordinate), location.horizontalAccuracy >= 0 else { return }
|
||||
if let current = realtimeLocation, current.timestamp > location.timestamp { return }
|
||||
realtimeLocation = location
|
||||
realtimeCoordinate = location.coordinate
|
||||
}
|
||||
|
||||
func updateRealtimeCoordinate(_ coordinate: CLLocationCoordinate2D?) {
|
||||
realtimeCoordinate = coordinate
|
||||
if coordinate == nil { realtimeLocation = nil }
|
||||
}
|
||||
|
||||
func updateExplicitName(_ name: String, forFavoriteID favoriteID: UUID) {
|
||||
guard selection.source == .favorite(favoriteID) else { return }
|
||||
selection = MapSelection(
|
||||
coordinate: selection.coordinate,
|
||||
source: selection.source,
|
||||
explicitName: name,
|
||||
revision: selection.revision
|
||||
)
|
||||
}
|
||||
|
||||
func updateViewport(distanceMeters: CLLocationDistance) {
|
||||
viewportMeters = max(50, distanceMeters)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
|
||||
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
|
||||
placeDescriptor = descriptor
|
||||
return true
|
||||
}
|
||||
|
||||
func focusSelection(distanceMeters: CLLocationDistance = 200) {
|
||||
issueCameraCommand(.focus(coordinate: selection.coordinate, distanceMeters: distanceMeters))
|
||||
}
|
||||
|
||||
func zoom(by factor: Double) {
|
||||
guard factor.isFinite, factor > 0 else { return }
|
||||
issueCameraCommand(.zoom(factor: factor))
|
||||
}
|
||||
|
||||
private func replaceSelection(
|
||||
coordinate: CLLocationCoordinate2D,
|
||||
source: MapSelectionSource,
|
||||
explicitName: String?,
|
||||
focusDistance: CLLocationDistance?
|
||||
) -> UInt64 {
|
||||
nextSelectionRevision &+= 1
|
||||
placeDescriptor = nil
|
||||
selection = MapSelection(
|
||||
coordinate: coordinate,
|
||||
source: source,
|
||||
explicitName: explicitName?.nonEmpty,
|
||||
revision: nextSelectionRevision
|
||||
)
|
||||
if let focusDistance {
|
||||
issueCameraCommand(.focus(coordinate: coordinate, distanceMeters: focusDistance))
|
||||
}
|
||||
return nextSelectionRevision
|
||||
}
|
||||
|
||||
private func issueCameraCommand(_ kind: MapCameraCommand.Kind) {
|
||||
nextCameraCommandID &+= 1
|
||||
cameraCommand = MapCameraCommand(id: nextCameraCommandID, kind: kind)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
extension CLLocationCoordinate2D {
|
||||
func isApproximatelyEqual(to other: CLLocationCoordinate2D, tolerance: CLLocationDegrees = 0.000_001) -> Bool {
|
||||
abs(latitude - other.latitude) < tolerance && abs(longitude - other.longitude) < tolerance
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 持久化存储
|
||||
|
||||
enum ViewportStore {
|
||||
private static let key = "mapViewportMeters"
|
||||
static func save(_ meters: CLLocationDistance) {
|
||||
UserDefaults.standard.set(meters, forKey: key)
|
||||
}
|
||||
/// 取持久化缩放值;未存过返回 nil
|
||||
static func load() -> CLLocationDistance? {
|
||||
let v = UserDefaults.standard.double(forKey: key)
|
||||
return v > 0 ? v : nil
|
||||
}
|
||||
/// 取持久化缩放值,取不到返回默认 1km 并立即存储
|
||||
static func loadOrDefault() -> CLLocationDistance {
|
||||
if let v = load() { return v }
|
||||
let fallback: CLLocationDistance = 1_000
|
||||
save(fallback)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
struct LastCoordinate {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) }
|
||||
var isValid: Bool { CLLocationCoordinate2DIsValid(coordinate) && (latitude != 0 || longitude != 0) }
|
||||
}
|
||||
|
||||
enum LastCoordinateStore {
|
||||
private static let latKey = "lastMapLat"
|
||||
private static let lonKey = "lastMapLon"
|
||||
static func save(lat: Double, lon: Double) {
|
||||
UserDefaults.standard.set(lat, forKey: latKey)
|
||||
UserDefaults.standard.set(lon, forKey: lonKey)
|
||||
}
|
||||
/// 取持久化坐标,未存过或无效返回 nil
|
||||
static func load() -> LastCoordinate? {
|
||||
let c = LastCoordinate(
|
||||
latitude: UserDefaults.standard.double(forKey: latKey),
|
||||
longitude: UserDefaults.standard.double(forKey: lonKey)
|
||||
)
|
||||
return c.isValid ? c : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum MapZoomMath {
|
||||
private static let minimumDelta = 0.000_05
|
||||
private static let maximumLatitudeDelta = 170.0
|
||||
private static let maximumLongitudeDelta = 360.0
|
||||
|
||||
static func scaledSpan(_ span: MKCoordinateSpan, factor: Double) -> MKCoordinateSpan {
|
||||
guard factor.isFinite, factor > 0 else { return span }
|
||||
return MKCoordinateSpan(
|
||||
latitudeDelta: min(max(span.latitudeDelta * factor, minimumDelta), maximumLatitudeDelta),
|
||||
longitudeDelta: min(max(span.longitudeDelta * factor, minimumDelta), maximumLongitudeDelta)
|
||||
)
|
||||
}
|
||||
|
||||
static func viewportScaleLabel(distanceMeters: CLLocationDistance) -> String {
|
||||
let meters = max(0, distanceMeters)
|
||||
if meters < 1_000 {
|
||||
return "\(Int(meters.rounded())) m"
|
||||
}
|
||||
let kilometers = meters / 1_000
|
||||
if kilometers < 10 {
|
||||
return String(format: "%.1f km", kilometers)
|
||||
}
|
||||
return "\(Int(kilometers.rounded())) km"
|
||||
}
|
||||
}
|
||||
|
||||
struct MapViewRepresentable: UIViewRepresentable {
|
||||
let selection: MapSelection
|
||||
let cameraCommand: MapCameraCommand?
|
||||
let onRealtimeLocationChanged: (CLLocation) -> Void
|
||||
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
|
||||
let onViewportChanged: (CLLocationDistance) -> Void
|
||||
let onMapTap: (CLLocationCoordinate2D) -> Void
|
||||
let onUserZoomChanged: ((CLLocationDistance) -> Void)?
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIView(context: Context) -> MKMapView {
|
||||
let map = MKMapView()
|
||||
map.delegate = context.coordinator
|
||||
map.showsUserLocation = true
|
||||
let initialDistance = ViewportStore.loadOrDefault()
|
||||
map.setRegion(
|
||||
MKCoordinateRegion(
|
||||
center: selection.coordinate,
|
||||
latitudinalMeters: initialDistance,
|
||||
longitudinalMeters: initialDistance
|
||||
),
|
||||
animated: false
|
||||
)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||
tap.cancelsTouchesInView = false
|
||||
map.addGestureRecognizer(tap)
|
||||
context.coordinator.map = map
|
||||
return map
|
||||
}
|
||||
|
||||
func updateUIView(_ map: MKMapView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
context.coordinator.consume(cameraCommand, on: map)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, MKMapViewDelegate {
|
||||
var parent: MapViewRepresentable
|
||||
weak var map: MKMapView?
|
||||
|
||||
private var lastConsumedCommandID: UInt64?
|
||||
private var activeCameraCommandID: UInt64?
|
||||
private var activeCommandIsZoom = false
|
||||
private var regionChangeWasUserDriven = false
|
||||
private var isPinchZoom = false
|
||||
|
||||
init(parent: MapViewRepresentable) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
|
||||
guard let command, command.id != lastConsumedCommandID else { return }
|
||||
lastConsumedCommandID = command.id
|
||||
activeCameraCommandID = command.id
|
||||
activeCommandIsZoom = command.kind.isZoom
|
||||
regionChangeWasUserDriven = false
|
||||
map.userTrackingMode = .none
|
||||
|
||||
let region: MKCoordinateRegion
|
||||
switch command.kind {
|
||||
case let .focus(coordinate, _):
|
||||
// 保持当前 span 不变,只移动中心点,避免 latitudinalMeters
|
||||
// 与 visibleVerticalDistance 之间因屏幕宽高比引入 2x 漂移
|
||||
region = MKCoordinateRegion(center: coordinate, span: map.region.span)
|
||||
case let .zoom(factor):
|
||||
region = MKCoordinateRegion(
|
||||
center: map.centerCoordinate,
|
||||
span: MapZoomMath.scaledSpan(map.region.span, factor: factor)
|
||||
)
|
||||
}
|
||||
|
||||
map.setRegion(region, animated: true)
|
||||
}
|
||||
|
||||
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
guard gesture.state == .ended, let map else { return }
|
||||
let point = gesture.location(in: map)
|
||||
parent.onMapTap(map.convert(point, toCoordinateFrom: map))
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||
guard let location = userLocation.location,
|
||||
CLLocationCoordinate2DIsValid(location.coordinate),
|
||||
location.horizontalAccuracy >= 0 else { return }
|
||||
parent.onRealtimeLocationChanged(location)
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
|
||||
let activeRecognizers = ([mapView as UIView] + mapView.subviews)
|
||||
.compactMap(\.gestureRecognizers)
|
||||
.flatMap { $0 }
|
||||
.filter { recognizer in
|
||||
recognizer.state == .began || recognizer.state == .changed
|
||||
}
|
||||
let hasActiveGesture = !activeRecognizers.isEmpty
|
||||
|
||||
// Pinching updates the viewport/name granularity only. MapKit can
|
||||
// keep its pan recognizer active during a pinch, so only a pure pan
|
||||
// is allowed to replace the selected center coordinate.
|
||||
let hasActivePan = activeRecognizers.contains { $0 is UIPanGestureRecognizer }
|
||||
let hasActivePinch = activeRecognizers.contains { $0 is UIPinchGestureRecognizer }
|
||||
regionChangeWasUserDriven = hasActivePan && !hasActivePinch
|
||||
isPinchZoom = hasActivePinch
|
||||
if hasActiveGesture {
|
||||
activeCameraCommandID = nil
|
||||
activeCommandIsZoom = false
|
||||
}
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
|
||||
let distance = visibleVerticalDistance(in: mapView)
|
||||
parent.onViewportChanged(distance)
|
||||
|
||||
let userZoomed: Bool
|
||||
if activeCameraCommandID != nil {
|
||||
userZoomed = activeCommandIsZoom
|
||||
activeCameraCommandID = nil
|
||||
activeCommandIsZoom = false
|
||||
} else if isPinchZoom {
|
||||
userZoomed = true
|
||||
isPinchZoom = false
|
||||
} else {
|
||||
userZoomed = false
|
||||
}
|
||||
|
||||
if userZoomed {
|
||||
parent.onUserZoomChanged?(distance)
|
||||
} else if regionChangeWasUserDriven {
|
||||
parent.onUserCenterChanged(mapView.centerCoordinate, distance)
|
||||
}
|
||||
|
||||
regionChangeWasUserDriven = false
|
||||
}
|
||||
|
||||
private func visibleVerticalDistance(in map: MKMapView) -> CLLocationDistance {
|
||||
let centerX = map.bounds.midX
|
||||
let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map)
|
||||
let south = map.convert(CGPoint(x: centerX, y: map.bounds.maxY), toCoordinateFrom: map)
|
||||
let northLocation = CLLocation(latitude: north.latitude, longitude: north.longitude)
|
||||
let southLocation = CLLocation(latitude: south.latitude, longitude: south.longitude)
|
||||
let measured = northLocation.distance(from: southLocation)
|
||||
return measured.isFinite && measured > 0 ? measured : 1_000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#ifndef PaopaoLocationSpoofer_Bridging_Header_h
|
||||
#define PaopaoLocationSpoofer_Bridging_Header_h
|
||||
#include "wloccore.h"
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PaopaoLocationSpooferApp: App {
|
||||
init() {
|
||||
RuntimeLogger.info("APP", "Lifecycle", "========== App 启动 ==========")
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class ProxyManager: ObservableObject {
|
||||
static let shared = ProxyManager()
|
||||
|
||||
nonisolated let proxyPort = 8888
|
||||
|
||||
@Published private(set) var isRunning = false
|
||||
@Published var error: String?
|
||||
|
||||
private let certificateStore = CertificateAuthorityStore()
|
||||
private var proxyHandle: UInt = 0
|
||||
private var coordinateRevision: UInt64 = 0
|
||||
|
||||
private init() { RuntimeLogger.info("APP", "Proxy", "初始化") }
|
||||
|
||||
func start() async throws {
|
||||
guard !isRunning else { return }
|
||||
RuntimeLogger.info("APP", "Proxy.start", "启动代理 127.0.0.1:8888")
|
||||
do {
|
||||
let authority = try certificateStore.ensure()
|
||||
let settings = WlocSettingsStore.load()
|
||||
let lat = settings.flatMap { $0.enabled ? $0.latitude : nil } ?? 0
|
||||
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
|
||||
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
|
||||
let accuracy = CInt(settings?.accuracy ?? 25)
|
||||
let result: UInt = authority.certPEM.withCString { cp in
|
||||
authority.keyPEM.withCString { kp in
|
||||
UInt(wloccore_startproxy(UnsafeMutablePointer(mutating: cp), UnsafeMutablePointer(mutating: kp), CDouble(lat), CDouble(lon), enabled, accuracy))
|
||||
}
|
||||
}
|
||||
guard result != 0 else { CoreBridge.flushLogs(category: "Proxy"); throw ProxyError.startFailed }
|
||||
proxyHandle = result
|
||||
isRunning = true
|
||||
error = nil
|
||||
BackgroundKeepAlive.shared.start()
|
||||
CoreBridge.flushLogs(category: "Proxy")
|
||||
RuntimeLogger.info("APP", "Proxy.start", "启动成功")
|
||||
} catch {
|
||||
CoreBridge.flushLogs(category: "Proxy")
|
||||
RuntimeLogger.error("APP", "Proxy.start", "启动失败", error: error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isRunning, proxyHandle != 0 else { return }
|
||||
_ = wloccore_stopproxy(proxyHandle)
|
||||
proxyHandle = 0; isRunning = false; error = nil
|
||||
BackgroundKeepAlive.shared.stop()
|
||||
CoreBridge.flushLogs(category: "Proxy")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int = 25) -> UInt64 {
|
||||
coordinateRevision &+= 1
|
||||
wloccore_setcoords(CDouble(lat), CDouble(lon), enabled ? 1 : 0, CInt(accuracy))
|
||||
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
|
||||
"revision": String(coordinateRevision),
|
||||
"enabled": String(enabled),
|
||||
"lat": String(lat),
|
||||
"lon": String(lon)
|
||||
])
|
||||
return coordinateRevision
|
||||
}
|
||||
|
||||
func coordinateSnapshot(accuracy: Int = 25) -> ProxyCoordinateSnapshot {
|
||||
let coordinates = getCoords()
|
||||
return ProxyCoordinateSnapshot(
|
||||
latitude: coordinates.lat,
|
||||
longitude: coordinates.lon,
|
||||
enabled: coordinates.enabled,
|
||||
accuracy: accuracy,
|
||||
revision: coordinateRevision
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setCoordsIfUnchanged(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
enabled: Bool,
|
||||
accuracy: Int = 25,
|
||||
expectedRevision: UInt64
|
||||
) -> UInt64? {
|
||||
guard coordinateRevision == expectedRevision else {
|
||||
RuntimeLogger.info("APP", "Proxy.coords", "跳过过期坐标写入", details: [
|
||||
"expectedRevision": String(expectedRevision),
|
||||
"currentRevision": String(coordinateRevision)
|
||||
])
|
||||
return nil
|
||||
}
|
||||
return setCoords(lat: lat, lon: lon, enabled: enabled, accuracy: accuracy)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreCoords(_ snapshot: ProxyCoordinateSnapshot, ifUnchangedSince revision: UInt64) -> Bool {
|
||||
guard coordinateRevision == revision else {
|
||||
RuntimeLogger.info("APP", "Proxy.coords", "跳过旧验证坐标恢复", details: [
|
||||
"verificationRevision": String(revision),
|
||||
"currentRevision": String(coordinateRevision)
|
||||
])
|
||||
return false
|
||||
}
|
||||
setCoords(
|
||||
lat: snapshot.latitude,
|
||||
lon: snapshot.longitude,
|
||||
enabled: snapshot.enabled,
|
||||
accuracy: snapshot.accuracy
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
func getCoords() -> (lat: Double, lon: Double, enabled: Bool) {
|
||||
let r = wloccore_getcoords()
|
||||
return (Double(r.r0), Double(r.r1), r.r2 != 0)
|
||||
}
|
||||
|
||||
func openCertificateDownload() async {
|
||||
do {
|
||||
if !isRunning { try await start() }
|
||||
// 本地代理直接提供 CA 证书下载
|
||||
guard let url = URL(string: "http://127.0.0.1:8888/cert") else { return }
|
||||
await MainActor.run { UIApplication.shared.open(url) }
|
||||
} catch {
|
||||
self.error = "启动代理失败: \(error.localizedDescription)"
|
||||
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated deinit {
|
||||
let h = proxyHandle
|
||||
if h != 0 { _ = wloccore_stopproxy(h) }
|
||||
}
|
||||
}
|
||||
|
||||
struct ProxyCoordinateSnapshot: Equatable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let enabled: Bool
|
||||
let accuracy: Int
|
||||
let revision: UInt64
|
||||
}
|
||||
|
||||
enum ProxyError: LocalizedError {
|
||||
case startFailed
|
||||
var errorDescription: String? { "Go proxy 启动失败" }
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import Combine
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol RealtimeLocationDriving: AnyObject {
|
||||
var location: CLLocation? { get }
|
||||
var authorizationStatus: CLAuthorizationStatus { get }
|
||||
var delegate: CLLocationManagerDelegate? { get set }
|
||||
func requestWhenInUseAuthorization()
|
||||
func requestLocation()
|
||||
func startUpdatingLocation()
|
||||
func stopUpdatingLocation()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CoreLocationDriver: RealtimeLocationDriving {
|
||||
private let manager: CLLocationManager
|
||||
|
||||
init(manager: CLLocationManager = CLLocationManager()) {
|
||||
self.manager = manager
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
manager.distanceFilter = kCLDistanceFilterNone
|
||||
}
|
||||
|
||||
var location: CLLocation? { manager.location }
|
||||
var authorizationStatus: CLAuthorizationStatus { manager.authorizationStatus }
|
||||
|
||||
var delegate: CLLocationManagerDelegate? {
|
||||
get { manager.delegate }
|
||||
set { manager.delegate = newValue }
|
||||
}
|
||||
|
||||
func requestWhenInUseAuthorization() { manager.requestWhenInUseAuthorization() }
|
||||
func requestLocation() { manager.requestLocation() }
|
||||
func startUpdatingLocation() { manager.startUpdatingLocation() }
|
||||
func stopUpdatingLocation() { manager.stopUpdatingLocation() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
|
||||
static let shared = RealtimeLocationManager(driver: CoreLocationDriver())
|
||||
|
||||
@Published private(set) var location: CLLocation?
|
||||
@Published private(set) var authorizationStatus: CLAuthorizationStatus
|
||||
@Published private(set) var isRequesting = false
|
||||
|
||||
private enum RequestPhase: Equatable {
|
||||
case awaitingAuthorization
|
||||
case oneShot
|
||||
case continuousFallback
|
||||
}
|
||||
|
||||
private struct ActiveRequest {
|
||||
let id: UInt64
|
||||
var startedAt: Date?
|
||||
let continuation: CheckedContinuation<CLLocationCoordinate2D?, Never>
|
||||
var phase: RequestPhase
|
||||
}
|
||||
|
||||
private let driver: RealtimeLocationDriving
|
||||
private let oneShotTimeoutNanoseconds: UInt64
|
||||
private let fallbackTimeoutNanoseconds: UInt64
|
||||
private let cacheMaxAge: TimeInterval
|
||||
private var nextRequestID: UInt64 = 0
|
||||
private var activeRequest: ActiveRequest?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
driver: RealtimeLocationDriving,
|
||||
oneShotTimeoutNanoseconds: UInt64 = 1_500_000_000,
|
||||
fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000,
|
||||
cacheMaxAge: TimeInterval = 20
|
||||
) {
|
||||
self.driver = driver
|
||||
self.oneShotTimeoutNanoseconds = oneShotTimeoutNanoseconds
|
||||
self.fallbackTimeoutNanoseconds = fallbackTimeoutNanoseconds
|
||||
self.cacheMaxAge = cacheMaxAge
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
super.init()
|
||||
driver.delegate = self
|
||||
if let cached = driver.location, Self.isValid(cached) {
|
||||
location = cached
|
||||
}
|
||||
}
|
||||
|
||||
convenience init(driver: RealtimeLocationDriving, timeoutNanoseconds: UInt64) {
|
||||
self.init(
|
||||
driver: driver,
|
||||
oneShotTimeoutNanoseconds: timeoutNanoseconds,
|
||||
fallbackTimeoutNanoseconds: timeoutNanoseconds
|
||||
)
|
||||
}
|
||||
|
||||
func requestLocation() async -> CLLocationCoordinate2D? {
|
||||
guard activeRequest == nil else {
|
||||
RuntimeLogger.warning("APP", "定位", "忽略重复实时定位请求")
|
||||
return nil
|
||||
}
|
||||
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
guard authorizationStatus != .denied, authorizationStatus != .restricted else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let cached = freshestCachedLocation() {
|
||||
location = cached
|
||||
RuntimeLogger.info("APP", "定位", "使用系统缓存实时定位", details: [
|
||||
"age": String(format: "%.2f", max(0, -cached.timestamp.timeIntervalSinceNow)),
|
||||
"lat": String(cached.coordinate.latitude),
|
||||
"lon": String(cached.coordinate.longitude)
|
||||
])
|
||||
return cached.coordinate
|
||||
}
|
||||
|
||||
nextRequestID &+= 1
|
||||
let requestID = nextRequestID
|
||||
isRequesting = true
|
||||
RuntimeLogger.info("APP", "定位", "请求实时定位…", details: ["requestID": String(requestID)])
|
||||
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
activeRequest = ActiveRequest(
|
||||
id: requestID,
|
||||
startedAt: nil,
|
||||
continuation: continuation,
|
||||
phase: .awaitingAuthorization
|
||||
)
|
||||
|
||||
if authorizationStatus == .notDetermined {
|
||||
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
||||
driver.requestWhenInUseAuthorization()
|
||||
} else {
|
||||
beginOneShot(for: requestID)
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.finishRequest(id: requestID, coordinate: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startUpdating() {
|
||||
if authorizationStatus == .notDetermined {
|
||||
driver.requestWhenInUseAuthorization()
|
||||
}
|
||||
driver.startUpdatingLocation()
|
||||
}
|
||||
|
||||
func stopUpdating() {
|
||||
driver.stopUpdatingLocation()
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
MainActor.assumeIsolated {
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
switch authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
if let request = activeRequest, request.phase == .awaitingAuthorization {
|
||||
beginOneShot(for: request.id)
|
||||
}
|
||||
case .denied, .restricted:
|
||||
finishRequest(id: activeRequest?.id, coordinate: nil)
|
||||
case .notDetermined:
|
||||
break
|
||||
@unknown default:
|
||||
finishRequest(id: activeRequest?.id, coordinate: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
MainActor.assumeIsolated {
|
||||
let validLocations = locations.filter(Self.isValid)
|
||||
if let latestValid = validLocations.last {
|
||||
location = latestValid
|
||||
}
|
||||
|
||||
guard let request = activeRequest,
|
||||
let startedAt = request.startedAt,
|
||||
request.phase != .awaitingAuthorization,
|
||||
let latest = validLocations.last(where: {
|
||||
$0.timestamp >= startedAt.addingTimeInterval(-1)
|
||||
}) else {
|
||||
return
|
||||
}
|
||||
finishRequest(id: request.id, coordinate: latest.coordinate)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
MainActor.assumeIsolated {
|
||||
guard let request = activeRequest else { return }
|
||||
RuntimeLogger.warning("APP", "定位", "定位回调失败", details: [
|
||||
"requestID": String(request.id),
|
||||
"error": error.localizedDescription
|
||||
])
|
||||
|
||||
let nsError = error as NSError
|
||||
let isAuthorizationDenied = nsError.domain == kCLErrorDomain
|
||||
&& nsError.code == CLError.denied.rawValue
|
||||
|
||||
if request.phase == .oneShot,
|
||||
!isAuthorizationDenied,
|
||||
authorizationStatus != .denied,
|
||||
authorizationStatus != .restricted {
|
||||
beginFallback(for: request.id)
|
||||
} else {
|
||||
finishRequest(id: request.id, coordinate: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func freshestCachedLocation(now: Date = Date()) -> CLLocation? {
|
||||
[location, driver.location]
|
||||
.compactMap { $0 }
|
||||
.filter(Self.isValid)
|
||||
.filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge }
|
||||
.max(by: { $0.timestamp < $1.timestamp })
|
||||
}
|
||||
|
||||
private static func isValid(_ location: CLLocation) -> Bool {
|
||||
CLLocationCoordinate2DIsValid(location.coordinate)
|
||||
&& location.horizontalAccuracy >= 0
|
||||
}
|
||||
|
||||
private func scheduleTimeout(for requestID: UInt64, nanoseconds: UInt64) {
|
||||
timeoutTask?.cancel()
|
||||
timeoutTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: nanoseconds)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.handleTimeout(for: requestID)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTimeout(for requestID: UInt64) {
|
||||
guard let request = activeRequest, request.id == requestID else { return }
|
||||
switch request.phase {
|
||||
case .awaitingAuthorization:
|
||||
RuntimeLogger.warning("APP", "定位", "等待定位授权超时", details: ["requestID": String(requestID)])
|
||||
finishRequest(id: requestID, coordinate: nil)
|
||||
case .oneShot:
|
||||
RuntimeLogger.warning("APP", "定位", "单次定位超时,切换持续定位", details: ["requestID": String(requestID)])
|
||||
beginFallback(for: requestID)
|
||||
case .continuousFallback:
|
||||
RuntimeLogger.warning("APP", "定位", "持续定位超时", details: ["requestID": String(requestID)])
|
||||
finishRequest(id: requestID, coordinate: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func beginOneShot(for requestID: UInt64) {
|
||||
guard var request = activeRequest,
|
||||
request.id == requestID,
|
||||
request.phase == .awaitingAuthorization else { return }
|
||||
request.phase = .oneShot
|
||||
request.startedAt = Date()
|
||||
activeRequest = request
|
||||
scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds)
|
||||
driver.requestLocation()
|
||||
}
|
||||
|
||||
private func beginFallback(for requestID: UInt64) {
|
||||
guard var request = activeRequest,
|
||||
request.id == requestID,
|
||||
request.phase == .oneShot else { return }
|
||||
request.phase = .continuousFallback
|
||||
activeRequest = request
|
||||
driver.startUpdatingLocation()
|
||||
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
||||
}
|
||||
|
||||
private func finishRequest(id requestID: UInt64?, coordinate: CLLocationCoordinate2D?) {
|
||||
guard let requestID,
|
||||
let request = activeRequest,
|
||||
request.id == requestID else { return }
|
||||
|
||||
timeoutTask?.cancel()
|
||||
timeoutTask = nil
|
||||
if request.phase == .continuousFallback {
|
||||
driver.stopUpdatingLocation()
|
||||
}
|
||||
activeRequest = nil
|
||||
isRequesting = false
|
||||
|
||||
if let coordinate {
|
||||
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
|
||||
"requestID": String(requestID),
|
||||
"lat": String(coordinate.latitude),
|
||||
"lon": String(coordinate.longitude)
|
||||
])
|
||||
} else {
|
||||
RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)])
|
||||
}
|
||||
request.continuation.resume(returning: coordinate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
@ObservedObject var actions: LocationActionCoordinator
|
||||
@ObservedObject private var proxy = ProxyManager.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var activeTip: TipKind?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("状态") {
|
||||
HStack {
|
||||
Label("代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
|
||||
Spacer()
|
||||
Toggle("", isOn: proxyBinding).labelsHidden()
|
||||
.tint(.blue)
|
||||
}
|
||||
HStack {
|
||||
Label("虚拟定位", systemImage: actions.virtualLocationEnabled ? "location.fill" : "location.slash")
|
||||
Spacer()
|
||||
Text(actions.virtualLocationEnabled ? "已开启" : "已关闭").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("说明") {
|
||||
Button {
|
||||
activeTip = .activation
|
||||
} label: {
|
||||
Label("生效说明", systemImage: "checklist")
|
||||
}
|
||||
Button {
|
||||
activeTip = .deactivation
|
||||
} label: {
|
||||
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
|
||||
}
|
||||
Button {
|
||||
activeTip = .removeProxy
|
||||
} label: {
|
||||
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
|
||||
}
|
||||
}
|
||||
|
||||
Section("工作原理") {
|
||||
Text("""
|
||||
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
||||
|
||||
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("应用") {
|
||||
Button {
|
||||
setup.needsSetup = true
|
||||
} label: {
|
||||
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
valueRow("版本", value: versionText)
|
||||
}
|
||||
|
||||
Section("支持") {
|
||||
NavigationLink {
|
||||
BugReportView(setup: setup)
|
||||
} label: {
|
||||
Label("报告 Issue", systemImage: "ladybug")
|
||||
}
|
||||
}
|
||||
|
||||
Section("关于") {
|
||||
Button {
|
||||
if let url = URL(string: "https://github.com/xweiba/location-spoofer") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("xweiba/location-spoofer", systemImage: "link")
|
||||
}
|
||||
Text("如果觉得好用,欢迎去 GitHub 给项目点个 Star")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("致谢") {
|
||||
Button {
|
||||
if let url = URL(string: "https://github.com/Yu9191/wloc") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("核心定位改写逻辑移植自 Yu9191/wloc", systemImage: "heart.fill")
|
||||
.foregroundStyle(.pink)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("设置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { Button("完成") { dismiss() } }
|
||||
}
|
||||
.sheet(item: $activeTip) { kind in
|
||||
TipSheetView(kind: kind)
|
||||
}
|
||||
}
|
||||
|
||||
private func valueRow(_ title: String, value: String) -> some View {
|
||||
HStack { Text(title); Spacer(); Text(value).font(.footnote.monospaced()).foregroundStyle(.secondary) }
|
||||
}
|
||||
|
||||
private var versionText: String {
|
||||
let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
|
||||
let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
|
||||
return "\(v) (\(b))"
|
||||
}
|
||||
|
||||
private var proxyBinding: Binding<Bool> {
|
||||
Binding(get: { proxy.isRunning }, set: { on in
|
||||
Task {
|
||||
if on {
|
||||
do { try await proxy.start() } catch { proxy.error = error.localizedDescription }
|
||||
} else {
|
||||
proxy.stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class SetupCoordinator: ObservableObject {
|
||||
@Published private(set) var trustState: CertificateTrustState = .checking
|
||||
@Published var message = ""
|
||||
@Published var isBrowsingWithoutTrust = false
|
||||
@Published var testLog = ""
|
||||
// 启动检测失败才弹引导页;检测通过则保持 false
|
||||
@Published var needsSetup = false
|
||||
|
||||
let certificateStore = CertificateAuthorityStore()
|
||||
let proxy = ProxyManager.shared
|
||||
private var isVerificationRunning = false
|
||||
|
||||
init() { RuntimeLogger.info("APP", "Setup", "初始化") }
|
||||
|
||||
private var appVersion: String {
|
||||
let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
|
||||
let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
|
||||
return "\(v) (\(b))"
|
||||
}
|
||||
|
||||
var canModify: Bool { proxy.isRunning && trustState == .trusted }
|
||||
|
||||
func refreshTrust() async {
|
||||
trustState = .checking
|
||||
message = "正在检测…"
|
||||
testLog = ""
|
||||
do {
|
||||
_ = try certificateStore.ensure()
|
||||
if !proxy.isRunning { try await proxy.start() }
|
||||
// 强制走代理 POST 到 Apple 定位接口:TLS 成功 = CA 信任
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 5
|
||||
config.timeoutIntervalForResource = 8
|
||||
config.connectionProxyDictionary = [
|
||||
kCFNetworkProxiesHTTPEnable as String: true,
|
||||
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
|
||||
kCFNetworkProxiesHTTPPort as String: 8888,
|
||||
]
|
||||
// 用当前保存的虚拟定位坐标做测试;没有则用默认坐标
|
||||
let saved = WlocSettingsStore.load()
|
||||
let testLat = saved?.latitude ?? 22.543099
|
||||
let testLon = saved?.longitude ?? 113.934576
|
||||
let testAccuracy = saved?.accuracy ?? 25
|
||||
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
|
||||
}
|
||||
// 走同一套改写验证:Go Core 内模拟 Apple 响应并确认坐标被改写
|
||||
let patchResult = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: testAccuracy)
|
||||
if patchResult.hasPrefix("ok:") {
|
||||
trustState = .trusted
|
||||
message = "✓ 定位环境正常(返回 \(status))"
|
||||
} else {
|
||||
trustState = .unavailable
|
||||
message = "定位数据改写验证失败:\(patchResult)"
|
||||
}
|
||||
} catch {
|
||||
trustState = .unavailable
|
||||
let ns = error as NSError
|
||||
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
|
||||
message = "CA 证书未信任,请去「设置→通用→关于→证书信任设置」开启或重新安装"
|
||||
} else if ns.domain == NSURLErrorDomain && ns.code == -1001 {
|
||||
message = "检测超时,请检查代理是否正常"
|
||||
} else if ns.domain == NSURLErrorDomain && ns.code == -1200 {
|
||||
message = "代理未启动或无法连接"
|
||||
} else {
|
||||
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
|
||||
}
|
||||
}
|
||||
needsSetup = !canModify
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive() {}
|
||||
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
|
||||
func completeSetup() { needsSetup = false }
|
||||
func requestSetup() { needsSetup = true }
|
||||
|
||||
// MARK: - Step-by-step verification test
|
||||
|
||||
private func makeWlocRequest() -> URLRequest {
|
||||
var req = URLRequest(url: URL(string: "https://gs-loc.apple.com/clls/wloc")!)
|
||||
req.httpMethod = "POST"
|
||||
req.httpBody = CoreBridge.testWlocRequestData()
|
||||
req.setValue("application/x-protobuf", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("wloc/1.0", forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("application/x-protobuf", forHTTPHeaderField: "Accept")
|
||||
return req
|
||||
}
|
||||
|
||||
func runVerificationTest(testLat: Double = 22.543099, testLon: Double = 113.934576) async -> VerificationResult {
|
||||
guard !isVerificationRunning else { return .verificationInProgress }
|
||||
isVerificationRunning = true
|
||||
defer { isVerificationRunning = false }
|
||||
|
||||
testLog = ""
|
||||
let log = { (msg: String) in self.testLog += msg + "\n" }
|
||||
|
||||
log("======== 代理验证测试 ========")
|
||||
log("App 版本: \(appVersion)")
|
||||
log("系统版本: iOS \(UIDevice.current.systemVersion)")
|
||||
log("测试目标: lat=\(testLat), lon=\(testLon)")
|
||||
log("")
|
||||
|
||||
// Step A: Proxy running
|
||||
log("[步骤 A] 检查代理是否运行…")
|
||||
log(" 端口: 127.0.0.1:8888")
|
||||
let stepAStart = Date()
|
||||
if !proxy.isRunning {
|
||||
log(" ⚠ 代理未运行,尝试启动…")
|
||||
do { try await proxy.start() } catch {
|
||||
log(" ✗ 启动失败: \(error.localizedDescription)")
|
||||
return .proxyNotRunning
|
||||
}
|
||||
log(" ✓ 代理启动成功")
|
||||
} else {
|
||||
log(" ✓ 代理已在运行中")
|
||||
}
|
||||
collectProxyLogs(since: stepAStart, to: log)
|
||||
|
||||
// Verification must never overwrite a newer location action.
|
||||
let previousCoordinates = proxy.coordinateSnapshot(
|
||||
accuracy: WlocSettingsStore.load()?.accuracy ?? 25
|
||||
)
|
||||
var verificationCoordinateRevision: UInt64?
|
||||
defer {
|
||||
if let revision = verificationCoordinateRevision {
|
||||
let restored = proxy.restoreCoords(previousCoordinates, ifUnchangedSince: revision)
|
||||
log(restored ? " ↩ 已恢复验证前的代理坐标" : " ↩ 检测到更新位置,跳过旧坐标恢复")
|
||||
}
|
||||
}
|
||||
|
||||
// Step B: Combined CA + WiFi proxy check (single request)
|
||||
log("")
|
||||
log("[步骤 B] 检测证书与 WiFi 代理…")
|
||||
log(" 方式: 请求 baidu.com/paopao-verify-<token>")
|
||||
log(" 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过")
|
||||
let stepBStart = Date()
|
||||
let verifyToken = CoreBridge.refreshVerifyToken()
|
||||
guard !verifyToken.isEmpty else {
|
||||
log(" ✗ 无法生成验证 token")
|
||||
return .certNotTrusted
|
||||
}
|
||||
do {
|
||||
let url = URL(string: "https://www.baidu.com/paopao-verify-\(verifyToken)")!
|
||||
var req = URLRequest(url: url)
|
||||
req.timeoutInterval = 8
|
||||
req.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
let (data, resp) = try await URLSession(configuration: config).data(for: req)
|
||||
let statusCode = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
if body == verifyToken {
|
||||
log(" ✓ 证书已信任,WiFi 代理已配置")
|
||||
} else {
|
||||
log(" ✗ 响应不匹配 (HTTP \(statusCode)),WiFi 代理未配置")
|
||||
log(" 收到: \(body.prefix(100))")
|
||||
return .wifiProxyNotConfigured
|
||||
}
|
||||
} catch {
|
||||
let ns = error as NSError
|
||||
let msg = error.localizedDescription
|
||||
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)")
|
||||
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
|
||||
log(" TLS 握手被拒,CA 证书未信任")
|
||||
return .certNotTrusted
|
||||
}
|
||||
if msg.contains("TLS") {
|
||||
log(" 包含 TLS → 证书问题")
|
||||
return .certNotTrusted
|
||||
}
|
||||
return .wifiProxyNotConfigured
|
||||
}
|
||||
collectProxyLogs(since: stepBStart, to: log)
|
||||
|
||||
// Step C: Write and verify coordinates
|
||||
log("[步骤 C] 写入测试坐标并验证…")
|
||||
guard let testCoordinateRevision = proxy.setCoordsIfUnchanged(
|
||||
lat: testLat,
|
||||
lon: testLon,
|
||||
enabled: true,
|
||||
accuracy: 25,
|
||||
expectedRevision: previousCoordinates.revision
|
||||
) else {
|
||||
log(" ↪ 检测到更新位置,取消过期验证坐标写入")
|
||||
return .verificationSuperseded
|
||||
}
|
||||
verificationCoordinateRevision = testCoordinateRevision
|
||||
let coords = proxy.getCoords()
|
||||
if coords.enabled && abs(coords.lat - testLat) < 0.001 && abs(coords.lon - testLon) < 0.001 {
|
||||
log(" ✓ 坐标写入成功: lat=\(coords.lat) lon=\(coords.lon)")
|
||||
} else {
|
||||
log(" ✗ 坐标验证失败: enabled=\(coords.enabled) lat=\(coords.lat) lon=\(coords.lon)")
|
||||
return .coordinateWriteFailed("写入后回读不一致")
|
||||
}
|
||||
|
||||
// Step D: verify data rewriting via Go test patch
|
||||
log("[步骤 D] 验证定位数据改写…")
|
||||
let result = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: 25)
|
||||
log(" \(result)")
|
||||
if result.hasPrefix("ok:") {
|
||||
log(" ✓ 定位数据改写验证通过")
|
||||
} else {
|
||||
log(" ✗ 定位数据改写失败")
|
||||
return .patchFailed(result)
|
||||
}
|
||||
|
||||
log("")
|
||||
log("======== 环境检测通过 ✓ ========")
|
||||
return .success
|
||||
}
|
||||
|
||||
/// 拉取 Go 代理的详细日志(CONNECT/请求/上游响应/改写结果)到 testLog
|
||||
private func collectProxyLogs(since date: Date, to log: (String) -> Void) {
|
||||
CoreBridge.flushLogs(category: "Proxy")
|
||||
let entries = RuntimeLogStore.loadAll(limit: 200)
|
||||
let proxyEntries = entries.filter {
|
||||
$0.source == "CORE" && $0.category == "Proxy" && $0.timestamp >= date
|
||||
}
|
||||
guard !proxyEntries.isEmpty else { return }
|
||||
log(" --- 代理日志 ---")
|
||||
for e in proxyEntries {
|
||||
log(" " + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum SystemSettingsDestination {
|
||||
case appPermissions
|
||||
case general
|
||||
case wifi
|
||||
case locationServices
|
||||
|
||||
var preferredURL: URL? {
|
||||
let value: String
|
||||
switch self {
|
||||
case .appPermissions:
|
||||
value = UIApplication.openSettingsURLString
|
||||
case .general:
|
||||
value = "App-Prefs:General"
|
||||
case .wifi:
|
||||
value = "App-Prefs:WIFI"
|
||||
case .locationServices:
|
||||
value = "App-Prefs:Privacy&path=LOCATION"
|
||||
}
|
||||
return URL(string: value)
|
||||
}
|
||||
|
||||
var manualPath: String {
|
||||
switch self {
|
||||
case .appPermissions:
|
||||
return "请手动打开「设置」,找到本 App 后检查定位权限。"
|
||||
case .general:
|
||||
return "请手动打开「设置 → 通用」。"
|
||||
case .wifi:
|
||||
return "请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。"
|
||||
case .locationServices:
|
||||
return "请手动打开「设置 → 隐私与安全性 → 定位服务」。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum SystemSettingsNavigator {
|
||||
static func open(
|
||||
_ destination: SystemSettingsDestination,
|
||||
completion: @escaping @MainActor @Sendable (String?) -> Void = { _ in }
|
||||
) {
|
||||
let appSettingsURL = URL(string: UIApplication.openSettingsURLString)
|
||||
let preferredURL = destination.preferredURL
|
||||
|
||||
openURL(preferredURL) { openedPreferred in
|
||||
guard !openedPreferred else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard preferredURL != appSettingsURL else {
|
||||
completion(destination.manualPath)
|
||||
return
|
||||
}
|
||||
openURL(appSettingsURL) { openedFallback in
|
||||
completion(openedFallback ? nil : destination.manualPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func openURL(_ url: URL?, completion: @escaping @MainActor @Sendable (Bool) -> Void) {
|
||||
guard let url else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
UIApplication.shared.open(url, options: [:]) { opened in
|
||||
completion(opened)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import SwiftUI
|
||||
|
||||
enum TipKind: String, Identifiable {
|
||||
case activation = "生效说明"
|
||||
case deactivation = "失效说明"
|
||||
case removeProxy = "关闭 WiFi 代理"
|
||||
case certificate = "证书问题"
|
||||
case proxySetup = "配置代理"
|
||||
case rewriteFailed = "改写失败"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct TipSheetView: View {
|
||||
let kind: TipKind
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
switch kind {
|
||||
case .activation: ActivationTipContent(dismiss: { dismiss() })
|
||||
case .deactivation: DeactivationTipContent(dismiss: { dismiss() })
|
||||
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
|
||||
case .certificate: CertificateTipContent(dismiss: { dismiss() })
|
||||
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
|
||||
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
|
||||
}
|
||||
}.padding(16)
|
||||
}
|
||||
.navigationTitle(kind.rawValue).navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Button { dismiss() } label: {
|
||||
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue).padding(.horizontal, 16).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func openSettings(_ destination: SystemSettingsDestination) {
|
||||
guard let appSettingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
// Try the preferred (private) URL scheme first; fall back to reliable app-settings:
|
||||
if let preferredURL = destination.preferredURL, preferredURL != appSettingsURL {
|
||||
UIApplication.shared.open(preferredURL) { opened in
|
||||
if !opened {
|
||||
UIApplication.shared.open(appSettingsURL)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UIApplication.shared.open(appSettingsURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 共享组件
|
||||
|
||||
private struct TipCloseButton: View {
|
||||
let action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 生效说明
|
||||
|
||||
struct ActivationTipContent: View {
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("让虚拟定位生效", systemImage: "checklist")) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
step(1, "开启飞行模式", "从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。")
|
||||
step(2, "关闭 Wi‑Fi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。")
|
||||
systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。")
|
||||
step(4, "打开 Wi‑Fi,启动虚拟定位", "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
|
||||
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
|
||||
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。")
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
|
||||
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。")
|
||||
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func step(_ n: Int, _ title: String, _ detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(n)").font(.caption2.bold())
|
||||
.frame(width: 20, height: 20)
|
||||
.background(Color.blue.opacity(0.15), in: Circle()).foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.caption.weight(.semibold))
|
||||
Text(detail).font(.caption2).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func systemStep(_ n: Int, _ title: String, _ detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(n)").font(.caption2.bold())
|
||||
.frame(width: 20, height: 20)
|
||||
.background(Color.orange.opacity(0.18), in: Circle()).foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title).font(.caption.weight(.semibold))
|
||||
Text(detail).font(.caption2).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
||||
Button { openSettings(.locationServices) } label: {
|
||||
Label("去设置", systemImage: "arrow.up.right.square").font(.caption)
|
||||
}.buttonStyle(.bordered).tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 失效说明
|
||||
|
||||
struct DeactivationTipContent: View {
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("取消虚拟定位", systemImage: "arrow.uturn.backward.circle")) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
step(1, "开启飞行模式", "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。")
|
||||
step(2, "关闭 Wi‑Fi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
|
||||
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
|
||||
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
|
||||
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
|
||||
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。")
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
|
||||
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。").font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func step(_ n: Int, _ title: String, _ detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(n)").font(.caption2.bold())
|
||||
.frame(width: 20, height: 20)
|
||||
.background(Color.blue.opacity(0.15), in: Circle()).foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.caption.weight(.semibold))
|
||||
Text(detail).font(.caption2).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func systemStep(_ n: Int, _ title: String, _ detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(n)").font(.caption2.bold())
|
||||
.frame(width: 20, height: 20)
|
||||
.background(Color.orange.opacity(0.18), in: Circle()).foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title).font(.caption.weight(.semibold))
|
||||
Text(detail).font(.caption2).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
||||
Button { openSettings(.locationServices) } label: {
|
||||
Label("去设置", systemImage: "arrow.up.right.square").font(.caption)
|
||||
}.buttonStyle(.bordered).tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 移除 WiFi 代理
|
||||
|
||||
struct RemoveProxyTipContent: View {
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("移除代理配置", systemImage: "wifi.slash")) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("停止虚拟定位后,需要手动移除 WiFi 代理配置,否则可能无法上网。\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前 WiFi 右侧 (i) 图标\n3. 找到「HTTP 代理」\n4. 选择「关闭」\n5. 点右上角「存储」")
|
||||
.font(.caption).foregroundStyle(.primary)
|
||||
Button { openSettings(.wifi) } label: {
|
||||
Label("去设置", systemImage: "arrow.up.right.square").font(.caption)
|
||||
}.buttonStyle(.bordered).tint(.blue)
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 证书问题
|
||||
|
||||
struct CertificateTipContent: View {
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("证书未安装或未信任", systemImage: "lock.shield")) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("代理的 HTTPS 请求被系统拦截了,原因是 CA 证书未完成安装或信任。\n\n请依次检查:\n1. 打开「设置 → 通用 → VPN与设备管理」,确认 WLOC CA 证书已安装。如果没有,请删除旧证书后回到 App 重新下载安装\n2. 打开「设置 → 通用 → 关于本机 → 证书信任设置」,找到 WLOC CA 开启开关\n\n⚠️ 每次重装 App 都需要重新下载安装证书。如报 TLS 错误,请删除旧证书后重装。")
|
||||
.font(.caption).foregroundStyle(.primary)
|
||||
Button { openSettings(.general) } label: {
|
||||
Label("去设置", systemImage: "arrow.up.right.square").font(.caption)
|
||||
}.buttonStyle(.bordered).tint(.blue)
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WiFi 代理配置
|
||||
|
||||
struct ProxySetupTipContent: View {
|
||||
@State private var copied = false
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("WiFi 代理未配置", systemImage: "wifi")) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("系统定位请求没有经过本地代理,请按以下步骤配置:\n\n1. 打开「设置 → 无线局域网」\n2. 确认已连接到正确的 WiFi(不是蜂窝数据)\n3. 点击当前 WiFi 右侧 (i) 图标\n4. 滑到底部「HTTP 代理」→ 选择「手动」\n5. 在「服务器」填写下面的地址,端口填写 8888\n6. 点右上角「存储」")
|
||||
.font(.caption).foregroundStyle(.primary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("127.0.0.1:8888").font(.caption.monospaced().bold()).foregroundStyle(.blue)
|
||||
Spacer()
|
||||
Button {
|
||||
UIPasteboard.general.string = "127.0.0.1:8888"
|
||||
copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
|
||||
} label: {
|
||||
Label(copied ? "已复制" : "复制地址", systemImage: copied ? "checkmark" : "doc.on.doc").font(.caption)
|
||||
}.buttonStyle(.bordered).tint(copied ? .green : .blue)
|
||||
}.padding(10).background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Button { openSettings(.wifi) } label: {
|
||||
Text("去设置 WiFi 代理").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 改写失败
|
||||
|
||||
struct RewriteFailedTipContent: View {
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("坐标改写失败", systemImage: "exclamationmark.triangle")) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("虚拟定位已连接,但坐标替换没有成功。可能原因:\n\n• 代理刚启动不久,数据还没开始改写(等待几秒后重试)\n• CA 证书与当前 App 版本不匹配\n• iOS 系统安全策略限制\n\n建议操作:\n1. 完全停止虚拟定位,再重新开启\n2. 删除旧证书,重新生成并安装\n3. 在诊断页查看详细日志")
|
||||
.font(.caption).foregroundStyle(.primary)
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user