commit e137ff10e9b287c1faf1aff102fb256198985a75 Author: xweiba Date: Mon Aug 3 08:16:48 2026 +0800 release: PaopaoLocationSpoofer v1.0.0 - iOS 虚拟定位工具,基于本地 HTTP 代理 MITM 方案 - MapKit 原生地图体验,支持搜索、收藏、实时定位 - 完整的设置引导流程(证书安装、WiFi 代理配置、环境验证) - 支持 iOS 15+,SwiftUI 构建 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d494af0 --- /dev/null +++ b/.github/workflows/release.yml @@ -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<> $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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..118a1dc --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c9c4c66 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ + +# 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`. + + diff --git a/App/BugReportView.swift b/App/BugReportView.swift new file mode 100644 index 0000000..c4663a3 --- /dev/null +++ b/App/BugReportView.swift @@ -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 + } + } +} diff --git a/App/CertificateInstaller.swift b/App/CertificateInstaller.swift new file mode 100644 index 0000000..1477fc7 --- /dev/null +++ b/App/CertificateInstaller.swift @@ -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) + } +} diff --git a/App/ContentView.swift b/App/ContentView.swift new file mode 100644 index 0000000..268ea69 --- /dev/null +++ b/App/ContentView.swift @@ -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 } + } + } + } + } + } +} diff --git a/App/DiagnosticsView.swift b/App/DiagnosticsView.swift new file mode 100644 index 0000000..2eaac98 --- /dev/null +++ b/App/DiagnosticsView.swift @@ -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() } +} diff --git a/App/FirstSetupView.swift b/App/FirstSetupView.swift new file mode 100644 index 0000000..ae35460 --- /dev/null +++ b/App/FirstSetupView.swift @@ -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 } + } + } +} diff --git a/App/LocationActionCoordinator.swift b/App/LocationActionCoordinator.swift new file mode 100644 index 0000000..53996a5 --- /dev/null +++ b/App/LocationActionCoordinator.swift @@ -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) + } +} diff --git a/App/MapHomeView.swift b/App/MapHomeView.swift new file mode 100644 index 0000000..14b6902 --- /dev/null +++ b/App/MapHomeView.swift @@ -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? + @State private var geocodeDebounceTask: Task? + @State private var showLocationAlert = false + @State private var realtimeRequestTask: Task? + @State private var realtimeRequestContext: RealtimeLocationRequestContext? + @State private var copyConfirmed = false + @State private var spoofState: SpoofState = .idle + @State private var locationOperationTask: Task? + @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) + } + } + } +} diff --git a/App/MapLocationState.swift b/App/MapLocationState.swift new file mode 100644 index 0000000..3e0188c --- /dev/null +++ b/App/MapLocationState.swift @@ -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 + } +} diff --git a/App/MapViewRepresentable.swift b/App/MapViewRepresentable.swift new file mode 100644 index 0000000..261444e --- /dev/null +++ b/App/MapViewRepresentable.swift @@ -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 + } + } +} diff --git a/App/PaopaoLocationSpoofer-Bridging-Header.h b/App/PaopaoLocationSpoofer-Bridging-Header.h new file mode 100644 index 0000000..8582007 --- /dev/null +++ b/App/PaopaoLocationSpoofer-Bridging-Header.h @@ -0,0 +1,4 @@ +#ifndef PaopaoLocationSpoofer_Bridging_Header_h +#define PaopaoLocationSpoofer_Bridging_Header_h +#include "wloccore.h" +#endif diff --git a/App/PaopaoLocationSpooferApp.swift b/App/PaopaoLocationSpooferApp.swift new file mode 100644 index 0000000..925301f --- /dev/null +++ b/App/PaopaoLocationSpooferApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct PaopaoLocationSpooferApp: App { + init() { + RuntimeLogger.info("APP", "Lifecycle", "========== App 启动 ==========") + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/App/ProxyManager.swift b/App/ProxyManager.swift new file mode 100644 index 0000000..48f4c7f --- /dev/null +++ b/App/ProxyManager.swift @@ -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 启动失败" } +} diff --git a/App/RealtimeLocationManager.swift b/App/RealtimeLocationManager.swift new file mode 100644 index 0000000..f7b9899 --- /dev/null +++ b/App/RealtimeLocationManager.swift @@ -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 + 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? + + 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) + } +} diff --git a/App/SettingsView.swift b/App/SettingsView.swift new file mode 100644 index 0000000..24e29b7 --- /dev/null +++ b/App/SettingsView.swift @@ -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 { + Binding(get: { proxy.isRunning }, set: { on in + Task { + if on { + do { try await proxy.start() } catch { proxy.error = error.localizedDescription } + } else { + proxy.stop() + } + } + }) + } +} diff --git a/App/SetupCoordinator.swift b/App/SetupCoordinator.swift new file mode 100644 index 0000000..059ced0 --- /dev/null +++ b/App/SetupCoordinator.swift @@ -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-") + 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) + } + } +} diff --git a/App/SystemSettingsNavigator.swift b/App/SystemSettingsNavigator.swift new file mode 100644 index 0000000..e509139 --- /dev/null +++ b/App/SystemSettingsNavigator.swift @@ -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) + } + } +} diff --git a/App/TipViews.swift b/App/TipViews.swift new file mode 100644 index 0000000..2248c09 --- /dev/null +++ b/App/TipViews.swift @@ -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) + } + } +} diff --git a/Config/App.xcconfig b/Config/App.xcconfig new file mode 100644 index 0000000..01de130 --- /dev/null +++ b/Config/App.xcconfig @@ -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 diff --git a/Core/bridge.go b/Core/bridge.go new file mode 100644 index 0000000..46be9cf --- /dev/null +++ b/Core/bridge.go @@ -0,0 +1,225 @@ +package main + +/* +#cgo CFLAGS: -DGOOS_ios -DNDEBUG +#include +#include +*/ +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 +} diff --git a/Core/ca.go b/Core/ca.go new file mode 100644 index 0000000..9125eaa --- /dev/null +++ b/Core/ca.go @@ -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 +} diff --git a/Core/ca_test.go b/Core/ca_test.go new file mode 100644 index 0000000..85b3ee1 --- /dev/null +++ b/Core/ca_test.go @@ -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) + } +} diff --git a/Core/cert_server.go b/Core/cert_server.go new file mode 100644 index 0000000..fd7ee06 --- /dev/null +++ b/Core/cert_server.go @@ -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 +} diff --git a/Core/cert_server_test.go b/Core/cert_server_test.go new file mode 100644 index 0000000..564640b --- /dev/null +++ b/Core/cert_server_test.go @@ -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") + } +} diff --git a/Core/go.mod b/Core/go.mod new file mode 100644 index 0000000..a79e432 --- /dev/null +++ b/Core/go.mod @@ -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 +) diff --git a/Core/go.sum b/Core/go.sum new file mode 100644 index 0000000..bbd613c --- /dev/null +++ b/Core/go.sum @@ -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= diff --git a/Core/main.go b/Core/main.go new file mode 100644 index 0000000..38dd16d --- /dev/null +++ b/Core/main.go @@ -0,0 +1,3 @@ +package main + +func main() {} diff --git a/Core/proxy.go b/Core/proxy.go new file mode 100644 index 0000000..0db7881 --- /dev/null +++ b/Core/proxy.go @@ -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(`CA Certificate

