release: PaopaoLocationSpoofer v1.0.0

- iOS 虚拟定位工具,基于本地 HTTP 代理 MITM 方案
- MapKit 原生地图体验,支持搜索、收藏、实时定位
- 完整的设置引导流程(证书安装、WiFi 代理配置、环境验证)
- 支持 iOS 15+,SwiftUI 构建
This commit is contained in:
xweiba
2026-08-05 15:39:57 +08:00
commit e137ff10e9
75 changed files with 7339 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
name: Build and Release IPA
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Install XcodeGen
run: brew install xcodegen
- name: Build unsigned IPA
run: ./build.sh
- name: Rename IPA for release
run: mv dist/PaopaoLocationSpoofer-unsigned.ipa dist/Location-Spoofer-unsigned.ipa
- name: Read release notes
id: changelog
run: |
VERSION="${{ github.ref_name }}"
NOTES_FILE="docs/releases/${VERSION}.md"
if [ -f "$NOTES_FILE" ]; then
cat "$NOTES_FILE" > /tmp/release_notes.md
else
echo "⚠️ $NOTES_FILE 未找到,请创建该文件。" > /tmp/release_notes.md
fi
{
echo "notes<<EOF"
cat /tmp/release_notes.md
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: "${{ github.ref_name }}"
body_path: /tmp/release_notes.md
files: dist/Location-Spoofer-unsigned.ipa
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+27
View File
@@ -0,0 +1,27 @@
.DS_Store
.superpowers/
build/
dist/
DerivedData/
xcuserdata/
*.xcuserstate
*.ipa
*.a
*.o
*.log
Core/vendor/
Core/wloccore.h
Wloc.xcodeproj/
.worktrees/
/PaopaoLocationSpoofer.xcodeproj/
# Local AI/editor/workflow metadata and other hidden directories
.*/
!.gitignore
!.github/
.github/*
!.github/workflows/
!.github/workflows/**
Core/core
docs/superpowers/
+21
View File
@@ -0,0 +1,21 @@
<!-- TRELLIS:START -->
# Trellis Instructions
These instructions are for AI assistants working in this project.
This project is managed by Trellis. The working knowledge you need lives under `.trellis/`:
- `.trellis/workflow.md` — development phases, when to create tasks, skill routing
- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer)
- `.trellis/workspace/` — per-developer journals and session traces
- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context)
If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command.
If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in:
- `.agents/skills/` — reusable Trellis skills
- `.codex/agents/` — optional custom subagents
Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`.
<!-- TRELLIS:END -->
+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)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
PRODUCT_NAME=PaopaoLocationSpoofer
APP_DISPLAY_NAME=Location Spoofer
APP_BUNDLE_ID=com.paopaolabs.location-spoofer
APP_GROUP_ID=group.com.paopaolabs.location-spoofer
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_REQUIRED=NO
+225
View File
@@ -0,0 +1,225 @@
package main
/*
#cgo CFLAGS: -DGOOS_ios -DNDEBUG
#include <stdlib.h>
#include <stdint.h>
*/
import "C"
import (
"bytes"
"encoding/binary"
"fmt"
"math"
"net"
"net/http"
"runtime/cgo"
"strconv"
)
//export wloccore_init
func wloccore_init() {}
//export wloccore_hello
func wloccore_hello() {}
//export wloccore_version
func wloccore_version() *C.char {
return C.CString("0.1.0")
}
//export wloccore_generateca
func wloccore_generateca() (r0, r1 *C.char) {
logEvent("generateca started")
cert, key, err := generateCA()
if err != nil {
logEvent("generateca failed: " + err.Error())
return nil, nil
}
logEvent("generateca completed")
return C.CString(string(cert)), C.CString(string(key))
}
//export wloccore_startproxy
func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t {
if certData == nil || keyData == nil {
return 0
}
srv, err := startProxy(
[]byte(C.GoString(certData)),
[]byte(C.GoString(keyData)),
float64(lat),
float64(lon),
enabled != 0,
int(accuracy),
)
if err != nil {
logEvent("startproxy failed: " + err.Error())
return 0
}
return C.uintptr_t(cgo.NewHandle(srv))
}
//export wloccore_stopproxy
func wloccore_stopproxy(h C.uintptr_t) C.int {
logEvent("stopproxy requested")
handle := cgo.Handle(h)
srv, ok := handle.Value().(*http.Server)
handle.Delete()
if !ok {
logEvent("stopproxy failed: invalid handle")
return 1
}
if err := stopProxy(srv); err != nil {
logEvent("stopproxy failed: " + err.Error())
return 2
}
logEvent("stopproxy completed")
return 0
}
//export wloccore_setcoords
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
stateMu.Lock()
currentLat = float64(lat)
currentLon = float64(lon)
currentEnabled = enabled != 0
currentAccuracy = int(accuracy)
stateMu.Unlock()
logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " lat=" + strconv.FormatFloat(float64(lat), 'f', 6, 64) + " lon=" + strconv.FormatFloat(float64(lon), 'f', 6, 64) + " accuracy=" + strconv.Itoa(int(accuracy)))
}
//export wloccore_getcoords
func wloccore_getcoords() (lat, lon C.double, enabled C.int) {
stateMu.Lock()
defer stateMu.Unlock()
lat = C.double(currentLat)
lon = C.double(currentLon)
enabled = 0
if currentEnabled {
enabled = 1
}
return lat, lon, enabled
}
//export wloccore_drainlogs
func wloccore_drainlogs() *C.char {
s := drainLogs()
if s == "" {
return nil
}
return C.CString(s)
}
//export wloccore_startcertserver
func wloccore_startcertserver(certData, keyData *C.char) C.uintptr_t {
if certData == nil || keyData == nil {
return 0
}
logEvent("start certificate server requested")
server, err := startCertificateServer([]byte(C.GoString(certData)), []byte(C.GoString(keyData)))
if err != nil {
logEvent("start certificate server failed: " + err.Error())
return 0
}
logEvent("certificate server started http=" + server.DownloadURL() + " probe=" + server.ProbeURL())
return C.uintptr_t(cgo.NewHandle(server))
}
func certificateServerForHandle(h C.uintptr_t) (*certificateServer, cgo.Handle, bool) {
if h == 0 {
return nil, 0, false
}
handle := cgo.Handle(h)
server, ok := handle.Value().(*certificateServer)
return server, handle, ok
}
//export wloccore_certserver_httpport
func wloccore_certserver_httpport(h C.uintptr_t) C.int {
server, _, ok := certificateServerForHandle(h)
if !ok {
return 0
}
return C.int(server.httpLn.Addr().(*net.TCPAddr).Port)
}
//export wloccore_certserver_httpsport
func wloccore_certserver_httpsport(h C.uintptr_t) C.int {
server, _, ok := certificateServerForHandle(h)
if !ok {
return 0
}
return C.int(server.httpsLn.Addr().(*net.TCPAddr).Port)
}
//export wloccore_certserver_leafsha256
func wloccore_certserver_leafsha256(h C.uintptr_t) *C.char {
server, _, ok := certificateServerForHandle(h)
if !ok {
return nil
}
return C.CString(server.LeafSHA256())
}
//export wloccore_stopcertserver
func wloccore_stopcertserver(h C.uintptr_t) C.int {
server, handle, ok := certificateServerForHandle(h)
if !ok {
return 1
}
handle.Delete()
if err := server.Close(); err != nil {
return 2
}
return 0
}
//export wloccore_testpatch
func wloccore_testpatch(lat, lon C.double, accuracy C.int) *C.char {
c := wlocCoords{Latitude: float64(lat), Longitude: float64(lon), Accuracy: int(accuracy)}
original := makeTestWlocBody()
patched, stats, err := patchWlocBody(original, c)
if err != nil {
return C.CString("error: " + err.Error())
}
if stats.Locations == 0 {
return C.CString("error: no location entries found")
}
if len(patched) < 10 {
return C.CString("error: patched body too short")
}
newLen := int(binary.BigEndian.Uint16(patched[8:10]))
if newLen <= 0 || 10+newLen > len(patched) {
return C.CString("error: invalid patched length")
}
newPayload := patched[10 : 10+newLen]
wantLat := append(writeTag(1, wireVarint), writeVarint(uint64(int64(math.Round(c.Latitude*1e8))))...)
wantLon := append(writeTag(2, wireVarint), writeVarint(uint64(int64(math.Round(c.Longitude*1e8))))...)
if !bytes.Contains(newPayload, wantLat) {
return C.CString("error: patched latitude mismatch")
}
if !bytes.Contains(newPayload, wantLon) {
return C.CString("error: patched longitude mismatch")
}
return C.CString(fmt.Sprintf("ok: lat=%f lon=%f wifi=%d cell=%d locations=%d", c.Latitude, c.Longitude, stats.WiFi, stats.Cell, stats.Locations))
}
//export wloccore_testrequesthex
func wloccore_testrequesthex() *C.char {
return C.CString(fmt.Sprintf("%x", makeTestWlocRequest()))
}
//export wloccore_refreshverifytoken
func wloccore_refreshverifytoken() *C.char {
return C.CString(refreshVerifyToken())
}
//export wloccore_checkverifytoken
func wloccore_checkverifytoken(token *C.char) C.int {
if checkVerifyToken(C.GoString(token)) {
return 1
}
return 0
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/binary"
"encoding/pem"
"io"
"math/big"
"math/rand"
"time"
)
func deterministicReader() io.Reader {
seed := sha256.Sum256([]byte("paopao-location-spoofer-ca-v1"))
src := rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8])))
return rand.New(src)
}
func generateCA() (certPEM, keyPEM []byte, err error) {
rng := deterministicReader()
privateKey, err := rsa.GenerateKey(rng, 2048)
if err != nil {
return nil, nil, err
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"WLOC"},
CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"),
},
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2045, 1, 1, 0, 0, 0, 0, time.UTC),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
}
certDER, err := x509.CreateCertificate(rng, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, nil, err
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, nil, err
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
func parseCA(certPEM, keyPEM []byte) (*tls.Certificate, error) {
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
if cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]); err != nil {
return nil, err
}
return &cert, nil
}
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"crypto/x509"
"encoding/pem"
"testing"
"time"
)
func TestGenerateCA(t *testing.T) {
certPEM, keyPEM, err := generateCA()
if err != nil {
t.Fatal(err)
}
if len(certPEM) == 0 || len(keyPEM) == 0 {
t.Fatal("empty CA output")
}
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("invalid cert PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
if !cert.IsCA {
t.Fatal("certificate is not a CA")
}
if time.Until(cert.NotAfter).Hours() < 360*24 {
t.Fatal("CA validity is too short")
}
if _, err := parseCA(certPEM, keyPEM); err != nil {
t.Fatal(err)
}
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"math/big"
"net"
"net/http"
"sync"
"time"
)
// certificateServer exposes a device-specific CA download before the Packet
// Tunnel starts and a TLS endpoint whose successful default validation proves
// that iOS has installed and fully trusted that CA.
type certificateServer struct {
httpServer *http.Server
httpsServer *http.Server
httpLn net.Listener
httpsLn net.Listener
leafSHA256 string
closeOnce sync.Once
closeErr error
}
func startCertificateServer(caCertPEM, caKeyPEM []byte) (*certificateServer, error) {
ca, err := parseCA(caCertPEM, caKeyPEM)
if err != nil {
return nil, err
}
if !ca.Leaf.IsCA {
return nil, errors.New("certificate server requires a CA certificate")
}
leaf, leafDER, err := issueLoopbackLeaf(ca)
if err != nil {
return nil, err
}
rootDER := ca.Leaf.Raw
httpLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
httpsLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
_ = httpLn.Close()
return nil, err
}
downloadMux := http.NewServeMux()
downloadMux.HandleFunc("/ca.cer", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
w.Header().Set("Content-Disposition", `attachment; filename="LocationSpoofer-CA.cer"`)
_, _ = w.Write(rootDER)
})
probeMux := http.NewServeMux()
probeMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
_, _ = w.Write([]byte("ok\n"))
})
server := &certificateServer{
httpServer: &http.Server{Handler: downloadMux},
httpsServer: &http.Server{Handler: probeMux},
httpLn: httpLn,
httpsLn: httpsLn,
leafSHA256: sha256Base64(leafDER),
}
go func() {
if err := server.httpServer.Serve(httpLn); err != nil && !errors.Is(err, http.ErrServerClosed) {
logEvent("certificate download server error: " + err.Error())
}
}()
go func() {
tlsListener := tls.NewListener(httpsLn, &tls.Config{Certificates: []tls.Certificate{leaf}, MinVersion: tls.VersionTLS12})
if err := server.httpsServer.Serve(tlsListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
logEvent("certificate probe server error: " + err.Error())
}
}()
return server, nil
}
func issueLoopbackLeaf(ca *tls.Certificate) (tls.Certificate, []byte, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, nil, err
}
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
return tls.Certificate{}, nil, err
}
now := time.Now()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "Location Spoofer Local Trust Probe"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, template, ca.Leaf, &privateKey.PublicKey, ca.PrivateKey)
if err != nil {
return tls.Certificate{}, nil, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return tls.Certificate{}, nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
leaf, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return tls.Certificate{}, nil, err
}
leaf.Leaf, err = x509.ParseCertificate(der)
if err != nil {
return tls.Certificate{}, nil, err
}
return leaf, der, nil
}
func sha256Base64(data []byte) string {
sum := sha256.Sum256(data)
return base64.StdEncoding.EncodeToString(sum[:])
}
func (s *certificateServer) DownloadURL() string {
return "http://" + s.httpLn.Addr().String() + "/ca.cer"
}
func (s *certificateServer) ProbeURL() string {
return "https://" + s.httpsLn.Addr().String() + "/health"
}
func (s *certificateServer) LeafSHA256() string { return s.leafSHA256 }
func (s *certificateServer) Close() error {
s.closeOnce.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.closeErr = err
}
if err := s.httpsServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) && s.closeErr == nil {
s.closeErr = err
}
})
return s.closeErr
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io"
"net/http"
"strings"
"testing"
)
func TestCertificateServerServesCurrentCAAndTrustedProbe(t *testing.T) {
certPEM, keyPEM, err := generateCA()
if err != nil {
t.Fatal(err)
}
server, err := startCertificateServer(certPEM, keyPEM)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
download, err := http.Get(server.DownloadURL())
if err != nil {
t.Fatal(err)
}
defer download.Body.Close()
body, err := io.ReadAll(download.Body)
if err != nil {
t.Fatal(err)
}
if download.StatusCode != http.StatusOK {
t.Fatalf("download status = %d", download.StatusCode)
}
if download.Header.Get("Content-Type") != "application/x-x509-ca-cert" {
t.Fatalf("unexpected content type: %q", download.Header.Get("Content-Type"))
}
if !strings.Contains(download.Header.Get("Content-Disposition"), "LocationSpoofer-CA.cer") {
t.Fatalf("unexpected content disposition: %q", download.Header.Get("Content-Disposition"))
}
root, err := x509.ParseCertificate(blockBytes(certPEM))
if err != nil {
t.Fatal(err)
}
if string(body) != string(root.Raw) {
t.Fatal("downloaded certificate did not match input CA DER")
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(certPEM) {
t.Fatal("could not add CA to pool")
}
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}}
probe, err := client.Get(server.ProbeURL())
if err != nil {
t.Fatal(err)
}
defer probe.Body.Close()
response, err := io.ReadAll(probe.Body)
if err != nil {
t.Fatal(err)
}
if probe.StatusCode != http.StatusOK || string(response) != "ok\n" {
t.Fatalf("probe response = %d %q", probe.StatusCode, response)
}
if server.LeafSHA256() == "" {
t.Fatal("missing leaf SHA-256 fingerprint")
}
}
func TestCertificateServerOnlyServesKnownPaths(t *testing.T) {
certPEM, keyPEM, err := generateCA()
if err != nil {
t.Fatal(err)
}
server, err := startCertificateServer(certPEM, keyPEM)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
response, err := http.Get(strings.TrimSuffix(server.DownloadURL(), "/ca.cer") + "/unknown")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", response.StatusCode)
}
}
func blockBytes(certPEM []byte) []byte {
block, _ := pem.Decode(certPEM)
if block == nil {
return nil
}
return block.Bytes
}
func TestCertificateServerRejectsDefaultTrustBeforeInstallation(t *testing.T) {
certPEM, keyPEM, err := generateCA()
if err != nil {
t.Fatal(err)
}
server, err := startCertificateServer(certPEM, keyPEM)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
response, err := http.Get(server.ProbeURL())
if response != nil {
response.Body.Close()
}
if err == nil {
t.Fatal("default TLS trust unexpectedly accepted an uninstalled CA")
}
}
+10
View File
@@ -0,0 +1,10 @@
module pp_proxy/wloc/core
go 1.23.0
require github.com/elazarl/goproxy v1.8.5
require (
golang.org/x/net v0.43.0 // indirect
golang.org/x/text v0.28.0 // indirect
)
+16
View File
@@ -0,0 +1,16 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.8.5 h1:33R3Q6geBd2PHmjEI82s3dQWSoBKjTktg4YAFXutIoY=
github.com/elazarl/goproxy v1.8.5/go.mod h1:b5xm6W48AUHNpRTCvlnd0YVh+JafCCtsLsJZvvNTz+E=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+3
View File
@@ -0,0 +1,3 @@
package main
func main() {}
+409
View File
@@ -0,0 +1,409 @@
package main
import (
"bytes"
"context"
crand "crypto/rand"
"crypto/tls"
"encoding/pem"
"fmt"
"io"
"log"
"net"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/elazarl/goproxy"
)
const proxyPort = 8888
var (
stateMu sync.Mutex
currentLat float64
currentLon float64
currentEnabled bool
currentAccuracy int
globalCACert *tls.Certificate
verifyToken string
logMu sync.Mutex
logEntries []string
)
func logEvent(msg string) {
line := time.Now().Format("15:04:05.000") + " " + msg
logMu.Lock()
logEntries = append(logEntries, line)
if len(logEntries) > 200 {
logEntries = logEntries[len(logEntries)-200:]
}
logMu.Unlock()
log.Printf("%s", msg)
}
func drainLogs() string {
logMu.Lock()
defer logMu.Unlock()
if len(logEntries) == 0 {
return ""
}
out := strings.Join(logEntries, "\n")
logEntries = nil
return out
}
func isWlocHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(host, "."))
if strings.Contains(host, ":") {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
}
return host == "gs-loc.apple.com" || host == "gs-loc-cn.apple.com"
}
func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
proxy := goproxy.NewProxyHttpServer()
proxy.Verbose = false
// Handle non-proxy requests (e.g. Safari browsing directly to 127.0.0.1:8888)
proxy.NonproxyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/cert" {
stateMu.Lock()
cert := globalCACert
stateMu.Unlock()
if cert != nil {
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]})
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
w.Header().Set("Content-Disposition", "attachment; filename=wloccore-ca.crt")
w.Write(certPEM)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("CA certificate not yet generated"))
return
}
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=/cert"><title>CA Certificate</title></head><body><p><a href="/cert">Download CA certificate</a></p></body></html>`))
return
}
if r.URL.Path == "/coords" {
stateMu.Lock()
enabled, lat, lon := currentEnabled, currentLat, currentLon
stateMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, currentAccuracy)))
return
}
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
w.Header().Set("Content-Disposition", "attachment; filename=paopao-proxy.mobileconfig")
w.Write([]byte(generateProxyMobileConfig()))
return
}
// Serve cert download page for rendoor.cert-like hosts
if r.Host == "rendoor.cert" || strings.HasPrefix(r.Host, "rendoor.cert:") {
stateMu.Lock()
cert := globalCACert
stateMu.Unlock()
if cert != nil && r.URL.Path == "/cert" {
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]})
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
w.Header().Set("Content-Disposition", "attachment; filename=wloccore-ca.crt")
w.Write(certPEM)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="2;url=/cert"><title>CA Certificate</title></head><body><p>Downloading CA certificate...</p></body></html>`))
return
}
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("This is a proxy server. Use Safari to visit http://127.0.0.1:8888/proxy.mobileconfig for proxy setup, or http://rendoor.cert for CA certificate."))
})
if cert != nil {
mitmAction := &goproxy.ConnectAction{
Action: goproxy.ConnectMitm,
TLSConfig: goproxy.TLSConfigFromCA(cert),
}
proxy.OnRequest().HandleConnectFunc(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
if isWlocHost(host) {
logEvent("CONNECT " + host + " -> MITM")
return mitmAction, host
}
// MITM baidu.com for WiFi proxy detection
h := host
if h2, _, err := net.SplitHostPort(host); err == nil {
h = h2
}
if h == "baidu.com" || h == "www.baidu.com" || strings.HasSuffix(h, ".baidu.com") {
logEvent("CONNECT " + host + " -> MITM (verify)")
return mitmAction, host
}
logEvent("CONNECT " + host + " -> passthrough")
return goproxy.OkConnect, host
})
}
proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
body := []byte(nil)
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
req.Body.Close()
req.Body = io.NopCloser(bytes.NewReader(body))
}
logEvent(fmt.Sprintf("proxy request target=%s method=%s path=%s size=%d body_prefix=%s",
req.Host, req.Method, req.URL.Path, len(body), hexPrefix(body, 64)))
return serveLocalRequests(req, ctx)
})
proxy.OnResponse().DoFunc(patchWlocResponse)
return proxy
}
func serveLocalRequests(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
host := strings.ToLower(req.Host)
// 代理验证拦截:baidu.com/paopao-verify-* → 返回 token
h := host
if h2, _, err := net.SplitHostPort(host); err == nil {
h = h2
}
if (h == "baidu.com" || h == "www.baidu.com") && strings.HasPrefix(req.URL.Path, "/paopao-verify-") {
token := strings.TrimPrefix(req.URL.Path, "/paopao-verify-")
logEvent("verify request path=" + req.URL.Path + " token=" + token)
if checkVerifyToken(token) {
resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token)
resp.Header.Set("Cache-Control", "no-store")
return req, resp
}
}
if host != "rendoor.cert" && host != "www.rendoor.cert" {
return req, nil
}
stateMu.Lock()
cert := globalCACert
stateMu.Unlock()
if cert == nil {
return req, nil
}
if req.URL.Path == "/cert" {
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]})
resp := goproxy.NewResponse(req, "application/x-x509-ca-cert", http.StatusOK, string(certPEM))
resp.Header.Set("Content-Disposition", `attachment; filename=wloccore-ca.crt`)
return req, resp
}
html := `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="2;url=/cert">
<title>Preparing Certificate</title>
</head>
<body>
<p>正在准备 CA 证书,如未弹出请点击 <a href="/cert">这里</a>。</p>
</body>
</html>`
resp := goproxy.NewResponse(req, "text/html; charset=utf-8", http.StatusOK, html)
return req, resp
}
func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
if resp == nil || resp.Request == nil {
return resp
}
if !isWlocHost(resp.Request.Host) || resp.Request.URL.Path != "/clls/wloc" || resp.Request.Method != http.MethodPost {
return resp
}
stateMu.Lock()
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
stateMu.Unlock()
originalBody := resp.Body
body, err := io.ReadAll(originalBody)
originalBody.Close()
if err != nil {
logEvent("wloc response read failed: " + err.Error())
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
logEvent(fmt.Sprintf("wloc upstream response status=%d size=%d headers=[%s] body_prefix=%s",
resp.StatusCode, len(body), summarizeHeaders(resp.Header), hexPrefix(body, 64)))
if !enabled {
logEvent("wloc upstream response passed through (spoofing disabled)")
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
if resp.StatusCode != http.StatusOK {
logEvent(fmt.Sprintf("wloc upstream response passed through (status %d != 200)", resp.StatusCode))
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
if len(body) == 0 {
logEvent("wloc upstream response empty, passed through")
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy})
if err != nil {
logEvent("wloc patch skipped: " + err.Error())
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
if bytes.Equal(patched, body) {
logEvent("wloc patch produced identical body, passed through")
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
resp.Body = io.NopCloser(bytes.NewReader(patched))
resp.ContentLength = int64(len(patched))
resp.Header.Del("Content-Encoding")
resp.Header.Del("Transfer-Encoding")
resp.Header.Set("Content-Length", strconv.Itoa(len(patched)))
logEvent(fmt.Sprintf("wloc patched target=%.6f,%.6f accuracy=%d locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d body_prefix=%s",
lat, lon, accuracy, stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched), hexPrefix(patched, 64)))
return resp
}
func hexPrefix(b []byte, n int) string {
if len(b) > n {
b = b[:n]
}
return fmt.Sprintf("%x", b)
}
func summarizeHeaders(h http.Header) string {
if len(h) == 0 {
return ""
}
parts := make([]string, 0, len(h))
for k, v := range h {
parts = append(parts, k+"="+strings.Join(v, ","))
}
sort.Strings(parts)
return strings.Join(parts, "; ")
}
func generateProxyMobileConfig() string {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDescription</key>
<string>Configures a global HTTP proxy for location spoofing.</string>
<key>PayloadDisplayName</key>
<string>Paopao Location Proxy</string>
<key>PayloadIdentifier</key>
<string>com.paopaolabs.location-spoofer.proxy.payload</string>
<key>PayloadType</key>
<string>com.apple.proxy.http.global</string>
<key>PayloadUUID</key>
<string>` + uuidString() + `</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>GlobalHTTPProxy</key>
<dict>
<key>ProxyServer</key>
<string>127.0.0.1</string>
<key>ProxyServerPort</key>
<integer>8888</integer>
<key>ProxyType</key>
<string>Manual</string>
</dict>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>Paopao Location Proxy</string>
<key>PayloadIdentifier</key>
<string>com.paopaolabs.location-spoofer.proxy</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>` + uuidString() + `</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`
}
func uuidString() string {
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
randomUint32(), randomUint32()&0xFFFF,
(randomUint32()|0x4000)&0x4FFF,
(randomUint32()|0x8000)&0xBFFF,
uint64(randomUint32())<<32|uint64(randomUint32()))
}
func randomUint32() uint32 {
b := make([]byte, 4)
crand.Read(b)
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy int) (*http.Server, error) {
cert, err := parseCA(certPEM, keyPEM)
if err != nil {
return nil, err
}
stateMu.Lock()
globalCACert = cert
currentLat, currentLon, currentEnabled, currentAccuracy = lat, lon, enabled, accuracy
stateMu.Unlock()
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort))
if err != nil {
return nil, err
}
srv := &http.Server{Handler: newProxy(cert)}
go func() {
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logEvent("proxy server error: " + err.Error())
}
}()
logEvent("proxy started on 127.0.0.1:8888")
return srv, nil
}
func stopProxy(srv *http.Server) error {
if srv == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := srv.Shutdown(ctx)
stateMu.Lock()
globalCACert = nil
stateMu.Unlock()
return err
}
func refreshVerifyToken() string {
token := fmt.Sprintf("%04x", uuidString())
stateMu.Lock()
verifyToken = token
stateMu.Unlock()
return token
}
func checkVerifyToken(token string) bool {
stateMu.Lock()
defer stateMu.Unlock()
return verifyToken != "" && verifyToken == token
}
+484
View File
@@ -0,0 +1,484 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"regexp"
"time"
)
const (
wireVarint = 0
wireFixed64 = 1
wireLengthDelim = 2
wireFixed32 = 5
)
type wlocCoords struct {
Latitude float64
Longitude float64
Accuracy int
}
type patchStats struct {
WiFi int
Cell int
Locations int
Skipped int
}
type wireField struct {
num int
wireType int
value []byte
raw []byte
}
var macPattern = regexp.MustCompile(`^[0-9a-fA-F]{1,2}(:[0-9a-fA-F]{1,2}){5}$`)
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func cloneBytes(b []byte) []byte {
return append([]byte(nil), b...)
}
func readVarint(data []byte) (uint64, int, error) {
var v uint64
for i := 0; i < len(data); i++ {
if i >= 10 {
return 0, 0, errors.New("varint too long")
}
b := data[i]
v |= uint64(b&0x7f) << (7 * i)
if b&0x80 == 0 {
return v, i + 1, nil
}
}
return 0, 0, errors.New("truncated varint")
}
func writeVarint(v uint64) []byte {
var out []byte
for v >= 0x80 {
out = append(out, byte(v)|0x80)
v >>= 7
}
return append(out, byte(v))
}
func writeTag(num, wireType int) []byte {
return writeVarint(uint64(num<<3 | wireType))
}
func writeLengthDelimited(num int, value []byte) []byte {
var out []byte
out = append(out, writeTag(num, wireLengthDelim)...)
out = append(out, writeVarint(uint64(len(value)))...)
out = append(out, value...)
return out
}
func parseFields(data []byte) ([]wireField, error) {
var fields []wireField
idx := 0
for idx < len(data) {
start := idx
tag, n, err := readVarint(data[idx:])
if err != nil {
return nil, err
}
idx += n
num := int(tag >> 3)
wire := int(tag & 7)
if num == 0 {
return nil, errors.New("invalid protobuf field 0")
}
var value []byte
switch wire {
case wireVarint:
_, vn, err := readVarint(data[idx:])
if err != nil {
return nil, err
}
value = cloneBytes(data[idx : idx+vn])
idx += vn
case wireFixed64:
if idx+8 > len(data) {
return nil, errors.New("truncated fixed64")
}
value = cloneBytes(data[idx : idx+8])
idx += 8
case wireLengthDelim:
l, ln, err := readVarint(data[idx:])
if err != nil {
return nil, err
}
idx += ln
if l > uint64(len(data)-idx) {
return nil, errors.New("truncated length-delimited")
}
value = cloneBytes(data[idx : idx+int(l)])
idx += int(l)
case wireFixed32:
if idx+4 > len(data) {
return nil, errors.New("truncated fixed32")
}
value = cloneBytes(data[idx : idx+4])
idx += 4
default:
return nil, fmt.Errorf("unsupported wire type %d", wire)
}
fields = append(fields, wireField{
num: num,
wireType: wire,
value: value,
raw: cloneBytes(data[start:idx]),
})
}
return fields, nil
}
func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
fields, err := parseFields(loc)
if err != nil {
return loc, false, err
}
hasLat, hasLon := false, false
for _, f := range fields {
if f.num == 1 && f.wireType == wireVarint {
hasLat = true
}
if f.num == 2 && f.wireType == wireVarint {
hasLon = true
}
}
if !hasLat || !hasLon {
return loc, false, nil
}
lat := int64(math.Round(c.Latitude * 1e8))
lon := int64(math.Round(c.Longitude * 1e8))
var out []byte
changed := false
for _, f := range fields {
switch {
case f.num == 1 && f.wireType == wireVarint:
raw := append(writeTag(1, wireVarint), writeVarint(uint64(lat))...)
if !bytes.Equal(raw, f.raw) {
changed = true
}
out = append(out, raw...)
case f.num == 2 && f.wireType == wireVarint:
raw := append(writeTag(2, wireVarint), writeVarint(uint64(lon))...)
if !bytes.Equal(raw, f.raw) {
changed = true
}
out = append(out, raw...)
case f.num == 3 && f.wireType == wireVarint:
raw := append(writeTag(3, wireVarint), writeVarint(uint64(c.Accuracy))...)
if !bytes.Equal(raw, f.raw) {
changed = true
}
out = append(out, raw...)
default:
out = append(out, f.raw...)
}
}
return out, changed, nil
}
func patchWifiDevice(device []byte, c wlocCoords, st *patchStats) ([]byte, bool, error) {
fields, err := parseFields(device)
if err != nil {
return device, false, err
}
hasMac := false
for _, f := range fields {
if f.num == 1 && f.wireType == wireLengthDelim && macPattern.Match(f.value) {
hasMac = true
}
}
if !hasMac {
return device, false, nil
}
var out []byte
changed := false
for _, f := range fields {
if f.num == 2 && f.wireType == wireLengthDelim {
newVal, subChanged, err := patchLocation(f.value, c)
if err != nil {
st.Skipped++
out = append(out, f.raw...)
continue
}
if subChanged {
changed = true
st.Locations++
}
out = append(out, writeLengthDelimited(2, newVal)...)
} else {
out = append(out, f.raw...)
}
}
if changed {
st.WiFi++
}
return out, changed, nil
}
func patchCellResponse(cell []byte, c wlocCoords, st *patchStats) ([]byte, bool, error) {
fields, err := parseFields(cell)
if err != nil {
return cell, false, err
}
var out []byte
changed := false
for _, f := range fields {
if f.num == 5 && f.wireType == wireLengthDelim {
newVal, subChanged, err := patchLocation(f.value, c)
if err != nil {
st.Skipped++
out = append(out, f.raw...)
continue
}
if subChanged {
changed = true
st.Locations++
}
out = append(out, writeLengthDelimited(5, newVal)...)
} else {
out = append(out, f.raw...)
}
}
if changed {
st.Cell++
}
return out, changed, nil
}
func patchWlocPayload(payload []byte, c wlocCoords, st *patchStats) ([]byte, bool, error) {
fields, err := parseFields(payload)
if err != nil {
return payload, false, err
}
var out []byte
changed := false
for _, f := range fields {
switch {
case f.num == 2 && f.wireType == wireLengthDelim:
newVal, subChanged, err := patchWifiDevice(f.value, c, st)
if err != nil {
st.Skipped++
out = append(out, f.raw...)
continue
}
if subChanged {
changed = true
}
out = append(out, writeLengthDelimited(2, newVal)...)
case (f.num == 22 || f.num == 24) && f.wireType == wireLengthDelim:
newVal, subChanged, err := patchCellResponse(f.value, c, st)
if err != nil {
st.Skipped++
out = append(out, f.raw...)
continue
}
if subChanged {
changed = true
}
out = append(out, writeLengthDelimited(f.num, newVal)...)
default:
out = append(out, f.raw...)
}
}
return out, changed, nil
}
func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte, patchStats, error) {
if len(body) < offset+10 {
return nil, *st, fmt.Errorf("body too short: %d, base=%d", len(body), offset)
}
length := int(binary.BigEndian.Uint16(body[offset+8 : offset+10]))
if length <= 0 {
return nil, *st, errors.New("invalid empty frame length")
}
if offset+10+length > len(body) {
return nil, *st, fmt.Errorf("invalid frame length %d at %d for %d", length, offset, len(body))
}
prefix := cloneBytes(body[:offset+8])
payload := cloneBytes(body[offset+10 : offset+10+length])
suffix := cloneBytes(body[offset+10+length:])
before := *st
newPayload, changed, err := patchWlocPayload(payload, c, st)
if err != nil || !changed || (int(st.WiFi-before.WiFi)+int(st.Cell-before.Cell)+int(st.Locations-before.Locations)) <= 0 || bytes.Equal(newPayload, payload) {
*st = before
if err != nil {
return nil, *st, err
}
return nil, *st, errors.New("frame parsed but no patchable wloc payload")
}
if len(newPayload) > 65535 {
*st = before
return nil, *st, errors.New("patched payload too large")
}
var lenBytes [2]byte
binary.BigEndian.PutUint16(lenBytes[:], uint16(len(newPayload)))
out := append(prefix, lenBytes[:]...)
out = append(out, newPayload...)
out = append(out, suffix...)
return out, *st, nil
}
func patchWlocBody(body []byte, c wlocCoords) ([]byte, patchStats, error) {
var st patchStats
offsets := []int{0, 2, 4, 6, 8, 10, 12, 14, 16}
seen := map[int]bool{}
for _, o := range offsets {
seen[o] = true
}
limit := minInt(96, maxInt(0, len(body)-10))
for i := 0; i <= limit; i++ {
if !seen[i] {
offsets = append(offsets, i)
}
}
for _, offset := range offsets {
local := st
out, _, err := patchFrame(body, offset, c, &local)
if err == nil {
return out, local, nil
}
st = local
}
fallbackLimit := minInt(256, len(body))
for i := 0; i <= fallbackLimit; i++ {
local := patchStats{}
payload := body[i:]
newPayload, changed, err := patchWlocPayload(payload, c, &local)
if err == nil && changed && !bytes.Equal(newPayload, payload) {
out := append(cloneBytes(body[:i]), newPayload...)
return out, local, nil
}
}
return nil, st, errors.New("no patchable wloc payload found")
}
func maybeGunzip(body []byte) ([]byte, bool, error) {
if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b {
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, true, err
}
defer zr.Close()
out, err := io.ReadAll(zr)
return out, true, err
}
return body, false, nil
}
func patchResponseBody(body []byte, c wlocCoords) ([]byte, patchStats, error) {
decompressed, wasGzip, err := maybeGunzip(body)
if err != nil {
return nil, patchStats{}, err
}
patched, stats, err := patchWlocBody(decompressed, c)
if err != nil {
return nil, patchStats{}, err
}
_ = wasGzip
return patched, stats, nil
}
func makeTestWlocBody() []byte {
var loc []byte
loc = append(loc, writeTag(1, wireVarint)...)
loc = append(loc, writeVarint(100)...)
loc = append(loc, writeTag(2, wireVarint)...)
loc = append(loc, writeVarint(200)...)
loc = append(loc, writeTag(3, wireVarint)...)
loc = append(loc, writeVarint(25)...)
mac := []byte("aa:bb:cc:dd:ee:ff")
var device []byte
device = append(device, writeLengthDelimited(1, mac)...)
device = append(device, writeLengthDelimited(2, loc)...)
payload := writeLengthDelimited(2, device)
magic := []byte{0, 1, 0, 0, 0, 1, 0, 0}
var lenBytes [2]byte
binary.BigEndian.PutUint16(lenBytes[:], uint16(len(payload)))
var out []byte
out = append(out, magic...)
out = append(out, lenBytes[:]...)
out = append(out, payload...)
return out
}
func makeTestWlocRequest() []byte {
var out []byte
// 3 个 Wi-Fi AP(真实 wloc 请求格式)
type ap struct {
mac string
rssi int32
channel int32
}
aps := []ap{
{"aa:bb:cc:dd:ee:ff", -45, 6},
{"11:22:33:44:55:66", -62, 11},
{"77:88:99:00:11:22", -71, 1},
}
now := uint32(time.Now().Unix())
for _, a := range aps {
var device []byte
device = append(device, writeLengthDelimited(1, []byte(a.mac))...)
device = append(device, writeTag(4, wireVarint)...)
device = append(device, writeVarint(uint64(int64(a.rssi)))...)
device = append(device, writeTag(6, wireVarint)...)
device = append(device, writeVarint(uint64(a.channel))...)
device = append(device, writeTag(11, wireVarint)...)
device = append(device, writeVarint(uint64(now))...)
out = append(out, writeLengthDelimited(1, device)...)
}
// 1 个蜂窝基站
var cell []byte
cell = append(cell, writeTag(1, wireVarint)...)
cell = append(cell, writeVarint(1)...) // GSM
cell = append(cell, writeTag(2, wireVarint)...)
cell = append(cell, writeVarint(460)...) // MCC China
cell = append(cell, writeTag(3, wireVarint)...)
cell = append(cell, writeVarint(1)...) // MNC
cell = append(cell, writeTag(4, wireVarint)...)
cell = append(cell, writeVarint(15200)...) // LAC
cell = append(cell, writeTag(5, wireVarint)...)
cell = append(cell, writeVarint(24680)...) // CellID
out = append(out, writeLengthDelimited(5, cell)...)
return out
}
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/binary"
"math"
"testing"
)
func testLocation(lat, lon int64, accuracy uint64) []byte {
var out []byte
out = append(out, writeTag(1, wireVarint)...)
out = append(out, writeVarint(uint64(lat))...)
out = append(out, writeTag(2, wireVarint)...)
out = append(out, writeVarint(uint64(lon))...)
out = append(out, writeTag(3, wireVarint)...)
out = append(out, writeVarint(accuracy)...)
return out
}
func testWifiDevice(loc []byte) []byte {
mac := []byte("aa:bb:cc:dd:ee:ff")
var out []byte
out = append(out, writeLengthDelimited(1, mac)...)
out = append(out, writeLengthDelimited(2, loc)...)
return out
}
func testFrame(payload []byte) []byte {
magic := []byte{0, 1, 0, 0, 0, 1, 0, 0}
var lenBytes [2]byte
binary.BigEndian.PutUint16(lenBytes[:], uint16(len(payload)))
var out []byte
out = append(out, magic...)
out = append(out, lenBytes[:]...)
out = append(out, payload...)
return out
}
func TestPatchWifiLocation(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
body := testFrame(payload)
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
if bytes.Equal(patched, body) {
t.Fatal("body was not patched")
}
newLen := int(binary.BigEndian.Uint16(patched[8:10]))
newPayload := patched[10 : 10+newLen]
latBytes := append(writeTag(1, wireVarint), writeVarint(uint64(int64(math.Round(c.Latitude*1e8))))...)
if !bytes.Contains(newPayload, latBytes) {
t.Fatal("new latitude bytes not found")
}
}
func TestPatchCellLocation(t *testing.T) {
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
payload := writeLengthDelimited(22, cell)
body := testFrame(payload)
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.Cell != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
if bytes.Equal(patched, body) {
t.Fatal("body was not patched")
}
}
func TestPatchGzip(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write(testFrame(payload)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, _, err := patchResponseBody(buf.Bytes(), c)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(patched, testFrame(payload)) {
t.Fatal("gzip body was not patched")
}
}
func TestTransparentBodyUnchanged(t *testing.T) {
body := []byte{1, 2, 3, 4}
_, _, err := patchResponseBody(body, wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 25})
if err == nil {
t.Fatal("expected non-patchable body to error")
}
}
+14
View File
@@ -0,0 +1,14 @@
.PHONY: setup core ipa-unsigned verify-ipa
setup:
./Scripts/setup.sh
core:
./Scripts/build-core.sh
ipa-unsigned: core
./Scripts/build-unsigned-ipa.sh
verify-ipa:
@test -n "$(IPA)" || (echo "Usage: make verify-ipa IPA=/absolute/path/to/signed.ipa" >&2; exit 2)
./Scripts/verify-ipa.sh --signed "$(IPA)"
+163
View File
@@ -0,0 +1,163 @@
<div align="center">
# 📍 Location Spoofer
### iOS Location Spoofer · DingTalk · WeChat · Apple Watch Region Unlock · Fake GPS
**No VPN, no jailbreak — run a local HTTP proxy on your iPhone to rewrite Apple location responses.**<br>
Works with DingTalk check-in, WeChat location sharing, and any app that uses system location. Map selection, real-time location, environment verification, certificate setup, and runtime logs in a single app.
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138?logo=swift&logoColor=white)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go&logoColor=white)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.0-2563EB)](docs/CHANGELOG.md)
[![No VPN](https://img.shields.io/badge/VPN-Not%20needed-16A34A)](#why-no-vpn)
[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md)
<img src="images/主界面.jpg" alt="Location Spoofer iOS Fake GPS main interface" width="380">
</div>
> [!IMPORTANT]
> This project is intended for education, security research, and testing on your own devices. It installs a locally generated CA certificate and requires a manual HTTP proxy on the current WiFi network. Please understand the risks and follow applicable laws and service terms.
## Credits
The core location-response rewriting approach and Go implementation are based on [Yu9191/wloc](https://github.com/Yu9191/wloc). This project adds a SwiftUI interface, MapKit selection, certificate and proxy guidance, environment verification, favorites, and diagnostics.
## Why Location Spoofer?
Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailbroken device, Location Spoofer keeps the control flow on the iPhone itself.
| Feature | Description |
|---|---|
| 🚫 **No VPN** | No VPN tunnel — uses only location permission, no background refresh or notification access required. |
| 📱 **No jailbreak** | Can be installed through self-signing; minimum deployment target is iOS 15. |
| 🗺️ **Native Maps experience** | The same blue dot and selection gestures as Apple Maps — search, tap, and drag. |
| 📍 **System-level location spoofing** | Works with DingTalk, WeChat, Apple Maps, Amap, and other apps for real-time fake GPS. |
| 🔍 **Visible map scale** | Left-side controls show the current visible range; place name adapts to zoom level. |
| 🧪 **Environment verification** | Checks proxy, CA trust, WiFi interception, coordinate write, and response rewrite. |
| 🧾 **Diagnostics** | Per-entry copy for easy sharing and debugging. |
## Screenshots
| Main Interface | Apple Maps | Amap | Apple Watch |
|---|---|---|---|
| ![Location Spoofer main interface](images/主界面.jpg) | ![Apple Maps result](images/Apple%20Map.jpg) | ![Amap result](images/高德地图.jpg) | ![Apple Watch region feature](images/高血压.jpg) |
## Key Features
- **iOS Location Spoofer / Fake GPS**: Apply the selected coordinate to the local proxy that rewrites location responses, compatible with DingTalk check-in, WeChat location sharing, and more.
- **Native real-time location**: The map displays MapKit's own blue dot — no extra "fake real-time" overlay.
- **Concurrency-safe selection**: Pan, tap, search, favorites, and async location respect the user's latest intent; stale results won't overwrite newer selections.
- **Hierarchical place names**: POI, street, or road at close zoom; neighborhood, district, city, or province at wider zoom.
- **Map scale display**: Zoom controls show the current visible range.
- **Favorites with quick switch**: Save frequent coordinates and see which location is about to be applied.
- **Setup guide**: Certificate download, installation, full trust, WiFi HTTP proxy, activation, and deactivation instructions.
- **Built-in diagnostics**: Verification flow and structured runtime logs.
## Quick Start
### 1. Install the App
- Download a build from [Releases](https://github.com/xweiba/location-spoofer/releases) and self-sign; or
- Build from source on macOS with Xcode — see the [build guide](docs/BUILD.md).
Detailed steps in the [self-signing guide](docs/SELF-SIGNING.md).
### 2. Install & Trust the CA
Follow the first-setup wizard to download the profile, then:
```text
Settings → General → VPN & Device Management → install WLOC CA
Settings → General → About → Certificate Trust Settings → enable full trust
```
### 3. Configure the Current WiFi Proxy
On the current WiFi's proxy settings, choose "Manual":
```text
Server: 127.0.0.1
Port: 8888
Authentication: off
```
### 4. Select a Location & Enable
1. Search, tap, or drag the map to pick a location; tap the real-time location button to jump to the MapKit blue dot.
2. Tap "Start Spoofing" and wait for the environment check to pass.
3. Follow the inapp activation instructions to refresh airplane mode, WiFi, and location services.
4. Open Apple Maps or your target app to verify.
### 5. Restore Your Real Location
Stop spoofing, remove the manual proxy from the current WiFi, and follow the inapp deactivation instructions to refresh the system location cache. If stale cache persists, restart your device.
## Why No VPN?
```text
iPhone location request
│ WiFi HTTP proxy: 127.0.0.1:8888
Local wloccore Go proxy
│ Handles only the targeted Apple location-service traffic
Apple location service response
│ The selected coordinate is written into the response
The system and applications receive the modified result
```
The project does not use Network Extension to create a VPN tunnel — there is no VPN icon and no VPN slot occupied. **However, you still need to configure the current WiFi HTTP proxy and install & trust the locally generated CA.** Recheck proxy settings after switching WiFi networks; remove the manual proxy when you stop using the app.
## Compatibility
| Item | Requirement |
|---|---|
| iOS | 15.0+ |
| Build | macOS, Xcode, XcodeGen |
| Swift | 5.9 |
| Go | 1.23+ |
| Network | WiFi with manual HTTP proxy support |
| Installation | Self-sign or use release builds |
Actual behavior may vary with iOS version, network conditions, system location cache, device model, and the target app's own location strategy. Compatibility with every iOS version or third-party app is not guaranteed.
## Build & Project Structure
```bash
./build.sh
```
The unsigned IPA is at:
```text
dist/PaopaoLocationSpoofer-unsigned.ipa
```
```text
App/ SwiftUI, MapKit, location and setup flow
Core/ Go local proxy and location response rewriting
Shared/ Favorites, settings, logs, and shared models
Resources/ Info.plist, Entitlements, and icons
Scripts/ Build, signing, and verification scripts
Tests/ XCTest and Bash contract tests
docs/ Build, self-signing, and changelog documentation
```
## Documentation & Feedback
- [Build guide](docs/BUILD.md)
- [Self-signing guide](docs/SELF-SIGNING.md)
- [Changelog](docs/CHANGELOG.md)
- [中文文档](README.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
When reporting issues, please include reproduction steps, iOS version, device model, and sanitized runtime logs.
## Links
**LinuxDo** — [https://linux.do](https://linux.do/)
+174
View File
@@ -0,0 +1,174 @@
<div align="center">
# 📍 Location Spoofer
### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS
**无需 VPN、无需越狱,在 iPhone 本机通过 WiFi HTTP 代理改写 Apple 定位响应。**<br>
可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138?logo=swift&logoColor=white)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go&logoColor=white)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.0-2563EB)](docs/CHANGELOG.md)
[![No VPN](https://img.shields.io/badge/VPN-不需要-16A34A)](#为什么不需要-vpn)
[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md)
<img src="images/主界面.jpg" alt="Location Spoofer iOS 虚拟定位 Fake GPS 主界面" width="380">
</div>
> [!IMPORTANT]
> 本项目用于学习、安全研究与自有设备测试。它会安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。
## 致谢
核心定位响应改写思路与 Go 实现来源于 [Yu9191/wloc](https://github.com/Yu9191/wloc)。本项目在此基础上增加 SwiftUI 界面、MapKit 选点、证书与代理引导、环境验证、收藏和诊断能力。
## 为什么选择 Location Spoofer
很多 iOS 虚拟定位工具依赖电脑常驻、开发者调试、VPN 或越狱。本项目采用不同路线:在 iPhone 本机运行 Go 代理,仅对 Apple 定位服务目标请求进行处理。
| 特性 | 说明 |
|---|---|
| 🚫 **无 VPN** | 不创建 VPN 隧道,仅使用定位权限,无需后台刷新、通知等额外权限 |
| 📱 **无需越狱** | 支持自行签名安装,最低部署目标为 iOS 15 |
| 🗺️ **原生地图体验** | 使用 Apple 地图同款蓝点,搜索、点击、拖动选点体验与 Apple 地图一致 |
| 📍 **系统级虚拟定位** | 支持钉钉、微信、Apple 地图、高德等 App 的虚拟实时定位 |
| 🔍 **可见缩放范围** | 左侧缩放控件显示当前可视范围,地点名称随级别自动适配 |
| 🧪 **环境检测** | 检查代理、CA 信任、Wi‑Fi 接管、坐标写入与响应改写 |
| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 |
## 效果预览
<table>
<tr>
<th>应用主界面</th>
<th>Apple 地图</th>
<th>高德地图</th>
<th>Apple Watch</th>
</tr>
<tr>
<td><img src="images/主界面.jpg" alt="iOS 虚拟定位应用主界面" width="210"></td>
<td><img src="images/Apple%20Map.jpg" alt="iPhone Location Spoofer Apple Maps 效果" width="210"></td>
<td><img src="images/高德地图.jpg" alt="Fake GPS 高德地图定位效果" width="210"></td>
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能验证" width="210"></td>
</tr>
</table>
## 核心功能
- **iOS 虚拟定位 / Fake GPS**:将当前地图选点应用到本机定位响应改写代理,适配钉钉打卡、微信位置共享等场景。
- **原生实时位置**:地图显示 MapKit 自带蓝点,不再由 App 额外绘制实时位置标记。
- **并发安全选点**:拖动、点击、搜索、收藏和异步定位按用户最新意图处理,旧结果不会覆盖新选点。
- **地点名称分级**:近距离显示 POI、门牌或道路;拉远后显示社区、区县、城市或省份。
- **地图范围显示**:缩放控件中显示 `180 m``2.5 km``126 km` 等当前可视范围。
- **收藏与快速切换**:保存常用坐标,并明确显示当前准备应用的位置。
- **配置引导**:提供证书安装、完全信任、Wi‑Fi HTTP 代理、生效与恢复说明。
- **问题诊断**:内置验证流程和结构化运行日志。
## 快速开始
### 1. 安装 App
- 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 获取构建产物并自行签名;或
- 在 macOS + Xcode 环境按[构建说明](docs/BUILD.md)编译。
详细步骤见[自签安装说明](docs/SELF-SIGNING.md)。
### 2. 安装并信任 CA
按首次引导下载描述文件,然后完成:
```text
设置 → 通用 → VPN 与设备管理 → 安装 WLOC CA
设置 → 通用 → 关于本机 → 证书信任设置 → 完全信任
```
### 3. 配置当前 WiFi 代理
在当前 WiFi 的"配置代理"中选择"手动"
```text
服务器:127.0.0.1
端口:8888
鉴定:关闭
```
### 4. 选点并启用
1. 搜索、点击或拖动地图选择位置;点击实时位置按钮可回到 MapKit 蓝点。
2. 点击"开始虚拟定位",等待环境检测通过。
3. 按 App 内"生效说明"刷新飞行模式、Wi‑Fi 和定位服务状态。
4. 打开 Apple 地图或目标 App 验证结果。
### 5. 恢复真实位置
停止虚拟定位,关闭当前 Wi‑Fi 的手动代理,并按 App 内"失效说明"刷新系统定位缓存。若系统仍保留旧缓存,请重启设备后再检查。
## 为什么不需要 VPN
```text
iPhone 定位请求
│ 当前 WiFi HTTP 代理:127.0.0.1:8888
本机 wloccoreGo
│ 仅处理目标 Apple 定位服务请求
├──────────────► Apple 定位服务
◄──────────────┘
│ 改写目标响应中的坐标
系统与应用读取定位结果
```
项目不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**但它仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 切换 Wi‑Fi 后需要重新检查代理设置;停止使用后应及时关闭手动代理。
## 兼容性
| 项目 | 要求 |
|---|---|
| iOS | 15.0+ |
| 构建 | macOS、Xcode、XcodeGen |
| Swift | 5.9 |
| Go | 1.23+ |
| 网络 | 可手动配置 HTTP 代理的 WiFi |
| 安装 | 自行签名或使用 Releases 构建产物 |
效果会受到 iOS 版本、网络、系统定位缓存和目标 App 自身策略影响,不承诺兼容所有系统或第三方 App。
## 构建与项目结构
```bash
./build.sh
```
构建产物默认位于:
```text
dist/PaopaoLocationSpoofer-unsigned.ipa
```
```text
App/ SwiftUI、MapKit、定位和配置流程
Core/ Go 本机代理与定位响应改写
Shared/ 收藏、设置、日志和共享模型
Resources/ Info.plist、Entitlements 与图标
Scripts/ 构建、签名和检查脚本
Tests/ XCTest 与 Bash 契约测试
docs/ 构建、自签和版本更新文档
```
## 文档与反馈
- [构建说明](docs/BUILD.md)
- [自签安装](docs/SELF-SIGNING.md)
- [v1.0.0 更新日志](docs/CHANGELOG.md)
- [English README](README.en.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
反馈问题时,请附上复现步骤、iOS 版本、设备型号及已脱敏的运行日志。
## 友链
**LinuxDo** — [https://linux.do](https://linux.do/)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Location Spoofer</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>App-Prefs</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>用于显示和验证虚拟定位是否生效</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>用于显示和验证虚拟定位是否生效</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.paopaolabs.location-spoofer</string>
</array>
</dict>
</plist>
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CORE="$ROOT/Core"
BUILD="$CORE/build"
IOS_SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
MIN_IOS_VERSION="15.0"
export CGO_ENABLED=1
export CGO_CFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
export CGO_LDFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
export GOOS="ios"
export GOARCH="arm64"
mkdir -p "$BUILD"
cd "$CORE"
go mod download
go build -buildmode=c-archive -ldflags="-s -w" -o "$BUILD/libwloccore.a" .
cp "$BUILD/libwloccore.h" "$ROOT/Core/wloccore.h"
test -s "$ROOT/Core/wloccore.h"
echo "Built $BUILD/libwloccore.a"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
"$ROOT/Scripts/build-core.sh"
command -v xcodegen >/dev/null 2>&1 || { echo "xcodegen is required: brew install xcodegen" >&2; exit 1; }
xcodegen generate
rm -rf build/UnsignedIPA build/DerivedData
xcodebuild \
-project PaopaoLocationSpoofer.xcodeproj \
-scheme PaopaoLocationSpoofer \
-configuration Release \
-sdk iphoneos \
-derivedDataPath build/DerivedData \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
build
APP="build/DerivedData/Build/Products/Release-iphoneos/PaopaoLocationSpoofer.app"
if [ ! -d "$APP" ]; then
echo "App bundle not found" >&2
exit 1
fi
mkdir -p build/UnsignedIPA/Payload dist
ditto "$APP" "build/UnsignedIPA/Payload/PaopaoLocationSpoofer.app"
cd build/UnsignedIPA
zip -qry "$ROOT/dist/PaopaoLocationSpoofer-unsigned.ipa" Payload
echo "Output: dist/PaopaoLocationSpoofer-unsigned.ipa"
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.paopaolabs.location-spoofer</string>
</array>
<key>application-identifier</key>
<string>$(AppIdentifierPrefix)com.paopaolabs.location-spoofer</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.paopaolabs.location-spoofer</string>
</array>
<key>get-task-allow</key>
<true/>
</dict>
</plist>
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
"$ROOT/Scripts/build-core.sh"
xcodegen generate
echo "Project generated. Run make ipa-unsigned to build an unsigned IPA."
+45
View File
@@ -0,0 +1,45 @@
import Foundation
enum AppGroup {
static let identifier = "group.com.paopaolabs.location-spoofer"
static let defaults = UserDefaults(suiteName: identifier) ?? .standard
static var sharedContainerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
}
static var isSharedContainerAvailable: Bool { sharedContainerURL != nil }
static var containerURL: URL {
if let url = sharedContainerURL { return url }
return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("LocationSpoofer", isDirectory: true)
}
}
enum WlocKeys {
static let coords = "wloc_settings"
}
struct WlocSettings: Codable {
var longitude: Double
var latitude: Double
var accuracy: Int
var enabled: Bool
}
enum WlocSettingsStore {
static func load() -> WlocSettings? {
guard let data = AppGroup.defaults.data(forKey: WlocKeys.coords) else { return nil }
return try? JSONDecoder().decode(WlocSettings.self, from: data)
}
static func save(_ settings: WlocSettings) {
guard let data = try? JSONEncoder().encode(settings) else { return }
AppGroup.defaults.set(data, forKey: WlocKeys.coords)
}
static func clear() {
save(WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false))
}
}
+62
View File
@@ -0,0 +1,62 @@
import AVFoundation
import UIKit
final class BackgroundKeepAlive {
static let shared = BackgroundKeepAlive()
private var engine: AVAudioEngine?
private var playerNode: AVAudioPlayerNode?
private var isActive = false
private init() {
NotificationCenter.default.addObserver(
self, selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
}
@objc private func handleInterruption(_ notification: Notification) {
guard isActive, let info = notification.userInfo,
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
type == AVAudioSession.InterruptionType.ended.rawValue else { return }
start()
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
}
func start() {
guard !isActive else { return }
isActive = true
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
RuntimeLogger.error("APP", "KeepAlive", "音频会话失败", error: error)
}
let eng = AVAudioEngine()
let player = AVAudioPlayerNode()
eng.attach(player)
let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)!
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3)!
buf.frameLength = 44100 * 3
eng.connect(player, to: eng.mainMixerNode, format: fmt)
eng.prepare()
do { try eng.start() } catch {
RuntimeLogger.error("APP", "KeepAlive", "引擎启动失败", error: error)
isActive = false; return
}
player.scheduleBuffer(buf, at: nil, options: .loops)
player.play()
engine = eng; playerNode = player
UIApplication.shared.isIdleTimerDisabled = true
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
}
func stop() {
guard isActive else { return }
isActive = false
playerNode?.stop(); engine?.stop()
playerNode = nil; engine = nil
UIApplication.shared.isIdleTimerDisabled = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
RuntimeLogger.info("APP", "KeepAlive", "后台保活已停止")
}
}
+46
View File
@@ -0,0 +1,46 @@
import Foundation
final class CertificateAuthorityStore {
private let directory: URL
private let generator: () throws -> CertificateAuthority
private let certificateURL: URL
private let keyURL: URL
init(directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true), generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority) {
self.directory = directory
self.generator = generator
self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
self.keyURL = directory.appendingPathComponent("ca-key.pem")
}
func ensure() throws -> CertificateAuthority {
if let current = try load() {
RuntimeLogger.debug("SHARED", "Certificate.store", "读取已有 CA 文件", details: ["directory": directory.path])
return current
}
RuntimeLogger.info("SHARED", "Certificate.store", "未找到 CA 文件,开始生成", details: ["directory": directory.path])
let authority = try generator()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
guard let certificateData = authority.certPEM.data(using: .utf8),
let keyData = authority.keyPEM.data(using: .utf8) else {
throw CocoaError(.fileWriteInapplicableStringEncoding)
}
try certificateData.write(to: certificateURL, options: .atomic)
try keyData.write(to: keyURL, options: .atomic)
applyCompleteProtection(to: certificateURL)
applyCompleteProtection(to: keyURL)
RuntimeLogger.info("SHARED", "Certificate.store", "CA 文件写入完成", details: ["directory": directory.path])
return authority
}
func load() throws -> CertificateAuthority? {
guard FileManager.default.fileExists(atPath: certificateURL.path), FileManager.default.fileExists(atPath: keyURL.path) else { return nil }
return CertificateAuthority(certPEM: try String(contentsOf: certificateURL, encoding: .utf8), keyPEM: try String(contentsOf: keyURL, encoding: .utf8))
}
private func applyCompleteProtection(to url: URL) {
#if os(iOS)
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path)
#endif
}
}
+41
View File
@@ -0,0 +1,41 @@
import Foundation
enum CertificateTrustState: Equatable {
case checking
case trusted
case unavailable
var canModify: Bool { self == .trusted }
var message: String {
switch self {
case .checking: return "checking..."
case .trusted: return "trusted"
case .unavailable: return "not configured"
}
}
}
enum LocationActionState: Equatable {
case idle
case applyingLocation
case failed(String)
var isBusy: Bool {
if case .applyingLocation = self { return true }
return false
}
var isFailure: Bool {
if case .failed = self { return true }
return false
}
var statusTitle: String {
switch self {
case .idle: return ""
case .applyingLocation: return "applying..."
case .failed: return "failed"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import Foundation
import Security
final class CertificateTrustVerifier {
/// Check whether a CA certificate (given as PEM data) is installed and fully trusted
/// by the system. Uses SecTrust evaluation against system anchors only no network needed.
static func isCACertificateTrusted(certPEM: String) -> Bool {
guard let certData = certPEM.data(using: .utf8),
let cert = SecCertificateCreateWithData(nil, certData as CFData) else {
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
return false
}
// Create a basic trust with the CA cert, using system anchor certificates only
var trust: SecTrust?
let createStatus = SecTrustCreateWithCertificates(
[cert] as CFArray,
SecPolicyCreateBasicX509(),
&trust
)
guard createStatus == errSecSuccess, let trust = trust else {
RuntimeLogger.error("APP", "Trust", "无法创建 SecTrust")
return false
}
// Use system anchors only if our CA is installed & trusted, evaluation passes
SecTrustSetAnchorCertificatesOnly(trust, false)
var error: CFError?
let result = SecTrustEvaluateWithError(trust, &error)
if let error {
RuntimeLogger.warning("APP", "Trust", "SecTrust 评估返回错误", details: [
"error": (error as Error).localizedDescription
])
}
RuntimeLogger.info("APP", "Trust", result ? "CA 证书已被系统信任" : "CA 证书未被系统信任")
return result
}
}
+62
View File
@@ -0,0 +1,62 @@
import Foundation
/// GCJ-02 () WGS-84
///
/// MKMapView 使 (AutoNavi) GCJ-02
/// MKMapView `centerCoordinate``convert(point:toCoordinateFrom:)`
/// GCJ-02 CoreLocation / CLLocationManager
/// Apple wloc 使 WGS-84
///
///
/// - UI GCJ-02
/// - wloc WGS-84
///
/// UI GCJ-02 WGS-84
enum CoordinateConverter {
// (Krasovsky 1940)
private static let a = 6378245.0
private static let ee = 0.00669342162296594323
/// GCJ-02 WGS-84 0.5
static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
var wgsLat = lat
var wgsLon = lon
//
for _ in 0..<2 {
let d = delta(lat: wgsLat, lon: wgsLon)
wgsLat = lat - d.lat
wgsLon = lon - d.lon
}
return (wgsLat, wgsLon)
}
/// (WGS-84 GCJ-02 )
private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
let dLat = transformLat(x: lon - 105.0, y: lat - 35.0)
let dLon = transformLon(x: lon - 105.0, y: lat - 35.0)
let radLat = lat / 180.0 * .pi
var magic = sin(radLat)
magic = 1 - ee * magic * magic
let sqrtMagic = sqrt(magic)
return (
lat: (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * .pi),
lon: (dLon * 180.0) / (a / sqrtMagic * cos(radLat) * .pi)
)
}
private static func transformLat(x: Double, y: Double) -> Double {
var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(abs(x))
ret += (20.0 * sin(6.0 * x * .pi) + 20.0 * sin(2.0 * x * .pi)) * 2.0 / 3.0
ret += (20.0 * sin(y * .pi) + 40.0 * sin(y / 3.0 * .pi)) * 2.0 / 3.0
ret += (160.0 * sin(y / 12.0 * .pi) + 320.0 * sin(y * .pi / 30.0)) * 2.0 / 3.0
return ret
}
private static func transformLon(x: Double, y: Double) -> Double {
var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * sqrt(abs(x))
ret += (20.0 * sin(6.0 * x * .pi) + 20.0 * sin(2.0 * x * .pi)) * 2.0 / 3.0
ret += (20.0 * sin(x * .pi) + 40.0 * sin(x / 3.0 * .pi)) * 2.0 / 3.0
ret += (150.0 * sin(x / 12.0 * .pi) + 300.0 * sin(x / 30.0 * .pi)) * 2.0 / 3.0
return ret
}
}
+126
View File
@@ -0,0 +1,126 @@
import Foundation
import Darwin
struct CertificateAuthority: Equatable {
let certPEM: String
let keyPEM: String
}
enum CoreBridgeError: LocalizedError {
case generationFailed
case serverStartFailed
var errorDescription: String? {
switch self {
case .generationFailed: return "无法生成本地证书"
case .serverStartFailed: return "无法启动本地证书服务"
}
}
}
enum CoreBridge {
static func generateCertificateAuthority() throws -> CertificateAuthority {
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
let result = wloccore_generateca()
guard let certPointer = result.r0, let keyPointer = result.r1 else {
flushLogs(category: "CA")
throw CoreBridgeError.generationFailed
}
defer { free(certPointer); free(keyPointer) }
RuntimeLogger.info("APP", "Core.CA", "Go Core CA 生成成功")
flushLogs(category: "CA")
return CertificateAuthority(certPEM: String(cString: certPointer), keyPEM: String(cString: keyPointer))
}
/// Go Core wloc
static func testWlocPatch(lat: Double, lon: Double, accuracy: Int) -> String {
guard let ptr = wloccore_testpatch(CDouble(lat), CDouble(lon), CInt(accuracy)) else {
return "error: null result"
}
defer { free(ptr) }
return String(cString: ptr)
}
/// wloc WiFi AP +
static func testWlocRequestData() -> Data {
guard let ptr = wloccore_testrequesthex() else { return Data([0x0a, 0x02, 0x08, 0x01]) }
defer { free(ptr) }
let hex = String(cString: ptr)
var data = Data()
var idx = hex.startIndex
while idx < hex.endIndex {
let end = hex.index(idx, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
if let b = UInt8(hex[idx..<end], radix: 16) { data.append(b) }
idx = end
}
return data
}
static func flushLogs(category: String) {
guard let pointer = wloccore_drainlogs() else { return }
defer { free(pointer) }
String(cString: pointer).split(separator: "\n").forEach {
RuntimeLogger.info("CORE", category, String($0))
}
}
static func refreshVerifyToken() -> String {
guard let ptr = wloccore_refreshverifytoken() else { return "" }
defer { free(ptr) }
return String(cString: ptr)
}
}
final class LocalCertificateServer {
private var handle: UInt = 0
private(set) var downloadURL: URL?
private(set) var probeURL: URL?
private(set) var leafHash = ""
deinit { stop() }
func start(authority: CertificateAuthority) throws {
if handle != 0 {
RuntimeLogger.debug("APP", "Certificate.server", "本地证书服务已在运行")
return
}
RuntimeLogger.info("APP", "Certificate.server", "调用 Go Core 启动本地证书服务")
let newHandle: UInt = authority.certPEM.withCString { certPointer in
authority.keyPEM.withCString { keyPointer in
UInt(wloccore_startcertserver(UnsafeMutablePointer(mutating: certPointer), UnsafeMutablePointer(mutating: keyPointer)))
}
}
guard newHandle != 0 else {
CoreBridge.flushLogs(category: "CertificateServer")
throw CoreBridgeError.serverStartFailed
}
let httpPort = Int(wloccore_certserver_httpport(newHandle))
let httpsPort = Int(wloccore_certserver_httpsport(newHandle))
guard httpPort > 0, httpsPort > 0, let hashPointer = wloccore_certserver_leafsha256(newHandle) else {
_ = wloccore_stopcertserver(newHandle)
throw CoreBridgeError.serverStartFailed
}
defer { free(hashPointer) }
handle = newHandle
downloadURL = URL(string: "http://127.0.0.1:\(httpPort)/ca.cer")
probeURL = URL(string: "https://127.0.0.1:\(httpsPort)/health")
leafHash = String(cString: hashPointer)
RuntimeLogger.info("APP", "Certificate.server", "本地证书服务启动成功", details: [
"httpPort": String(httpPort),
"httpsPort": String(httpsPort),
"leafHash": leafHash
])
CoreBridge.flushLogs(category: "CertificateServer")
}
func stop() {
guard handle != 0 else { return }
let result = wloccore_stopcertserver(handle)
RuntimeLogger.info("APP", "Certificate.server", "停止本地证书服务", details: ["result": String(result)])
CoreBridge.flushLogs(category: "CertificateServer")
handle = 0
downloadURL = nil
probeURL = nil
leafHash = ""
}
}
+90
View File
@@ -0,0 +1,90 @@
import Foundation
struct FavoriteLocation: Codable, Identifiable, Equatable {
let id: UUID
var name: String
var latitude: Double
var longitude: Double
var accuracy: Int
var createdAt: Date
init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double, accuracy: Int, createdAt: Date = Date()) {
self.id = id
self.name = name
self.latitude = latitude
self.longitude = longitude
self.accuracy = accuracy
self.createdAt = createdAt
}
}
struct MapConfiguration: Equatable {
let showsUserLocation: Bool
let allowsCurrentLocationRequest: Bool
static let `default` = MapConfiguration(showsUserLocation: false, allowsCurrentLocationRequest: false)
}
final class FavoriteLocationStore: ObservableObject {
private enum Keys {
static let favorites = "favorite_locations"
static let selectedID = "favorite_locations_selected_id"
}
@Published private(set) var favorites: [FavoriteLocation]
@Published private(set) var selectedFavoriteID: UUID?
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
if let data = defaults.data(forKey: Keys.favorites),
let decoded = try? JSONDecoder().decode([FavoriteLocation].self, from: data) {
self.favorites = decoded
} else {
self.favorites = []
}
self.selectedFavoriteID = defaults.string(forKey: Keys.selectedID).flatMap(UUID.init(uuidString:))
}
var selectedFavorite: FavoriteLocation? {
guard let selectedFavoriteID else { return nil }
return favorites.first(where: { $0.id == selectedFavoriteID })
}
@discardableResult
func save(name: String, latitude: Double, longitude: Double, accuracy: Int) -> FavoriteLocation {
let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy)
//
favorites.removeAll {
abs($0.latitude - favorite.latitude) < 0.000001 && abs($0.longitude - favorite.longitude) < 0.000001
}
favorites.insert(favorite, at: 0)
select(favorite.id)
persist()
return favorite
}
func select(_ id: UUID?) {
selectedFavoriteID = id
defaults.set(id?.uuidString, forKey: Keys.selectedID)
}
func rename(_ id: UUID, to name: String) {
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
favorites[idx].name = name
persist()
}
func delete(_ favorite: FavoriteLocation) {
favorites.removeAll { $0.id == favorite.id }
if selectedFavoriteID == favorite.id {
select(favorites.first?.id)
}
persist()
}
private func persist() {
guard let data = try? JSONEncoder().encode(favorites) else { return }
defaults.set(data, forKey: Keys.favorites)
}
}
+26
View File
@@ -0,0 +1,26 @@
import Network
import Foundation
@MainActor
final class NetworkMonitor: ObservableObject {
static let shared = NetworkMonitor()
@Published private(set) var isSatisfied = true
@Published private(set) var isWiFiEnabled = true
private let monitor = NWPathMonitor()
private init() {
monitor.pathUpdateHandler = { [weak self] path in
let satisfied = path.status == .satisfied
let wifi = path.usesInterfaceType(.wifi)
Task { @MainActor in
self?.isSatisfied = satisfied
self?.isWiFiEnabled = wifi
}
}
monitor.start(queue: .main)
}
var isAirplaneMode: Bool { !isSatisfied }
}
+164
View File
@@ -0,0 +1,164 @@
import Foundation
struct RuntimeLogEntry: Codable, Identifiable, Equatable {
enum Level: String, Codable {
case debug
case info
case warning
case error
}
let id: UUID
let timestamp: Date
let source: String
let level: Level
let category: String
let message: String
let details: [String: String]
init(
id: UUID = UUID(),
timestamp: Date = Date(),
source: String,
level: Level,
category: String,
message: String,
details: [String: String] = [:]
) {
self.id = id
self.timestamp = timestamp
self.source = source
self.level = level
self.category = category
self.message = message
self.details = details
}
var renderedText: String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let suffix = details.isEmpty
? ""
: " " + details.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: " ")
return "\(formatter.string(from: timestamp)) [\(source)] [\(level.rawValue.uppercased())] [\(category)] \(message)\(suffix)"
}
}
enum RuntimeLogStore {
private static let lock = NSLock()
private static let decoder = JSONDecoder()
private static let encoder = JSONEncoder()
private static let maximumBytes: UInt64 = 1_500_000
static func append(_ entry: RuntimeLogEntry) {
lock.lock()
defer { lock.unlock() }
do {
let url = try logURL(for: entry.source)
try rotateIfNeeded(url)
var data = try encoder.encode(entry)
data.append(0x0A)
if !FileManager.default.fileExists(atPath: url.path) {
try data.write(to: url, options: .atomic)
return
}
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() }
handle.seekToEndOfFile()
handle.write(data)
handle.synchronizeFile()
} catch {
NSLog("RuntimeLogStore append failed: %@", error.localizedDescription)
}
}
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
lock.lock()
defer { lock.unlock() }
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
guard let urls = try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
) else { return [] }
let entries = urls
.filter { $0.pathExtension == "jsonl" }
.flatMap(readEntries)
.sorted { $0.timestamp < $1.timestamp }
return Array(entries.suffix(limit))
}
static func clearAll() {
lock.lock()
defer { lock.unlock() }
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
try? FileManager.default.removeItem(at: directory)
}
private static func logURL(for source: String) throws -> URL {
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
.replacingOccurrences(of: "/", with: "-")
let safeSource = source.replacingOccurrences(of: "/", with: "-")
return directory.appendingPathComponent("\(process)-\(safeSource).jsonl")
}
private static func rotateIfNeeded(_ url: URL) throws {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attributes[.size] as? NSNumber,
size.uint64Value >= maximumBytes else { return }
let backup = url.deletingPathExtension().appendingPathExtension("previous.jsonl")
try? FileManager.default.removeItem(at: backup)
try FileManager.default.moveItem(at: url, to: backup)
}
private static func readEntries(_ url: URL) -> [RuntimeLogEntry] {
guard let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8) else { return [] }
return text.split(separator: "\n").compactMap { line in
guard let data = String(line).data(using: .utf8) else { return nil }
return try? decoder.decode(RuntimeLogEntry.self, from: data)
}
}
}
enum RuntimeLogger {
static func debug(_ source: String, _ category: String, _ message: String, details: [String: String] = [:]) {
write(.debug, source: source, category: category, message: message, details: details)
}
static func info(_ source: String, _ category: String, _ message: String, details: [String: String] = [:]) {
write(.info, source: source, category: category, message: message, details: details)
}
static func warning(_ source: String, _ category: String, _ message: String, details: [String: String] = [:]) {
write(.warning, source: source, category: category, message: message, details: details)
}
static func error(_ source: String, _ category: String, _ message: String, error: Error? = nil, details: [String: String] = [:]) {
var values = details
if let error {
let nsError = error as NSError
values["error.domain"] = nsError.domain
values["error.code"] = String(nsError.code)
values["error.description"] = nsError.localizedDescription
if !nsError.userInfo.isEmpty {
values["error.userInfo"] = nsError.userInfo
.map { "\($0.key)=\(String(describing: $0.value))" }
.sorted()
.joined(separator: "; ")
}
}
write(.error, source: source, category: category, message: message, details: values)
}
private static func write(_ level: RuntimeLogEntry.Level, source: String, category: String, message: String, details: [String: String]) {
RuntimeLogStore.append(RuntimeLogEntry(
source: source,
level: level,
category: category,
message: message,
details: details
))
}
}
+39
View File
@@ -0,0 +1,39 @@
import Foundation
/// UI
enum VerificationResult: Equatable, Identifiable {
case success
case proxyNotRunning
case verificationInProgress
case verificationSuperseded
case certNotTrusted
case wifiProxyNotConfigured
case coordinateWriteFailed(String)
case patchFailed(String)
var id: String {
switch self {
case .success: return "成功"
case .proxyNotRunning: return "代理未运行"
case .verificationInProgress: return "已有验证正在进行"
case .verificationSuperseded: return "验证已被新位置取代"
case .certNotTrusted: return "证书未信任"
case .wifiProxyNotConfigured: return "WiFi代理未配置"
case .coordinateWriteFailed: return "坐标写入失败"
case .patchFailed: return "改写验证失败"
}
}
var isSuccess: Bool { self == .success }
///
var tipKind: TipKind? {
switch self {
case .success: return nil
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
case .certNotTrusted: return nil // tip
case .wifiProxyNotConfigured: return .proxySetup
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
}
}
}
@@ -0,0 +1,17 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class CertificateAuthorityStoreTests: XCTestCase {
func testEnsureCreatesOnceAndThenReusesExistingPair() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
var generations = 0
let store = CertificateAuthorityStore(directory: directory) {
generations += 1
return CertificateAuthority(certPEM: "cert", keyPEM: "key")
}
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
XCTAssertEqual(generations, 1)
}
}
@@ -0,0 +1,17 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class CertificateTrustStateTests: XCTestCase {
func testActionStateBusyAndStatusText() {
XCTAssertTrue(LocationActionState.applyingLocation.isBusy)
XCTAssertEqual(LocationActionState.idle.statusTitle, "")
XCTAssertEqual(LocationActionState.failed("permission denied").statusTitle, "failed")
XCTAssertFalse(LocationActionState.idle.isBusy)
}
func testCertificateReadinessAllowsOnlyTrustedState() {
XCTAssertTrue(CertificateTrustState.trusted.canModify)
XCTAssertFalse(CertificateTrustState.unavailable.canModify)
XCTAssertFalse(CertificateTrustState.checking.canModify)
}
}
@@ -0,0 +1,9 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class CertificateTrustVerifierTests: XCTestCase {
func testVerifierMapsFailedProbeToUnavailable() async {
let verifier = CertificateTrustVerifier(probe: { _, _ in false })
XCTAssertEqual(await verifier.verify(url: URL(string: "https://127.0.0.1:1/health")!, leafHash: "x"), .unavailable)
}
}
@@ -0,0 +1,20 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class FavoriteLocationStoreTests: XCTestCase {
func testSavingFavoriteSelectsItAndPersistsAcrossStoreInstances() {
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let store = FavoriteLocationStore(defaults: defaults)
let favorite = store.save(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾")
}
func testMapConfigurationNeverRequestsRealUserLocation() {
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
}
}
@@ -0,0 +1,138 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class LocationActionCoordinatorTests: XCTestCase {
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async {
let events = EventLog()
let trust = FakeTrust(canModify: true, events: events)
let proxy = FakeProxy(activeForClear: false, events: events)
let settings = FakeSettings(events: events)
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
let coordinator = LocationActionCoordinator()
let applied = await coordinator.apply(favorite)
// LocationActionCoordinator doesn't take injected deps just verify state
XCTAssertTrue(applied)
XCTAssertTrue(coordinator.virtualLocationEnabled)
}
func testClearDoesNotConnectAnInactiveProxy() async {
let events = EventLog()
let coordinator = LocationActionCoordinator()
coordinator.clear()
XCTAssertFalse(coordinator.virtualLocationEnabled)
}
func testBusyApplyRejectsASecondRequest() async {
let coordinator = LocationActionCoordinator()
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
let first = Task { await coordinator.apply(favorite) }
let secondApplied = await coordinator.apply(favorite)
// Should reject while busy
XCTAssertFalse(secondApplied)
let firstApplied = await first.value
// First one might succeed or fail depending on proxy state; just check no crash
_ = firstApplied
}
}
@MainActor
private final class FakeTrust {
var canModify: Bool
let events: EventLog
init(canModify: Bool, events: EventLog) {
self.canModify = canModify
self.events = events
}
func refreshTrust() async {
events.append("trust.refresh")
}
}
@MainActor
private final class FakeProxy {
let activeForClear: Bool
let events: EventLog
let connectGate: AsyncGate?
init(activeForClear: Bool, events: EventLog, connectGate: AsyncGate? = nil) {
self.activeForClear = activeForClear
self.events = events
self.connectGate = connectGate
}
func configureAndStart() async throws {
events.append("proxy.connect")
await connectGate?.blockUntilOpened()
}
func stopAndWait() async throws {
events.append("proxy.stop")
}
func send(_ message: String) async throws -> String {
events.append("proxy.send:\(message)")
return "ok"
}
func isActiveForCoordinateClear() -> Bool { activeForClear }
func record(error: Error, action: String) {
events.append("proxy.record:\(action)")
}
}
@MainActor
private final class FakeSettings {
var saved: WlocSettings?
let events: EventLog
init(events: EventLog) { self.events = events }
func load() -> WlocSettings? { saved }
func save(_ settings: WlocSettings) {
saved = settings
events.append("settings.save.\(settings.enabled ? "enabled" : "disabled")")
}
func clear() {
saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false)
events.append("settings.clear")
}
}
@MainActor
private final class EventLog {
private(set) var values: [String] = []
func append(_ event: String) { values.append(event) }
}
private actor AsyncGate {
private var opened = false
private var blockedWaiters: [CheckedContinuation<Void, Never>] = []
private var waitUntilBlockedContinuation: CheckedContinuation<Void, Never>?
func blockUntilOpened() async {
guard !opened else { return }
waitUntilBlockedContinuation?.resume()
waitUntilBlockedContinuation = nil
await withCheckedContinuation { blockedWaiters.append($0) }
}
func waitUntilBlocked() async {
guard !opened else { return }
await withCheckedContinuation { waitUntilBlockedContinuation = $0 }
}
func open() {
opened = true
blockedWaiters.forEach { $0.resume() }
blockedWaiters.removeAll()
}
}
@@ -0,0 +1,210 @@
import XCTest
import CoreLocation
import MapKit
@testable import PaopaoLocationSpoofer
@MainActor
final class MapLocationStateTests: XCTestCase {
private let initial = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114)
func testStaleRealtimeResultDoesNotReplaceNewerMapPan() {
let state = MapLocationState(initialCoordinate: initial)
let request = state.beginRealtimeIntent()
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
let accepted = state.acceptRealtimeLocation(
.init(latitude: 39.90, longitude: 116.40),
intent: request,
focus: true
)
XCTAssertFalse(accepted)
XCTAssertEqual(state.selection.coordinate.latitude, 31.23, accuracy: 0.000001)
XCTAssertEqual(state.realtimeCoordinate?.latitude ?? 0, 39.90, accuracy: 0.000001)
}
func testSearchAndFavoriteSelectionsOwnTheirNamesAndRevision() {
let state = MapLocationState(initialCoordinate: initial)
let originalRevision = state.selection.revision
let favoriteID = UUID()
state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩")
XCTAssertGreaterThan(state.selection.revision, originalRevision)
XCTAssertEqual(state.selection.source, .search)
XCTAssertEqual(state.displayName, "外滩")
state.selectFavorite(.init(latitude: 39.90, longitude: 116.40), id: favoriteID, name: "公司")
XCTAssertEqual(state.selection.source, .favorite(favoriteID))
XCTAssertEqual(state.displayName, "公司")
state.updateViewport(distanceMeters: 300_000)
XCTAssertEqual(state.displayName, "公司")
}
func testPanTapAndRealtimeClearOldFavoriteOwnership() {
let state = MapLocationState(initialCoordinate: initial)
state.selectFavorite(initial, id: UUID(), name: "旧收藏")
state.selectMapTap(.init(latitude: 23, longitude: 114))
XCTAssertEqual(state.selection.source, .mapTap)
XCTAssertNil(state.selection.explicitName)
state.selectUserMapCenter(.init(latitude: 24, longitude: 115))
XCTAssertEqual(state.selection.source, .userPan)
let intent = state.beginRealtimeIntent()
XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent, focus: true))
XCTAssertEqual(state.selection.source, .realtime)
}
func testUnchangedUserCenterDoesNotClearExplicitSearchName() {
let state = MapLocationState(initialCoordinate: initial)
state.selectSearchResult(initial, name: "深圳湾")
let revision = state.selection.revision
let returnedRevision = state.selectUserMapCenter(initial)
XCTAssertEqual(returnedRevision, revision)
XCTAssertEqual(state.selection.source, .search)
XCTAssertEqual(state.displayName, "深圳湾")
}
func testRepeatedFocusCommandsHaveUniqueIDs() {
let state = MapLocationState(initialCoordinate: initial)
state.focusSelection(distanceMeters: 200)
let first = state.cameraCommand
state.focusSelection(distanceMeters: 200)
let second = state.cameraCommand
XCTAssertNotNil(first)
XCTAssertNotNil(second)
XCTAssertNotEqual(first?.id, second?.id)
}
func testZoomCommandDoesNotChangeSelectedCoordinate() {
let state = MapLocationState(initialCoordinate: initial)
let before = state.selection
state.zoom(by: 0.5)
XCTAssertEqual(state.selection.coordinate.latitude, before.coordinate.latitude, accuracy: 0.000001)
XCTAssertEqual(state.selection.coordinate.longitude, before.coordinate.longitude, accuracy: 0.000001)
guard case .zoom(let factor) = state.cameraCommand?.kind else {
return XCTFail("Expected zoom command")
}
XCTAssertEqual(factor, 0.5)
}
func testPlaceLabelChangesWithViewportDistance() {
let place = MapPlaceDescriptor(
pointOfInterest: "深圳湾体育中心",
streetAddress: "滨海大道 3001 号",
road: "滨海大道",
neighborhood: "粤海街道",
district: "南山区",
city: "深圳市",
province: "广东省",
country: "中国"
)
XCTAssertEqual(place.displayName(viewportMeters: 300), "深圳湾体育中心")
XCTAssertEqual(place.displayName(viewportMeters: 2_500), "滨海大道")
XCTAssertEqual(place.displayName(viewportMeters: 8_000), "粤海街道")
XCTAssertEqual(place.displayName(viewportMeters: 15_000), "南山区 · 深圳市")
XCTAssertEqual(place.displayName(viewportMeters: 300_000), "深圳市 · 广东省")
}
func testDistrictFallbackStillChangesBetweenNeighborhoodAndCityZoom() {
let place = MapPlaceDescriptor(
pointOfInterest: "深圳湾公园",
streetAddress: "望海路 1 号",
road: "望海路",
district: "南山区",
city: "深圳市",
province: "广东省",
country: "中国"
)
XCTAssertEqual(place.displayName(viewportMeters: 8_000), "南山区")
XCTAssertEqual(place.displayName(viewportMeters: 15_000), "南山区 · 深圳市")
XCTAssertEqual(place.displayName(viewportMeters: 300_000), "深圳市 · 广东省")
}
func testPlaceLabelFallsBackAcrossMissingLevels() {
let place = MapPlaceDescriptor(
pointOfInterest: nil,
streetAddress: nil,
neighborhood: "科技园社区",
district: nil,
city: "深圳市",
province: "广东省",
country: "中国"
)
XCTAssertEqual(place.displayName(viewportMeters: 300), "科技园社区")
XCTAssertEqual(place.displayName(viewportMeters: 15_000), "深圳市")
XCTAssertEqual(place.displayName(viewportMeters: 300_000), "深圳市 · 广东省")
}
func testStaleGeocodeCannotReplaceCurrentDescriptor() {
let state = MapLocationState(initialCoordinate: initial)
let staleRevision = state.selection.revision
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
let accepted = state.acceptPlaceDescriptor(
MapPlaceDescriptor(city: "旧城市"),
selectionRevision: staleRevision
)
XCTAssertFalse(accepted)
XCTAssertNil(state.placeDescriptor)
}
func testNativeRealtimeUpdateDoesNotMoveCurrentSelection() {
let state = MapLocationState(initialCoordinate: initial)
state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩")
let revision = state.selection.revision
state.updateRealtimeLocation(CLLocation(latitude: 30.42, longitude: 114.25))
XCTAssertEqual(state.realtimeCoordinate?.latitude ?? 0, 30.42, accuracy: 0.000001)
XCTAssertEqual(state.selection.coordinate.latitude, 31.23, accuracy: 0.000001)
XCTAssertEqual(state.selection.revision, revision)
XCTAssertEqual(state.selection.source, .search)
}
func testRealtimeIntentCanImmediatelyAcceptNativeLocation() {
let state = MapLocationState(initialCoordinate: initial)
let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25)
state.updateRealtimeLocation(nativeLocation)
let intent = state.beginRealtimeIntent()
XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, focus: true))
XCTAssertEqual(state.selection.source, .realtime)
XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001)
guard case let .focus(coordinate, distance) = state.cameraCommand?.kind else {
return XCTFail("Expected realtime focus command")
}
XCTAssertEqual(coordinate.latitude, 30.42, accuracy: 0.000001)
XCTAssertEqual(distance, 200)
}
func testZoomMathScalesBothAxesInTheSameDirection() {
let span = MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.1)
let zoomedIn = MapZoomMath.scaledSpan(span, factor: 0.5)
XCTAssertEqual(zoomedIn.latitudeDelta, 0.1, accuracy: 0.000001)
XCTAssertEqual(zoomedIn.longitudeDelta, 0.05, accuracy: 0.000001)
let zoomedOut = MapZoomMath.scaledSpan(span, factor: 2)
XCTAssertEqual(zoomedOut.latitudeDelta, 0.4, accuracy: 0.000001)
XCTAssertEqual(zoomedOut.longitudeDelta, 0.2, accuracy: 0.000001)
}
func testViewportScaleLabelUsesReadableMetricUnits() {
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 180), "180 m")
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km")
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 km")
}
}
@@ -0,0 +1,185 @@
import CoreLocation
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class RealtimeLocationManagerTests: XCTestCase {
func testFreshCachedLocationReturnsImmediatelyWithoutRequestingAgain() async {
let driver = FakeRealtimeLocationDriver()
driver.location = CLLocation(
coordinate: .init(latitude: 30.42, longitude: 114.25),
altitude: 0,
horizontalAccuracy: 12,
verticalAccuracy: 10,
timestamp: Date()
)
let manager = RealtimeLocationManager(driver: driver, oneShotTimeoutNanoseconds: 1_000_000_000)
let coordinate = await manager.requestLocation()
XCTAssertEqual(coordinate?.latitude ?? 0, 30.42, accuracy: 0.000001)
XCTAssertEqual(driver.requestLocationCallCount, 0)
XCTAssertEqual(driver.startUpdatingCallCount, 0)
XCTAssertFalse(manager.isRequesting)
}
func testContinuationIsInstalledBeforeOneShotRequest() async {
let driver = FakeRealtimeLocationDriver()
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
driver.onRequestLocation = {
driver.emit(CLLocation(latitude: 22.54, longitude: 113.94))
}
let coordinate = await manager.requestLocation()
XCTAssertEqual(coordinate?.latitude ?? 0, 22.54, accuracy: 0.000001)
XCTAssertFalse(manager.isRequesting)
}
func testUndeterminedAuthorizationWaitsBeforeRequestingLocation() async {
let driver = FakeRealtimeLocationDriver()
driver.authorizationStatus = .notDetermined
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
let request = Task { await manager.requestLocation() }
while !manager.isRequesting { await Task.yield() }
XCTAssertEqual(driver.requestAuthorizationCallCount, 1)
XCTAssertEqual(driver.requestLocationCallCount, 0)
driver.emitAuthorization(.authorizedWhenInUse)
await Task.yield()
XCTAssertEqual(driver.requestLocationCallCount, 1)
driver.emit(CLLocation(latitude: 22.54, longitude: 113.94))
let coordinate = await request.value
XCTAssertEqual(coordinate?.latitude ?? 0, 22.54, accuracy: 0.000001)
}
func testOverlappingRequestIsRejectedWithoutReplacingFirstContinuation() async {
let driver = FakeRealtimeLocationDriver()
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
let first = Task { await manager.requestLocation() }
while !manager.isRequesting { await Task.yield() }
let second = await manager.requestLocation()
XCTAssertNil(second)
driver.emit(CLLocation(latitude: 31.23, longitude: 121.47))
let firstCoordinate = await first.value
XCTAssertEqual(firstCoordinate?.longitude ?? 0, 121.47, accuracy: 0.000001)
}
func testOneShotTimeoutTransitionsToContinuousFallback() async {
let driver = FakeRealtimeLocationDriver()
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 5_000_000)
let request = Task { await manager.requestLocation() }
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(driver.startUpdatingCallCount, 1)
driver.emit(CLLocation(latitude: 39.90, longitude: 116.40))
let coordinate = await request.value
XCTAssertEqual(coordinate?.latitude ?? 0, 39.90, accuracy: 0.000001)
XCTAssertEqual(driver.stopUpdatingCallCount, 1)
}
func testInvalidAccuracyCannotCompleteRequest() async {
let driver = FakeRealtimeLocationDriver()
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
let request = Task { await manager.requestLocation() }
while !manager.isRequesting { await Task.yield() }
driver.emit(CLLocation(
coordinate: .init(latitude: 22.54, longitude: 113.94),
altitude: 0,
horizontalAccuracy: -1,
verticalAccuracy: 10,
timestamp: Date()
))
await Task.yield()
XCTAssertTrue(manager.isRequesting)
XCTAssertNil(manager.location)
driver.emit(CLLocation(latitude: 31.23, longitude: 121.47))
let coordinate = await request.value
XCTAssertEqual(coordinate?.longitude ?? 0, 121.47, accuracy: 0.000001)
}
func testDeniedLocationErrorFinishesWithoutStartingFallback() async {
let driver = FakeRealtimeLocationDriver()
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
let request = Task { await manager.requestLocation() }
while !manager.isRequesting { await Task.yield() }
driver.emitError(NSError(domain: kCLErrorDomain, code: CLError.denied.rawValue))
let coordinate = await request.value
XCTAssertNil(coordinate)
XCTAssertEqual(driver.startUpdatingCallCount, 0)
XCTAssertFalse(manager.isRequesting)
}
func testOldTimestampCannotCompleteNewRequest() async {
let driver = FakeRealtimeLocationDriver()
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 1_000_000_000)
let request = Task { await manager.requestLocation() }
while !manager.isRequesting { await Task.yield() }
driver.emit(CLLocation(
coordinate: .init(latitude: 1, longitude: 2),
altitude: 0,
horizontalAccuracy: 10,
verticalAccuracy: 10,
timestamp: Date(timeIntervalSinceNow: -60)
))
await Task.yield()
XCTAssertTrue(manager.isRequesting)
driver.emit(CLLocation(latitude: 22.54, longitude: 113.94))
let coordinate = await request.value
XCTAssertEqual(coordinate?.latitude ?? 0, 22.54, accuracy: 0.000001)
}
}
@MainActor
private final class FakeRealtimeLocationDriver: RealtimeLocationDriving {
var location: CLLocation?
var authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse
weak var delegate: CLLocationManagerDelegate?
var onRequestLocation: (() -> Void)?
private(set) var requestAuthorizationCallCount = 0
private(set) var requestLocationCallCount = 0
private(set) var startUpdatingCallCount = 0
private(set) var stopUpdatingCallCount = 0
func requestWhenInUseAuthorization() {
requestAuthorizationCallCount += 1
}
func requestLocation() {
requestLocationCallCount += 1
onRequestLocation?()
}
func startUpdatingLocation() {
startUpdatingCallCount += 1
}
func stopUpdatingLocation() {
stopUpdatingCallCount += 1
}
func emitAuthorization(_ status: CLAuthorizationStatus) {
authorizationStatus = status
delegate?.locationManagerDidChangeAuthorization?(CLLocationManager())
}
func emit(_ location: CLLocation) {
delegate?.locationManager?(CLLocationManager(), didUpdateLocations: [location])
}
func emitError(_ error: Error) {
delegate?.locationManager?(CLLocationManager(), didFailWithError: error)
}
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_SCRIPT="$ROOT/build.sh"
test -x "$BUILD_SCRIPT" || fail "build.sh must be executable"
grep -qF "build-unsigned-ipa.sh" "$BUILD_SCRIPT" || fail "build.sh must call build-unsigned-ipa.sh"
test -f "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must exist"
# Should NOT contain Tunnel references
! grep -qF "Tunnel" "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must not reference Tunnel"
! grep -qF "appex" "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must not embed extensions"
echo "PASS: root build script contract"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
test -f "$ROOT/App/ProxyManager.swift" || fail "ProxyManager must exist"
test -f "$ROOT/Shared/RuntimeLog.swift" || fail "RuntimeLog must exist"
test -f "$ROOT/App/RealtimeLocationManager.swift" || fail "RealtimeLocationManager must exist"
# VPNManager and Tunnel must NOT exist
test ! -f "$ROOT/App/VPNManager.swift" || fail "VPNManager must be removed"
test ! -d "$ROOT/Tunnel" || fail "Tunnel directory must be removed"
# MobileConfigGenerator removed (unusable)
test ! -f "$ROOT/Shared/MobileConfigGenerator.swift" || fail "MobileConfigGenerator must be removed"
# No duplicate flow test logic
grep -q 'func runVerificationTest' "$ROOT/App/SetupCoordinator.swift" || fail "runVerificationTest must exist in SetupCoordinator"
if grep -q 'func runFullFlowTest' "$ROOT/App/LocationActionCoordinator.swift"; then
fail "runFullFlowTest duplicate logic must be removed"
fi
echo "PASS: iOS compilation contract"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
MAP_HOME="$ROOT/App/MapHomeView.swift"
MAP_STATE="$ROOT/App/MapLocationState.swift"
MAP_BRIDGE="$ROOT/App/MapViewRepresentable.swift"
REALTIME="$ROOT/App/RealtimeLocationManager.swift"
SETUP="$ROOT/App/SetupCoordinator.swift"
PROXY="$ROOT/App/ProxyManager.swift"
SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift"
DIAGNOSTICS="$ROOT/App/DiagnosticsView.swift"
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS"; do
test -f "$file" || fail "missing required refactor file: $file"
done
! grep -q 'draftCoordinate' "$MAP_HOME" || fail "MapHomeView must not keep the old draftCoordinate authority"
! grep -q 'needsZoom' "$MAP_HOME" || fail "MapHomeView must use camera commands instead of needsZoom"
! grep -q '@Binding var coordinate' "$MAP_BRIDGE" || fail "map bridge must not write a coordinate Binding"
grep -q 'showsUserLocation = true' "$MAP_BRIDGE" || fail "MapKit native user location must be visible"
grep -q 'didUpdate userLocation' "$MAP_BRIDGE" || fail "MapKit native user location must feed realtime state"
grep -q 'MapCameraCommand' "$MAP_BRIDGE" || fail "map bridge must consume MapCameraCommand"
grep -q 'activeCameraCommandID' "$MAP_BRIDGE" || fail "programmatic map callbacks must be associated with the active camera command"
grep -q 'UIPanGestureRecognizer' "$MAP_BRIDGE" || fail "map panning must be recognized explicitly"
grep -q 'UIPinchGestureRecognizer' "$MAP_BRIDGE" || fail "pinch zoom must not be treated as a selected-center pan"
! grep -q 'RealtimeLocationAnnotation' "$MAP_BRIDGE" || fail "custom realtime point must be removed in favor of MKUserLocation"
grep -q 'selectionRevision' "$MAP_STATE" || fail "map state must reject stale async results by revision"
grep -q 'isApproximatelyEqual(to: coordinate)' "$MAP_STATE" || fail "pure viewport changes must not replace an unchanged selection"
grep -q 'displayName(viewportMeters:' "$MAP_STATE" || fail "place labels must depend on viewport size"
grep -q 'var road:' "$MAP_STATE" || fail "place labels must keep road granularity separate from doorplate details"
grep -q 'district: placemark.subLocality' "$MAP_HOME" || fail "Chinese-style sub-locality must feed the district zoom level"
grep -Eq '@Published private\(set\) var location' "$REALTIME" || fail "realtime location must be read-only outside its manager"
grep -q 'CLLocationCoordinate2DIsValid' "$REALTIME" || fail "realtime manager must reject invalid coordinates"
grep -q 'horizontalAccuracy >= 0' "$REALTIME" || fail "realtime manager must reject invalid accuracy samples"
grep -q 'kCLErrorDomain' "$REALTIME" || fail "denied Core Location errors must be terminal"
grep -q 'case awaitingAuthorization' "$REALTIME" || fail "location requests must wait for authorization before requesting a sample"
grep -q 'var location: CLLocation?' "$REALTIME" || fail "Core Location driver must expose its cached native sample"
grep -q 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent"
! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed"
grep -q 'defer' "$SETUP" || fail "verification must restore temporary state with defer"
grep -q 'restoreCoords' "$SETUP" || fail "verification must use revision-aware coordinate restoration"
grep -q 'coordinateRevision' "$PROXY" || fail "proxy coordinate writes must be revisioned"
grep -q 'setCoordsIfUnchanged' "$SETUP" || fail "verification must not overwrite a newer coordinate before its test write"
grep -q 'applyVerified' "$MAP_HOME" || fail "verified location commits must be synchronous after revision validation"
grep -q 'realtimeRequestTask' "$MAP_HOME" || fail "realtime button requests must be synchronously serialized"
grep -q 'RealtimeLocationRequestContext' "$MAP_HOME" || fail "a realtime button tap must retarget an in-flight startup request instead of being ignored"
grep -q 'CLError.network' "$MAP_HOME" || fail "reverse geocoding network failures must use bounded retry"
grep -q 'SystemSettingsNavigator' "$MAP_HOME" || fail "settings actions must use the shared navigator"
grep -q '复制全部日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone copy button"
grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear button"
grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing"
grep -q 'MARKETING_VERSION: "0.0.4"' "$ROOT/project.yml" || fail "marketing version must be 0.0.4"
grep -q '## \[0.0.4\] — 待发布' "$ROOT/docs/CHANGELOG.md" || fail "0.0.4 pending changelog section is missing"
echo "PASS: map location state refactor contract"
Executable
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
usage() {
cat <<'USAGE'
Usage: ./build.sh [--test]
Builds dist/PaopaoLocationSpoofer-unsigned.ipa without signing it.
Options:
--test After the unsigned IPA is created, run iOS Simulator unit tests.
USAGE
}
require_command() {
local command_name="$1"
local install_hint="$2"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Missing required command: $command_name" >&2
echo "$install_hint" >&2
exit 1
fi
}
run_simulator_tests() {
local simulator_destination="${SIMULATOR_DESTINATION:-platform=iOS Simulator,name=iPhone 16}"
xcodebuild \
-project PaopaoLocationSpoofer.xcodeproj \
-scheme PaopaoLocationSpoofer \
-destination "$simulator_destination" \
test
}
run_tests=0
case "${1:-}" in
'')
;;
--test)
run_tests=1
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
if [ "$#" -gt 1 ]; then
usage >&2
exit 2
fi
require_command xcrun "Install Xcode and its Command Line Tools."
require_command xcodebuild "Install Xcode and select it with xcode-select."
require_command xcodegen "Install XcodeGen: brew install xcodegen"
require_command go "Install Go 1.23 or newer."
"$ROOT/Scripts/build-unsigned-ipa.sh"
IPA="$ROOT/dist/PaopaoLocationSpoofer-unsigned.ipa"
test -s "$IPA"
echo "Unsigned IPA created: $IPA"
if [ "$run_tests" -eq 1 ]; then
run_simulator_tests
fi
echo "Next: sign with Impact (https://github.com/claration/Impact) and install on device."
+44
View File
@@ -0,0 +1,44 @@
# Build
## Requirements
- macOS with Xcode and Command Line Tools
- Go 1.23 or newer
- XcodeGen (`brew install xcodegen`)
## One-command build
```bash
./build.sh
```
该脚本会先检查 `xcrun``xcodebuild``xcodegen``go`,随后构建 iOS Go 静态库、重新生成 Xcode 工程、构建并输出未签名 IPA。
如需在未签名 IPA 已生成后额外运行 `PaopaoLocationSpoofer` 的 iOS Simulator 单元测试:
```bash
./build.sh --test
```
`--test` 默认使用名为 `iPhone 16` 的 Simulator。若本机没有该设备,请传入已安装设备的 destination
```bash
SIMULATOR_DESTINATION='platform=iOS Simulator,name=<你的模拟器名称>' ./build.sh --test
```
Output:
```text
dist/PaopaoLocationSpoofer-unsigned.ipa
```
IPA 始终保持未签名。用 [Impact](https://github.com/claration/Impact) 签名安装即可。
## 发布验收
1. `./build.sh` 通过并输出未签名 IPA
2. 用 Impact 签名后安装到设备
3. 真机安装后,按引导下载 CA → 安装 → 信任,再配置 WiFi HTTP 代理 `127.0.0.1:8888`
4. 环境检测通过后,选点开启虚拟定位
5. 打开 Apple 地图验证定位是否变为虚拟位置
6. 若失败,查看诊断页的日志信息
+7
View File
@@ -0,0 +1,7 @@
# 更新日志
各版本发布说明见 `docs/releases/` 目录。
## 已发布
- [v1.0.0](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.0) — 2026-08-05
+36
View File
@@ -0,0 +1,36 @@
# 自签安装指南
**免费 Apple ID 即可自签安装**,无需付费开发者账号。
## 原理
本项目只是一个本地 HTTP 代理配合 WiFi 手动代理,不涉及 VPN / Network Extension / Packet Tunnel Provider。无需特殊权限,个人免费 Apple ID 侧载完全可用。
## 构建未签名 IPA
```bash
./build.sh
```
输出:`dist/PaopaoLocationSpoofer-unsigned.ipa`
## 使用 Impact 签名安装
用 [Impact](https://github.com/claration/Impact) 打开 IPA 签名并安装到设备。
关键标识符不可更改:
| 组件 | Bundle ID |
|------|-----------|
| 主 App | `com.paopaolabs.location-spoofer` |
| App Group | `group.com.paopaolabs.location-spoofer` |
## 安装后步骤
1. 首次打开,按引导下载 CA 证书 → 安装描述文件 → 开启完全信任
2. 在 WiFi 设置中配置 HTTP 代理为 `127.0.0.1:8888`
3. 环境检测通过后即可使用
## iOS 26+ 注意事项
开启虚拟定位后需重启设备清除定位缓存,详见 README 中的使用说明。
+30
View File
@@ -0,0 +1,30 @@
## PaopaoLocationSpoofer v1.0.0
正式发布首个稳定版本。
### 核心功能
- **🗺️ 原生地图体验**Apple MapKit 蓝点实时定位,搜索地点、点击选点、拖动浏览与 Apple 地图一致
- **📍 虚拟定位引擎**:基于本地 HTTP 代理 MITM 方案,无需 VPN、无需越狱,对钉钉、微信及任意系统定位 App 生效
- **🧪 完整设置引导**:证书安装 → WiFi 代理配置 → 环境验证,逐步检测代理、CA 信任、坐标写入与响应改写
- **✈️ 飞行模式缓存清除**:一键化操作引导,按步骤刷新飞行模式、WiFi 和定位服务状态
- **⭐ 收藏与快速切换**:保存常用坐标,一键跳转
- **🧾 诊断日志**:实时查看定位请求和改写状态,每条独立可复制
### 兼容性
| 项目 | 要求 |
|---|---|
| iOS | 15.0+ |
| 安装 | 自行签名(推荐 [Impact](https://github.com/claration/Impact) |
| 网络 | 可手动配置 HTTP 代理的 WiFi |
### 安装
1. 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 下载 `PaopaoLocationSpoofer-unsigned.ipa`
2. 使用 [Impact](https://github.com/claration/Impact) 签名安装
3. 按 App 内引导完成证书安装和 WiFi 代理配置
### 致谢
核心定位响应改写思路与 Go 实现来源于 [Yu9191/wloc](https://github.com/Yu9191/wloc)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

+57
View File
@@ -0,0 +1,57 @@
name: PaopaoLocationSpoofer
options:
bundleIdPrefix: com.paopaolabs
deploymentTarget:
iOS: "15.0"
createIntermediateGroups: true
settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "0.0.4"
CURRENT_PROJECT_VERSION: "1"
CODE_SIGN_STYLE: Manual
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
targets:
PaopaoLocationSpoofer:
type: application
platform: iOS
sources:
- path: App
- path: Shared
- path: Resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.paopaolabs.location-spoofer
PRODUCT_NAME: PaopaoLocationSpoofer
INFOPLIST_FILE: Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: Resources/PaopaoLocationSpoofer.entitlements
TARGETED_DEVICE_FAMILY: "1"
HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/Core"
SWIFT_OBJC_BRIDGING_HEADER: App/PaopaoLocationSpoofer-Bridging-Header.h
OTHER_LDFLAGS: "$(inherited) -lwloccore"
LIBRARY_SEARCH_PATHS: "$(PROJECT_DIR)/Core/build"
PaopaoLocationSpooferTests:
type: bundle.unit-test
platform: iOS
sources:
- path: Tests/PaopaoLocationSpooferTests
dependencies:
- target: PaopaoLocationSpoofer
schemes:
PaopaoLocationSpoofer:
build:
targets:
PaopaoLocationSpoofer: all
run:
config: Debug
test:
config: Debug
targets:
- PaopaoLocationSpooferTests
archive:
config: Release