release: PaopaoLocationSpoofer v1.0.0

- iOS 虚拟定位工具,基于本地 HTTP 代理 MITM 方案
- MapKit 原生地图体验,支持搜索、收藏、实时定位
- 完整的设置引导流程(证书安装、WiFi 代理配置、环境验证)
- 支持 iOS 15+,SwiftUI 构建
This commit is contained in:
xweiba
2026-08-05 15:45:24 +08:00
commit c8aba2be78
75 changed files with 7339 additions and 0 deletions
+124
View File
@@ -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
}
}
}
+8
View File
@@ -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)
}
}
+71
View File
@@ -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 }
}
}
}
}
}
}
+204
View File
@@ -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() }
}
+301
View File
@@ -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 }
}
}
}
+91
View File
@@ -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)
}
}
+908
View File
@@ -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)
}
}
}
}
+332
View File
@@ -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
}
}
+178
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct PaopaoLocationSpooferApp: App {
init() {
RuntimeLogger.info("APP", "Lifecycle", "========== App 启动 ==========")
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
+149
View File
@@ -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 启动失败" }
}
+301
View File
@@ -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)
}
}
+126
View File
@@ -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()
}
}
})
}
}
+234
View File
@@ -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)
}
}
}
+72
View File
@@ -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)
}
}
}
+251
View File
@@ -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, "关闭 WiFi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。")
step(4, "打开 WiFi,启动虚拟定位", "从控制中心打开 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, "关闭 WiFi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
systemStep(4, "打开 WiFi,移除代理", "从控制中心打开 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)
}
}
}