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