Download CA certificate

`)) + 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(`CA Certificate

Downloading CA certificate...

`)) + 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 := ` + + + + +Preparing Certificate + + +

正在准备 CA 证书,如未弹出请点击 这里

+ +` + 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 ` + + + + PayloadContent + + + PayloadDescription + Configures a global HTTP proxy for location spoofing. + PayloadDisplayName + Paopao Location Proxy + PayloadIdentifier + com.paopaolabs.location-spoofer.proxy.payload + PayloadType + com.apple.proxy.http.global + PayloadUUID + ` + uuidString() + ` + PayloadVersion + 1 + GlobalHTTPProxy + + ProxyServer + 127.0.0.1 + ProxyServerPort + 8888 + ProxyType + Manual + + + + PayloadDisplayName + Paopao Location Proxy + PayloadIdentifier + com.paopaolabs.location-spoofer.proxy + PayloadType + Configuration + PayloadUUID + ` + uuidString() + ` + PayloadVersion + 1 + +` +} + +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 +} diff --git a/Core/wloc_patch.go b/Core/wloc_patch.go new file mode 100644 index 0000000..535bc83 --- /dev/null +++ b/Core/wloc_patch.go @@ -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 +} diff --git a/Core/wloc_patch_test.go b/Core/wloc_patch_test.go new file mode 100644 index 0000000..eb79374 --- /dev/null +++ b/Core/wloc_patch_test.go @@ -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") + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d2290eb --- /dev/null +++ b/Makefile @@ -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)" diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..b30ab8a --- /dev/null +++ b/README.en.md @@ -0,0 +1,163 @@ +
+ +# 📍 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.**
+Works with DingTalk check-in, WeChat location sharing, and any app that uses system location. Map selection, real-time location, environment verification, certificate setup, and runtime logs in a single app. + +[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml) +[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138?logo=swift&logoColor=white)](project.yml) +[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go&logoColor=white)](Core/go.mod) +[![Version](https://img.shields.io/badge/version-v1.0.0-2563EB)](docs/CHANGELOG.md) +[![No VPN](https://img.shields.io/badge/VPN-Not%20needed-16A34A)](#why-no-vpn) + +[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md) + +Location Spoofer iOS Fake GPS main interface + +
+ +> [!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 | +|---|---|---|---| +| ![Location Spoofer main interface](images/主界面.jpg) | ![Apple Maps result](images/Apple%20Map.jpg) | ![Amap result](images/高德地图.jpg) | ![Apple Watch region feature](images/高血压.jpg) | + +## Key Features + +- **iOS Location Spoofer / Fake GPS**: Apply the selected coordinate to the local proxy that rewrites location responses, compatible with DingTalk check-in, WeChat location sharing, and more. +- **Native real-time location**: The map displays MapKit's own blue dot — no extra "fake real-time" overlay. +- **Concurrency-safe selection**: Pan, tap, search, favorites, and async location respect the user's latest intent; stale results won't overwrite newer selections. +- **Hierarchical place names**: POI, street, or road at close zoom; neighborhood, district, city, or province at wider zoom. +- **Map scale display**: Zoom controls show the current visible range. +- **Favorites with quick switch**: Save frequent coordinates and see which location is about to be applied. +- **Setup guide**: Certificate download, installation, full trust, 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/) diff --git a/README.md b/README.md new file mode 100644 index 0000000..70c8159 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +
+ +# 📍 Location Spoofer + +### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS + +**无需 VPN、无需越狱,在 iPhone 本机通过 Wi‑Fi HTTP 代理改写 Apple 定位响应。**
+可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。 + +[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml) +[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138?logo=swift&logoColor=white)](project.yml) +[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go&logoColor=white)](Core/go.mod) +[![Version](https://img.shields.io/badge/version-v1.0.0-2563EB)](docs/CHANGELOG.md) +[![No VPN](https://img.shields.io/badge/VPN-不需要-16A34A)](#为什么不需要-vpn) + +[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md) + +Location Spoofer iOS 虚拟定位 Fake GPS 主界面 + +
+ +> [!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 接管、坐标写入与响应改写 | +| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 | + +## 效果预览 + + + + + + + + + + + + + + +
应用主界面Apple 地图高德地图Apple Watch
iOS 虚拟定位应用主界面iPhone Location Spoofer Apple Maps 效果Fake GPS 高德地图定位效果Apple Watch 地区功能验证
+ +## 核心功能 + +- **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/) diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..27d2544 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..da520c9 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Location Spoofer + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationQueriesSchemes + + App-Prefs + + LSRequiresIPhoneOS + + NSLocationAlwaysAndWhenInUseUsageDescription + 用于显示和验证虚拟定位是否生效 + NSLocationWhenInUseUsageDescription + 用于显示和验证虚拟定位是否生效 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + audio + + UILaunchScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/Resources/PaopaoLocationSpoofer.entitlements b/Resources/PaopaoLocationSpoofer.entitlements new file mode 100644 index 0000000..62913c9 --- /dev/null +++ b/Resources/PaopaoLocationSpoofer.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.paopaolabs.location-spoofer + + + \ No newline at end of file diff --git a/Scripts/build-core.sh b/Scripts/build-core.sh new file mode 100755 index 0000000..74c8871 --- /dev/null +++ b/Scripts/build-core.sh @@ -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" diff --git a/Scripts/build-unsigned-ipa.sh b/Scripts/build-unsigned-ipa.sh new file mode 100755 index 0000000..0014579 --- /dev/null +++ b/Scripts/build-unsigned-ipa.sh @@ -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" diff --git a/Scripts/impactor-entitlements-app.plist b/Scripts/impactor-entitlements-app.plist new file mode 100644 index 0000000..4bbdcc0 --- /dev/null +++ b/Scripts/impactor-entitlements-app.plist @@ -0,0 +1,18 @@ + + + + + com.apple.security.application-groups + + group.com.paopaolabs.location-spoofer + + application-identifier + $(AppIdentifierPrefix)com.paopaolabs.location-spoofer + keychain-access-groups + + $(AppIdentifierPrefix)com.paopaolabs.location-spoofer + + get-task-allow + + + diff --git a/Scripts/setup.sh b/Scripts/setup.sh new file mode 100755 index 0000000..1dff858 --- /dev/null +++ b/Scripts/setup.sh @@ -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." diff --git a/Shared/AppGroup.swift b/Shared/AppGroup.swift new file mode 100644 index 0000000..2a9df69 --- /dev/null +++ b/Shared/AppGroup.swift @@ -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)) + } +} diff --git a/Shared/BackgroundKeepAlive.swift b/Shared/BackgroundKeepAlive.swift new file mode 100644 index 0000000..28c5c86 --- /dev/null +++ b/Shared/BackgroundKeepAlive.swift @@ -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", "后台保活已停止") + } +} diff --git a/Shared/CertificateAuthorityStore.swift b/Shared/CertificateAuthorityStore.swift new file mode 100644 index 0000000..36de1e8 --- /dev/null +++ b/Shared/CertificateAuthorityStore.swift @@ -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 + } +} diff --git a/Shared/CertificateTrustState.swift b/Shared/CertificateTrustState.swift new file mode 100644 index 0000000..34bf876 --- /dev/null +++ b/Shared/CertificateTrustState.swift @@ -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" + } + } +} diff --git a/Shared/CertificateTrustVerifier.swift b/Shared/CertificateTrustVerifier.swift new file mode 100644 index 0000000..2f5ebef --- /dev/null +++ b/Shared/CertificateTrustVerifier.swift @@ -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 + } +} diff --git a/Shared/CoordinateConverter.swift b/Shared/CoordinateConverter.swift new file mode 100644 index 0000000..106f70d --- /dev/null +++ b/Shared/CoordinateConverter.swift @@ -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 + } +} diff --git a/Shared/CoreBridge.swift b/Shared/CoreBridge.swift new file mode 100644 index 0000000..31b1325 --- /dev/null +++ b/Shared/CoreBridge.swift @@ -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.. 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 = "" + } +} diff --git a/Shared/FavoriteLocationStore.swift b/Shared/FavoriteLocationStore.swift new file mode 100644 index 0000000..57d9e14 --- /dev/null +++ b/Shared/FavoriteLocationStore.swift @@ -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) + } +} diff --git a/Shared/NetworkMonitor.swift b/Shared/NetworkMonitor.swift new file mode 100644 index 0000000..c1e7aab --- /dev/null +++ b/Shared/NetworkMonitor.swift @@ -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 } +} diff --git a/Shared/RuntimeLog.swift b/Shared/RuntimeLog.swift new file mode 100644 index 0000000..eb6ba37 --- /dev/null +++ b/Shared/RuntimeLog.swift @@ -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 + )) + } +} diff --git a/Shared/VerificationResult.swift b/Shared/VerificationResult.swift new file mode 100644 index 0000000..540c981 --- /dev/null +++ b/Shared/VerificationResult.swift @@ -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 + } + } +} diff --git a/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift b/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift new file mode 100644 index 0000000..5a8b354 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift @@ -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) + } +} diff --git a/Tests/PaopaoLocationSpooferTests/CertificateTrustStateTests.swift b/Tests/PaopaoLocationSpooferTests/CertificateTrustStateTests.swift new file mode 100644 index 0000000..f597848 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/CertificateTrustStateTests.swift @@ -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) + } +} diff --git a/Tests/PaopaoLocationSpooferTests/CertificateTrustVerifierTests.swift b/Tests/PaopaoLocationSpooferTests/CertificateTrustVerifierTests.swift new file mode 100644 index 0000000..61a02c1 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/CertificateTrustVerifierTests.swift @@ -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) + } +} diff --git a/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift b/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift new file mode 100644 index 0000000..86e2fe2 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift @@ -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) + } +} diff --git a/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift b/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift new file mode 100644 index 0000000..19f9935 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift @@ -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] = [] + private var waitUntilBlockedContinuation: CheckedContinuation? + + 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() + } +} diff --git a/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift b/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift new file mode 100644 index 0000000..879562a --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift @@ -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") + } +} diff --git a/Tests/PaopaoLocationSpooferTests/RealtimeLocationManagerTests.swift b/Tests/PaopaoLocationSpooferTests/RealtimeLocationManagerTests.swift new file mode 100644 index 0000000..0e3895d --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/RealtimeLocationManagerTests.swift @@ -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) + } +} diff --git a/Tests/build_script_test.sh b/Tests/build_script_test.sh new file mode 100755 index 0000000..cb5af0e --- /dev/null +++ b/Tests/build_script_test.sh @@ -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" diff --git a/Tests/ios_compilation_contract_test.sh b/Tests/ios_compilation_contract_test.sh new file mode 100755 index 0000000..5846b90 --- /dev/null +++ b/Tests/ios_compilation_contract_test.sh @@ -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" diff --git a/Tests/map_refactor_contract_test.sh b/Tests/map_refactor_contract_test.sh new file mode 100755 index 0000000..a67d414 --- /dev/null +++ b/Tests/map_refactor_contract_test.sh @@ -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" diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..1c2520d --- /dev/null +++ b/build.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +usage() { + cat <<'USAGE' +Usage: ./build.sh [--test] + +Builds dist/PaopaoLocationSpoofer-unsigned.ipa without signing it. + +Options: + --test After the unsigned IPA is created, run iOS Simulator unit tests. +USAGE +} + +require_command() { + local command_name="$1" + local install_hint="$2" + + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Missing required command: $command_name" >&2 + echo "$install_hint" >&2 + exit 1 + fi +} + +run_simulator_tests() { + local simulator_destination="${SIMULATOR_DESTINATION:-platform=iOS Simulator,name=iPhone 16}" + + xcodebuild \ + -project PaopaoLocationSpoofer.xcodeproj \ + -scheme PaopaoLocationSpoofer \ + -destination "$simulator_destination" \ + test +} + +run_tests=0 +case "${1:-}" in + '') + ;; + --test) + run_tests=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; +esac + +if [ "$#" -gt 1 ]; then + usage >&2 + exit 2 +fi + +require_command xcrun "Install Xcode and its Command Line Tools." +require_command xcodebuild "Install Xcode and select it with xcode-select." +require_command xcodegen "Install XcodeGen: brew install xcodegen" +require_command go "Install Go 1.23 or newer." + +"$ROOT/Scripts/build-unsigned-ipa.sh" + +IPA="$ROOT/dist/PaopaoLocationSpoofer-unsigned.ipa" +test -s "$IPA" +echo "Unsigned IPA created: $IPA" + +if [ "$run_tests" -eq 1 ]; then + run_simulator_tests +fi + +echo "Next: sign with Impact (https://github.com/claration/Impact) and install on device." diff --git a/docs/BUILD.md b/docs/BUILD.md new file mode 100644 index 0000000..a1a112d --- /dev/null +++ b/docs/BUILD.md @@ -0,0 +1,44 @@ +# Build + +## Requirements + +- macOS with Xcode and Command Line Tools +- Go 1.23 or newer +- XcodeGen (`brew install xcodegen`) + +## One-command build + +```bash +./build.sh +``` + +该脚本会先检查 `xcrun`、`xcodebuild`、`xcodegen` 和 `go`,随后构建 iOS Go 静态库、重新生成 Xcode 工程、构建并输出未签名 IPA。 + +如需在未签名 IPA 已生成后额外运行 `PaopaoLocationSpoofer` 的 iOS Simulator 单元测试: + +```bash +./build.sh --test +``` + +`--test` 默认使用名为 `iPhone 16` 的 Simulator。若本机没有该设备,请传入已安装设备的 destination: + +```bash +SIMULATOR_DESTINATION='platform=iOS Simulator,name=<你的模拟器名称>' ./build.sh --test +``` + +Output: + +```text +dist/PaopaoLocationSpoofer-unsigned.ipa +``` + +IPA 始终保持未签名。用 [Impact](https://github.com/claration/Impact) 签名安装即可。 + +## 发布验收 + +1. `./build.sh` 通过并输出未签名 IPA +2. 用 Impact 签名后安装到设备 +3. 真机安装后,按引导下载 CA → 安装 → 信任,再配置 WiFi HTTP 代理 `127.0.0.1:8888` +4. 环境检测通过后,选点开启虚拟定位 +5. 打开 Apple 地图验证定位是否变为虚拟位置 +6. 若失败,查看诊断页的日志信息 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..5a84df7 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,7 @@ +# 更新日志 + +各版本发布说明见 `docs/releases/` 目录。 + +## 已发布 + +- [v1.0.0](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.0) — 2026-08-05 diff --git a/docs/SELF-SIGNING.md b/docs/SELF-SIGNING.md new file mode 100644 index 0000000..6ba56e6 --- /dev/null +++ b/docs/SELF-SIGNING.md @@ -0,0 +1,36 @@ +# 自签安装指南 + +**免费 Apple ID 即可自签安装**,无需付费开发者账号。 + +## 原理 + +本项目只是一个本地 HTTP 代理配合 WiFi 手动代理,不涉及 VPN / Network Extension / Packet Tunnel Provider。无需特殊权限,个人免费 Apple ID 侧载完全可用。 + +## 构建未签名 IPA + +```bash +./build.sh +``` + +输出:`dist/PaopaoLocationSpoofer-unsigned.ipa` + +## 使用 Impact 签名安装 + +用 [Impact](https://github.com/claration/Impact) 打开 IPA 签名并安装到设备。 + +关键标识符不可更改: + +| 组件 | Bundle ID | +|------|-----------| +| 主 App | `com.paopaolabs.location-spoofer` | +| App Group | `group.com.paopaolabs.location-spoofer` | + +## 安装后步骤 + +1. 首次打开,按引导下载 CA 证书 → 安装描述文件 → 开启完全信任 +2. 在 WiFi 设置中配置 HTTP 代理为 `127.0.0.1:8888` +3. 环境检测通过后即可使用 + +## iOS 26+ 注意事项 + +开启虚拟定位后需重启设备清除定位缓存,详见 README 中的使用说明。 diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 0000000..4773eaf --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,30 @@ +## PaopaoLocationSpoofer v1.0.0 + +正式发布首个稳定版本。 + +### 核心功能 + +- **🗺️ 原生地图体验**:Apple MapKit 蓝点实时定位,搜索地点、点击选点、拖动浏览与 Apple 地图一致 +- **📍 虚拟定位引擎**:基于本地 HTTP 代理 MITM 方案,无需 VPN、无需越狱,对钉钉、微信及任意系统定位 App 生效 +- **🧪 完整设置引导**:证书安装 → WiFi 代理配置 → 环境验证,逐步检测代理、CA 信任、坐标写入与响应改写 +- **✈️ 飞行模式缓存清除**:一键化操作引导,按步骤刷新飞行模式、WiFi 和定位服务状态 +- **⭐ 收藏与快速切换**:保存常用坐标,一键跳转 +- **🧾 诊断日志**:实时查看定位请求和改写状态,每条独立可复制 + +### 兼容性 + +| 项目 | 要求 | +|---|---| +| iOS | 15.0+ | +| 安装 | 自行签名(推荐 [Impact](https://github.com/claration/Impact)) | +| 网络 | 可手动配置 HTTP 代理的 WiFi | + +### 安装 + +1. 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 下载 `PaopaoLocationSpoofer-unsigned.ipa` +2. 使用 [Impact](https://github.com/claration/Impact) 签名安装 +3. 按 App 内引导完成证书安装和 WiFi 代理配置 + +### 致谢 + +核心定位响应改写思路与 Go 实现来源于 [Yu9191/wloc](https://github.com/Yu9191/wloc)。 diff --git a/images/Apple Map.jpg b/images/Apple Map.jpg new file mode 100644 index 0000000..a829688 Binary files /dev/null and b/images/Apple Map.jpg differ diff --git a/images/主界面.jpg b/images/主界面.jpg new file mode 100644 index 0000000..56a327a Binary files /dev/null and b/images/主界面.jpg differ diff --git a/images/高德地图.jpg b/images/高德地图.jpg new file mode 100644 index 0000000..2c8c7b9 Binary files /dev/null and b/images/高德地图.jpg differ diff --git a/images/高血压.jpg b/images/高血压.jpg new file mode 100644 index 0000000..fadec47 Binary files /dev/null and b/images/高血压.jpg differ diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..ae9f05a --- /dev/null +++ b/project.yml @@ -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