feat: add third-party proxy mode and improve onboarding

This commit is contained in:
xweiba
2026-08-06 19:20:25 +08:00
parent e86909cc2f
commit b52d72bef2
36 changed files with 1946 additions and 218 deletions
+9 -11
View File
@@ -34,21 +34,19 @@ jobs:
- name: Rename IPA for release
run: mv dist/PaopaoLocationSpoofer-unsigned.ipa dist/Location-Spoofer-unsigned.ipa
- name: Read release notes
id: changelog
- name: Validate archived release notes
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
if [ ! -s "$NOTES_FILE" ]; then
echo "::error::缺少版本归档 $NOTES_FILE。请在打 tag 前运行 ./Scripts/generate-release-notes.sh $VERSION 并提交。"
exit 1
fi
{
echo "notes<<EOF"
cat /tmp/release_notes.md
echo "EOF"
} >> $GITHUB_OUTPUT
if ! grep -Fq "# ${VERSION}" "$NOTES_FILE"; then
echo "::error::$NOTES_FILE 的标题必须包含 # ${VERSION}"
exit 1
fi
cp "$NOTES_FILE" /tmp/release_notes.md
- name: Create Release
uses: softprops/action-gh-release@v2
+15 -2
View File
@@ -7,6 +7,8 @@ struct BugReportView: View {
@State private var isReproducible = true
@State private var isRunning = false
@State private var showCopiedAlert = false
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
var body: some View {
VStack(spacing: 0) {
@@ -85,9 +87,19 @@ struct BugReportView: View {
private func generateReport() {
isRunning = true
Task {
//
let testLog: String
if runtimeMode.mode == .thirdParty {
do {
let response = try await thirdPartyProxy.query()
let active = response.success && response.latitude != nil && response.longitude != nil
testLog = "第三方代理测试模式:模块连接成功;已保存坐标=\(active ? "" : "")"
} catch {
testLog = "第三方代理测试模式:模块连接失败;\(error.localizedDescription)"
}
} else {
_ = await setup.runVerificationTest()
let testLog = setup.testLog
testLog = setup.testLog
}
//
let appVersion: String = {
@@ -102,6 +114,7 @@ struct BugReportView: View {
### 环境信息
App 版本: \(appVersion)
系统版本: iOS \(systemVersion)
运行模式: \(runtimeMode.mode.displayName)
可复现环境: \(isReproducible ? "" : "")
### 问题描述
-8
View File
@@ -1,8 +0,0 @@
import Foundation
import UIKit
enum CertificateInstaller {
static func open(url: URL, completion: @escaping (Bool) -> Void) {
UIApplication.shared.open(url, options: [:], completionHandler: completion)
}
}
+51 -3
View File
@@ -2,10 +2,12 @@ import SwiftUI
struct ContentView: View {
@StateObject private var setup = SetupCoordinator()
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@State private var phase: AppPhase = .splash
@State private var verifiedDuringInitialSetup = false
@AppStorage("setupCompleted") private var setupCompleted = false
enum AppPhase { case splash, map }
enum AppPhase { case splash, setup, map }
var body: some View {
Group {
@@ -15,8 +17,13 @@ struct ContentView: View {
Image(systemName: "location.fill")
.font(.system(size: 48)).foregroundStyle(.blue)
ProgressView()
Text("正在初始化地图与本地代理…").font(.subheadline).foregroundStyle(.secondary)
Text(runtimeMode.hasSelectedMode && runtimeMode.mode == .localWiFi
? "正在初始化地图与本地代理…"
: "正在初始化地图…")
.font(.subheadline).foregroundStyle(.secondary)
}
case .setup:
FirstSetupView(setup: setup, onComplete: finishInitialSetup)
case .map:
NavigationView {
MapHomeView(setup: setup)
@@ -34,7 +41,35 @@ struct ContentView: View {
@MainActor
private func bootstrap() async {
guard runtimeMode.hasSelectedMode else {
ProxyManager.shared.stop()
RuntimeLogger.info("APP", "Startup", "尚未选择运行模式,跳过本地 CA 和代理初始化")
setup.requestModeSelection()
phase = .setup
return
}
let launchMode = runtimeMode.mode
guard setupCompleted else {
if launchMode == .localWiFi {
await setup.prepareLocalServices()
setup.requestSetup()
} else {
ProxyManager.shared.stop()
BackgroundKeepAlive.shared.stop()
setup.requestThirdPartySetup()
}
phase = .setup
return
}
if launchMode == .localWiFi {
await setup.prepareLocalServices()
} else {
ProxyManager.shared.stop()
setup.completeSetup()
RuntimeLogger.info("APP", "Startup", "第三方代理测试模式:跳过本地 CA、代理和环境检测")
}
do {
try CoordinateStorageMigration.migrateIfNeeded(favorites: FavoriteLocationStore())
} catch {
@@ -83,7 +118,12 @@ struct ContentView: View {
}
guard !Task.isCancelled else { return }
if setupCompleted {
if launchMode == .thirdParty {
setup.completeSetup()
} else if verifiedDuringInitialSetup {
verifiedDuringInitialSetup = false
setup.completeSetup()
} else if setupCompleted {
let result = await setup.runVerificationTest()
setup.applyVerificationResult(result)
} else {
@@ -92,4 +132,12 @@ struct ContentView: View {
RuntimeLogger.info("APP", "Startup", "启动门禁全部完成,现在创建 MapHomeView")
phase = .map
}
private func finishInitialSetup() {
verifiedDuringInitialSetup = runtimeMode.mode == .localWiFi
setupCompleted = true
setup.completeSetup()
phase = .splash
Task { await bootstrap() }
}
}
+36 -2
View File
@@ -7,6 +7,8 @@ struct RuntimeLogsView: View {
let testFavorite: FavoriteLocation
@Environment(\.dismiss) private var dismiss
@ObservedObject private var proxy = ProxyManager.shared
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@State private var entries: [RuntimeLogEntry] = []
@State private var isTesting = false
@State private var testResult = ""
@@ -97,10 +99,14 @@ struct RuntimeLogsView: View {
Button {
isTesting = true; testResult = ""; testLogCopied = false
Task {
if runtimeMode.mode == .thirdParty {
await runThirdPartyConnectionTest()
} else {
let result = await setup.runVerificationTest()
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
if !result.isSuccess { testResult += ",查看下方日志" }
testMessage = setup.testLog
}
isTesting = false; refresh()
}
} label: {
@@ -110,7 +116,7 @@ struct RuntimeLogsView: View {
} else {
Image(systemName: "play.fill").font(.system(size: 13, weight: .bold))
}
Text(isTesting ? "正在测" : "虚拟定位测试").font(.subheadline.weight(.semibold))
Text(isTesting ? "正在测…" : "环境检测").font(.subheadline.weight(.semibold))
Spacer()
Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).opacity(0.5)
}
@@ -125,7 +131,9 @@ struct RuntimeLogsView: View {
.fill(isTesting ? Color.gray : Color.blue)
)
.disabled(isTesting || actions.state.isBusy)
Text("依次执行:代理 → 证书 → WiFi 代理 → 坐标写入 → 数据改写验证。")
Text(runtimeMode.mode == .thirdParty
? "检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。"
: "依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。")
.font(.caption).foregroundStyle(.secondary)
if !testMessage.isEmpty {
VStack(alignment: .leading, spacing: 6) {
@@ -170,10 +178,12 @@ struct RuntimeLogsView: View {
}.frame(maxHeight: 180)
}
}
if runtimeMode.mode == .localWiFi {
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)
@@ -224,4 +234,28 @@ struct RuntimeLogsView: View {
}
private func refresh() { entries = RuntimeLogStore.loadAll() }
@MainActor
private func runThirdPartyConnectionTest() async {
do {
let response = try await thirdPartyProxy.query()
let active = response.success && response.latitude != nil && response.longitude != nil
testResult = active ? "第三方模块连接通过,已有坐标" : "第三方模块连接通过,暂无坐标"
testMessage = """
======== 第三方代理连接检测 ========
模式: 测试模式
请求: wloc-settings/save?action=query
拦截响应: 有效 JSON
已保存坐标: \(active ? "" : "")
"""
} catch {
testResult = "第三方模块连接失败"
testMessage = """
======== 第三方代理连接检测 ========
模式: 测试模式
请求: wloc-settings/save?action=query
结果: \(error.localizedDescription)
"""
}
}
}
+351 -5
View File
@@ -1,13 +1,21 @@
import SwiftUI
enum SetupStep: Int, CaseIterable {
case mode
case proxy
case cert
case thirdPartyClient
case thirdPartyImport
case thirdPartyTest
var title: String {
switch self {
case .mode: return "选择模式"
case .proxy: return "配置 Wi-Fi 代理"
case .cert: return "初始化 CA 证书"
case .thirdPartyClient: return "选择客户端"
case .thirdPartyImport: return "导入配置"
case .thirdPartyTest: return "连接检测"
}
}
}
@@ -22,9 +30,16 @@ struct FirstSetupView: View {
@State private var trustedDone = false
@State private var result: VerificationResult?
@State private var isVerifying = false
@State private var isPreparingMode = false
@State private var manualHint = ""
@State private var setupActionError = ""
@State private var showDiagnostics = false
@StateObject private var diagnosticActions = LocationActionCoordinator()
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
@State private var copiedSubscriptionURL = false
@State private var copiedMITMHostname = false
init(setup: SetupCoordinator, onComplete: @escaping () -> Void) {
self.setup = setup
@@ -44,15 +59,25 @@ struct FirstSetupView: View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch step {
case .mode: modeStep
case .proxy: proxyStep
case .cert: certificateStep
case .thirdPartyClient: thirdPartyClientStep
case .thirdPartyImport: thirdPartyImportStep
case .thirdPartyTest: thirdPartyTestStep
}
if let result { resultView(result) }
}
.padding(20)
}
Divider()
VStack(spacing: 10) {
primaryAction
if step != .mode {
Button("上一步") { returnToPreviousStep() }
.disabled(isVerifying || thirdPartyProxy.isRequesting)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
@@ -73,19 +98,28 @@ struct FirstSetupView: View {
)) {
Button("知道了", role: .cancel) {}
} message: { Text(manualHint) }
.alert("操作失败", isPresented: Binding(
get: { !setupActionError.isEmpty },
set: { if !$0 { setupActionError = "" } }
)) {
Button("查看诊断日志") { showDiagnostics = true }
Button("知道了", role: .cancel) {}
} message: {
Text(setupActionError)
}
}
}
private var progress: some View {
HStack(spacing: 8) {
ForEach(SetupStep.allCases, id: \.rawValue) { value in
ForEach(visibleSteps, id: \.rawValue) { value in
HStack(spacing: 6) {
Circle()
.fill(value.rawValue <= step.rawValue ? Color.blue : Color.gray.opacity(0.3))
.frame(width: 10, height: 10)
Text(value.title).font(.caption).foregroundStyle(.secondary)
}
if value != SetupStep.allCases.last {
if value != visibleSteps.last {
Rectangle().fill(Color.gray.opacity(0.3)).frame(width: 28, height: 2)
}
}
@@ -93,6 +127,94 @@ struct FirstSetupView: View {
.padding(.vertical, 16)
}
private var visibleSteps: [SetupStep] {
switch step {
case .mode:
return [.mode]
case .proxy, .cert:
return [.mode, .proxy, .cert]
case .thirdPartyClient, .thirdPartyImport, .thirdPartyTest:
return [.mode, .thirdPartyClient, .thirdPartyImport, .thirdPartyTest]
}
}
private var modeStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("选择运行模式")
.font(.title2.bold())
Text("后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。")
.font(.footnote)
.foregroundStyle(.secondary)
modeCard(
title: "APP模式",
icon: "iphone.and.arrow.forward",
badges: ["仅 Wi-Fi", "无外部依赖"],
description: "App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。",
tint: .blue
) {
selectMode(.localWiFi)
}
.disabled(isPreparingMode)
modeCard(
title: "第三方代理模式",
icon: "network.badge.shield.half.filled",
badges: ["Wi-Fi + 4G/5G", "测试模式"],
description: "App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。",
tint: .orange
) {
selectMode(.thirdParty)
}
.disabled(isPreparingMode)
if isPreparingMode {
HStack(spacing: 8) {
ProgressView()
Text("正在准备 APP模式本地服务…")
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
}
private func modeCard(
title: String,
icon: String,
badges: [String],
description: String,
tint: Color,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 12) {
Label(title, systemImage: icon)
.font(.headline)
.foregroundStyle(tint)
HStack(spacing: 6) {
ForEach(badges, id: \.self) { badge in
Text(badge)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(tint.opacity(0.12), in: Capsule())
}
}
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(tint.opacity(0.25)))
}
.buttonStyle(.plain)
}
private var proxyStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("先配置 Wi-Fi 系统代理", systemImage: "wifi")) {
@@ -123,7 +245,14 @@ struct FirstSetupView: View {
actionTitle: "去下载",
actionIcon: "arrow.down.circle.fill",
complete: downloadedDone,
action: { Task { await setup.proxy.openCertificateDownload() } },
action: {
Task {
let opened = await setup.proxy.openCertificateDownload()
if !opened {
setupActionError = setup.proxy.error ?? "无法打开证书下载页面,请查看诊断日志"
}
}
},
markComplete: { downloadedDone = true }
)
certificateCard(
@@ -149,6 +278,171 @@ struct FirstSetupView: View {
}
}
private var thirdPartyClientStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("选择第三方代理客户端", systemImage: "app.badge.checkmark")) {
VStack(spacing: 0) {
ForEach(ThirdPartyProxyClient.allCases) { client in
Button {
thirdPartyClient.select(client)
} label: {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(client.name).foregroundStyle(.primary)
Text(client.verificationText)
.font(.caption2)
.foregroundStyle(client == .shadowrocket ? .green : .orange)
}
Spacer()
Image(systemName: thirdPartyClient.selectedClient == client ? "checkmark.circle.fill" : "circle")
.foregroundStyle(thirdPartyClient.selectedClient == client ? .blue : .secondary)
}
.padding(.vertical, 10)
}
.buttonStyle(.plain)
if client != ThirdPartyProxyClient.allCases.last { Divider() }
}
}
}
Text("Egern 直接使用 Surge 模块。Stash 直接订阅 .stoverride,不需要 Script Hub 转换。除 Shadowrocket 外,当前仅提供配置,尚未完成真机验证。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var thirdPartyImportStep: some View {
let client = thirdPartyClient.selectedClient
return VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("第 1 步:导入 \(client.name) 模块", systemImage: "square.and.arrow.down")) {
VStack(alignment: .leading, spacing: 12) {
Text(importInstructions(for: client))
.font(.caption)
.foregroundStyle(.secondary)
Button {
UIPasteboard.general.string = client.subscriptionURL.absoluteString
copiedSubscriptionURL = true
} label: {
Label(copiedSubscriptionURL ? "已复制订阅地址" : "复制订阅地址", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
openThirdPartyClient(client)
} label: {
Label("打开 \(client.name)", systemImage: "arrow.up.forward.app")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
if client == .shadowrocket {
shadowrocketHTTPSDecryptionGuide
} else {
Text("请复制订阅地址,在客户端的模块、重写或覆写订阅入口中添加。证书、MITM、VPN 和代理连接请按第三方客户端自己的流程配置。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
private var shadowrocketHTTPSDecryptionGuide: some View {
GroupBox(label: Label("第 2 步:配置 HTTPS 解密", systemImage: "lock.open")) {
VStack(alignment: .leading, spacing: 12) {
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
Button {
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostname
copiedMITMHostname = true
} label: {
Label(copiedMITMHostname ? "已复制 gs-loc.apple.com" : "复制解密域名", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
instructionRow(5, "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。")
Button {
openThirdPartyClient(.shadowrocket)
} label: {
Label("打开 Shadowrocket 继续配置", systemImage: "arrow.up.forward.app")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Text("App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private func instructionRow(_ number: Int, _ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("\(number)")
.font(.caption2.bold())
.foregroundStyle(.white)
.frame(width: 20, height: 20)
.background(Color.blue, in: Circle())
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func importInstructions(for client: ThirdPartyProxyClient) -> String {
if client == .shadowrocket {
return "先复制订阅地址。然后打开 Shadowrocket,进入“配置 → 模块”,点击右上角“+”,粘贴订阅地址并完成导入。导入完成后,模块配置由 Shadowrocket 保存,不依赖本 App 持续运行。"
}
return "先复制订阅地址,然后打开 \(client.name),在模块、重写或覆写订阅入口中粘贴并导入。配置由 \(client.name) 保存,不依赖本 App 持续运行。"
}
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
guard let url = client.launchURL else { return }
UIApplication.shared.open(url, options: [:]) { opened in
guard !opened else { return }
Task { @MainActor in
manualHint = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
}
}
}
private var thirdPartyTestStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("检测配置接口", systemImage: "network")) {
Text("请先在第三方客户端中启用刚导入的配置,并按客户端要求完成 MITM、证书和代理/VPN 连接。App 只调用 WLOC 查询接口确认模块能否正常响应,不检查或管理第三方证书。")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
}
private func returnToPreviousStep() {
result = nil
setupActionError = ""
switch step {
case .mode:
break
case .proxy, .thirdPartyClient:
step = .mode
case .cert:
step = .proxy
case .thirdPartyImport:
step = .thirdPartyClient
case .thirdPartyTest:
step = .thirdPartyImport
}
}
private func certificateCard(
title: String,
icon: String,
@@ -212,7 +506,9 @@ struct FirstSetupView: View {
@ViewBuilder
private var primaryAction: some View {
if step == .proxy {
if step == .mode {
EmptyView()
} else if step == .proxy {
Button {
verifyAfterProxyConfirmation()
} label: {
@@ -220,7 +516,7 @@ struct FirstSetupView: View {
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying)
} else {
} else if step == .cert {
Button {
verifyAfterCertificateConfirmation()
} label: {
@@ -228,6 +524,56 @@ struct FirstSetupView: View {
}
.buttonStyle(.borderedProminent)
.disabled(!certificateStepsComplete || isVerifying)
} else if step == .thirdPartyClient {
Button("下一步:导入配置") { step = .thirdPartyImport }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
} else if step == .thirdPartyImport {
Button("我已导入,下一步") { step = .thirdPartyTest }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
} else {
Button {
verifyThirdPartyConnection()
} label: {
actionLabel("检测接口连接")
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying || thirdPartyProxy.isRequesting)
}
}
private func selectMode(_ mode: ProxyRuntimeMode) {
guard !isPreparingMode else { return }
runtimeMode.setMode(mode)
result = nil
switch mode {
case .localWiFi:
isPreparingMode = true
Task { @MainActor in
await setup.prepareLocalServices()
isPreparingMode = false
step = .proxy
}
case .thirdParty:
setup.proxy.stop()
BackgroundKeepAlive.shared.stop()
step = .thirdPartyClient
}
}
private func verifyThirdPartyConnection() {
guard !isVerifying else { return }
isVerifying = true
result = nil
Task { @MainActor in
defer { isVerifying = false }
do {
_ = try await thirdPartyProxy.query()
onComplete()
} catch {
setupActionError = error.localizedDescription
}
}
}
+5 -1
View File
@@ -103,7 +103,11 @@ final class LocationActionCoordinator: ObservableObject {
enabled: true,
accuracy: favorite.accuracy
)
RuntimeLogger.info("APP", "坐标转换", "已向代理写入 WGS-84 坐标", details: [
RuntimeLogger.info("APP", "坐标转换", "设置虚拟定位坐标", details: [
"WLOC写入标准": CoordinateConverter.MapCoordinateSystem.wgs84.diagnosticName,
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"目标所在区域": CoordinateConverter.usesGCJ02ServiceArea(lat: wgs.latitude, lon: wgs.longitude) ? "国内转换区域" : "国外非转换区域",
"取值字段": "coordinatePair.wgs84",
"accuracy": String(favorite.accuracy)
])
state = .idle
+263 -53
View File
@@ -25,11 +25,33 @@ private struct RealtimeLocationRequestContext {
let showFailureAlert: Bool
}
private enum RealtimeCoordinateSource: Equatable {
case mapKitBluePoint
case coreLocation
@MainActor
var coordinateSystem: CoordinateConverter.MapCoordinateSystem {
switch self {
case .mapKitBluePoint: return CoordinateConverter.currentMapCoordinateSystem
case .coreLocation: return .wgs84
}
}
var diagnosticName: String {
switch self {
case .mapKitBluePoint: return "MapKit地图蓝点"
case .coreLocation: return "CLLocationManager"
}
}
}
struct MapHomeView: View {
@ObservedObject var setup: SetupCoordinator
@StateObject private var favorites: FavoriteLocationStore
@StateObject private var actions = LocationActionCoordinator()
@ObservedObject private var proxy = ProxyManager.shared
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@StateObject private var realtime = RealtimeLocationManager.shared
@StateObject private var mapState: MapLocationState
@ObservedObject private var net = NetworkMonitor.shared
@@ -63,6 +85,8 @@ struct MapHomeView: View {
// C
@State private var activeSpoofLat: Double?
@State private var activeSpoofLon: Double?
@State private var lastSpoofDiagnosisSystem: CoordinateConverter.MapCoordinateSystem?
@State private var hasLoggedSpoofDiagnosis = false
init(setup: SetupCoordinator) {
self.setup = setup
@@ -115,7 +139,8 @@ struct MapHomeView: View {
initialName: initialName
))
if let settings = WlocSettingsStore.load(), settings.enabled {
if ProxyRuntimeModeStore.shared.mode == .localWiFi,
let settings = WlocSettingsStore.load(), settings.enabled {
_spoofState = State(initialValue: .active)
_activeSpoofLat = State(initialValue: settings.latitude)
_activeSpoofLon = State(initialValue: settings.longitude)
@@ -145,7 +170,7 @@ struct MapHomeView: View {
zoomMeters: mapState.viewportMeters
)
favorites.select(nil)
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
scheduleGeocode(pair: pair, revision: revision)
},
onViewportChanged: { distance in
mapState.updateViewport(distanceMeters: distance)
@@ -161,7 +186,7 @@ struct MapHomeView: View {
coordinatePair: pair,
zoomMeters: mapState.viewportMeters
)
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
scheduleGeocode(pair: pair, revision: revision)
},
onUserZoomChanged: { distance in
ViewportStore.save(distance)
@@ -228,7 +253,7 @@ struct MapHomeView: View {
}
}
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind)
TipSheetView(kind: kind, runtimeMode: runtimeMode.mode)
}
.alert("无法直接跳转", isPresented: Binding(
get: { !manualHint.isEmpty },
@@ -240,7 +265,11 @@ struct MapHomeView: View {
startMapRuntimeOnce()
// ContentView performs the startup environment test before this map
// view is constructed. Do not immediately run it again here.
if runtimeMode.mode == .localWiFi {
registerWiFiChangeObserver()
} else {
refreshThirdPartyState()
}
}
.onDisappear {
if let token = wifiChangeObserverToken {
@@ -252,11 +281,31 @@ struct MapHomeView: View {
wifiVerificationID = nil
}
.onChange(of: proxy.isRunning) { running in
if !running && spoofState == .active {
if runtimeMode.mode == .localWiFi, !running && spoofState == .active {
spoofState = .idle
actions.clear()
}
}
.onChange(of: runtimeMode.mode) { mode in
locationOperationTask?.cancel()
locationOperationTask = nil
locationOperationID &+= 1
if let token = wifiChangeObserverToken {
net.removeWiFiChangeObserver(token)
wifiChangeObserverToken = nil
}
wifiVerificationTask?.cancel()
wifiVerificationTask = nil
activeSpoofLat = nil
activeSpoofLon = nil
if mode == .localWiFi {
spoofState = actions.virtualLocationEnabled ? .active : .idle
registerWiFiChangeObserver()
} else {
spoofState = .idle
refreshThirdPartyState()
}
}
.sheet(isPresented: $showEnableTip) { enableTipSheet }
.sheet(isPresented: $showDisableTip) { disableTipSheet }
.alert("定位失败", isPresented: $showLocationAlert) {
@@ -388,13 +437,13 @@ struct MapHomeView: View {
Spacer()
//
Button {
if actions.virtualLocationEnabled {
if spoofState == .active {
activeTip = .activation
} else {
activeTip = .deactivation
}
} label: {
Text(actions.virtualLocationEnabled ? "无法生效?" : "无法取消?")
Text(spoofState == .active ? "无法生效?" : "无法取消?")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
@@ -409,6 +458,11 @@ struct MapHomeView: View {
return
}
let snapshot = currentSelectionFavorite
RuntimeLogger.info("APP", "坐标转换", "保存当前选点为收藏", details: [
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"输入字段": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"持久化字段": "国际标准(WGS-84)+国内标准(GCJ-02)"
])
let favorite = favorites.save(
name: snapshot.name,
mapCoordinate: mapState.selection.coordinate,
@@ -491,6 +545,13 @@ struct MapHomeView: View {
}
private var buttonTitle: String {
if runtimeMode.mode == .thirdParty {
switch spoofState {
case .idle: return "同步到第三方代理"
case .verifying: return "检测并同步中…"
case .active: return "停止第三方虚拟定位"
}
}
switch spoofState {
case .idle: return "开始虚拟定位"
case .verifying: return "验证环境中…"
@@ -519,6 +580,7 @@ struct MapHomeView: View {
private func beginLocationOperation() {
guard spoofState != .verifying, locationOperationTask == nil else { return }
let wasActive = spoofState == .active
locationOperationID &+= 1
let operationID = locationOperationID
let selectionRevision = mapState.selection.revision
@@ -526,6 +588,39 @@ struct MapHomeView: View {
spoofState = .verifying
locationOperationTask = Task { @MainActor in
if runtimeMode.mode == .thirdParty {
do {
let response = try await thirdPartyProxy.save(target)
guard !Task.isCancelled,
operationID == locationOperationID,
runtimeMode.mode == .thirdParty else {
return
}
// The remote write has already succeeded. If the user moved
// the map meanwhile, keep this target active and let
// needsSwitchButton offer syncing the newer selection.
spoofState = .active
activeSpoofLat = response.latitude
activeSpoofLon = response.longitude
RuntimeLogger.info("APP", "定位", "第三方代理坐标同步成功", details: [
"坐标标准": "WGS-84",
"客户端模式": "测试模式",
"选点期间发生变化": String(selectionRevision != mapState.selection.revision)
])
presentSuccessfulOperationTip(.activation)
} catch {
guard operationID == locationOperationID else { return }
// A failed replacement does not clear the coordinate that
// was already persisted inside the third-party client.
spoofState = wasActive ? .active : .idle
manualHint = error.localizedDescription
}
if operationID == locationOperationID {
locationOperationTask = nil
}
return
}
let result = await setup.runVerificationTest()
guard !Task.isCancelled,
operationID == locationOperationID,
@@ -543,6 +638,8 @@ struct MapHomeView: View {
if applied {
activeSpoofLat = target.latitude
activeSpoofLon = target.longitude
lastSpoofDiagnosisSystem = nil
hasLoggedSpoofDiagnosis = false
}
RuntimeLogger.info("APP", "定位", "验证结果", details: [
"success": "true",
@@ -550,12 +647,7 @@ struct MapHomeView: View {
"spoofState": String(describing: spoofState)
])
if applied {
let count = tipPreferences.recordSuccessfulOperation(.activation)
RuntimeLogger.info("APP", "提醒", "累计开启虚拟定位次数", details: [
"次数": String(count),
"可显示不再提醒": String(tipPreferences.canSuppress(.activation))
])
showEnableTip = tipPreferences.shouldPresentAutomaticTip(.activation)
presentSuccessfulOperationTip(.activation)
}
} else {
spoofState = actions.virtualLocationEnabled ? .active : .idle
@@ -579,16 +671,48 @@ struct MapHomeView: View {
locationOperationTask?.cancel()
locationOperationTask = nil
locationOperationID &+= 1
if runtimeMode.mode == .thirdParty {
spoofState = .verifying
locationOperationTask = Task { @MainActor in
do {
try await thirdPartyProxy.clear()
spoofState = .idle
activeSpoofLat = nil
activeSpoofLon = nil
presentSuccessfulOperationTip(.deactivation)
} catch {
spoofState = .active
manualHint = error.localizedDescription
}
locationOperationTask = nil
}
return
}
actions.clear()
spoofState = .idle
activeSpoofLat = nil
activeSpoofLon = nil
let count = tipPreferences.recordSuccessfulOperation(.deactivation)
RuntimeLogger.info("APP", "提醒", "累计关闭虚拟定位次数", details: [
lastSpoofDiagnosisSystem = nil
hasLoggedSpoofDiagnosis = false
presentSuccessfulOperationTip(.deactivation)
}
private func presentSuccessfulOperationTip(_ kind: VirtualLocationTipKind) {
let count = tipPreferences.recordSuccessfulOperation(kind)
let operationName = kind == .activation ? "开启" : "关闭"
RuntimeLogger.info("APP", "提醒", "累计\(operationName)虚拟定位次数", details: [
"次数": String(count),
"可显示不再提醒": String(tipPreferences.canSuppress(.deactivation))
"运行模式": runtimeMode.mode.displayName,
"可显示不再提醒": String(tipPreferences.canSuppress(kind))
])
showDisableTip = tipPreferences.shouldPresentAutomaticTip(.deactivation)
guard tipPreferences.shouldPresentAutomaticTip(kind) else { return }
switch kind {
case .activation:
showEnableTip = true
case .deactivation:
showDisableTip = true
}
}
@@ -647,10 +771,7 @@ struct MapHomeView: View {
mapCoordinate: mapState.selection.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
)
scheduleGeocode(
coordinate: pair.wgs84.coordinate,
revision: mapState.selection.revision
)
scheduleGeocode(pair: pair, revision: mapState.selection.revision)
}
private func reprojectMapSelection(for change: CoordinateConverter.MapCoordinateSystemChange) {
@@ -669,12 +790,45 @@ struct MapHomeView: View {
}
private func registerWiFiChangeObserver() {
guard runtimeMode.mode == .localWiFi else { return }
guard wifiChangeObserverToken == nil else { return }
wifiChangeObserverToken = net.observeWiFiChanges { [self] reason in
handleWiFiChange(reason: reason)
}
}
private func refreshThirdPartyState() {
guard runtimeMode.mode == .thirdParty,
locationOperationTask == nil else { return }
locationOperationTask = Task { @MainActor in
do {
let response = try await thirdPartyProxy.query()
if response.success,
let latitude = response.latitude,
let longitude = response.longitude {
activeSpoofLat = latitude
activeSpoofLon = longitude
spoofState = .active
} else if response.error?.contains("无已保存") == true {
activeSpoofLat = nil
activeSpoofLon = nil
spoofState = .idle
} else {
spoofState = .idle
RuntimeLogger.warning("APP", "ThirdPartyProxy", "第三方代理查询返回失败", details: [
"错误": response.error ?? "未知错误"
])
}
} catch {
spoofState = .idle
RuntimeLogger.warning("APP", "ThirdPartyProxy", "启动后第三方代理状态查询失败", details: [
"错误": error.localizedDescription
])
}
locationOperationTask = nil
}
}
private func handleWiFiChange(reason: WiFiChangeReason) {
RuntimeLogger.info("APP", "WiFi", "检测到 Wi-Fi 网络变化", details: [
"原因": reason.rawValue,
@@ -694,9 +848,9 @@ struct MapHomeView: View {
wifiVerificationID = nil
}
}
let stabilizationNanoseconds: UInt64 = 5_000_000_000
let stabilizationNanoseconds: UInt64 = 3_000_000_000
RuntimeLogger.info("APP", "WiFi", "等待 Wi-Fi 连接稳定后检测", details: [
"等待秒数": "5",
"等待秒数": "3",
"事件原因": reason.rawValue
])
do {
@@ -776,7 +930,12 @@ struct MapHomeView: View {
"intentID": String(intent.id),
"来源": "MapLocationState.realtimeLocation"
])
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
acceptRealtimeLocation(
loc.coordinate,
intent: intent,
source: .mapKitBluePoint,
sourceDescription: "MapKit蓝点缓存"
)
return
}
RuntimeLogger.info("APP", "实时定位", "MapKit 蓝点尚不可用,启动 CLLocationManager 兜底", details: [
@@ -790,16 +949,16 @@ struct MapHomeView: View {
}
private func handleNativeRealtimeLocation(_ location: CLLocation) {
RealtimeLocationTrace.log("主页收到 MapKit 蓝点回调", location: location, details: [
"存在待处理请求": String(realtimeRequestContext != nil),
"当前选点revision": String(mapState.selection.revision)
])
mapState.updateRealtimeLocation(location)
logSpoofCoordinateDiagnosisIfNeeded(location)
guard let context = realtimeRequestContext else {
RuntimeLogger.debug("APP", "实时定位", "蓝点已缓存;当前没有待处理的实时定位意图")
return
}
RealtimeLocationTrace.log("主页收到待处理请求所需的 MapKit 蓝点回调", location: location, details: [
"当前选点revision": String(mapState.selection.revision)
])
// 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.
@@ -809,7 +968,42 @@ struct MapHomeView: View {
"intentID": String(context.intent.id),
"原兜底来源": context.source
])
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "蓝点(途中)→\(context.source)")
acceptRealtimeLocation(
location.coordinate,
intent: context.intent,
source: .mapKitBluePoint,
sourceDescription: "蓝点(途中)→\(context.source)"
)
}
private func logSpoofCoordinateDiagnosisIfNeeded(_ location: CLLocation) {
guard spoofState == .active,
let latitude = activeSpoofLat,
let longitude = activeSpoofLon else { return }
let targetPair = CoordinateConverter.coordinatePair(
lat: latitude,
lon: longitude,
mapCoordinateSystem: .wgs84
)
let diagnosis = CoordinateConverter.diagnoseRepresentation(
sample: location.coordinate,
pair: targetPair,
maximumDistance: max(1_000, location.horizontalAccuracy * 4),
minimumSeparation: max(30, location.horizontalAccuracy)
)
let shouldLog = !hasLoggedSpoofDiagnosis
|| diagnosis.inferredSystem != lastSpoofDiagnosisSystem
guard shouldLog else { return }
hasLoggedSpoofDiagnosis = true
lastSpoofDiagnosisSystem = diagnosis.inferredSystem
RuntimeLogger.info("APP", "坐标转换", "虚拟定位开启后的 MapKit 蓝点标准判定", details: [
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"WLOC目标标准": CoordinateConverter.MapCoordinateSystem.wgs84.diagnosticName,
"蓝点回调更接近": diagnosis.inferredName,
"蓝点距WGS目标米": String(format: "%.1f", diagnosis.distanceToWGS84),
"蓝点距GCJ目标米": String(format: "%.1f", diagnosis.distanceToGCJ02),
"日志策略": "每次开启首次或判定变化"
])
}
private func startRealtimeLocationRequest(
@@ -869,40 +1063,46 @@ struct MapHomeView: View {
])
return
}
acceptRealtimeLocation(coordinate, intent: context.intent, source: context.source)
acceptRealtimeLocation(
coordinate,
intent: context.intent,
source: .coreLocation,
sourceDescription: context.source
)
}
}
private func acceptRealtimeLocation(
_ coordinate: CLLocationCoordinate2D,
intent: RealtimeLocationIntent,
source: String
source: RealtimeCoordinateSource,
sourceDescription: String
) {
let currentViewport = mapState.viewportMeters
let previousMapCoordinateSystem = CoordinateConverter.currentMapCoordinateSystem
let usesDomesticStandard = CoordinateConverter.usesGCJ02ServiceArea(
lat: coordinate.latitude,
lon: coordinate.longitude
)
let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(coordinate)
let sourceCoordinateSystem = source.coordinateSystem
let mapCoordinateSystemChange = source == .coreLocation
? CoordinateConverter.correctMapCoordinateSystemUsingRealtime(coordinate)
: nil
if let change = mapCoordinateSystemChange {
reprojectMapSelection(for: change)
}
let pair = CoordinateConverter.coordinatePair(
lat: coordinate.latitude,
lon: coordinate.longitude,
mapCoordinateSystem: .wgs84
mapCoordinateSystem: sourceCoordinateSystem
)
let accepted = mapState.acceptRealtimeLocation(
pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
intent: intent
)
RuntimeLogger.info("APP", "实时定位", "实时定位坐标完成标准判断并提交到地图", details: [
"来源": source,
"来源": sourceDescription,
"来源类型": source.diagnosticName,
"输入坐标标准": sourceCoordinateSystem.diagnosticName,
"intentID": String(intent.id),
"intent选点revision": String(intent.selectionRevision),
"当前选点revision": String(mapState.selection.revision),
"服务区域使用GCJ": String(usesDomesticStandard),
"修正前地图标准": previousMapCoordinateSystem.rawValue,
"修正后地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"地图标准发生修正": String(mapCoordinateSystemChange != nil),
@@ -910,8 +1110,9 @@ struct MapHomeView: View {
"显示坐标字段": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"持久化字段": "WGS-84+GCJ-02"
])
RealtimeLocationTrace.coordinate("原始实时定位坐标(按 WGS-84", coordinate: coordinate, details: [
"来源": source
RealtimeLocationTrace.coordinate("原始实时定位坐标", coordinate: coordinate, details: [
"来源": sourceDescription,
"输入坐标标准": sourceCoordinateSystem.diagnosticName
])
RealtimeLocationTrace.coordinate("地图实际显示坐标", coordinate: pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem), details: [
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
@@ -919,13 +1120,13 @@ struct MapHomeView: View {
guard accepted else { return }
//
mapState.focusSelection(distanceMeters: currentViewport)
// Core Location is WGS-84; persist both forms once and render the active form.
// Persist both forms once from the explicitly typed input boundary.
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: currentViewport)
favorites.select(nil)
scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision)
scheduleGeocode(pair: pair, revision: mapState.selection.revision)
}
private func scheduleGeocode(coordinate: CLLocationCoordinate2D, revision: UInt64) {
private func scheduleGeocode(pair: CoordinatePair, revision: UInt64) {
geocodeDebounceTask?.cancel()
reverseGeocodeTask?.cancel()
geocodeDebounceTask = Task { @MainActor in
@@ -935,13 +1136,15 @@ struct MapHomeView: View {
return
}
guard !Task.isCancelled, mapState.selection.revision == revision else { return }
reverseGeocode(coordinate, revision: revision)
reverseGeocode(pair, revision: revision)
}
}
private func reverseGeocode(_ coordinate: CLLocationCoordinate2D, revision: UInt64) {
private func reverseGeocode(_ pair: CoordinatePair, revision: UInt64) {
reverseGeocodeTask?.cancel()
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let wgsCoordinate = pair.wgs84.coordinate
let mapCoordinate = pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
let location = CLLocation(latitude: wgsCoordinate.latitude, longitude: wgsCoordinate.longitude)
reverseGeocodeTask = Task { @MainActor in
let retryDelays: [UInt64] = [0, 800_000_000, 1_600_000_000]
var lastError: Error?
@@ -958,7 +1161,7 @@ struct MapHomeView: View {
// MKLocalSearch CLGeocoder try await
async let clPlacemarks = CLGeocoder().reverseGeocodeLocation(location)
let mkRequest = MKLocalSearch.Request()
mkRequest.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 400, longitudinalMeters: 400)
mkRequest.region = MKCoordinateRegion(center: mapCoordinate, latitudinalMeters: 400, longitudinalMeters: 400)
let mkResponse = try? await MKLocalSearch(request: mkRequest).start()
let placemarks = try await clPlacemarks
@@ -1077,6 +1280,11 @@ struct MapHomeView: View {
geocodeDebounceTask?.cancel()
reverseGeocodeTask?.cancel()
favorites.select(favorite.id)
RuntimeLogger.info("APP", "坐标转换", "点击收藏点并回显到地图", details: [
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
"地图取值字段": CoordinateConverter.currentMapCoordinateSystem == .gcj02 ? "coordinatePair.gcj02" : "coordinatePair.wgs84",
"保存数据包含": "国际标准(WGS-84)+国内标准(GCJ-02)"
])
mapState.selectFavorite(
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
id: favorite.id,
@@ -1092,7 +1300,7 @@ struct MapHomeView: View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ActivationTipContent(dismiss: {})
ActivationTipContent(runtimeMode: runtimeMode.mode, dismiss: {})
}.padding(16)
}
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
@@ -1117,7 +1325,7 @@ struct MapHomeView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.buttonStyle(.borderedProminent)
.tint(.blue)
} else {
Button { showEnableTip = false } label: {
@@ -1138,8 +1346,10 @@ struct MapHomeView: View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
DeactivationTipContent(dismiss: {})
DeactivationTipContent(runtimeMode: runtimeMode.mode, dismiss: {})
if runtimeMode.mode == .localWiFi {
RemoveProxyTipContent(dismiss: {})
}
}.padding(16)
}
.navigationTitle("虚拟定位已关闭").navigationBarTitleDisplayMode(.inline)
@@ -1164,7 +1374,7 @@ struct MapHomeView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.buttonStyle(.borderedProminent)
.tint(.blue)
} else {
Button { showDisableTip = false } label: {
+19 -8
View File
@@ -239,7 +239,7 @@ struct MapViewRepresentable: UIViewRepresentable {
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard let location = userLocation.location else {
guard let location = visibleUserLocationSample(userLocation) else {
RuntimeLogger.warning("APP", "实时定位", "MapKit 蓝点更新但 location 为空", details: [
"来源": "MKMapView.didUpdate"
])
@@ -257,9 +257,6 @@ struct MapViewRepresentable: UIViewRepresentable {
], level: .warning)
return
}
RealtimeLocationTrace.log("收到 MapKit 可见蓝点样本", location: location, details: [
"来源": "MKMapView.didUpdate"
])
forwardRealtimeLocation(location)
}
@@ -291,12 +288,9 @@ struct MapViewRepresentable: UIViewRepresentable {
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
updatePinPosition(on: mapView)
// delegate mapState.realtimeLocation nil
if let ul = mapView.userLocation.location,
if let ul = visibleUserLocationSample(mapView.userLocation),
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
if lastForwardedRealtimeTimestamp.map({ ul.timestamp > $0 }) ?? true {
RealtimeLocationTrace.log("区域变化后同步到较新的 MapKit 蓝点样本", location: ul, details: [
"来源": "MKMapView.regionDidChange"
])
forwardRealtimeLocation(ul)
}
}
@@ -327,6 +321,23 @@ struct MapViewRepresentable: UIViewRepresentable {
parent.onRealtimeLocationChanged(location)
}
/// `MKUserLocation.coordinate` is the coordinate of the blue-point
/// annotation in the current map representation. Keep the native
/// accuracy/timestamp metadata, but do not replace it with the raw
/// Core Location coordinate carried by `location.coordinate`.
private func visibleUserLocationSample(_ userLocation: MKUserLocation) -> CLLocation? {
guard let native = userLocation.location else { return nil }
return CLLocation(
coordinate: userLocation.coordinate,
altitude: native.altitude,
horizontalAccuracy: native.horizontalAccuracy,
verticalAccuracy: native.verticalAccuracy,
course: native.course,
speed: native.speed,
timestamp: native.timestamp
)
}
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)
+19 -3
View File
@@ -119,15 +119,31 @@ final class ProxyManager: ObservableObject {
return (Double(r.r0), Double(r.r1), r.r2 != 0)
}
func openCertificateDownload() async {
@discardableResult
func openCertificateDownload() async -> Bool {
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) }
guard let url = URL(string: "http://127.0.0.1:8888/cert") else {
error = "证书下载地址无效"
return false
}
let opened = await withCheckedContinuation { continuation in
UIApplication.shared.open(url, options: [:]) { accepted in
continuation.resume(returning: accepted)
}
}
guard opened else {
error = "系统无法打开证书下载页面,请稍后重试"
RuntimeLogger.warning("APP", "Certificate", "系统拒绝打开证书下载地址")
return false
}
error = nil
return true
} catch {
self.error = "启动代理失败: \(error.localizedDescription)"
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
return false
}
}
+242 -16
View File
@@ -4,25 +4,70 @@ struct SettingsView: View {
@ObservedObject var setup: SetupCoordinator
@ObservedObject var actions: LocationActionCoordinator
@ObservedObject private var proxy = ProxyManager.shared
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
@Environment(\.dismiss) private var dismiss
@State private var activeTip: TipKind?
@State private var proxyOperationError = ""
@State private var proxyOperationAlertTitle = "代理操作失败"
@State private var modeOperationRunning = false
@State private var copiedClient: ThirdPartyProxyClient?
var body: some View {
Form {
Section("状态") {
HStack {
Label("代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
Spacer()
Toggle("", isOn: proxyBinding).labelsHidden()
.tint(.blue)
Section("运行模式") {
Picker("模式", selection: runtimeModeBinding) {
ForEach(ProxyRuntimeMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
HStack {
Label("虚拟定位", systemImage: actions.virtualLocationEnabled ? "location.fill" : "location.slash")
Spacer()
Text(actions.virtualLocationEnabled ? "已开启" : "已关闭").foregroundStyle(.secondary)
}
.pickerStyle(.inline)
.disabled(modeOperationRunning || actions.state.isBusy || thirdPartyProxy.isRequesting)
if runtimeMode.mode == .thirdParty {
Label("测试模式:仅 Shadowrocket 当前可测试", systemImage: "testtube.2")
.font(.footnote)
.foregroundStyle(.orange)
}
}
Section("状态") {
if runtimeMode.mode == .localWiFi {
HStack {
Label("本机代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
Spacer()
Toggle("", isOn: proxyBinding).labelsHidden()
.tint(.blue)
.disabled(actions.state.isBusy)
}
} else {
HStack {
Label("第三方模块", systemImage: thirdPartyStatusIcon)
Spacer()
Text(thirdPartyStatusText).foregroundStyle(.secondary)
}
Button {
detectThirdPartyConnection()
} label: {
if thirdPartyProxy.isRequesting {
HStack { ProgressView(); Text("正在检测…") }
} else {
Label("检测连接", systemImage: "network")
}
}
.disabled(thirdPartyProxy.isRequesting)
}
HStack {
Label("虚拟定位", systemImage: virtualLocationIsActive ? "location.fill" : "location.slash")
Spacer()
Text(virtualLocationStatusText).foregroundStyle(.secondary)
}
}
if runtimeMode.mode == .thirdParty {
thirdPartyConfigurationSection
} else {
Section("说明") {
Button {
activeTip = .activation
@@ -40,23 +85,22 @@ struct SettingsView: View {
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
}
}
}
Section("工作原理") {
Text("""
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
""")
Text(workflowDescription)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("应用") {
if runtimeMode.mode == .localWiFi {
Button {
setup.requestSetup()
} label: {
Label("进入引导页", systemImage: "arrow.clockwise.circle")
}
}
valueRow("版本", value: versionText)
}
@@ -100,6 +144,14 @@ struct SettingsView: View {
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind)
}
.alert(proxyOperationAlertTitle, isPresented: Binding(
get: { !proxyOperationError.isEmpty },
set: { if !$0 { proxyOperationError = "" } }
)) {
Button("知道了", role: .cancel) {}
} message: {
Text(proxyOperationError)
}
}
private func valueRow(_ title: String, value: String) -> some View {
@@ -116,11 +168,185 @@ struct SettingsView: View {
Binding(get: { proxy.isRunning }, set: { on in
Task {
if on {
do { try await proxy.start() } catch { proxy.error = error.localizedDescription }
do {
try await proxy.start()
} catch {
proxy.error = error.localizedDescription
proxyOperationAlertTitle = "代理操作失败"
proxyOperationError = error.localizedDescription
}
} else {
if actions.virtualLocationEnabled {
actions.clear()
RuntimeLogger.info("APP", "Settings", "关闭代理前已同步关闭虚拟定位")
}
proxy.stop()
}
}
})
}
private var runtimeModeBinding: Binding<ProxyRuntimeMode> {
Binding(
get: { runtimeMode.mode },
set: { newMode in switchRuntimeMode(to: newMode) }
)
}
@ViewBuilder
private var thirdPartyConfigurationSection: some View {
Section("第三方代理配置") {
Picker("客户端", selection: Binding(
get: { thirdPartyClient.selectedClient },
set: { thirdPartyClient.select($0) }
)) {
ForEach(ThirdPartyProxyClient.allCases) { client in
Text(client.name).tag(client)
}
}
HStack {
Text("验证状态")
Spacer()
Text(thirdPartyClient.selectedClient.verificationText)
.font(.footnote)
.foregroundStyle(thirdPartyClient.selectedClient == .shadowrocket ? .green : .orange)
}
Button {
UIPasteboard.general.string = thirdPartyClient.selectedClient.subscriptionURL.absoluteString
copiedClient = thirdPartyClient.selectedClient
} label: {
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制订阅链接" : "复制订阅链接", systemImage: "doc.on.doc")
}
Button {
openThirdPartyClient(thirdPartyClient.selectedClient)
} label: {
Label("打开 \(thirdPartyClient.selectedClient.name)", systemImage: "arrow.up.forward.app")
}
Button {
setup.requestThirdPartySetup()
dismiss()
} label: {
Label("重新打开配置引导", systemImage: "arrow.clockwise.circle")
}
if thirdPartyClient.selectedClient == .egern {
Text("Egern 直接使用 Surge 的 .sgmodule 模块。")
.font(.footnote).foregroundStyle(.secondary)
} else if thirdPartyClient.selectedClient == .stash {
Text("Stash 直接订阅 .stoverride,不要通过 Script Hub 转换。")
.font(.footnote).foregroundStyle(.secondary)
}
Text("复制链接后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
.font(.footnote).foregroundStyle(.secondary)
}
}
private var thirdPartyStatusIcon: String {
switch thirdPartyProxy.connectionState {
case .unknown: return "questionmark.circle"
case .connected: return "checkmark.circle.fill"
case .failed: return "xmark.circle.fill"
}
}
private var thirdPartyStatusText: String {
switch thirdPartyProxy.connectionState {
case .unknown: return "未检测"
case .connected(let active): return active ? "已连接,有坐标" : "已连接,无坐标"
case .failed: return "连接失败"
}
}
private var virtualLocationStatusText: String {
if runtimeMode.mode == .localWiFi {
return actions.virtualLocationEnabled ? "已开启" : "已关闭"
}
if case .connected(let active) = thirdPartyProxy.connectionState {
return active ? "第三方已保存" : "未保存"
}
return "未知"
}
private var workflowDescription: String {
if runtimeMode.mode == .thirdParty {
return "App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。"
}
return """
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
"""
}
private func switchRuntimeMode(to newMode: ProxyRuntimeMode) {
guard newMode != runtimeMode.mode, !modeOperationRunning else { return }
modeOperationRunning = true
Task { @MainActor in
defer { modeOperationRunning = false }
switch newMode {
case .thirdParty:
if actions.virtualLocationEnabled { actions.clear() }
proxy.stop()
setup.completeSetup()
runtimeMode.setMode(.thirdParty)
setup.requestThirdPartySetup()
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "已切换到第三方代理模式。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,并按引导导入第三方配置。"
case .localWiFi:
do {
try await thirdPartyProxy.clear()
} catch {
RuntimeLogger.warning("APP", "Mode", "切换 APP 模式前无法清除第三方坐标", details: [
"错误": error.localizedDescription
])
}
runtimeMode.setMode(.localWiFi)
await setup.prepareLocalServices()
setup.requestSetup()
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "已切换到 APP 模式。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
}
}
}
private func detectThirdPartyConnection() {
Task { @MainActor in
do {
let response = try await thirdPartyProxy.query()
if !response.success, response.error?.contains("无已保存") != true {
proxyOperationAlertTitle = "检测失败"
proxyOperationError = response.error ?? "第三方代理模块返回失败"
}
} catch {
proxyOperationAlertTitle = "检测失败"
proxyOperationError = error.localizedDescription
}
}
}
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
guard let url = client.launchURL else { return }
UIApplication.shared.open(url, options: [:]) { opened in
guard !opened else { return }
Task { @MainActor in
proxyOperationAlertTitle = "无法打开客户端"
proxyOperationError = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
}
}
}
private var virtualLocationIsActive: Bool {
if runtimeMode.mode == .localWiFi {
return actions.virtualLocationEnabled
}
if case .connected(let active) = thirdPartyProxy.connectionState {
return active
}
return false
}
}
+8
View File
@@ -59,6 +59,14 @@ final class SetupCoordinator: ObservableObject {
func sceneDidBecomeActive() {}
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
func completeSetup() { needsSetup = false }
func requestModeSelection() {
setupStep = .mode
needsSetup = true
}
func requestThirdPartySetup() {
setupStep = .thirdPartyClient
needsSetup = true
}
func requestSetup() {
setupStep = .proxy
needsSetup = true
+13 -3
View File
@@ -11,6 +11,7 @@ enum TipKind: String, Identifiable {
struct TipSheetView: View {
let kind: TipKind
var runtimeMode: ProxyRuntimeMode = .localWiFi
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -18,8 +19,8 @@ struct TipSheetView: View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
switch kind {
case .activation: ActivationTipContent(dismiss: { dismiss() })
case .deactivation: DeactivationTipContent(dismiss: { dismiss() })
case .activation: ActivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
case .deactivation: DeactivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
@@ -65,15 +66,19 @@ private struct TipCloseButton: View {
// MARK: -
struct ActivationTipContent: View {
var runtimeMode: ProxyRuntimeMode = .localWiFi
let dismiss: () -> Void
var body: some View {
GroupBox(label: Label("让虚拟定位生效", systemImage: "checklist")) {
VStack(alignment: .leading, spacing: 10) {
if runtimeMode == .thirdParty {
step(0, "确认第三方代理已开启", "保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。")
}
step(1, "开启飞行模式", "从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。")
step(2, "关闭 WiFi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。")
step(4, "打开 WiFi,启动虚拟定位", "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
step(4, "打开 WiFi,启动虚拟定位", runtimeMode == .thirdParty ? "从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" : "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。")
}.padding(.vertical, 4)
@@ -117,6 +122,7 @@ struct ActivationTipContent: View {
// MARK: -
struct DeactivationTipContent: View {
var runtimeMode: ProxyRuntimeMode = .localWiFi
let dismiss: () -> Void
var body: some View {
@@ -125,7 +131,11 @@ struct DeactivationTipContent: View {
step(1, "开启飞行模式", "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。")
step(2, "关闭 WiFi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
if runtimeMode == .thirdParty {
step(4, "确认坐标已清除", "App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。")
} else {
systemStep(4, "打开 WiFi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
}
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。")
}.padding(.vertical, 4)
+25 -13
View File
@@ -4,14 +4,14 @@
### iOS Location Spoofer · DingTalk · WeChat · Apple Watch Region Unlock · Fake GPS
**No VPN, no jailbreak — run a local HTTP proxy on your iPhone to rewrite Apple location responses.**<br>
**No jailbreak — use App Mode's on-device Wi-Fi HTTP proxy or Third-party Proxy Mode (Wi-Fi/4G/5G) to rewrite Apple location responses.**<br>
Works with DingTalk check-in, WeChat location sharing, and any app that uses system location. Map selection, real-time location, environment verification, certificate setup, and runtime logs in a single app.
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138?logo=swift&logoColor=white)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go&logoColor=white)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.0-2563EB)](docs/CHANGELOG.md)
[![No VPN](https://img.shields.io/badge/VPN-Not%20needed-16A34A)](#why-no-vpn)
[![Version](https://img.shields.io/badge/version-v1.0.1-2563EB)](docs/CHANGELOG.md)
[![App Mode](https://img.shields.io/badge/App%20Mode-No%20VPN-16A34A)](#why-no-vpn)
[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md)
@@ -20,7 +20,7 @@ Works with DingTalk check-in, WeChat location sharing, and any app that uses sys
</div>
> [!IMPORTANT]
> This project is intended for education, security research, and testing on your own devices. It installs a locally generated CA certificate and requires a manual HTTP proxy on the current WiFi network. Please understand the risks and follow applicable laws and service terms.
> This project is intended for education, security research, and testing on your own devices. App Mode installs a locally generated CA and requires a manual HTTP proxy on the current Wi-Fi network. In Third-party Proxy Mode, the selected client owns certificate, MITM, and proxy/VPN setup. Please understand the risks and follow applicable laws and service terms.
## Credits
@@ -32,7 +32,7 @@ Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailb
| Feature | Description |
|---|---|
| 🚫 **No VPN** | No VPN tunnel — uses only location permission, no background refresh or notification access required. |
| 🔀 **Two runtime modes** | Stable App Mode, plus a Third-party Proxy Mode under testing for Wi-Fi, 4G, and 5G. |
| 📱 **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. |
@@ -54,8 +54,9 @@ Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailb
- **Hierarchical place names**: POI, street, or road at close zoom; neighborhood, district, city, or province at wider zoom.
- **Map scale display**: Zoom controls show the current visible range.
- **Favorites with quick switch**: Save frequent coordinates and see which location is about to be applied.
- **Setup guide**: Certificate download, installation, full trust, WiFi HTTP proxy, activation, and deactivation instructions.
- **Mode-specific setup**: Choose a mode first; App Mode guides local proxy and CA setup, while Third-party Proxy Mode guides client selection, configuration import, and API verification.
- **Built-in diagnostics**: Verification flow and structured runtime logs.
- **Third-party Proxy Mode (testing)**: Send a favorite or current pin as WGS-84 to a supported proxy module; the proxy client persists it after this App exits.
## Quick Start
@@ -64,9 +65,21 @@ Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailb
- 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).
The release asset is an unsigned IPA. Sign it with [Impact](https://github.com/claration/Impactor) before installation. Keep the app Bundle ID `com.paopaolabs.location-spoofer`, the App Group `group.com.paopaolabs.location-spoofer`, and the declared entitlements unchanged. A free Apple ID signature normally expires after seven days and must then be renewed.
### 2. Install & Trust the CA
### 2. Choose a Runtime Mode
#### App Mode
Choose App Mode during first launch, then configure `127.0.0.1:8888` and fully trust the locally generated CA. It has no third-party client dependency but supports Wi-Fi only. Free self-signed apps cannot use the VPN/Network Extension capability required for cellular interception, so this mode uses the current Wi-Fi's manual HTTP proxy.
#### Third-party Proxy Mode
The App handles map selection, favorites, and WGS-84 coordinate delivery. A third-party proxy client handles WLOC interception, MITM, and persistence over Wi-Fi, 4G, or 5G. This mode skips the App's local proxy and CA checks.
Shadowrocket is the only client currently available for device testing. Surge, Quantumult X, Loon, Stash, and Egern configurations are provided but unverified. The App lets the user copy the official subscription URL and open the selected client; the URL is then pasted into that client's module, rewrite, or override subscription UI. Configuration snapshots remain bundled for release provenance and offline inspection, but the setup UI does not export files. Egern uses the Surge module, and Stash imports `.stoverride` directly without Script Hub conversion. The third-party client—not this App—owns MITM, certificate, and proxy/VPN setup. Snapshot provenance is recorded in [the module snapshot document](docs/THIRD_PARTY_MODULES.md).
### 3. Local-mode CA Setup
Follow the first-setup wizard to download the profile, then:
@@ -75,7 +88,7 @@ Settings → General → VPN & Device Management → install WLOC CA
Settings → General → About → Certificate Trust Settings → enable full trust
```
### 3. Configure the Current WiFi Proxy
Configure the current Wi-Fi proxy before installing the CA:
On the current WiFi's proxy settings, choose "Manual":
@@ -88,7 +101,7 @@ 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.
2. In App Mode, tap Start Spoofing and wait for verification. In Third-party Proxy Mode, tap “Sync to Third-party Proxy.”
3. Follow the inapp activation instructions to refresh airplane mode, WiFi, and location services.
4. Open Apple Maps or your target app to verify.
@@ -111,7 +124,7 @@ Apple location service response
The system and applications receive the modified result
```
The project does not use Network Extension to create a VPN tunnel — there is no VPN icon and no VPN slot occupied. **However, you still need to configure the current WiFi HTTP proxy and install & trust the locally generated CA.** Recheck proxy settings after switching WiFi networks; remove the manual proxy when you stop using the app.
App Mode does not use Network Extension, so it does not occupy the VPN slot. **App Mode still requires the current Wi-Fi HTTP proxy and the locally generated CA.** Third-party Proxy Mode delegates proxy/VPN and MITM handling to the selected proxy client and may cover cellular networks. Do not enable both interception paths at once.
## Compatibility
@@ -121,7 +134,7 @@ The project does not use Network Extension to create a VPN tunnel — there is n
| Build | macOS, Xcode, XcodeGen |
| Swift | 5.9 |
| Go | 1.23+ |
| Network | WiFi with manual HTTP proxy support |
| Network | Local mode: Wi-Fi with manual HTTP proxy support; third-party test mode: client-dependent Wi-Fi/4G/5G |
| 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.
@@ -151,7 +164,6 @@ 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)
+70 -24
View File
@@ -4,14 +4,14 @@
### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS
**无需 VPN、无需越狱,在 iPhone 本机通过 WiFi HTTP 代理改写 Apple 定位响应。**<br>
**无需越狱;可使用 APP模式的本机 Wi‑Fi HTTP 代理,或第三方代理模式(支持 Wi‑Fi/4G/5G)改写 Apple 定位响应。**<br>
可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138?logo=swift&logoColor=white)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go&logoColor=white)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.0-2563EB)](docs/CHANGELOG.md)
[![No VPN](https://img.shields.io/badge/VPN-不需要-16A34A)](#为什么不需要-vpn)
[![Version](https://img.shields.io/badge/version-v1.0.1-2563EB)](docs/CHANGELOG.md)
[![App Mode](https://img.shields.io/badge/APP模式-无需VPN-16A34A)](#为什么不需要-vpn)
[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md)
@@ -20,7 +20,7 @@
</div>
> [!IMPORTANT]
> 本项目用于学习、安全研究与自有设备测试。它会安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。
> 本项目用于学习、安全研究与自有设备测试。APP模式需要安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理;第三方代理模式的证书、MITM 和代理/VPN 连接由所选客户端处理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。
## 致谢
@@ -32,12 +32,12 @@
| 特性 | 说明 |
|---|---|
| 🚫 **无 VPN** | 不创建 VPN 隧道,仅使用定位权限,无需后台刷新、通知等额外权限 |
| 🔀 **双运行模式** | APP模式无需第三方客户端、支持 Wi‑Fi;第三方代理模式可覆盖 Wi‑Fi、4G 和 5G |
| 📱 **无需越狱** | 支持自行签名安装,最低部署目标为 iOS 15 |
| 🗺️ **原生地图体验** | 使用 Apple 地图同款蓝点,搜索、点击、拖动选点体验与 Apple 地图一致 |
| 📍 **系统级虚拟定位** | 支持钉钉、微信、Apple 地图、高德等 App 的虚拟实时定位 |
| 🔍 **可见缩放范围** | 左侧缩放控件显示当前可视范围,地点名称随级别自动适配 |
| 🧪 **环境检测** | 检查代理、CA 信任、Wi‑Fi 接管、坐标写入与响应改写 |
| 🧪 **环境检测** | 检查本地代理、CA 证书信任与 WiFi 代理链路 |
| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 |
## 效果预览
@@ -73,8 +73,9 @@
- **地点名称分级**:近距离显示 POI、门牌或道路;拉远后显示社区、区县、城市或省份。
- **地图范围显示**:缩放控件中显示 `180 m``2.5 km``126 km` 等当前可视范围。
- **收藏与快速切换**:保存常用坐标,并明确显示当前准备应用的位置。
- **配置引导**提供证书安装、完全信任、Wi‑Fi HTTP 代理、生效与恢复说明
- **分流配置引导**首次启动先选择模式;APP模式引导本机代理和 CA,第三方代理模式引导客户端、配置导入和接口检测
- **问题诊断**:内置验证流程和结构化运行日志。
- **第三方代理模式(测试)**:内置各客户端模块配置,把收藏或当前选点的 WGS-84 坐标发送到第三方代理模块;代理客户端持久化坐标,关闭本 App 后仍可继续生效。
## 快速开始
@@ -83,9 +84,46 @@
- 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 获取构建产物并自行签名;或
- 在 macOS + Xcode 环境按[构建说明](docs/BUILD.md)编译。
详细步骤见[自签安装说明](docs/SELF-SIGNING.md)。
#### 自签安装
### 2. 安装并信任 CA
免费 Apple ID 即可侧载,无需付费开发者账号。本项目不使用 VPN、Network Extension 或 Packet Tunnel Provider,但签名工具仍需保留 App 的能力与标识。
需要自行构建时执行:
```bash
./build.sh
```
输出文件为 `dist/PaopaoLocationSpoofer-unsigned.ipa`。也可以直接下载 Release 附带的未签名 IPA,然后使用 [Impact](https://github.com/claration/Impactor) 签名并安装到设备。
签名时不要修改以下标识,也不要移除 App Group 和 Wi-Fi 信息能力:
| 组件 | 标识 |
|---|---|
| 主 App Bundle ID | `com.paopaolabs.location-spoofer` |
| App Group | `group.com.paopaolabs.location-spoofer` |
免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装;这是 Apple 的侧载限制,不是 App 的证书失效。App 生成的 WLOC CA 私钥保存在 iOS 钥匙串中:使用相同 Bundle ID 和钥匙串访问范围重装时通常可继续复用,但卸载、系统清理或签名能力变化后不保证保留。
### 2. 选择运行模式
#### APP模式
首次打开后选择 APP模式,再按 App 内引导配置代理和 CA。APP模式没有第三方代理客户端依赖,但只支持 Wi‑Fi。免费自签应用无法使用此功能所需的 VPN/Network Extension 能力,因此使用当前 Wi‑Fi 的手动 HTTP 代理实现流量接入。
##### 配置当前 WiFi 代理
首次打开后,先在当前 Wi‑Fi 的“配置代理”中选择“手动”:
```text
服务器:127.0.0.1
端口:8888
鉴定:关闭
```
返回 App 后运行环境检测;如果设备尚未信任 CA,App 会自动进入证书初始化。
##### 安装并信任 CA
按首次引导下载描述文件,然后完成:
@@ -94,24 +132,33 @@
设置 → 通用 → 关于本机 → 证书信任设置 → 完全信任
```
### 3. 配置当前 WiFi 代理
#### 第三方代理模式
在当前 WiFi 的"配置代理"中选择"手动"
此模式由 App 负责地图选点、收藏和发送 WGS-84 坐标;WLOC 拦截、MITM 与坐标持久化由第三方代理客户端负责。它不会启动本机 Go 代理,不检查或使用 App 的 CA,也不要求配置 `127.0.0.1:8888`,可用于 WiFi、4G 和 5G。
```text
服务器:127.0.0.1
端口:8888
鉴定:关闭
```
首次引导或“设置 → 运行模式”切换后,选择客户端。App 提供官方订阅地址复制和客户端跳转按钮;在对应客户端的模块、重写或覆写订阅入口粘贴地址并导入。仓库和 App 包内仍保留以下模块配置快照,用于版本归档和离线核对:
### 4. 选点并启用
| 客户端 | 状态 | 模块 |
|---|---|---|
| Shadowrocket(小火箭) | **当前可测试** | [wloc.module](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module) |
| Surge | 配置已提供,尚未验证 | [wloc.sgmodule](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule) |
| Quantumult X | 配置已提供,尚未验证 | [wloc.conf](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf) |
| Loon | 配置已提供,尚未验证 | [wloc.lpx](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx) |
| Stash | 配置已提供,尚未验证 | [wloc.stoverride](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride) |
| Egern | 配置已提供,尚未验证 | 直接使用 Surge 模块 |
Stash 应直接订阅 `.stoverride`,无需通过 Script Hub 转换。Egern 复用 Surge 配置。导入后还需要按对应客户端自己的流程启用模块、MITM、证书和代理/VPN 连接;这些状态不由本 App 管理。App 只调用 WLOC 配置接口查询和同步坐标,“检测连接”仅在查询返回模块 JSON 时判定成功,不会把普通 HTTP 200 当作成功。
> 第三方代理模式目前是测试模式。内置模块快照来源及版本记录见 [第三方模块说明](docs/THIRD_PARTY_MODULES.md);模块引用的运行脚本仍由第三方客户端按配置访问。上游更新可能改变行为;当前仅计划使用 Shadowrocket 做真机验证。
### 3. 选点并启用
1. 搜索、点击或拖动地图选择位置;点击实时位置按钮可回到 MapKit 蓝点。
2. 点击"开始虚拟定位"等待环境检测通过
2. APP 模式点击开始虚拟定位”并等待环境检测;第三方代理模式点击“同步到第三方代理”
3. 按 App 内"生效说明"刷新飞行模式、Wi‑Fi 和定位服务状态。
4. 打开 Apple 地图或目标 App 验证结果。
### 5. 恢复真实位置
### 4. 恢复真实位置
停止虚拟定位,关闭当前 Wi‑Fi 的手动代理,并按 App 内"失效说明"刷新系统定位缓存。若系统仍保留旧缓存,请重启设备后再检查。
@@ -130,7 +177,7 @@ iPhone 定位请求
系统与应用读取定位结果
```
项目不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**但它仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 切换 Wi‑Fi 后需要重新检查代理设置;停止使用后应及时关闭手动代理
APP 模式不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**APP 模式仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 第三方代理模式则由所选代理客户端管理代理/VPN 和 MITM,可覆盖蜂窝网络;两种模式不得同时拦截 WLOC 请求
## 兼容性
@@ -140,7 +187,7 @@ iPhone 定位请求
| 构建 | macOS、Xcode、XcodeGen |
| Swift | 5.9 |
| Go | 1.23+ |
| 网络 | 可手动配置 HTTP 代理的 WiFi |
| 网络 | APP 模式:可手动配置 HTTP 代理的 Wi‑Fi;第三方代理模式(测试):取决于代理客户端,可覆盖 Wi‑Fi/4G/5G |
| 安装 | 自行签名或使用 Releases 构建产物 |
效果会受到 iOS 版本、网络、系统定位缓存和目标 App 自身策略影响,不承诺兼容所有系统或第三方 App。
@@ -164,14 +211,13 @@ Shared/ 收藏、设置、日志和共享模型
Resources/ Info.plist、Entitlements 与图标
Scripts/ 构建、签名和检查脚本
Tests/ XCTest 与 Bash 契约测试
docs/ 构建、自签和版本更新文
docs/ 构建和版本发布归
```
## 文档与反馈
- [构建说明](docs/BUILD.md)
- [自签安装](docs/SELF-SIGNING.md)
- [v1.0.0 更新日志](docs/CHANGELOG.md)
- [更新日志](docs/CHANGELOG.md)
- [English README](README.en.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
+7 -1
View File
@@ -23,6 +23,12 @@
<key>LSApplicationQueriesSchemes</key>
<array>
<string>App-Prefs</string>
<string>shadowrocket</string>
<string>surge</string>
<string>quantumult-x</string>
<string>loon</string>
<string>stash</string>
<string>egern</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
@@ -33,7 +39,7 @@
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
@@ -0,0 +1,11 @@
#!name=Apple WLOC 定位修改
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
#!author=Yu9191 Rewrite
#!homepage=https://github.com/Yu9191/wloc
[rewrite_local]
^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc url script-response-body https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save url script-echo-response https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
[mitm]
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
+19
View File
@@ -0,0 +1,19 @@
#!name=Apple WLOC 定位修改
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
#!author=Yu9191 Rewrite
#!homepage=https://github.com/Yu9191/wloc
#!openUrl=https://wloc-pages.pages.dev/
[Argument]
longitude = input, "113.94114", tag=经度(在线选点优先)
latitude = input, "22.544577", tag=纬度(在线选点优先)
accuracy = input, "25", tag=精度(米)
logLevel = select, "info", "off", "error", "warn", "debug", "all", tag=日志级别
[Script]
http-response ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC, argument=[{longitude},{latitude},{accuracy},{logLevel}]
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js, timeout=10, tag=WLOC Settings
[MITM]
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
@@ -0,0 +1,13 @@
#!name=Apple WLOC 定位修改
#!desc=修改 Apple 网络定位返回坐标 (Shadowrocket 小火箭) | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
#!author=Yu9191 Rewrite
#!homepage=https://github.com/Yu9191/wloc
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
#!category=Tools
[Script]
Apple WLOC = type=http-response,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc,requires-body=1,binary-body-mode=1,max-size=0,timeout=30,script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js,argument=longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save,requires-body=0,max-size=0,timeout=10,script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
[MITM]
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
@@ -0,0 +1,14 @@
#!name=Apple WLOC 定位修改
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
#!author=Yu9191 Rewrite
#!homepage=https://github.com/Yu9191/wloc
#!category=Tools
#!arguments=经度:113.94114, 纬度:22.544577, 精度:25, 日志级别:info
#!arguments-desc=经度/纬度: 默认坐标(在线选点储存后优先)\n精度: GPS精度(米)\n日志级别: off/error/warn/info/debug/all\n\n使用方法: 打开选点页面 -> 选位置 -> 储存到设备
[Script]
Apple WLOC = type=http-response, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc", requires-body=1, binary-body-mode=1, max-size=0, timeout=30, script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js, argument=longitude={{{经度}}}&latitude={{{纬度}}}&accuracy={{{精度}}}&logLevel={{{日志级别}}}
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save", requires-body=0, max-size=0, timeout=10, script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
[MITM]
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
@@ -0,0 +1,33 @@
name: Apple WLOC 定位修改
desc: "修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/"
author: Yu9191 Rewrite
homepage: https://github.com/Yu9191/wloc
icon: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
category: Tools
http:
mitm:
- "gs-loc.apple.com"
- "gs-loc-cn.apple.com"
script:
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc
name: WLOC.Location
type: response
require-body: true
binary-mode: true
max-size: 0
timeout: 30
argument: longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save
name: WLOC.Settings
type: request
require-body: false
timeout: 10
script-providers:
WLOC.Location:
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js
interval: 86400
WLOC.Settings:
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
interval: 86400
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:-}"
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Usage: $0 v<major>.<minor>.<patch>" >&2
exit 2
fi
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "Tag $VERSION already exists; generate the archive before creating the tag." >&2
exit 1
fi
PREVIOUS_TAG="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RANGE="$PREVIOUS_TAG..HEAD"
RANGE_LABEL="$PREVIOUS_TAG..$VERSION"
else
RANGE="HEAD"
RANGE_LABEL="initial..$VERSION"
fi
COMMITS="$(git log "$RANGE" --no-merges --format='- `%h` %s')"
if [[ -z "$COMMITS" ]]; then
echo "No commits found in $RANGE; refusing to create empty release notes." >&2
exit 1
fi
OUTPUT="docs/releases/${VERSION}.md"
mkdir -p "$(dirname "$OUTPUT")"
cat > "$OUTPUT" <<EOF
# ${VERSION}
发布日期:$(date +%F)
## 提交变更总结
${COMMITS}
## 自签安装
- Release 附件为未签名 IPA,安装前需要自行签名。
- 可使用免费 Apple ID 和 Impact 完成签名安装,无需付费开发者账号。
- 签名时请保留 Bundle ID \`com.paopaolabs.location-spoofer\`、App Group \`group.com.paopaolabs.location-spoofer\` 及原有 entitlements。
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
<!-- commit-range: ${RANGE_LABEL} -->
EOF
echo "Generated $OUTPUT from $RANGE"
+12 -3
View File
@@ -29,19 +29,28 @@ final class BackgroundKeepAlive {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
RuntimeLogger.error("APP", "KeepAlive", "音频会话失败", error: error)
isActive = false
return
}
let eng = AVAudioEngine()
let player = AVAudioPlayerNode()
eng.attach(player)
let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)!
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3)!
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1),
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3) else {
RuntimeLogger.error("APP", "KeepAlive", "无法创建静音音频缓冲区")
isActive = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
return
}
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
isActive = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
return
}
player.scheduleBuffer(buf, at: nil, options: .loops)
player.play()
+52
View File
@@ -59,6 +59,23 @@ enum CoordinateConverter {
enum MapCoordinateSystem: String {
case gcj02 = "GCJ-02"
case wgs84 = "WGS-84"
var diagnosticName: String {
switch self {
case .gcj02: return "国内标准(GCJ-02)"
case .wgs84: return "国际标准(WGS-84)"
}
}
}
struct CoordinateRepresentationDiagnosis: Equatable {
let inferredSystem: MapCoordinateSystem?
let distanceToWGS84: CLLocationDistance
let distanceToGCJ02: CLLocationDistance
var inferredName: String {
inferredSystem?.diagnosticName ?? "无法判定"
}
}
// MARK: -
@@ -243,6 +260,41 @@ enum CoordinateConverter {
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
/// Identifies which stored representation a runtime sample most closely
/// resembles. Overseas identity pairs and unrelated samples stay ambiguous.
static func diagnoseRepresentation(
sample: CLLocationCoordinate2D,
pair: CoordinatePair,
maximumDistance: CLLocationDistance = 1_000,
minimumSeparation: CLLocationDistance = 30
) -> CoordinateRepresentationDiagnosis {
let distanceToWGS84 = distance(
lat1: sample.latitude,
lon1: sample.longitude,
lat2: pair.wgs84.latitude,
lon2: pair.wgs84.longitude
)
let distanceToGCJ02 = distance(
lat1: sample.latitude,
lon1: sample.longitude,
lat2: pair.gcj02.latitude,
lon2: pair.gcj02.longitude
)
let nearestDistance = min(distanceToWGS84, distanceToGCJ02)
let separation = abs(distanceToWGS84 - distanceToGCJ02)
let inferredSystem: MapCoordinateSystem?
if nearestDistance > maximumDistance || separation < minimumSeparation {
inferredSystem = nil
} else {
inferredSystem = distanceToWGS84 < distanceToGCJ02 ? .wgs84 : .gcj02
}
return CoordinateRepresentationDiagnosis(
inferredSystem: inferredSystem,
distanceToWGS84: distanceToWGS84,
distanceToGCJ02: distanceToGCJ02
)
}
// MARK: -
/// GCJ-02 WGS-84 0.5
+49
View File
@@ -0,0 +1,49 @@
import Foundation
enum ProxyRuntimeMode: String, CaseIterable, Codable, Identifiable {
case localWiFi
case thirdParty
var id: String { rawValue }
var displayName: String {
switch self {
case .localWiFi: return "APP模式"
case .thirdParty: return "第三方代理模式"
}
}
}
@MainActor
final class ProxyRuntimeModeStore: ObservableObject {
static let shared = ProxyRuntimeModeStore()
private enum Key {
static let runtimeMode = "proxyRuntimeMode"
static let hasSelectedRuntimeMode = "hasSelectedProxyRuntimeMode"
}
@Published private(set) var mode: ProxyRuntimeMode
@Published private(set) var hasSelectedMode: Bool
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
self.mode = defaults.string(forKey: Key.runtimeMode)
.flatMap(ProxyRuntimeMode.init(rawValue:)) ?? .localWiFi
self.hasSelectedMode = defaults.bool(forKey: Key.hasSelectedRuntimeMode)
}
func setMode(_ mode: ProxyRuntimeMode) {
let changed = self.mode != mode
self.mode = mode
hasSelectedMode = true
defaults.set(mode.rawValue, forKey: Key.runtimeMode)
defaults.set(true, forKey: Key.hasSelectedRuntimeMode)
if changed {
RuntimeLogger.info("APP", "Mode", "代理运行模式已切换", details: [
"模式": mode.displayName
])
}
}
}
+264
View File
@@ -0,0 +1,264 @@
import Foundation
struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
let success: Bool
let longitude: Double?
let latitude: Double?
let accuracy: Int?
let error: String?
}
enum ThirdPartyProxyConnectionState: Equatable {
case unknown
case connected(active: Bool)
case failed(String)
}
enum ThirdPartyProxyError: LocalizedError, Equatable {
case invalidResponse
case moduleNotIntercepted
case rejected(String)
case coordinateMismatch
case network(String)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "第三方代理返回了无法识别的数据"
case .moduleNotIntercepted:
return "请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接"
case .rejected(let message):
return message
case .coordinateMismatch:
return "第三方代理保存的坐标与当前选点不一致"
case .network(let message):
return "第三方代理请求失败:\(message)"
}
}
}
protocol ThirdPartyProxyRequesting {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: ThirdPartyProxyRequesting {}
@MainActor
final class ThirdPartyProxyManager: ObservableObject {
static let shared = ThirdPartyProxyManager()
static let interceptionHostname = "gs-loc.apple.com"
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
@Published private(set) var isRequesting = false
private let requester: any ThirdPartyProxyRequesting
private let endpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
init(requester: (any ThirdPartyProxyRequesting)? = nil) {
if let requester {
self.requester = requester
} else {
let configuration = URLSessionConfiguration.ephemeral
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 8
configuration.timeoutIntervalForResource = 10
self.requester = URLSession(configuration: configuration)
}
}
func query() async throws -> ThirdPartyProxySettingsResponse {
let response = try await perform(action: .query)
if response.success,
response.latitude != nil,
response.longitude != nil {
activeSettings = response
connectionState = .connected(active: true)
} else if response.error?.contains("无已保存") == true {
activeSettings = nil
connectionState = .connected(active: false)
} else {
let error = ThirdPartyProxyError.rejected(response.error ?? "第三方代理查询失败")
connectionState = .failed(error.localizedDescription)
throw error
}
return response
}
func save(_ favorite: FavoriteLocation) async throws -> ThirdPartyProxySettingsResponse {
let wgs84 = favorite.coordinatePair.wgs84
let response = try await perform(action: .save(
latitude: wgs84.latitude,
longitude: wgs84.longitude,
accuracy: favorite.accuracy
))
guard response.success else {
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标")
}
guard let latitude = response.latitude,
let longitude = response.longitude,
abs(latitude - wgs84.latitude) <= 0.000_001,
abs(longitude - wgs84.longitude) <= 0.000_001 else {
throw ThirdPartyProxyError.coordinateMismatch
}
activeSettings = response
connectionState = .connected(active: true)
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理已保存 WGS-84 坐标", details: [
"坐标标准": "WGS-84",
"取值字段": "coordinatePair.wgs84",
"accuracy": String(favorite.accuracy)
])
return response
}
func clear() async throws {
let response = try await perform(action: .clear)
guard response.success else {
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理清除坐标失败")
}
activeSettings = nil
connectionState = .connected(active: false)
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理坐标已清除")
}
private enum Action {
case query
case save(latitude: Double, longitude: Double, accuracy: Int)
case clear
}
private func perform(action: Action) async throws -> ThirdPartyProxySettingsResponse {
guard !isRequesting else {
throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行")
}
isRequesting = true
defer { isRequesting = false }
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
switch action {
case .query:
components.queryItems = [URLQueryItem(name: "action", value: "query")]
case .clear:
components.queryItems = [URLQueryItem(name: "action", value: "clear")]
case .save(let latitude, let longitude, let accuracy):
components.queryItems = [
URLQueryItem(name: "lon", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), longitude)),
URLQueryItem(name: "lat", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), latitude)),
URLQueryItem(name: "acc", value: String(accuracy))
]
}
guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
request.timeoutInterval = 8
do {
let (data, urlResponse) = try await requester.data(for: request)
guard let http = urlResponse as? HTTPURLResponse, http.statusCode == 200 else {
throw ThirdPartyProxyError.moduleNotIntercepted
}
guard let response = try? JSONDecoder().decode(ThirdPartyProxySettingsResponse.self, from: data) else {
throw ThirdPartyProxyError.moduleNotIntercepted
}
return response
} catch let error as ThirdPartyProxyError {
connectionState = .failed(error.localizedDescription)
RuntimeLogger.error("APP", "ThirdPartyProxy", "第三方代理请求失败", error: error)
throw error
} catch {
let mapped = ThirdPartyProxyError.network(error.localizedDescription)
connectionState = .failed(mapped.localizedDescription)
RuntimeLogger.error("APP", "ThirdPartyProxy", "第三方代理请求失败", error: error)
throw mapped
}
}
}
enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
case shadowrocket
case surge
case quantumultX
case loon
case stash
case egern
var id: String { rawValue }
var name: String {
switch self {
case .shadowrocket: return "Shadowrocket"
case .surge: return "Surge"
case .quantumultX: return "Quantumult X"
case .loon: return "Loon"
case .stash: return "Stash"
case .egern: return "Egern"
}
}
var verificationText: String {
self == .shadowrocket ? "当前可测试" : "配置已提供,尚未验证"
}
var moduleFileName: String {
switch self {
case .shadowrocket: return "wloc.module"
case .surge, .egern: return "wloc.sgmodule"
case .quantumultX: return "wloc.conf"
case .loon: return "wloc.lpx"
case .stash: return "wloc.stoverride"
}
}
var subscriptionURL: URL {
let url: String
switch self {
case .surge, .egern:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule"
case .quantumultX:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf"
case .loon:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx"
case .stash:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride"
case .shadowrocket:
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module"
}
return URL(string: url)!
}
var launchURL: URL? {
switch self {
case .shadowrocket: return URL(string: "shadowrocket://")
case .surge: return URL(string: "surge://")
case .quantumultX: return URL(string: "quantumult-x://")
case .loon: return URL(string: "loon://")
case .stash: return URL(string: "stash://")
case .egern: return URL(string: "egern://")
}
}
}
@MainActor
final class ThirdPartyProxyClientStore: ObservableObject {
static let shared = ThirdPartyProxyClientStore()
private enum Key {
static let selectedClient = "selectedThirdPartyProxyClient"
}
@Published private(set) var selectedClient: ThirdPartyProxyClient
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
selectedClient = defaults.string(forKey: Key.selectedClient)
.flatMap(ThirdPartyProxyClient.init(rawValue:)) ?? .shadowrocket
}
func select(_ client: ThirdPartyProxyClient) {
selectedClient = client
defaults.set(client.rawValue, forKey: Key.selectedClient)
}
}
@@ -82,6 +82,46 @@ final class FavoriteLocationStoreTests: XCTestCase {
XCTAssertFalse(pair.matchesWGS84(latitude: gcj.latitude, longitude: gcj.longitude))
}
func testCoordinateRepresentationDiagnosisDistinguishesDomesticPair() {
let pair = CoordinateConverter.coordinatePair(
lat: 22.539,
lon: 113.934,
mapCoordinateSystem: .wgs84
)
XCTAssertEqual(
CoordinateConverter.diagnoseRepresentation(sample: pair.wgs84.coordinate, pair: pair).inferredSystem,
.wgs84
)
XCTAssertEqual(
CoordinateConverter.diagnoseRepresentation(sample: pair.gcj02.coordinate, pair: pair).inferredSystem,
.gcj02
)
}
func testCoordinateRepresentationDiagnosisKeepsOverseasIdentityPairAmbiguous() {
let pair = CoordinateConverter.coordinatePair(
lat: 48.858_37,
lon: 2.294_481,
mapCoordinateSystem: .wgs84
)
XCTAssertNil(
CoordinateConverter.diagnoseRepresentation(sample: pair.wgs84.coordinate, pair: pair).inferredSystem
)
}
func testCoordinateRepresentationDiagnosisRejectsUnrelatedSample() {
let pair = CoordinateConverter.coordinatePair(
lat: 22.539,
lon: 113.934,
mapCoordinateSystem: .wgs84
)
let unrelated = CLLocationCoordinate2D(latitude: 31.2304, longitude: 121.4737)
XCTAssertNil(CoordinateConverter.diagnoseRepresentation(sample: unrelated, pair: pair).inferredSystem)
}
func testMapConfigurationNeverRequestsRealUserLocation() {
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
@@ -0,0 +1,20 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class ProxyRuntimeModeTests: XCTestCase {
func testDefaultsToLocalWiFiAndPersistsThirdPartyMode() {
let suiteName = "ProxyRuntimeModeTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let initial = ProxyRuntimeModeStore(defaults: defaults)
XCTAssertEqual(initial.mode, .localWiFi)
XCTAssertFalse(initial.hasSelectedMode)
initial.setMode(.thirdParty)
let restored = ProxyRuntimeModeStore(defaults: defaults)
XCTAssertEqual(restored.mode, .thirdParty)
XCTAssertTrue(restored.hasSelectedMode)
}
}
@@ -0,0 +1,113 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class ThirdPartyProxyManagerTests: XCTestCase {
func testQueryDistinguishesConnectedWithoutCoordinate() async throws {
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":""}"#)
let manager = ThirdPartyProxyManager(requester: requester)
let response = try await manager.query()
XCTAssertFalse(response.success)
XCTAssertEqual(manager.connectionState, .connected(active: false))
XCTAssertEqual(requester.lastURL?.query, "action=query")
}
func testSaveUsesFavoriteWGS84AndAcceptsMatchingResponse() async throws {
let favorite = FavoriteLocation(
name: "深圳湾",
latitude: 22.494,
longitude: 113.951,
accuracy: 20,
mapCoordinateSystem: .gcj02
)
let wgs84 = favorite.coordinatePair.wgs84
let body = String(format: #"{"success":true,"longitude":%.8f,"latitude":%.8f,"accuracy":20}"#,
locale: Locale(identifier: "en_US_POSIX"), wgs84.longitude, wgs84.latitude)
let requester = FakeThirdPartyRequester(body: body)
let manager = ThirdPartyProxyManager(requester: requester)
_ = try await manager.save(favorite)
let components = URLComponents(url: try XCTUnwrap(requester.lastURL), resolvingAgainstBaseURL: false)
let values = Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") })
let latitude = try XCTUnwrap(Double(values["lat"] ?? ""))
let longitude = try XCTUnwrap(Double(values["lon"] ?? ""))
XCTAssertEqual(latitude, wgs84.latitude, accuracy: 0.000_000_01)
XCTAssertEqual(longitude, wgs84.longitude, accuracy: 0.000_000_01)
XCTAssertEqual(values["acc"], "20")
XCTAssertEqual(manager.connectionState, .connected(active: true))
}
func testSaveRejectsCoordinateMismatchWithoutMarkingActive() async {
let requester = FakeThirdPartyRequester(body: #"{"success":true,"longitude":1,"latitude":2,"accuracy":25}"#)
let manager = ThirdPartyProxyManager(requester: requester)
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 25)
do {
_ = try await manager.save(favorite)
XCTFail("expected coordinate mismatch")
} catch {
XCTAssertEqual(error as? ThirdPartyProxyError, .coordinateMismatch)
}
XCTAssertEqual(manager.connectionState, .unknown)
XCTAssertNil(manager.activeSettings)
}
func testMalformedResponseIsNotTreatedAsSuccess() async {
let manager = ThirdPartyProxyManager(requester: FakeThirdPartyRequester(body: "not-json"))
do {
_ = try await manager.query()
XCTFail("expected interception failure")
} catch {
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
}
}
func testClientLinksUseOfficialUpstreamModulesAndVerificationLabels() {
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.verificationText, "当前可测试")
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText.contains("尚未验证"))
XCTAssertEqual(ThirdPartyProxyClient.egern.subscriptionURL, ThirdPartyProxyClient.surge.subscriptionURL)
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.stoverride"))
XCTAssertTrue(ThirdPartyProxyClient.shadowrocket.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.module"))
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.launchURL?.scheme, "shadowrocket")
XCTAssertEqual(ThirdPartyProxyClient.surge.launchURL?.scheme, "surge")
XCTAssertEqual(ThirdPartyProxyClient.quantumultX.launchURL?.scheme, "quantumult-x")
XCTAssertEqual(ThirdPartyProxyClient.loon.launchURL?.scheme, "loon")
XCTAssertEqual(ThirdPartyProxyClient.stash.launchURL?.scheme, "stash")
XCTAssertEqual(ThirdPartyProxyClient.egern.launchURL?.scheme, "egern")
}
func testSelectedClientPersists() {
let suiteName = "ThirdPartyProxyClientStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = ThirdPartyProxyClientStore(defaults: defaults)
XCTAssertEqual(store.selectedClient, .shadowrocket)
store.select(.stash)
XCTAssertEqual(ThirdPartyProxyClientStore(defaults: defaults).selectedClient, .stash)
}
}
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
private let data: Data
private(set) var lastURL: URL?
init(body: String) {
data = Data(body.utf8)
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
lastURL = request.url
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (data, response)
}
}
+3 -1
View File
@@ -25,6 +25,8 @@ done
! 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 'coordinate: userLocation.coordinate' "$MAP_BRIDGE" || fail "visible MapKit blue-point samples must use MKUserLocation.coordinate"
! grep -Eq 'userLocation\.location\??\.coordinate' "$MAP_BRIDGE" || fail "MapKit blue-point samples must not use the underlying Core Location coordinate"
grep -q 'MapCameraCommand' "$MAP_BRIDGE" || fail "map bridge must consume MapCameraCommand"
grep -q 'let initialViewportMeters:' "$MAP_BRIDGE" || fail "map bridge must receive initial viewport from MapLocationState"
! grep -q 'ViewportStore.loadOrDefault()' "$MAP_BRIDGE" || fail "map bridge must not bypass MapLocationState for initial viewport"
@@ -72,7 +74,7 @@ grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime p
! grep -q '瓦片检测' "$CONVERTER" || fail "coordinate-system probe logs must not claim to inspect map tiles"
grep -q 'minimumCountForSuppression = 3' Shared/AppGroup.swift || fail "automatic tip suppression must require three successful operations"
grep -q 'activeTip = .deactivation' "$MAP_HOME" || fail "manual deactivation help must use the non-suppressible generic tip sheet"
grep -q 'stabilizationNanoseconds: UInt64 = 5_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait five seconds before environment verification"
grep -q 'stabilizationNanoseconds: UInt64 = 3_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait three seconds before environment verification"
grep -q 'result.wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi proxy failures must use the background reminder mapping"
grep -q 'if result == .certNotTrusted' "$MAP_HOME" || fail "Wi-Fi certificate failures must enter certificate setup"
grep -q 'setup.applyVerificationResult(result)' "$MAP_HOME" || fail "Wi-Fi certificate failures must use the setup routing reducer"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
MODE="$ROOT/Shared/ProxyRuntimeMode.swift"
MANAGER="$ROOT/Shared/ThirdPartyProxyManager.swift"
CONTENT="$ROOT/App/ContentView.swift"
SETUP="$ROOT/App/FirstSetupView.swift"
SETTINGS="$ROOT/App/SettingsView.swift"
MODULES="$ROOT/Resources/ThirdPartyProxyModules"
grep -q 'return "APP模式"' "$MODE" || fail "APP mode display name is missing"
grep -q 'return "第三方代理模式"' "$MODE" || fail "third-party mode display name is missing"
grep -q 'hasSelectedMode' "$MODE" || fail "first-launch mode selection must be persisted"
grep -q 'guard runtimeMode.hasSelectedMode else' "$CONTENT" || fail "mode selection must gate startup"
grep -q 'phase = .setup' "$CONTENT" || fail "first launch must enter setup before map construction"
grep -q 'case thirdPartyClient' "$SETUP" || fail "third-party client selection step is missing"
grep -q 'case thirdPartyImport' "$SETUP" || fail "third-party import step is missing"
grep -q 'case thirdPartyTest' "$SETUP" || fail "third-party connection test step is missing"
! grep -q '生成并导入配置文件' "$SETUP" || fail "setup must not offer file generation/import"
grep -q '复制订阅地址' "$SETUP" || fail "subscription URL copy action is missing"
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" || fail "setup must expose a client launch action"
grep -Fq 'Label("打开 \(thirdPartyClient.selectedClient.name)"' "$SETTINGS" || fail "Settings must expose a client launch action"
! grep -q '在浏览器打开模块文件' "$SETTINGS" || fail "Settings must not open the module URL as the primary client action"
grep -q 'requestThirdPartySetup' "$SETTINGS" || fail "Settings must reopen third-party setup"
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
test -s "$MODULES/$file" || fail "missing bundled module: $file"
done
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
grep -q 'wloc.stoverride' "$MANAGER" || fail "Stash must use .stoverride directly"
grep -q 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing"
for scheme in surge quantumult-x loon stash egern; do
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
done
grep -q '复制解密域名' "$SETUP" || fail "Shadowrocket MITM hostname copy action is missing"
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
! grep -q 'ToolbarItem(placement: .navigationBarLeading)' "$SETUP" || fail "setup must not show a top-left navigation action"
grep -q 'presentSuccessfulOperationTip(.activation)' "$ROOT/App/MapHomeView.swift" || fail "third-party save must present the activation tip"
grep -q 'presentSuccessfulOperationTip(.deactivation)' "$ROOT/App/MapHomeView.swift" || fail "third-party clear must present the deactivation tip"
grep -q 'if spoofState == .active' "$ROOT/App/MapHomeView.swift" || fail "manual help must follow the shared spoof state"
grep -q 'MARKETING_VERSION: "1.0.1"' "$ROOT/project.yml" || fail "marketing version must be 1.0.1"
grep -q 'CURRENT_PROJECT_VERSION: "2"' "$ROOT/project.yml" || fail "build version must be 2"
echo "PASS: third-party proxy mode contract"
+15 -1
View File
@@ -38,7 +38,21 @@ IPA 始终保持未签名。用 [Impact](https://github.com/claration/Impactor)
1. `./build.sh` 通过并输出未签名 IPA
2. 用 Impact 签名后安装到设备
3. 真机安装后,按引导下载 CA → 安装 → 信任,再配置 WiFi HTTP 代理 `127.0.0.1:8888`
3. 真机安装后,配置 WiFi HTTP 代理 `127.0.0.1:8888`,再按检测结果完成 CA 下载、安装和信任
4. 环境检测通过后,选点开启虚拟定位
5. 打开 Apple 地图验证定位是否变为虚拟位置
6. 若失败,查看诊断页的日志信息
## 发布版本
发布前先生成版本归档并提交:
```bash
./Scripts/generate-release-notes.sh v1.1.0
git add docs/releases/v1.1.0.md
git commit -m "docs: archive v1.1.0 release notes"
git tag v1.1.0
git push origin main v1.1.0
```
归档文件根据“上一个版本标签到当前提交”的 commit subject 生成。GitHub Actions 会校验归档存在,并将文件正文直接作为 GitHub Release 内容;不会发布文档链接或缺失说明的占位 Release。
-36
View File
@@ -1,36 +0,0 @@
# 自签安装指南
**免费 Apple ID 即可自签安装**,无需付费开发者账号。
## 原理
本项目只是一个本地 HTTP 代理配合 WiFi 手动代理,不涉及 VPN / Network Extension / Packet Tunnel Provider。无需特殊权限,个人免费 Apple ID 侧载完全可用。
## 构建未签名 IPA
```bash
./build.sh
```
输出:`dist/PaopaoLocationSpoofer-unsigned.ipa`
## 使用 Impact 签名安装
用 [Impact](https://github.com/claration/Impactor) 打开 IPA 签名并安装到设备。
关键标识符不可更改:
| 组件 | Bundle ID |
|------|-----------|
| 主 App | `com.paopaolabs.location-spoofer` |
| App Group | `group.com.paopaolabs.location-spoofer` |
## 安装后步骤
1. 首次打开,按引导下载 CA 证书 → 安装描述文件 → 开启完全信任
2. 在 WiFi 设置中配置 HTTP 代理为 `127.0.0.1:8888`
3. 环境检测通过后即可使用
## iOS 26+ 注意事项
开启虚拟定位后需重启设备清除定位缓存,详见 README 中的使用说明。
+31
View File
@@ -0,0 +1,31 @@
# Third-party proxy module snapshots
The files under `Resources/ThirdPartyProxyModules/` are bundled configuration
snapshots from [Yu9191/wloc](https://github.com/Yu9191/wloc). They are retained
in the App bundle for release provenance and offline inspection. The setup UI
copies the official subscription URL instead of exporting these files.
- Upstream commit: `eec07a8dc8de6dbaee8eac1fb376e4d03020154a`
- Snapshot date: 2026-08-06
- Source directory: `modules/`
| Bundled file | Client |
|---|---|
| `wloc.module` | Shadowrocket |
| `wloc.sgmodule` | Surge and Egern |
| `wloc.conf` | Quantumult X |
| `wloc.lpx` | Loon |
| `wloc.stoverride` | Stash |
SHA-256:
```text
bb5e17b60027704971660b0ea2df3560ceff973c27d43e7f2c2c18b48d368ac6 wloc.conf
1fb451616fb17242849f72490f016afcdb8aa81a0b086f6dd5f94e1af3d58ee1 wloc.lpx
97cab104056428aa0e90521c3bf2646e9739b0b4c83272b31790f99584bca89e wloc.module
5d6b82c31316f4a7be65e3b8d2335f4338e01af98e262948118eefe63abf7034 wloc.sgmodule
cb06593752db8b223dfa5cd1cbd089115fe3a541f5c8532491615923e83df2cb wloc.stoverride
```
The official subscription URLs are the setup UI's import path. Egern reuses the
Surge module. Stash imports `.stoverride` directly.
+3 -3
View File
@@ -6,7 +6,7 @@
- **🗺️ 原生地图体验**:Apple MapKit 蓝点实时定位,搜索地点、点击选点、拖动浏览与 Apple 地图一致
- **📍 虚拟定位引擎**:基于本地 HTTP 代理 MITM 方案,无需 VPN、无需越狱,对钉钉、微信及任意系统定位 App 生效
- **🧪 完整设置引导**证书安装 → WiFi 代理配置 → 环境验证,逐步检代理、CA 信任、坐标写入与响应改写
- **🧪 完整设置引导**:WiFi 代理配置 → 证书安装与信任 → 环境验证,逐步检查本地代理、CA 信任和 WiFi 代理链路
- **✈️ 飞行模式缓存清除**:一键化操作引导,按步骤刷新飞行模式、WiFi 和定位服务状态
- **⭐ 收藏与快速切换**:保存常用坐标,一键跳转
- **🧾 诊断日志**:实时查看定位请求和改写状态,每条独立可复制
@@ -19,11 +19,11 @@
| 安装 | 自行签名(推荐 [Impact](https://github.com/claration/Impactor) |
| 网络 | 可手动配置 HTTP 代理的 WiFi |
### 安装
### 自签安装
1. 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 下载 `PaopaoLocationSpoofer-unsigned.ipa`
2. 使用 [Impact](https://github.com/claration/Impactor) 签名安装
3. 按 App 内引导完成证书安装和 WiFi 代理配置
3. 签名时保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements
### 致谢
+2 -2
View File
@@ -8,8 +8,8 @@ options:
settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "1.0.1"
CURRENT_PROJECT_VERSION: "2"
CODE_SIGN_STYLE: Manual
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"