45 Commits
Author SHA1 Message Date
xweiba a6b9e8056b chore: prepare v1.0.5 release 2026-08-10 16:19:56 +08:00
xweiba 9510d9aa42 fix: bust third-party module cache 2026-08-10 13:39:58 +08:00
xweiba 41dfe7a8ed feat: unify app and third-party wloc features 2026-08-10 13:35:47 +08:00
xweiba 27af50375d chore: prepare v1.0.4 release 2026-08-09 18:36:02 +08:00
xweiba a2f46dd166 docs: refine acknowledgements and links 2026-08-09 17:59:21 +08:00
xweiba b0d2f0e8e4 feat: improve wloc response compatibility 2026-08-09 16:23:41 +08:00
xweiba 7cf38363cf docs: add community acknowledgements 2026-08-09 16:00:37 +08:00
xweiba a6688b4a49 chore: prepare v1.0.3 release 2026-08-09 15:32:41 +08:00
xweiba 893f674004 docs: explain third-party client integration 2026-08-09 01:07:25 +08:00
xweiba 8cc65172b2 feat: add GitHub community and bug workflows 2026-08-08 16:55:17 +08:00
xweiba b07fa61195 feat: improve runtime setup and guidance 2026-08-08 15:18:40 +08:00
xweiba 3499f080d7 chore: prepare v1.0.2 release 2026-08-07 17:01:54 +08:00
xweiba c7ae14d4b2 docs: refresh installation and feature previews 2026-08-07 16:45:02 +08:00
xweiba a3f39a93e7 fix: improve coordinate selection feedback 2026-08-07 16:43:34 +08:00
weiba 811f9c9343 Merge pull request #5 from lixiaobaivv/agent/upstream-impact-install-guide
docs: 完善自签安装步骤
2026-08-07 15:01:17 +08:00
weiba 8bd15eb13c Merge pull request #4 from lixiaobaivv/agent/fix-mapkit-coordinate-drift
fix: 修复跨境定位后的 MapKit 坐标偏移
2026-08-07 14:54:03 +08:00
xweiba b0c60673af merge: integrate complete MapKit runtime refresh
# Conflicts:
#	App/MapHomeView.swift
#	Shared/CoordinateConverter.swift
#	Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift
#	Tests/map_refactor_contract_test.sh
2026-08-07 14:51:30 +08:00
Yzzz 111080ad1a docs: 完善自签安装步骤 2026-08-07 14:33:15 +08:00
xweiba 8acc33186c fix: refresh MapKit coordinate type at runtime 2026-08-07 14:13:25 +08:00
Yzzz 7721443484 fix: 修复跨境定位后的 MapKit 坐标偏移 2026-08-07 13:59:50 +08:00
weiba 7e362e6b6c Merge pull request #3 from lixiaobaivv/agent/update-changelog-v1-0-1 2026-08-07 12:38:32 +08:00
Yzzz d11550228c docs: 补充 v1.0.1 更新日志 2026-08-07 11:58:34 +08:00
weiba 104df4e4a2 Merge pull request #2 from lixiaobaivv/agent/upstream-readme-copy
docs: 更新中英文 README
2026-08-07 11:19:07 +08:00
xweiba f38a4ebff1 docs: refresh bilingual README 2026-08-07 11:13:06 +08:00
xweiba bae701d4c6 docs: 移除 README 中的 App Group 标识行 2026-08-07 11:05:51 +08:00
xweiba 97cbe247ba docs: archive v1.0.1 release notes 2026-08-06 19:21:32 +08:00
xweiba b52d72bef2 feat: add third-party proxy mode and improve onboarding 2026-08-06 19:20:25 +08:00
xweiba e86909cc2f fix: 优化启动引导、坐标处理和日志管理 2026-08-06 16:23:09 +08:00
xweiba 0e8e83fd33 fix: harden startup proxy and coordinate handling 2026-08-06 12:17:23 +08:00
xweiba 881270a21a fix: 用 CLGeocoder 做固定坐标反查 2026-08-06 00:55:56 +08:00
xweiba 5bd4899bc8 fix: WiFi重连后等3秒再检测 2026-08-06 00:43:20 +08:00
xweiba 1bd298ed3d fix: 启动时检测一次瓦片类型 2026-08-06 00:33:49 +08:00
xweiba 577a1bdb24 fix: 固定坐标 (22.283819,114.158439) 反查,返回林士街=GCJ-02 2026-08-06 00:33:26 +08:00
xweiba 96012f5afa fix: 瓦片类型通过 MKLocalSearch 反查地名含中文判断(30s缓存) 2026-08-05 23:20:02 +08:00
xweiba 794cf9d004 fix: 瓦片类型仅由搜索结果坐标+蓝点推算,去掉其他判断逻辑 2026-08-05 23:15:06 +08:00
xweiba 3260af748b fix: 搜索框 placeholder 改为「搜索地点或坐标」 2026-08-05 23:09:01 +08:00
xweiba 094027e3fe fix: MKLocalSearch 返回坐标与输入坐标对比日志 2026-08-05 23:05:30 +08:00
xweiba ba17fd765b fix: 搜索结果坐标打印到日志 2026-08-05 22:50:54 +08:00
xweiba 5dd0ef711d fix: 推算瓦片类型——GCJ→WGS后更接近蓝点=GCJ-02瓦片,反之=WGS-84 2026-08-05 22:44:41 +08:00
xweiba f9b137b0f7 fix: 瓦片类型用坐标地理边界判断,蓝点直接存不转换 2026-08-05 22:44:02 +08:00
xweiba 542d74e94f fix: 蓝点坐标已是 WGS-84,不再过 toStored 2026-08-05 22:40:58 +08:00
xweiba 2d2ce4f06d fix: 坐标转换日志加上当前瓦片类型 2026-08-05 22:36:30 +08:00
xweiba 3862ddc3c8 fix: MARKETING_VERSION 改为 1.0.0 2026-08-05 21:35:13 +08:00
xweiba 4aa4f63e62 fix: 坐标转换修复、图钉键盘对齐、WiFi监控、UI优化
- 坐标转换: detectType 用地理边界判断瓦片类型,加载路径去掉重复 toStored
- 图钉: 放 MKMapView 内部 + keyboard 通知双保险,针尖对齐地理中心
- 缩放控件: 转 UIKit 子视图放地图内,键盘弹起一起移动
- 网络监控: WiFi SSID 轮询 + 重连检测,虚拟定位激活时自动验证
- UI: 去飞行模式弹窗和面板,引导页只在首次安装和设置入口触发
- 实时定位: 蓝点数据优先,不限时缓存
2026-08-05 21:20:56 +08:00
xweiba c8aba2be78 release: PaopaoLocationSpoofer v1.0.0
- iOS 虚拟定位工具,基于本地 HTTP 代理 MITM 方案
- MapKit 原生地图体验,支持搜索、收藏、实时定位
- 完整的设置引导流程(证书安装、WiFi 代理配置、环境验证)
- 支持 iOS 15+,SwiftUI 构建
2026-08-05 15:45:24 +08:00
131 changed files with 10345 additions and 1453 deletions
@@ -0,0 +1,59 @@
title: "[第三方配置分享] "
body:
- type: markdown
attributes:
value: |
感谢分享已验证的第三方客户端配置。请先移除账号、订阅、密码、设备标识、真实位置等敏感信息。
截图除敏感信息遮挡外不要添加箭头、编号或说明文字,维护者会统一裁剪和标注。
- type: dropdown
id: client
attributes:
label: 第三方客户端
options:
- Shadowrocket
- Surge
- Quantumult X
- Loon
- Stash
- Egern
validations:
required: true
- type: input
id: client-version
attributes:
label: 第三方客户端版本
placeholder: 例如 2.2.70
validations:
required: true
- type: input
id: ios-version
attributes:
label: iOS 版本
placeholder: 例如 iOS 26.5
validations:
required: true
- type: textarea
id: setup-steps
attributes:
label: 配置步骤
description: 请描述模块导入、证书或解密设置、代理连接和最终验证过程。
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: 截图与补充说明
description: 可以直接拖入脱敏原图,并说明每张图片对应的步骤。
validations:
required: false
- type: checkboxes
id: attribution
attributes:
label: README 收录署名
options:
- label: 匿名收录,不在 README 展示投稿账号
required: false
+45
View File
@@ -0,0 +1,45 @@
name: Bug 报告
description: 报告 App 功能异常和可复现问题
title: "[Bug] "
labels:
- bug
body:
- type: markdown
attributes:
value: |
请优先在 App 的“设置 → 支持 → 报告 Bug”中生成诊断报告,再粘贴到下方字段。
提交前请移除真实位置、账号、订阅、密码、证书私钥和其他敏感信息。
- type: textarea
id: app-report
attributes:
label: App 生成的诊断报告
description: 粘贴 App 自动复制的完整报告;如果无法进入 App,请按占位格式手动填写。
placeholder: |
### 环境信息
App 版本:
系统版本:
运行模式:
第三方客户端:
可复现环境:
### 问题描述
### 诊断日志
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 可选。请先遮挡账号、通知、真实位置和其他敏感信息。
validations:
required: false
- type: checkboxes
id: confirmations
attributes:
label: 提交确认
options:
- label: 我已搜索现有 Issues,确认没有相同问题
required: true
- label: 我已移除真实位置、认证信息、订阅地址、证书私钥和其他敏感信息
required: true
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 使用帮助
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9
about: 安装、证书、代理配置、环境检测和问题排查
- name: 功能建议
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE
about: 提交功能改进、交互优化和新场景建议
- name: 第三方配置分享
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB
about: 分享已验证的第三方客户端模块、配置步骤和脱敏截图
+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
+7
View File
@@ -2,6 +2,9 @@
.superpowers/
build/
dist/
!ThirdParty/WlocScripts/dist/
!ThirdParty/WlocScripts/dist/**
node_modules/
DerivedData/
xcuserdata/
*.xcuserstate
@@ -23,5 +26,9 @@ Wloc.xcodeproj/
.github/*
!.github/workflows/
!.github/workflows/**
!.github/DISCUSSION_TEMPLATE/
!.github/DISCUSSION_TEMPLATE/**
!.github/ISSUE_TEMPLATE/
!.github/ISSUE_TEMPLATE/**
Core/core
docs/superpowers/
+29 -12
View File
@@ -7,13 +7,17 @@ struct BugReportView: View {
@State private var isReproducible = true
@State private var isRunning = false
@State private var showCopiedAlert = false
@State private var githubDestination: SafariDestination?
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
//
Text("遇到问题时,在这里生成 Issue 报告。系统会运行一次诊断测试,将测试结果和你的描述一起复制到剪切板,然后跳转到 GitHub 提交")
Text("遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段")
.font(.caption)
.foregroundStyle(.secondary)
@@ -52,7 +56,7 @@ struct BugReportView: View {
if isRunning {
ProgressView().tint(.white)
}
Text(isRunning ? "正在生成报告…" : "生成 Issue 报告")
Text(isRunning ? "正在生成报告…" : "生成 Bug 报告")
.font(.body.weight(.medium))
}
.frame(maxWidth: .infinity)
@@ -65,29 +69,40 @@ struct BugReportView: View {
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.navigationTitle("报告 Issue")
.navigationTitle("报告 Bug")
.navigationBarTitleDisplayMode(.inline)
.alert("已生成", isPresented: $showCopiedAlert) {
Button("跳转到 GitHub") {
dismiss()
if let url = URL(string: "https://github.com/xweiba/location-spoofer/issues/new") {
UIApplication.shared.open(url)
}
Button("打开 GitHub 表单") {
githubDestination = SafariDestination(url: GitHubSubmission.bugReportURL)
}
Button("稍后再说", role: .cancel) {
dismiss()
}
} message: {
Text("Issue 报告已复制到剪切板。请在 GitHub Issues 页面粘贴并提交。")
Text("Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。")
}
.sheet(item: $githubDestination) { destination in
SafariView(url: destination.url)
.ignoresSafeArea()
}
}
private func generateReport() {
isRunning = true
Task {
//
_ = await setup.runVerificationTest(testLat: 22.543099, testLon: 113.934576)
let testLog = setup.testLog
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()
testLog = setup.testLog
}
//
let appVersion: String = {
@@ -102,6 +117,8 @@ struct BugReportView: View {
### 环境信息
App 版本: \(appVersion)
系统版本: iOS \(systemVersion)
运行模式: \(runtimeMode.mode.displayName)
第三方客户端: \(runtimeMode.mode == .thirdParty ? thirdPartyClient.selectedClient.name : "不适用")
可复现环境: \(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)
}
}
+197 -54
View File
@@ -1,71 +1,214 @@
import SwiftUI
import UIKit
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var setup = SetupCoordinator()
@ObservedObject private var net = NetworkMonitor.shared
@State private var showSetup = false
@State private var showEnableTip = false
@AppStorage("setupCompleted") private var setupCompleted = false
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var remoteConfiguration = AppRemoteConfigurationStore.shared
@State private var phase: AppPhase = .splash
@State private var updatePrompt: AppUpdatePrompt?
@State private var requiredUpdatePrompt: AppUpdatePrompt?
enum AppPhase { case splash, setup, map }
var body: some View {
NavigationView {
MapHomeView(setup: setup)
}
.task {
//
if !setupCompleted {
showSetup = true
return
Group {
switch phase {
case .splash:
VStack(spacing: 16) {
Image(systemName: "location.fill")
.font(.system(size: 48)).foregroundStyle(.blue)
ProgressView()
Text(runtimeMode.hasSelectedMode && runtimeMode.mode == .localWiFi
? "正在初始化地图与本地代理…"
: "正在初始化地图…")
.font(.subheadline).foregroundStyle(.secondary)
}
case .setup:
FirstSetupView(setup: setup, onComplete: finishInitialSetup)
case .map:
NavigationView {
MapHomeView(setup: setup)
}
.fullScreenCover(isPresented: $setup.needsSetup) {
FirstSetupView(setup: setup, onComplete: finishPresentedSetup)
}
}
await setup.refreshTrust()
}
.onChange(of: net.isAirplaneMode) { airplane in
guard setupCompleted else { return }
if airplane {
showEnableTip = true
.task { await bootstrap() }
.task { await checkForUpdates() }
.onChange(of: scenePhase) { newPhase in
guard newPhase == .active, let requiredUpdatePrompt else { return }
updatePrompt = requiredUpdatePrompt
}
.alert(item: $updatePrompt) { prompt in
updateAlert(for: prompt)
}
}
@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 runtimeMode.isInitialized(launchMode) else {
if launchMode == .localWiFi {
await setup.prepareLocalServices()
setup.requestSetup()
} else {
showEnableTip = false
Task { await setup.refreshTrust() }
ProxyManager.shared.stop()
BackgroundKeepAlive.shared.stop()
setup.requestThirdPartyOnboarding()
}
phase = .setup
return
}
.fullScreenCover(isPresented: $showSetup) {
FirstSetupView(setup: setup, onComplete: {
setupCompleted = true
setup.completeSetup()
showSetup = false
})
if launchMode == .localWiFi {
await setup.prepareLocalServices()
} else {
ProxyManager.shared.stop()
BackgroundKeepAlive.shared.stop()
RuntimeLogger.info("APP", "Startup", "第三方代理测试模式:跳过本地 CA、代理和环境检测")
}
//
.onChange(of: setup.needsSetup) { needs in
if needs { showSetup = true }
do {
try CoordinateStorageMigration.migrateIfNeeded(favorites: FavoriteLocationStore())
} catch {
RuntimeLogger.error("APP", "Startup", "旧坐标数据迁移失败,将在下次启动重试", error: error)
}
.sheet(isPresented: $showEnableTip) {
NavigationView {
VStack(spacing: 20) {
Image(systemName: "airplane")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("飞行模式已开启")
.font(.title3.weight(.semibold))
Text("Wi‑Fi 和蜂窝数据已关闭,虚拟定位无法生效。请关闭飞行模式后重试。")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("知道了") {
showEnableTip = false
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
}
.padding(30)
.navigationTitle("提示")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("完成") { showEnableTip = false }
}
}
// MapHomeView is intentionally constructed only after this required
// coordinate-system gate resolves, so cached pins are never replayed
// into an unknown Apple Maps coordinate system.
let mapCoordinateSystem = await CoordinateConverter.resolveInitialMapCoordinateSystem()
guard !Task.isCancelled else { return }
RuntimeLogger.info("APP", "Startup", "地图坐标标准初始化完成,开始后续启动流程", details: [
"地图标准": mapCoordinateSystem.rawValue,
"使用兜底": String(CoordinateConverter.initialMapCoordinateSystemUsedFallback)
])
// Resolve the first map center before constructing MapHomeView. This
// prevents a Shenzhen/cache frame followed by a second realtime frame.
if LastCoordinateStore.load() == nil {
RuntimeLogger.info("APP", "Startup", "没有持久化图钉,地图创建前请求实时定位")
if let realtime = await RealtimeLocationManager.shared.requestLocation() {
let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(realtime)
let pair = CoordinateConverter.coordinatePair(
lat: realtime.latitude,
lon: realtime.longitude,
mapCoordinateSystem: .wgs84
)
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: 1_000)
RuntimeLogger.info("APP", "Startup", "已使用实时定位准备唯一初始地图状态", details: [
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"修正兜底标准": String(mapCoordinateSystemChange != nil),
"缩放米": "1000"
])
RealtimeLocationTrace.coordinate(
"地图创建前取得的初始实时位置(WGS-84)",
coordinate: realtime
)
} else {
RuntimeLogger.warning("APP", "Startup", "地图创建前无法取得实时定位,唯一初始位置使用深圳", details: [
"地图标准": mapCoordinateSystem.rawValue,
"缩放米": "1000"
])
}
} else {
RuntimeLogger.info("APP", "Startup", "已找到持久化图钉,直接准备唯一初始地图状态")
}
guard !Task.isCancelled else { return }
setup.completeSetup()
RuntimeLogger.info("APP", "Startup", "启动门禁全部完成,现在创建 MapHomeView")
phase = .map
}
private func finishInitialSetup() {
let completedMode = runtimeMode.mode
runtimeMode.markInitialized(completedMode)
setup.completeSetup()
phase = .splash
Task { await bootstrap() }
}
private func finishPresentedSetup() {
runtimeMode.markInitialized(runtimeMode.mode)
setup.completeSetup()
}
@MainActor
private func checkForUpdates() async {
let currentVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String ?? AppRemoteConfiguration.fallback.latestVersion
let configuration: AppRemoteConfiguration
if let remote = await AppRemoteConfigurationService.fetch() {
remoteConfiguration.apply(remote)
configuration = remote
} else {
configuration = remoteConfiguration.configuration
}
guard let pendingPrompt = configuration.updatePrompt(currentVersion: currentVersion) else { return }
let releaseNotes = await AppRemoteConfigurationService.fetchReleaseNotes(
version: pendingPrompt.latestVersion
)
guard !Task.isCancelled,
let prompt = configuration.updatePrompt(
currentVersion: currentVersion,
releaseNotes: releaseNotes
) else {
return
}
if prompt.requirement == .required {
requiredUpdatePrompt = prompt
}
updatePrompt = prompt
}
private func updateAlert(for prompt: AppUpdatePrompt) -> Alert {
switch prompt.requirement {
case .required:
let details = prompt.releaseNotes
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
return Alert(
title: Text("需要更新"),
message: Text(
"当前版本 \(prompt.currentVersion) 已停止支持,请更新到 \(prompt.latestVersion) 后继续使用。\n\n\(details)"
),
dismissButton: .default(Text("立即更新")) {
openUpdatePage(requiredPrompt: prompt)
}
)
case .recommended:
let details = prompt.releaseNotes
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
return Alert(
title: Text("发现新版本"),
message: Text(
"当前版本 \(prompt.currentVersion),最新版本 \(prompt.latestVersion)\n\n\(details)"
),
primaryButton: .default(Text("前往更新")) {
openUpdatePage(requiredPrompt: nil)
},
secondaryButton: .cancel(Text("稍后"))
)
}
}
private func openUpdatePage(requiredPrompt: AppUpdatePrompt?) {
UIApplication.shared.open(AppRemoteConfigurationService.releasesURL)
guard requiredPrompt != nil else { return }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 800_000_000)
updatePrompt = self.requiredUpdatePrompt
}
}
}
+70 -13
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 = ""
@@ -15,25 +17,48 @@ struct RuntimeLogsView: View {
@State private var copiedEntryID: UUID?
@State private var copyLogsConfirmed = false
@State private var testLogCopied = false
@State private var logFilter = ""
private var filteredEntries: [RuntimeLogEntry] {
let q = logFilter.trimmingCharacters(in: .whitespacesAndNewlines)
guard !q.isEmpty else { return entries }
return entries.filter { $0.message.localizedCaseInsensitiveContains(q) }
}
var body: some View {
VStack(spacing: 0) {
testPanel
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("过滤日志", text: $logFilter)
.textFieldStyle(.plain).font(.caption)
if !logFilter.isEmpty {
Button { logFilter = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.caption)
}
}
}.padding(.horizontal, 12).padding(.vertical, 6)
Divider()
if entries.isEmpty {
if filteredEntries.isEmpty {
VStack(spacing: 10) {
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
Text("暂无运行日志").foregroundStyle(.secondary)
Text(entries.isEmpty ? "暂无运行日志" : "无匹配日志").foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(entries.reversed()) { entry in logRow(entry) }
ForEach(filteredEntries.reversed()) { entry in logRow(entry) }
}.padding(12)
}
}
}
.navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
Text("日志自动清理,仅保留近 3 天")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } }
ToolbarItem(placement: .navigationBarTrailing) {
@@ -74,10 +99,14 @@ struct RuntimeLogsView: View {
Button {
isTesting = true; testResult = ""; testLogCopied = false
Task {
let result = await setup.runVerificationTest(testLat: testFavorite.latitude, testLon: testFavorite.longitude)
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
if !result.isSuccess { testResult += ",查看下方日志" }
testMessage = setup.testLog
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: {
@@ -87,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)
}
@@ -102,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) {
@@ -147,10 +178,12 @@ struct RuntimeLogsView: View {
}.frame(maxHeight: 180)
}
}
HStack(spacing: 14) {
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
}.font(.caption).foregroundStyle(.secondary)
if 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)
@@ -201,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)
"""
}
}
}
+884 -220
View File
File diff suppressed because it is too large Load Diff
+53 -14
View File
@@ -1,15 +1,47 @@
import Foundation
@MainActor
protocol LocationActionProxying: AnyObject {
var isRunning: Bool { get }
func start() async throws
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64
}
extension ProxyManager: LocationActionProxying {}
@MainActor
protocol LocationActionSettingsStoring: AnyObject {
func load() -> WlocSettings?
func save(_ settings: WlocSettings)
func clear()
}
@MainActor
final class DeviceWlocSettingsStorage: LocationActionSettingsStoring {
func load() -> WlocSettings? { WlocSettingsStore.load() }
func save(_ settings: WlocSettings) { WlocSettingsStore.save(settings) }
func clear() { WlocSettingsStore.clear() }
}
@MainActor
final class LocationActionCoordinator: ObservableObject {
@Published private(set) var state: LocationActionState = .idle
@Published private(set) var virtualLocationEnabled = false
@Published private(set) var message = ""
private let proxy = ProxyManager.shared
private let proxy: any LocationActionProxying
private let settings: any LocationActionSettingsStoring
init() {
self.virtualLocationEnabled = WlocSettingsStore.load()?.enabled == true
self.proxy = ProxyManager.shared
self.settings = DeviceWlocSettingsStorage()
self.virtualLocationEnabled = settings.load()?.enabled == true
}
init(proxy: any LocationActionProxying, settings: any LocationActionSettingsStoring) {
self.proxy = proxy
self.settings = settings
self.virtualLocationEnabled = settings.load()?.enabled == true
}
func apply(_ favorite: FavoriteLocation) async -> Bool {
@@ -41,8 +73,8 @@ final class LocationActionCoordinator: ObservableObject {
func clear() {
guard !state.isBusy else { return }
proxy.setCoords(lat: 0, lon: 0, enabled: false)
WlocSettingsStore.clear()
_ = proxy.setCoords(lat: 0, lon: 0, enabled: false, accuracy: 25)
settings.clear()
state = .idle
virtualLocationEnabled = false
message = "已恢复真实定位"
@@ -56,21 +88,28 @@ final class LocationActionCoordinator: ObservableObject {
}
private func commit(_ favorite: FavoriteLocation) -> Bool {
// MKMapView 使GCJ-02 GCJ-02
// Apple wloc 使 WGS-84 WGS-84
let wgs = CoordinateConverter.gcj02ToWgs84(lat: favorite.latitude, lon: favorite.longitude)
WlocSettingsStore.save(WlocSettings(
longitude: wgs.lon,
latitude: wgs.lat,
// WLOC 使 WGS-84
let wgs = favorite.coordinatePair.wgs84
let value = WlocSettings(
longitude: wgs.longitude,
latitude: wgs.latitude,
accuracy: favorite.accuracy,
enabled: true
))
proxy.setCoords(
lat: wgs.lat,
lon: wgs.lon,
)
settings.save(value)
_ = proxy.setCoords(
lat: wgs.latitude,
lon: wgs.longitude,
enabled: true,
accuracy: favorite.accuracy
)
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
virtualLocationEnabled = true
message = "虚拟定位已开启"
+964 -202
View File
File diff suppressed because it is too large Load Diff
+145 -32
View File
@@ -131,12 +131,17 @@ final class MapLocationState: ObservableObject {
private var nextRealtimeIntentID: UInt64 = 0
private var latestRealtimeIntentID: UInt64 = 0
init(initialCoordinate: CLLocationCoordinate2D, initialViewportMeters: CLLocationDistance = 1_000) {
init(
initialCoordinate: CLLocationCoordinate2D,
initialViewportMeters: CLLocationDistance = 1_000,
initialSource: MapSelectionSource = .initial,
initialName: String? = nil
) {
viewportMeters = initialViewportMeters
selection = MapSelection(
coordinate: initialCoordinate,
source: .initial,
explicitName: nil,
source: initialSource,
explicitName: initialName?.nonEmpty,
revision: nextSelectionRevision
)
}
@@ -217,6 +222,13 @@ final class MapLocationState: ObservableObject {
if coordinate == nil { realtimeLocation = nil }
}
/// Discards a blue-point sample represented in a superseded MapKit
/// coordinate system. The next native callback repopulates the cache.
func clearRealtimeLocationForMapCoordinateSystemChange() {
realtimeLocation = nil
realtimeCoordinate = nil
}
func updateExplicitName(_ name: String, forFavoriteID favoriteID: UUID) {
guard selection.source == .favorite(favoriteID) else { return }
selection = MapSelection(
@@ -231,6 +243,20 @@ final class MapLocationState: ObservableObject {
viewportMeters = max(50, distanceMeters)
}
/// Updates only the map representation of the current physical selection.
/// This preserves revision/source so an in-flight user action is not invalidated.
func reprojectSelectionForMapCoordinateSystemChange(_ coordinate: CLLocationCoordinate2D) {
guard CLLocationCoordinate2DIsValid(coordinate),
!selection.coordinate.isApproximatelyEqual(to: coordinate) else { return }
selection = MapSelection(
coordinate: coordinate,
source: selection.source,
explicitName: selection.explicitName,
revision: selection.revision
)
issueCameraCommand(.focus(coordinate: coordinate, distanceMeters: viewportMeters))
}
@discardableResult
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
@@ -288,45 +314,132 @@ extension CLLocationCoordinate2D {
// MARK: -
struct LastCoordinate: Codable, Equatable {
let coordinatePair: CoordinatePair
let zoomMeters: CLLocationDistance
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
coordinatePair.coordinate(for: mapCoordinateSystem)
}
}
enum LastCoordinateStore {
private struct StoredPosition: Codable {
let coordinatePair: CoordinatePair
let zoomMeters: CLLocationDistance
}
private static let positionKey = "lastMapPositionV1"
private static let legacyLatKey = "lastMapLat"
private static let legacyLonKey = "lastMapLon"
static func save(
mapCoordinate: CLLocationCoordinate2D,
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
zoomMeters: CLLocationDistance,
defaults: UserDefaults = AppGroup.defaults
) {
save(
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
zoomMeters: zoomMeters,
defaults: defaults
)
}
static func save(
coordinatePair: CoordinatePair,
zoomMeters: CLLocationDistance,
defaults: UserDefaults = AppGroup.defaults
) {
do {
let value = StoredPosition(coordinatePair: coordinatePair, zoomMeters: max(50, zoomMeters))
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
ViewportStore.save(value.zoomMeters, defaults: defaults)
} catch {
RuntimeLogger.error("APP", "地图", "保存当前图钉失败", error: error)
}
}
static func load(defaults: UserDefaults = AppGroup.defaults) -> LastCoordinate? {
if let data = defaults.data(forKey: positionKey),
let value = try? JSONDecoder().decode(StoredPosition.self, from: data) {
return LastCoordinate(coordinatePair: value.coordinatePair, zoomMeters: value.zoomMeters)
}
let legacyLatitude = defaults.double(forKey: legacyLatKey)
let legacyLongitude = defaults.double(forKey: legacyLonKey)
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else {
return nil
}
return LastCoordinate(
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
zoomMeters: ViewportStore.load(defaults: defaults) ?? 1_000
)
}
static func updateZoom(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
guard let current = load(defaults: defaults) else { return }
save(coordinatePair: current.coordinatePair, zoomMeters: meters, defaults: defaults)
}
static func migrateLegacyCoordinate(
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) throws {
guard defaults.data(forKey: positionKey) == nil else { return }
let legacyLatitude = legacyDefaults.double(forKey: legacyLatKey)
let legacyLongitude = legacyDefaults.double(forKey: legacyLonKey)
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else { return }
let value = StoredPosition(
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
zoomMeters: ViewportStore.load(defaults: legacyDefaults) ?? 1_000
)
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
}
}
enum ViewportStore {
private static let key = "mapViewportMeters"
static func save(_ meters: CLLocationDistance) {
UserDefaults.standard.set(meters, forKey: key)
static func save(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
let value = max(50, meters)
defaults.set(value, forKey: key)
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(value)])
}
/// nil
static func load() -> CLLocationDistance? {
let v = UserDefaults.standard.double(forKey: key)
return v > 0 ? v : nil
static func load(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance? {
let value = defaults.double(forKey: key)
return value > 0 ? value : nil
}
/// 1km
static func loadOrDefault() -> CLLocationDistance {
if let v = load() { return v }
static func loadOrDefault(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance {
if let value = load(defaults: defaults) { return value }
let fallback: CLLocationDistance = 1_000
save(fallback)
save(fallback, defaults: defaults)
return fallback
}
}
struct LastCoordinate {
let latitude: Double
let longitude: Double
var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) }
var isValid: Bool { CLLocationCoordinate2DIsValid(coordinate) && (latitude != 0 || longitude != 0) }
}
enum CoordinateStorageMigration {
private static let versionKey = "coordinateStorageMigrationVersion"
static let currentVersion = 1
enum LastCoordinateStore {
private static let latKey = "lastMapLat"
private static let lonKey = "lastMapLon"
static func save(lat: Double, lon: Double) {
UserDefaults.standard.set(lat, forKey: latKey)
UserDefaults.standard.set(lon, forKey: lonKey)
}
/// nil
static func load() -> LastCoordinate? {
let c = LastCoordinate(
latitude: UserDefaults.standard.double(forKey: latKey),
longitude: UserDefaults.standard.double(forKey: lonKey)
static func migrateIfNeeded(
favorites: FavoriteLocationStore,
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) throws {
guard defaults.integer(forKey: versionKey) < currentVersion else { return }
// Pre-migration map pin and viewport values lived in UserDefaults.standard;
// favorites already lived in App Group defaults.
try LastCoordinateStore.migrateLegacyCoordinate(
defaults: defaults,
legacyDefaults: legacyDefaults
)
return c.isValid ? c : nil
try favorites.migrateLegacyCoordinates()
defaults.set(currentVersion, forKey: versionKey)
RuntimeLogger.info("APP", "坐标转换", "旧坐标数据迁移完成")
}
}
+180 -7
View File
@@ -32,12 +32,15 @@ enum MapZoomMath {
struct MapViewRepresentable: UIViewRepresentable {
let selection: MapSelection
let initialViewportMeters: CLLocationDistance
let cameraCommand: MapCameraCommand?
let onRealtimeLocationChanged: (CLLocation) -> Void
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
let onViewportChanged: (CLLocationDistance) -> Void
let onMapTap: (CLLocationCoordinate2D) -> Void
let onUserZoomChanged: ((CLLocationDistance) -> Void)?
var onZoomIn: (() -> Void)?
var onZoomOut: (() -> Void)?
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
@@ -45,7 +48,12 @@ struct MapViewRepresentable: UIViewRepresentable {
let map = MKMapView()
map.delegate = context.coordinator
map.showsUserLocation = true
let initialDistance = ViewportStore.loadOrDefault()
let initialDistance = max(50, initialViewportMeters)
RuntimeLogger.info("APP", "地图", "makeUIView", details: [
"zoom": String(initialDistance),
"初始坐标来源": String(describing: selection.source),
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
])
map.setRegion(
MKCoordinateRegion(
center: selection.coordinate,
@@ -55,10 +63,83 @@ struct MapViewRepresentable: UIViewRepresentable {
animated: false
)
// Center pin positioned relative to geographic center, not Auto Layout
let pinSize: CGFloat = 38
let sizeCfg = UIImage.SymbolConfiguration(pointSize: pinSize, weight: .semibold)
let paletteCfg = UIImage.SymbolConfiguration(paletteColors: [.white, .red])
let pinImage = UIImage(systemName: "mappin",
withConfiguration: sizeCfg.applying(paletteCfg))
let pin = UIImageView(image: pinImage)
pin.frame = CGRect(x: 0, y: 0, width: pinSize, height: pinSize)
pin.isUserInteractionEnabled = false
pin.layer.shadowColor = UIColor.black.cgColor
pin.layer.shadowOpacity = 0.28
pin.layer.shadowRadius = 5
pin.layer.shadowOffset = CGSize(width: 0, height: 3)
map.addSubview(pin)
context.coordinator.centerPin = pin
// Zoom controls UIKit subviews inside MKMapView, move with the map
let zoomStack = UIStackView()
zoomStack.axis = .vertical
zoomStack.spacing = 0
zoomStack.translatesAutoresizingMaskIntoConstraints = false
zoomStack.alignment = .center
zoomStack.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.8)
zoomStack.layer.cornerRadius = 13
zoomStack.layer.shadowColor = UIColor.black.cgColor
zoomStack.layer.shadowOpacity = 0.18
zoomStack.layer.shadowRadius = 7
zoomStack.layer.shadowOffset = CGSize(width: 0, height: 3)
let zoomInBtn = UIButton(type: .system)
zoomInBtn.setImage(UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
zoomInBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomInTapped), for: .touchUpInside)
zoomInBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
zoomInBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
let zoomLabel = UILabel()
let roundedDesc = UIFont.systemFont(ofSize: 9, weight: .semibold).fontDescriptor.withDesign(.rounded)
zoomLabel.font = roundedDesc.flatMap { UIFont(descriptor: $0, size: 9) } ?? .systemFont(ofSize: 9, weight: .semibold)
zoomLabel.textColor = .secondaryLabel
zoomLabel.textAlignment = .center
zoomLabel.adjustsFontSizeToFitWidth = true
zoomLabel.minimumScaleFactor = 0.7
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: initialDistance)
zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true
context.coordinator.zoomLabel = zoomLabel
let zoomOutBtn = UIButton(type: .system)
zoomOutBtn.setImage(UIImage(systemName: "minus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
zoomOutBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomOutTapped), for: .touchUpInside)
zoomOutBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
zoomOutBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
let sep1 = UIView(); sep1.translatesAutoresizingMaskIntoConstraints = false
sep1.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
sep1.backgroundColor = .separator
sep1.widthAnchor.constraint(equalToConstant: 28).isActive = true
let sep2 = UIView(); sep2.translatesAutoresizingMaskIntoConstraints = false
sep2.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
sep2.backgroundColor = .separator
sep2.widthAnchor.constraint(equalToConstant: 28).isActive = true
zoomStack.addArrangedSubview(zoomInBtn)
zoomStack.addArrangedSubview(sep1)
zoomStack.addArrangedSubview(zoomLabel)
zoomStack.addArrangedSubview(sep2)
zoomStack.addArrangedSubview(zoomOutBtn)
map.addSubview(zoomStack)
NSLayoutConstraint.activate([
zoomStack.leadingAnchor.constraint(equalTo: map.safeAreaLayoutGuide.leadingAnchor, constant: 16),
zoomStack.topAnchor.constraint(equalTo: map.safeAreaLayoutGuide.topAnchor, constant: 130),
])
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
tap.cancelsTouchesInView = false
map.addGestureRecognizer(tap)
context.coordinator.map = map
context.coordinator.setupKeyboardObservers()
return map
}
@@ -76,11 +157,48 @@ struct MapViewRepresentable: UIViewRepresentable {
private var activeCommandIsZoom = false
private var regionChangeWasUserDriven = false
private var isPinchZoom = false
weak var zoomLabel: UILabel?
weak var centerPin: UIImageView?
private let pinSize: CGFloat = 38
// MKUserLocationView 20pt
private var userDotDiameter: CGFloat = 20
private var keyboardObserverTokens: [NSObjectProtocol] = []
private var lastForwardedRealtimeTimestamp: Date?
deinit {
keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver)
}
@objc func zoomInTapped() { parent.onZoomIn?() }
@objc func zoomOutTapped() { parent.onZoomOut?() }
init(parent: MapViewRepresentable) {
self.parent = parent
}
private func updatePinPosition(on mapView: MKMapView) {
guard let pin = centerPin else { return }
let centerPt = mapView.convert(mapView.centerCoordinate, toPointTo: mapView)
// pin +
pin.center = CGPoint(
x: centerPt.x,
y: centerPt.y - pinSize / 2 + 5
)
}
func setupKeyboardObservers() {
guard keyboardObserverTokens.isEmpty else { return }
let nc = NotificationCenter.default
keyboardObserverTokens.append(nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in
guard let self, let map = self.map else { return }
self.updatePinPosition(on: map)
})
keyboardObserverTokens.append(nc.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in
guard let self, let map = self.map else { return }
self.updatePinPosition(on: map)
})
}
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
guard let command, command.id != lastConsumedCommandID else { return }
lastConsumedCommandID = command.id
@@ -92,8 +210,7 @@ struct MapViewRepresentable: UIViewRepresentable {
let region: MKCoordinateRegion
switch command.kind {
case let .focus(coordinate, _):
// span latitudinalMeters
// visibleVerticalDistance 2x
// span region inflate
region = MKCoordinateRegion(center: coordinate, span: map.region.span)
case let .zoom(factor):
region = MKCoordinateRegion(
@@ -111,11 +228,36 @@ struct MapViewRepresentable: UIViewRepresentable {
parent.onMapTap(map.convert(point, toCoordinateFrom: map))
}
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
for view in views where view.annotation is MKUserLocation {
//
userDotDiameter = view.bounds.width
for sub in view.subviews where sub.bounds.width > userDotDiameter + 4 {
sub.isHidden = true
}
}
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard let location = userLocation.location,
CLLocationCoordinate2DIsValid(location.coordinate),
location.horizontalAccuracy >= 0 else { return }
parent.onRealtimeLocationChanged(location)
guard let location = visibleUserLocationSample(userLocation) else {
RuntimeLogger.warning("APP", "实时定位", "MapKit 蓝点更新但 location 为空", details: [
"来源": "MKMapView.didUpdate"
])
return
}
guard CLLocationCoordinate2DIsValid(location.coordinate) else {
RealtimeLocationTrace.log("拒绝 MapKit 蓝点样本:坐标无效", location: location, details: [
"来源": "MKMapView.didUpdate"
], level: .warning)
return
}
guard location.horizontalAccuracy >= 0 else {
RealtimeLocationTrace.log("拒绝 MapKit 蓝点样本:水平精度无效", location: location, details: [
"来源": "MKMapView.didUpdate"
], level: .warning)
return
}
forwardRealtimeLocation(location)
}
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
@@ -143,6 +285,15 @@ struct MapViewRepresentable: UIViewRepresentable {
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
let distance = visibleVerticalDistance(in: mapView)
parent.onViewportChanged(distance)
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
updatePinPosition(on: mapView)
// delegate mapState.realtimeLocation nil
if let ul = visibleUserLocationSample(mapView.userLocation),
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
if lastForwardedRealtimeTimestamp.map({ ul.timestamp > $0 }) ?? true {
forwardRealtimeLocation(ul)
}
}
let userZoomed: Bool
if activeCameraCommandID != nil {
@@ -165,6 +316,28 @@ struct MapViewRepresentable: UIViewRepresentable {
regionChangeWasUserDriven = false
}
private func forwardRealtimeLocation(_ location: CLLocation) {
lastForwardedRealtimeTimestamp = location.timestamp
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)
+46 -9
View File
@@ -25,9 +25,21 @@ final class ProxyManager: ObservableObject {
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
let accuracy = CInt(settings?.accuracy ?? 25)
let motionEnabled = MotionSimulationStore.shared.isEnabled ? CInt(1) : CInt(0)
if enabled != 0 {
RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位")
}
let result: UInt = authority.certPEM.withCString { cp in
authority.keyPEM.withCString { kp in
UInt(wloccore_startproxy(UnsafeMutablePointer(mutating: cp), UnsafeMutablePointer(mutating: kp), CDouble(lat), CDouble(lon), enabled, accuracy))
UInt(wloccore_startproxyv2(
UnsafeMutablePointer(mutating: cp),
UnsafeMutablePointer(mutating: kp),
CDouble(lat),
CDouble(lon),
enabled,
accuracy,
motionEnabled
))
}
}
guard result != 0 else { CoreBridge.flushLogs(category: "Proxy"); throw ProxyError.startFailed }
@@ -55,12 +67,17 @@ final class ProxyManager: ObservableObject {
@discardableResult
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int = 25) -> UInt64 {
coordinateRevision &+= 1
wloccore_setcoords(CDouble(lat), CDouble(lon), enabled ? 1 : 0, CInt(accuracy))
wloccore_setpatchconfig(
CDouble(lat),
CDouble(lon),
enabled ? 1 : 0,
CInt(accuracy),
MotionSimulationStore.shared.isEnabled ? 1 : 0
)
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
"revision": String(coordinateRevision),
"enabled": String(enabled),
"lat": String(lat),
"lon": String(lon)
"accuracy": String(accuracy)
])
return coordinateRevision
}
@@ -117,15 +134,35 @@ final class ProxyManager: ObservableObject {
return (Double(r.r0), Double(r.r1), r.r2 != 0)
}
func openCertificateDownload() async {
func applyMotionSimulation(_ enabled: Bool) {
MotionSimulationStore.shared.setEnabled(enabled)
let settings = WlocSettingsStore.load()
wloccore_setpatchconfig(
CDouble(settings?.latitude ?? 0),
CDouble(settings?.longitude ?? 0),
settings?.enabled == true ? 1 : 0,
CInt(settings?.accuracy ?? 25),
enabled ? 1 : 0
)
RuntimeLogger.info("APP", "Proxy.motion", "运动状态模拟设置已更新", details: [
"enabled": String(enabled)
])
CoreBridge.flushLogs(category: "Proxy")
}
func prepareCertificateDownloadURL() async -> URL? {
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 nil
}
error = nil
return url
} catch {
self.error = "启动代理失败: \(error.localizedDescription)"
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
RuntimeLogger.error("APP", "Certificate", "准备证书下载失败", error: error)
return nil
}
}
+111 -17
View File
@@ -61,7 +61,7 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
private let driver: RealtimeLocationDriving
private let oneShotTimeoutNanoseconds: UInt64
private let fallbackTimeoutNanoseconds: UInt64
private let cacheMaxAge: TimeInterval
private let cacheMaxAge: TimeInterval = 20
private var nextRequestID: UInt64 = 0
private var activeRequest: ActiveRequest?
private var timeoutTask: Task<Void, Never>?
@@ -69,13 +69,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
init(
driver: RealtimeLocationDriving,
oneShotTimeoutNanoseconds: UInt64 = 1_500_000_000,
fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000,
cacheMaxAge: TimeInterval = 20
fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000
) {
self.driver = driver
self.oneShotTimeoutNanoseconds = oneShotTimeoutNanoseconds
self.fallbackTimeoutNanoseconds = fallbackTimeoutNanoseconds
self.cacheMaxAge = cacheMaxAge
authorizationStatus = driver.authorizationStatus
super.init()
driver.delegate = self
@@ -94,21 +92,31 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
func requestLocation() async -> CLLocationCoordinate2D? {
guard activeRequest == nil else {
RuntimeLogger.warning("APP", "定位", "忽略重复实时定位请求")
RuntimeLogger.warning("APP", "实时定位", "忽略重复 CLLocationManager 请求", details: [
"活动requestID": activeRequest.map { String($0.id) } ?? "nil",
"活动阶段": activeRequest.map { phaseName($0.phase) } ?? "nil"
])
return nil
}
authorizationStatus = driver.authorizationStatus
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求入口", details: [
"授权状态": authorizationName(authorizationStatus),
"内存缓存存在": String(location != nil),
"系统缓存存在": String(driver.location != nil)
])
guard authorizationStatus != .denied, authorizationStatus != .restricted else {
RuntimeLogger.warning("APP", "实时定位", "授权状态不允许定位", details: [
"授权状态": authorizationName(authorizationStatus)
])
return nil
}
if let cached = freshestCachedLocation() {
location = cached
RuntimeLogger.info("APP", "定位", "使用系统缓存实时定位", details: [
"age": String(format: "%.2f", max(0, -cached.timestamp.timeIntervalSinceNow)),
"lat": String(cached.coordinate.latitude),
"lon": String(cached.coordinate.longitude)
RealtimeLocationTrace.log("使用 CLLocationManager 新鲜缓存", location: cached, details: [
"来源": "manager-memory-or-system",
"缓存上限秒": String(Int(cacheMaxAge))
])
return cached.coordinate
}
@@ -116,7 +124,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
nextRequestID &+= 1
let requestID = nextRequestID
isRequesting = true
RuntimeLogger.info("APP", "定位", "请求实时定位…", details: ["requestID": String(requestID)])
RuntimeLogger.info("APP", "实时定位", "创建 CLLocationManager 请求", details: [
"requestID": String(requestID),
"授权状态": authorizationName(authorizationStatus),
"初始阶段": "awaitingAuthorization"
])
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
@@ -128,6 +140,9 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
)
if authorizationStatus == .notDetermined {
RuntimeLogger.info("APP", "实时定位", "请求前台定位授权", details: [
"requestID": String(requestID)
])
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
driver.requestWhenInUseAuthorization()
} else {
@@ -155,6 +170,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
MainActor.assumeIsolated {
authorizationStatus = driver.authorizationStatus
RuntimeLogger.info("APP", "实时定位", "定位授权状态变化", details: [
"授权状态": authorizationName(authorizationStatus),
"requestID": activeRequest.map { String($0.id) } ?? "nil",
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
])
switch authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
if let request = activeRequest, request.phase == .awaitingAuthorization {
@@ -172,6 +192,27 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
MainActor.assumeIsolated {
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 返回样本批次", details: [
"样本数": String(locations.count),
"requestID": activeRequest.map { String($0.id) } ?? "nil",
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
])
for (index, candidate) in locations.enumerated() {
let valid = Self.isValid(candidate)
let freshEnough = activeRequest?.startedAt.map {
candidate.timestamp >= $0.addingTimeInterval(-1)
} ?? false
RealtimeLocationTrace.log(
valid ? "检查 CLLocationManager 样本" : "拒绝 CLLocationManager 样本:坐标或精度无效",
location: candidate,
details: [
"批次索引": String(index),
"基础校验通过": String(valid),
"满足当前请求时间窗": String(freshEnough)
],
level: valid ? .info : .warning
)
}
let validLocations = locations.filter(Self.isValid)
if let latestValid = validLocations.last {
location = latestValid
@@ -183,8 +224,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
let latest = validLocations.last(where: {
$0.timestamp >= startedAt.addingTimeInterval(-1)
}) else {
if let request = activeRequest, request.phase != .awaitingAuthorization {
RuntimeLogger.warning("APP", "实时定位", "本批次没有可完成当前请求的样本", details: [
"requestID": String(request.id),
"阶段": phaseName(request.phase),
"有效样本数": String(validLocations.count)
])
}
return
}
RealtimeLocationTrace.log("接受 CLLocationManager 样本并完成请求", location: latest, details: [
"requestID": String(request.id),
"阶段": phaseName(request.phase)
])
finishRequest(id: request.id, coordinate: latest.coordinate)
}
}
@@ -213,8 +265,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
}
private func freshestCachedLocation(now: Date = Date()) -> CLLocation? {
[location, driver.location]
.compactMap { $0 }
let candidates = [location, driver.location].compactMap { $0 }
for candidate in candidates {
let valid = Self.isValid(candidate)
let age = abs(candidate.timestamp.timeIntervalSince(now))
if !valid || age > cacheMaxAge {
RealtimeLocationTrace.log("跳过 CLLocationManager 缓存样本", location: candidate, details: [
"基础校验通过": String(valid),
"缓存时效通过": String(age <= cacheMaxAge),
"缓存上限秒": String(Int(cacheMaxAge))
], level: .warning)
}
}
return candidates
.filter(Self.isValid)
.filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge }
.max(by: { $0.timestamp < $1.timestamp })
@@ -260,6 +323,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
request.phase = .oneShot
request.startedAt = Date()
activeRequest = request
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 单次定位", details: [
"requestID": String(requestID),
"超时毫秒": String(oneShotTimeoutNanoseconds / 1_000_000)
])
scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds)
driver.requestLocation()
}
@@ -270,6 +337,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
request.phase == .oneShot else { return }
request.phase = .continuousFallback
activeRequest = request
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 持续定位兜底", details: [
"requestID": String(requestID),
"超时毫秒": String(fallbackTimeoutNanoseconds / 1_000_000)
])
driver.startUpdatingLocation()
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
}
@@ -287,15 +358,38 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
activeRequest = nil
isRequesting = false
if let coordinate {
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
if coordinate != nil {
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求完成", details: [
"requestID": String(requestID),
"lat": String(coordinate.latitude),
"lon": String(coordinate.longitude)
"最终阶段": phaseName(request.phase),
"有坐标": "true"
])
} else {
RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)])
RuntimeLogger.warning("APP", "实时定位", "CLLocationManager 请求结束但没有坐标", details: [
"requestID": String(requestID),
"最终阶段": phaseName(request.phase),
"有坐标": "false"
])
}
request.continuation.resume(returning: coordinate)
}
private func phaseName(_ phase: RequestPhase) -> String {
switch phase {
case .awaitingAuthorization: return "awaitingAuthorization"
case .oneShot: return "oneShot"
case .continuousFallback: return "continuousFallback"
}
}
private func authorizationName(_ status: CLAuthorizationStatus) -> String {
switch status {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .authorizedAlways: return "authorizedAlways"
case .authorizedWhenInUse: return "authorizedWhenInUse"
@unknown default: return "unknown(\(status.rawValue))"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import SafariServices
import SwiftUI
struct SafariDestination: Identifiable {
let id = UUID()
let url: URL
}
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
let controller = SFSafariViewController(url: url)
controller.dismissButtonStyle = .close
return controller
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
}
+598 -33
View File
@@ -1,70 +1,200 @@
import SwiftUI
private enum UpdateCheckResult: Identifiable {
case current(currentVersion: String, latestVersion: String)
case available(AppUpdatePrompt)
case failed
var id: String {
switch self {
case .current(let currentVersion, let latestVersion):
return "current-\(currentVersion)-\(latestVersion)"
case .available(let prompt):
return "available-\(prompt.id)"
case .failed:
return "failed"
}
}
}
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
@ObservedObject private var motionSimulation = MotionSimulationStore.shared
@ObservedObject private var moduleSource = ThirdPartyModuleSourceStore.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?
@State private var copiedMITMHostnames = false
@State private var showCertificateResetConfirmation = false
@State private var githubDestination: SafariDestination?
@State private var isCheckingForUpdates = false
@State private var updateCheckResult: UpdateCheckResult?
var body: some View {
Form {
Section("运行模式") {
Picker("模式", selection: runtimeModeBinding) {
ForEach(ProxyRuntimeMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.inline)
.disabled(modeOperationRunning || actions.state.isBusy || thirdPartyProxy.isRequesting)
}
Section("状态") {
HStack {
Label("代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
Spacer()
Toggle("", isOn: proxyBinding).labelsHidden()
.tint(.blue)
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: actions.virtualLocationEnabled ? "location.fill" : "location.slash")
Label("虚拟定位", systemImage: virtualLocationIsActive ? "location.fill" : "location.slash")
Spacer()
Text(actions.virtualLocationEnabled ? "已开启" : "已关闭").foregroundStyle(.secondary)
Text(virtualLocationStatusText).foregroundStyle(.secondary)
}
}
Section("说明") {
Button {
activeTip = .activation
} label: {
Label("生效说明", systemImage: "checklist")
}
Button {
activeTip = .deactivation
} label: {
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
}
Button {
activeTip = .removeProxy
} label: {
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
Section("定位模拟") {
Toggle("运动状态模拟", isOn: motionSimulationBinding)
.disabled(
modeOperationRunning ||
actions.state.isBusy ||
thirdPartyProxy.isRequesting
)
Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。")
.font(.footnote)
.foregroundStyle(.secondary)
}
if runtimeMode.mode == .thirdParty {
thirdPartyConfigurationSection
} else {
Section("说明") {
Button {
activeTip = .activation
} label: {
Label("生效说明", systemImage: "checklist")
}
Button {
activeTip = .deactivation
} label: {
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
}
Button {
activeTip = .removeProxy
} label: {
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
}
}
}
Section("工作原理") {
Text("""
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
""")
Text(workflowDescription)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("应用") {
Button {
setup.needsSetup = true
} label: {
Label("进入引导页", systemImage: "arrow.clockwise.circle")
if runtimeMode.mode == .localWiFi {
Button {
setup.requestSetup()
} label: {
Label("进入引导页", systemImage: "arrow.clockwise.circle")
}
}
Button {
checkForUpdates()
} label: {
if isCheckingForUpdates {
HStack {
ProgressView()
Text("正在检查…")
}
} else {
Label("检查更新", systemImage: "arrow.triangle.2.circlepath")
}
}
.disabled(isCheckingForUpdates)
valueRow("版本", value: versionText)
}
if runtimeMode.mode == .localWiFi {
Section("证书") {
Button(role: .destructive) {
showCertificateResetConfirmation = true
} label: {
Label("重置证书", systemImage: "arrow.clockwise.circle")
}
.disabled(modeOperationRunning || actions.state.isBusy)
Text("仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("支持") {
NavigationLink {
BugReportView(setup: setup)
} label: {
Label("报告 Issue", systemImage: "ladybug")
Label("报告 Bug", systemImage: "ladybug")
}
Button {
githubDestination = SafariDestination(url: GitHubSubmission.usageHelpURL)
} label: {
Label("使用帮助", systemImage: "questionmark.circle")
}
Button {
githubDestination = SafariDestination(url: GitHubSubmission.featureRequestURL)
} label: {
Label("功能建议", systemImage: "lightbulb")
}
if runtimeMode.mode == .thirdParty {
Button {
UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate(
for: thirdPartyClient.selectedClient,
systemVersion: UIDevice.current.systemVersion
)
githubDestination = SafariDestination(
url: GitHubSubmission.communityContributionURL
)
} label: {
Label("分享第三方配置", systemImage: "square.and.arrow.up")
}
}
}
@@ -100,6 +230,43 @@ struct SettingsView: View {
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind)
}
.sheet(item: $githubDestination) { destination in
SafariView(url: destination.url)
.ignoresSafeArea()
}
.alert(proxyOperationAlertTitle, isPresented: Binding(
get: { !proxyOperationError.isEmpty },
set: { if !$0 { proxyOperationError = "" } }
)) {
Button("知道了", role: .cancel) {}
} message: {
Text(proxyOperationError)
}
.alert(item: $updateCheckResult) { result in
updateCheckAlert(for: result)
}
.confirmationDialog(
"重置证书?",
isPresented: $showCertificateResetConfirmation,
titleVisibility: .visible
) {
Button("重置并生成新证书", role: .destructive) {
resetCertificateAuthority()
}
Button("取消", role: .cancel) {}
} message: {
Text("当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。")
}
.task(id: runtimeMode.mode) {
guard runtimeMode.mode == .thirdParty, !modeOperationRunning else { return }
if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) {
disableUnsupportedThirdPartyMotionSimulation()
}
}
.onChange(of: thirdPartyProxy.moduleUpdateRecommended) { updateRecommended in
guard updateRecommended else { return }
disableUnsupportedThirdPartyMotionSimulation()
}
}
private func valueRow(_ title: String, value: String) -> some View {
@@ -112,15 +279,413 @@ struct SettingsView: View {
return "\(v) (\(b))"
}
private func checkForUpdates() {
guard !isCheckingForUpdates else { return }
isCheckingForUpdates = true
Task { @MainActor in
defer { isCheckingForUpdates = false }
guard let configuration = await AppRemoteConfigurationService.fetch() else {
updateCheckResult = .failed
return
}
AppRemoteConfigurationStore.shared.apply(configuration)
let currentVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String ?? AppRemoteConfiguration.fallback.latestVersion
guard let pendingPrompt = configuration.updatePrompt(currentVersion: currentVersion) else {
updateCheckResult = .current(
currentVersion: currentVersion,
latestVersion: configuration.latestVersion
)
return
}
let releaseNotes = await AppRemoteConfigurationService.fetchReleaseNotes(
version: pendingPrompt.latestVersion
)
let prompt = configuration.updatePrompt(
currentVersion: currentVersion,
releaseNotes: releaseNotes
) ?? pendingPrompt
updateCheckResult = .available(prompt)
}
}
private func updateCheckAlert(for result: UpdateCheckResult) -> Alert {
switch result {
case .current(let currentVersion, let latestVersion):
return Alert(
title: Text("已是最新版本"),
message: Text("当前版本 \(currentVersion),远程最新版本 \(latestVersion)"),
dismissButton: .default(Text("知道了"))
)
case .available(let prompt):
let details = prompt.releaseNotes
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
let message: String
if prompt.requirement == .required {
message = "当前版本 \(prompt.currentVersion) 已停止支持,请更新到 \(prompt.latestVersion) 后继续使用。\n\n\(details)"
} else {
message = "当前版本 \(prompt.currentVersion),最新版本 \(prompt.latestVersion)\n\n\(details)"
}
return Alert(
title: Text(prompt.requirement == .required ? "需要更新" : "发现新版本"),
message: Text(message),
primaryButton: .default(Text("前往更新")) {
UIApplication.shared.open(AppRemoteConfigurationService.releasesURL)
},
secondaryButton: .cancel(Text("稍后"))
)
case .failed:
return Alert(
title: Text("检查更新失败"),
message: Text("无法获取远程版本信息,请检查网络后重试。"),
dismissButton: .default(Text("知道了"))
)
}
}
private var proxyBinding: Binding<Bool> {
Binding(get: { proxy.isRunning }, set: { on in
Task {
if on {
do { try await proxy.start() } catch { proxy.error = error.localizedDescription }
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) }
)
}
private var motionSimulationBinding: Binding<Bool> {
Binding(
get: { motionSimulation.isEnabled },
set: { enabled in
if runtimeMode.mode == .localWiFi {
proxy.applyMotionSimulation(enabled)
return
}
guard thirdPartyProxy.activeSettings?.success == true else {
guard enabled else {
motionSimulation.setEnabled(false)
return
}
modeOperationRunning = true
Task { @MainActor in
if await thirdPartyProxy.refreshAdvancedFeatureAvailability() {
motionSimulation.setEnabled(true)
} else {
presentMotionSimulationModuleUpdateAlert()
}
modeOperationRunning = false
}
return
}
modeOperationRunning = true
Task { @MainActor in
do {
_ = try await thirdPartyProxy.updateMotionSimulation(enabled)
motionSimulation.setEnabled(enabled)
} catch {
RuntimeLogger.error(
"APP",
"ThirdPartyProxy",
"同步运动状态设置失败",
error: error,
details: ["当前客户端": thirdPartyClient.selectedClient.name]
)
if error as? ThirdPartyProxyError == .moduleOutdated {
presentMotionSimulationModuleUpdateAlert()
} else {
setup.requestThirdPartySetup(message: error.localizedDescription)
}
}
modeOperationRunning = false
}
}
)
}
@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)
}
}
Toggle("使用国内镜像下载模块", isOn: Binding(
get: { moduleSource.useMirror },
set: { moduleSource.setUseMirror($0) }
))
Text("仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。")
.font(.footnote)
.foregroundStyle(.secondary)
if thirdPartyProxy.moduleUpdateRecommended {
Text("当前模块版本较旧,基础坐标功能仍可继续使用。重新导入最新模块后可使用版本检测和运动状态模拟。")
.font(.footnote)
.foregroundStyle(.orange)
}
if let verificationText = thirdPartyClient.selectedClient.verificationText {
HStack {
Text("验证状态")
Spacer()
Text(verificationText)
.font(.footnote)
.foregroundStyle(.orange)
}
}
Button {
UIPasteboard.general.string = thirdPartyClient.selectedClient.subscriptionURL.absoluteString
copiedClient = thirdPartyClient.selectedClient
} label: {
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostnamesText
copiedMITMHostnames = true
} label: {
Label(copiedMITMHostnames ? "已复制两个解密域名" : "复制两个解密域名", systemImage: "doc.on.doc")
}
Button {
openThirdPartyClient(thirdPartyClient.selectedClient)
} label: {
Label("打开 \(thirdPartyClient.selectedClient.name)", systemImage: "arrow.up.forward.app")
}
Button {
setup.requestThirdPartyOnboarding()
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("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为 gs-loc.apple.com 和 gs-loc-cn.apple.com 启用 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 和 gs-loc-cn.apple.com 的定位请求经过这个本地代理。代理使用已安装的 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)
if runtimeMode.isInitialized(.thirdParty) {
do {
_ = try await thirdPartyProxy.validateConnection()
refreshThirdPartyAdvancedFeatures()
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。"
} catch {
openThirdPartySetup(for: error)
}
} else {
setup.requestThirdPartyOnboarding()
dismiss()
}
case .localWiFi:
do {
try await thirdPartyProxy.clear()
} catch {
RuntimeLogger.warning("APP", "Mode", "切换 APP 模式前无法清除第三方坐标", details: [
"错误": error.localizedDescription
])
}
runtimeMode.setMode(.localWiFi)
await setup.prepareLocalServices()
if runtimeMode.isInitialized(.localWiFi) {
let result = await setup.runVerificationTest()
setup.applyVerificationResult(result)
if result.isSuccess {
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
} else {
dismiss()
}
} else {
setup.requestSetup()
dismiss()
}
}
}
}
private func detectThirdPartyConnection() {
let client = thirdPartyClient.selectedClient
let startedAt = Date()
Task { @MainActor in
do {
_ = try await thirdPartyProxy.validateConnection()
refreshThirdPartyAdvancedFeatures()
RuntimeLogger.info("APP", "ThirdPartyProxy", "设置页第三方连接检测通过", details: [
"当前客户端": client.name,
"请求动作": "WLOC query",
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000))
])
runtimeMode.markInitialized(.thirdParty)
} catch {
RuntimeLogger.error(
"APP",
"ThirdPartyProxy",
"设置页第三方连接检测失败",
error: error,
details: [
"当前客户端": client.name,
"请求动作": "WLOC query",
"连接状态": String(describing: thirdPartyProxy.connectionState),
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000)),
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
]
)
openThirdPartySetup(for: error)
}
}
}
private func refreshThirdPartyAdvancedFeatures() {
Task { @MainActor in
if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) {
disableUnsupportedThirdPartyMotionSimulation()
}
}
}
private func presentMotionSimulationModuleUpdateAlert() {
disableUnsupportedThirdPartyMotionSimulation()
proxyOperationAlertTitle = "无法开启运动状态模拟"
proxyOperationError = "当前模块脚本不支持运动状态模拟,请重新导入最新模块脚本后再开启。基础坐标功能仍可继续使用。"
}
private func disableUnsupportedThirdPartyMotionSimulation() {
guard runtimeMode.mode == .thirdParty else { return }
motionSimulation.setEnabled(false)
}
private func openThirdPartySetup(for error: Error) {
setup.requestThirdPartySetup(message: error.localizedDescription)
dismiss()
}
private func resetCertificateAuthority() {
guard runtimeMode.mode == .localWiFi, !modeOperationRunning else { return }
modeOperationRunning = true
Task { @MainActor in
defer { modeOperationRunning = false }
if actions.virtualLocationEnabled {
actions.clear()
}
proxy.stop()
do {
try setup.certificateStore.reset()
runtimeMode.resetInitialization(.localWiFi)
guard await setup.prepareLocalServices() else {
proxyOperationAlertTitle = "证书重置失败"
proxyOperationError = setup.message
return
}
setup.requestCertificateSetup()
dismiss()
} 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
}
}
+88 -112
View File
@@ -7,8 +7,10 @@ final class SetupCoordinator: ObservableObject {
@Published var message = ""
@Published var isBrowsingWithoutTrust = false
@Published var testLog = ""
@Published private(set) var lastVerificationResult: VerificationResult?
// false
@Published var needsSetup = false
@Published private(set) var setupStep: SetupStep = .proxy
let certificateStore = CertificateAuthorityStore()
let proxy = ProxyManager.shared
@@ -24,79 +26,81 @@ final class SetupCoordinator: ObservableObject {
var canModify: Bool { proxy.isRunning && trustState == .trusted }
func refreshTrust() async {
trustState = .checking
message = "正在检测…"
testLog = ""
/// Local services are prepared before the setup UI so the environment test
/// can distinguish Wi-Fi proxy configuration from CA trust failures.
@discardableResult
func prepareLocalServices() async -> Bool {
do {
_ = try certificateStore.ensure()
if !proxy.isRunning { try await proxy.start() }
// POST Apple TLS = CA
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 5
config.timeoutIntervalForResource = 8
config.connectionProxyDictionary = [
kCFNetworkProxiesHTTPEnable as String: true,
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
kCFNetworkProxiesHTTPPort as String: 8888,
]
//
let saved = WlocSettingsStore.load()
let testLat = saved?.latitude ?? 22.543099
let testLon = saved?.longitude ?? 113.934576
let testAccuracy = saved?.accuracy ?? 25
let req = makeWlocRequest()
let (_, resp) = try await URLSession(configuration: config).data(for: req)
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
// 400 = Apple TLS =
if status == 0 {
trustState = .unavailable
message = "代理链路异常,未收到响应"
return
}
// Go Core Apple
let patchResult = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: testAccuracy)
if patchResult.hasPrefix("ok:") {
trustState = .trusted
message = "✓ 定位环境正常(返回 \(status)"
} else {
trustState = .unavailable
message = "定位数据改写验证失败:\(patchResult)"
}
message = ""
return true
} catch {
message = "本地代理初始化失败:\(error.localizedDescription)"
RuntimeLogger.error("APP", "Startup", "本地服务初始化失败", error: error)
return false
}
}
func applyVerificationResult(_ result: VerificationResult) {
lastVerificationResult = result
switch result {
case .success:
trustState = .trusted
needsSetup = false
message = "✓ 定位环境正常"
case .certNotTrusted:
trustState = .unavailable
let ns = error as NSError
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
message = "CA 证书未信任,请去「设置→通用→关于→证书信任设置」开启或重新安装"
} else if ns.domain == NSURLErrorDomain && ns.code == -1001 {
message = "检测超时,请检查代理是否正常"
} else if ns.domain == NSURLErrorDomain && ns.code == -1200 {
message = "代理未启动或无法连接"
} else {
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
}
setupStep = .cert
needsSetup = true
message = "CA 证书未安装或未信任"
case .verificationInProgress, .verificationSuperseded:
break
default:
trustState = .unavailable
setupStep = .proxy
needsSetup = true
message = "Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888"
}
needsSetup = !canModify
}
func sceneDidBecomeActive() {}
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
func completeSetup() { needsSetup = false }
func requestSetup() { needsSetup = true }
func requestModeSelection() {
lastVerificationResult = nil
message = ""
setupStep = .mode
needsSetup = true
}
func requestThirdPartyOnboarding() {
lastVerificationResult = nil
message = ""
setupStep = .thirdPartyClient
needsSetup = true
}
func requestThirdPartySetup(message: String) {
lastVerificationResult = nil
self.message = message
setupStep = .thirdPartyImport
needsSetup = true
}
func requestCertificateSetup() {
lastVerificationResult = nil
message = ""
setupStep = .cert
needsSetup = true
}
func requestSetup(message: String = "") {
lastVerificationResult = nil
self.message = message
setupStep = .proxy
needsSetup = true
}
// MARK: - Step-by-step verification test
private func makeWlocRequest() -> URLRequest {
var req = URLRequest(url: URL(string: "https://gs-loc.apple.com/clls/wloc")!)
req.httpMethod = "POST"
req.httpBody = CoreBridge.testWlocRequestData()
req.setValue("application/x-protobuf", forHTTPHeaderField: "Content-Type")
req.setValue("wloc/1.0", forHTTPHeaderField: "User-Agent")
req.setValue("application/x-protobuf", forHTTPHeaderField: "Accept")
return req
}
func runVerificationTest(testLat: Double = 22.543099, testLon: Double = 113.934576) async -> VerificationResult {
func runVerificationTest() async -> VerificationResult {
guard !isVerificationRunning else { return .verificationInProgress }
isVerificationRunning = true
defer { isVerificationRunning = false }
@@ -107,7 +111,6 @@ final class SetupCoordinator: ObservableObject {
log("======== 代理验证测试 ========")
log("App 版本: \(appVersion)")
log("系统版本: iOS \(UIDevice.current.systemVersion)")
log("测试目标: lat=\(testLat), lon=\(testLon)")
log("")
// Step A: Proxy running
@@ -126,18 +129,6 @@ final class SetupCoordinator: ObservableObject {
}
collectProxyLogs(since: stepAStart, to: log)
// Verification must never overwrite a newer location action.
let previousCoordinates = proxy.coordinateSnapshot(
accuracy: WlocSettingsStore.load()?.accuracy ?? 25
)
var verificationCoordinateRevision: UInt64?
defer {
if let revision = verificationCoordinateRevision {
let restored = proxy.restoreCoords(previousCoordinates, ifUnchangedSince: revision)
log(restored ? " ↩ 已恢复验证前的代理坐标" : " ↩ 检测到更新位置,跳过旧坐标恢复")
}
}
// Step B: Combined CA + WiFi proxy check (single request)
log("")
log("[步骤 B] 检测证书与 WiFi 代理…")
@@ -161,63 +152,48 @@ final class SetupCoordinator: ObservableObject {
if body == verifyToken {
log(" ✓ 证书已信任,WiFi 代理已配置")
} else {
log(" ✗ 响应不匹配 (HTTP \(statusCode))WiFi 代理未配置")
log(" 收到: \(body.prefix(100))")
log(" ✗ 响应不匹配: HTTP \(statusCode), \(data.count) bytesWiFi 代理未配置")
return .wifiProxyNotConfigured
}
} catch {
let ns = error as NSError
let msg = error.localizedDescription
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)")
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
log(" TLS 握手被拒CA 证书未信任")
return .certNotTrusted
}
if msg.contains("TLS") {
log(" 包含 TLS → 证书问题")
if isCertificateTrustError(nsError: ns, message: msg) {
log(" TLS/证书校验失败CA 证书未信任")
return .certNotTrusted
}
return .wifiProxyNotConfigured
}
collectProxyLogs(since: stepBStart, to: log)
// Step C: Write and verify coordinates
log("[步骤 C] 写入测试坐标并验证…")
guard let testCoordinateRevision = proxy.setCoordsIfUnchanged(
lat: testLat,
lon: testLon,
enabled: true,
accuracy: 25,
expectedRevision: previousCoordinates.revision
) else {
log(" ↪ 检测到更新位置,取消过期验证坐标写入")
return .verificationSuperseded
}
verificationCoordinateRevision = testCoordinateRevision
let coords = proxy.getCoords()
if coords.enabled && abs(coords.lat - testLat) < 0.001 && abs(coords.lon - testLon) < 0.001 {
log(" ✓ 坐标写入成功: lat=\(coords.lat) lon=\(coords.lon)")
} else {
log(" ✗ 坐标验证失败: enabled=\(coords.enabled) lat=\(coords.lat) lon=\(coords.lon)")
return .coordinateWriteFailed("写入后回读不一致")
}
// Step D: verify data rewriting via Go test patch
log("[步骤 D] 验证定位数据改写…")
let result = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: 25)
log(" \(result)")
if result.hasPrefix("ok:") {
log(" ✓ 定位数据改写验证通过")
} else {
log(" ✗ 定位数据改写失败")
return .patchFailed(result)
}
log("")
log("======== 环境检测通过 ✓ ========")
return .success
}
/// Classifies TLS trust failures without relying on localized error text alone.
private func isCertificateTrustError(nsError: NSError, message: String) -> Bool {
if nsError.domain == NSURLErrorDomain {
let trustErrorCodes: Set<Int> = [
-1200, // secure connection failed
-1201, // server certificate has bad date
-1202, // server certificate untrusted
-1203, // server certificate has unknown root
-1204, // server certificate not yet valid
-1205, // client certificate rejected
-1206, // client certificate required
]
return trustErrorCodes.contains(nsError.code)
}
let normalized = message.lowercased()
return normalized.contains("tls")
|| normalized.contains("ssl")
|| normalized.contains("certificate")
|| normalized.contains("证书")
}
/// Go CONNECT/// testLog
private func collectProxyLogs(since date: Date, to log: (String) -> Void) {
CoreBridge.flushLogs(category: "Proxy")
+27 -90
View File
@@ -4,14 +4,12 @@ enum TipKind: String, Identifiable {
case activation = "生效说明"
case deactivation = "失效说明"
case removeProxy = "关闭 WiFi 代理"
case certificate = "证书问题"
case proxySetup = "配置代理"
case rewriteFailed = "改写失败"
var id: String { rawValue }
}
struct TipSheetView: View {
let kind: TipKind
var runtimeMode: ProxyRuntimeMode = .localWiFi
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -19,12 +17,9 @@ 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 .certificate: CertificateTipContent(dismiss: { dismiss() })
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
}
}.padding(16)
}
@@ -53,37 +48,34 @@ private func openSettings(_ destination: SystemSettingsDestination) {
}
}
// MARK: -
private struct TipCloseButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
}.buttonStyle(.borderedProminent).tint(.blue)
}
}
// MARK: -
struct ActivationTipContent: View {
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)
}
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
GroupBox(label: Label("还是无法生效?", systemImage: "exclamationmark.triangle")) {
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。")
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
}
@@ -119,6 +111,7 @@ struct ActivationTipContent: View {
// MARK: -
struct DeactivationTipContent: View {
var runtimeMode: ProxyRuntimeMode = .localWiFi
let dismiss: () -> Void
var body: some View {
@@ -127,14 +120,23 @@ struct DeactivationTipContent: View {
step(1, "开启飞行模式", "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。")
step(2, "关闭 WiFi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
systemStep(4, "打开 WiFi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 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)
}
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。").font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
GroupBox(label: Label("还是无法取消?", systemImage: "exclamationmark.triangle")) {
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
}
@@ -184,68 +186,3 @@ struct RemoveProxyTipContent: View {
}
}
}
// MARK: -
struct CertificateTipContent: View {
let dismiss: () -> Void
var body: some View {
GroupBox(label: Label("证书未安装或未信任", systemImage: "lock.shield")) {
VStack(alignment: .leading, spacing: 8) {
Text("代理的 HTTPS 请求被系统拦截了,原因是 CA 证书未完成安装或信任。\n\n请依次检查:\n1. 打开「设置 → 通用 → VPN与设备管理」,确认 WLOC CA 证书已安装。如果没有,请删除旧证书后回到 App 重新下载安装\n2. 打开「设置 → 通用 → 关于本机 → 证书信任设置」,找到 WLOC CA 开启开关\n\n⚠️ 每次重装 App 都需要重新下载安装证书。如报 TLS 错误,请删除旧证书后重装。")
.font(.caption).foregroundStyle(.primary)
Button { openSettings(.general) } label: {
Label("去设置", systemImage: "arrow.up.right.square").font(.caption)
}.buttonStyle(.bordered).tint(.blue)
}.padding(.vertical, 4)
}
}
}
// MARK: - WiFi
struct ProxySetupTipContent: View {
@State private var copied = false
let dismiss: () -> Void
var body: some View {
GroupBox(label: Label("WiFi 代理未配置", systemImage: "wifi")) {
VStack(alignment: .leading, spacing: 10) {
Text("系统定位请求没有经过本地代理,请按以下步骤配置:\n\n1. 打开「设置 → 无线局域网」\n2. 确认已连接到正确的 WiFi(不是蜂窝数据)\n3. 点击当前 WiFi 右侧 (i) 图标\n4. 滑到底部「HTTP 代理」→ 选择「手动」\n5. 在「服务器」填写下面的地址,端口填写 8888\n6. 点右上角「存储」")
.font(.caption).foregroundStyle(.primary)
HStack(spacing: 8) {
Text("127.0.0.1:8888").font(.caption.monospaced().bold()).foregroundStyle(.blue)
Spacer()
Button {
UIPasteboard.general.string = "127.0.0.1:8888"
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
} label: {
Label(copied ? "已复制" : "复制地址", systemImage: copied ? "checkmark" : "doc.on.doc").font(.caption)
}.buttonStyle(.bordered).tint(copied ? .green : .blue)
}.padding(10).background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
Button { openSettings(.wifi) } label: {
Text("去设置 WiFi 代理").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
}.buttonStyle(.borderedProminent).tint(.blue)
}.padding(.vertical, 4)
}
}
}
// MARK: -
struct RewriteFailedTipContent: View {
let dismiss: () -> Void
var body: some View {
GroupBox(label: Label("坐标改写失败", systemImage: "exclamationmark.triangle")) {
VStack(alignment: .leading, spacing: 8) {
Text("虚拟定位已连接,但坐标替换没有成功。可能原因:\n\n• 代理刚启动不久,数据还没开始改写(等待几秒后重试)\n• CA 证书与当前 App 版本不匹配\n• iOS 系统安全策略限制\n\n建议操作:\n1. 完全停止虚拟定位,再重新开启\n2. 删除旧证书,重新生成并安装\n3. 在诊断页查看详细日志")
.font(.caption).foregroundStyle(.primary)
}.padding(.vertical, 4)
}
}
}
+51 -7
View File
@@ -41,8 +41,25 @@ func wloccore_generateca() (r0, r1 *C.char) {
return C.CString(string(cert)), C.CString(string(key))
}
//export wloccore_validateca
func wloccore_validateca(certData, keyData *C.char) C.int {
if certData == nil || keyData == nil {
return 0
}
if _, err := parseCA([]byte(C.GoString(certData)), []byte(C.GoString(keyData))); err != nil {
logEvent("validateca failed: " + err.Error())
return 0
}
return 1
}
//export wloccore_startproxy
func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t {
return wloccore_startproxyv2(certData, keyData, lat, lon, enabled, accuracy, 0)
}
//export wloccore_startproxyv2
func wloccore_startproxyv2(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int, motionEnabled C.int) C.uintptr_t {
if certData == nil || keyData == nil {
return 0
}
@@ -53,6 +70,7 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
float64(lon),
enabled != 0,
int(accuracy),
motionEnabled != 0,
)
if err != nil {
logEvent("startproxy failed: " + err.Error())
@@ -64,13 +82,12 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
//export wloccore_stopproxy
func wloccore_stopproxy(h C.uintptr_t) C.int {
logEvent("stopproxy requested")
handle := cgo.Handle(h)
srv, ok := handle.Value().(*http.Server)
handle.Delete()
srv, handle, ok := proxyForHandle(h)
if !ok {
logEvent("stopproxy failed: invalid handle")
return 1
}
handle.Delete()
if err := stopProxy(srv); err != nil {
logEvent("stopproxy failed: " + err.Error())
return 2
@@ -79,15 +96,37 @@ func wloccore_stopproxy(h C.uintptr_t) C.int {
return 0
}
func proxyForHandle(h C.uintptr_t) (server *http.Server, handle cgo.Handle, ok bool) {
if h == 0 {
return nil, 0, false
}
defer func() {
if recover() != nil {
server, handle, ok = nil, 0, false
}
}()
handle = cgo.Handle(h)
server, ok = handle.Value().(*http.Server)
return server, handle, ok
}
//export wloccore_setcoords
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
wloccore_setpatchconfig(lat, lon, enabled, accuracy, 0)
}
//export wloccore_setpatchconfig
func wloccore_setpatchconfig(lat, lon C.double, enabled C.int, accuracy C.int, motionEnabled C.int) {
stateMu.Lock()
currentLat = float64(lat)
currentLon = float64(lon)
currentEnabled = enabled != 0
currentAccuracy = int(accuracy)
currentMotionSimulationEnabled = motionEnabled != 0
stateMu.Unlock()
logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " lat=" + strconv.FormatFloat(float64(lat), 'f', 6, 64) + " lon=" + strconv.FormatFloat(float64(lon), 'f', 6, 64) + " accuracy=" + strconv.Itoa(int(accuracy)))
logEvent("setpatchconfig enabled=" + strconv.FormatBool(enabled != 0) +
" accuracy=" + strconv.Itoa(int(accuracy)) +
" motion=" + strconv.FormatBool(motionEnabled != 0))
}
//export wloccore_getcoords
@@ -127,12 +166,17 @@ func wloccore_startcertserver(certData, keyData *C.char) C.uintptr_t {
return C.uintptr_t(cgo.NewHandle(server))
}
func certificateServerForHandle(h C.uintptr_t) (*certificateServer, cgo.Handle, bool) {
func certificateServerForHandle(h C.uintptr_t) (server *certificateServer, handle cgo.Handle, ok bool) {
if h == 0 {
return nil, 0, false
}
handle := cgo.Handle(h)
server, ok := handle.Value().(*certificateServer)
defer func() {
if recover() != nil {
server, handle, ok = nil, 0, false
}
}()
handle = cgo.Handle(h)
server, ok = handle.Value().(*certificateServer)
return server, handle, ok
}
+15 -14
View File
@@ -1,37 +1,38 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/binary"
"encoding/pem"
"io"
"math/big"
"math/rand"
"time"
)
func deterministicReader() io.Reader {
seed := sha256.Sum256([]byte("paopao-location-spoofer-ca-v1"))
src := rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8])))
return rand.New(src)
func randomSerialNumber() (*big.Int, error) {
// Keep the serial positive and within the RFC 5280 recommended 20-octet bound.
limit := new(big.Int).Lsh(big.NewInt(1), 159)
return rand.Int(rand.Reader, limit)
}
func generateCA() (certPEM, keyPEM []byte, err error) {
rng := deterministicReader()
privateKey, err := rsa.GenerateKey(rng, 2048)
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
serialNumber, err := randomSerialNumber()
if err != nil {
return nil, nil, err
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"WLOC"},
CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"),
Organization: []string{"Location Spoofer"},
CommonName: "Location Spoofer CA",
},
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2045, 1, 1, 0, 0, 0, 0, time.UTC),
@@ -41,7 +42,7 @@ func generateCA() (certPEM, keyPEM []byte, err error) {
IsCA: true,
}
certDER, err := x509.CreateCertificate(rng, &template, &template, &privateKey.PublicKey, privateKey)
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, nil, err
}
+39 -8
View File
@@ -15,17 +15,16 @@ func TestGenerateCA(t *testing.T) {
if len(certPEM) == 0 || len(keyPEM) == 0 {
t.Fatal("empty CA output")
}
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("invalid cert PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
cert := parseCertificatePEM(t, certPEM)
if !cert.IsCA {
t.Fatal("certificate is not a CA")
}
if cert.Subject.CommonName != "Location Spoofer CA" {
t.Fatalf("unexpected CA common name: %q", cert.Subject.CommonName)
}
if len(cert.Subject.Organization) != 1 || cert.Subject.Organization[0] != "Location Spoofer" {
t.Fatalf("unexpected CA organization: %v", cert.Subject.Organization)
}
if time.Until(cert.NotAfter).Hours() < 360*24 {
t.Fatal("CA validity is too short")
}
@@ -33,3 +32,35 @@ func TestGenerateCA(t *testing.T) {
t.Fatal(err)
}
}
func TestGenerateCAUsesUniquePrivateKeysAndSerialNumbers(t *testing.T) {
firstCertPEM, firstKey, err := generateCA()
if err != nil {
t.Fatal(err)
}
secondCertPEM, secondKey, err := generateCA()
if err != nil {
t.Fatal(err)
}
if string(firstKey) == string(secondKey) {
t.Fatal("generated CA private keys must not be deterministic")
}
firstCert := parseCertificatePEM(t, firstCertPEM)
secondCert := parseCertificatePEM(t, secondCertPEM)
if firstCert.SerialNumber.Cmp(secondCert.SerialNumber) == 0 {
t.Fatal("generated CA serial numbers must not be deterministic")
}
}
func parseCertificatePEM(t *testing.T, certPEM []byte) *x509.Certificate {
t.Helper()
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("invalid cert PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
return cert
}
+43 -68
View File
@@ -11,7 +11,6 @@ import (
"log"
"net"
"net/http"
"sort"
"strconv"
"strings"
"sync"
@@ -23,13 +22,14 @@ import (
const proxyPort = 8888
var (
stateMu sync.Mutex
currentLat float64
currentLon float64
currentEnabled bool
currentAccuracy int
globalCACert *tls.Certificate
verifyToken string
stateMu sync.Mutex
currentLat float64
currentLon float64
currentEnabled bool
currentAccuracy int
currentMotionSimulationEnabled bool
globalCACert *tls.Certificate
verifyToken string
logMu sync.Mutex
logEntries []string
@@ -95,10 +95,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
}
if r.URL.Path == "/coords" {
stateMu.Lock()
enabled, lat, lon := currentEnabled, currentLat, currentLon
enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
stateMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, currentAccuracy)))
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d,"motionSimulationEnabled":%t}`, enabled, lat, lon, accuracy, motionEnabled)))
return
}
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
@@ -146,20 +146,16 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
logEvent("CONNECT " + host + " -> MITM (verify)")
return mitmAction, host
}
logEvent("CONNECT " + host + " -> passthrough")
// Global Wi-Fi proxy mode sends all HTTPS CONNECT traffic here. Logging
// unrelated passthrough hosts creates high-volume noise and can disclose
// browsing destinations; diagnostics only retain WLOC and verify traffic.
return goproxy.OkConnect, host
})
}
proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
body := []byte(nil)
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
req.Body.Close()
req.Body = io.NopCloser(bytes.NewReader(body))
}
logEvent(fmt.Sprintf("proxy request target=%s method=%s path=%s size=%d body_prefix=%s",
req.Host, req.Method, req.URL.Path, len(body), hexPrefix(body, 64)))
// Do not buffer or log arbitrary global-proxy traffic. Keep requests streaming
// and do not persist unrelated request content in diagnostics.
return serveLocalRequests(req, ctx)
})
proxy.OnResponse().DoFunc(patchWlocResponse)
@@ -176,7 +172,7 @@ func serveLocalRequests(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request
}
if (h == "baidu.com" || h == "www.baidu.com") && strings.HasPrefix(req.URL.Path, "/paopao-verify-") {
token := strings.TrimPrefix(req.URL.Path, "/paopao-verify-")
logEvent("verify request path=" + req.URL.Path + " token=" + token)
logEvent("verify request received")
if checkVerifyToken(token) {
resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token)
resp.Header.Set("Cache-Control", "no-store")
@@ -226,44 +222,42 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
}
stateMu.Lock()
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
stateMu.Unlock()
const maxPatchBodyBytes int64 = 1 << 20
if resp.ContentLength > maxPatchBodyBytes {
logEvent(fmt.Sprintf("wloc response passed through: body exceeds patch limit (%d bytes)", resp.ContentLength))
return resp
}
originalBody := resp.Body
body, err := io.ReadAll(originalBody)
originalBody.Close()
body, err := io.ReadAll(io.LimitReader(originalBody, maxPatchBodyBytes+1))
if err != nil {
logEvent("wloc response read failed: " + err.Error())
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), originalBody))
return resp
}
logEvent(fmt.Sprintf("wloc upstream response status=%d size=%d headers=[%s] body_prefix=%s",
resp.StatusCode, len(body), summarizeHeaders(resp.Header), hexPrefix(body, 64)))
if int64(len(body)) > maxPatchBodyBytes {
logEvent("wloc response passed through: body exceeds patch limit")
resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), originalBody))
return resp
}
originalBody.Close()
if !enabled {
logEvent("wloc upstream response passed through (spoofing disabled)")
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
if resp.StatusCode != http.StatusOK {
logEvent(fmt.Sprintf("wloc upstream response passed through (status %d != 200)", resp.StatusCode))
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
if len(body) == 0 {
logEvent("wloc upstream response empty, passed through")
if !enabled || resp.StatusCode != http.StatusOK || len(body) == 0 {
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy})
if err != nil {
logEvent("wloc patch skipped: " + err.Error())
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
if bytes.Equal(patched, body) {
logEvent("wloc patch produced identical body, passed through")
patched, stats, err := patchResponseBody(body, wlocCoords{
Latitude: lat, Longitude: lon, Accuracy: accuracy,
MotionSimulationEnabled: motionEnabled,
})
if err != nil || bytes.Equal(patched, body) {
if err != nil {
logEvent("wloc patch skipped: " + err.Error())
}
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
@@ -273,30 +267,10 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
resp.Header.Del("Content-Encoding")
resp.Header.Del("Transfer-Encoding")
resp.Header.Set("Content-Length", strconv.Itoa(len(patched)))
logEvent(fmt.Sprintf("wloc patched target=%.6f,%.6f accuracy=%d locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d body_prefix=%s",
lat, lon, accuracy, stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched), hexPrefix(patched, 64)))
logEvent(fmt.Sprintf("wloc patched locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d", stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched)))
return resp
}
func hexPrefix(b []byte, n int) string {
if len(b) > n {
b = b[:n]
}
return fmt.Sprintf("%x", b)
}
func summarizeHeaders(h http.Header) string {
if len(h) == 0 {
return ""
}
parts := make([]string, 0, len(h))
for k, v := range h {
parts = append(parts, k+"="+strings.Join(v, ","))
}
sort.Strings(parts)
return strings.Join(parts, "; ")
}
func generateProxyMobileConfig() string {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -356,7 +330,7 @@ func randomUint32() uint32 {
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy int) (*http.Server, error) {
func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy int, motionEnabled bool) (*http.Server, error) {
cert, err := parseCA(certPEM, keyPEM)
if err != nil {
return nil, err
@@ -365,6 +339,7 @@ func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy
stateMu.Lock()
globalCACert = cert
currentLat, currentLon, currentEnabled, currentAccuracy = lat, lon, enabled, accuracy
currentMotionSimulationEnabled = motionEnabled
stateMu.Unlock()
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort))
+136 -3
View File
@@ -20,11 +20,17 @@ const (
)
type wlocCoords struct {
Latitude float64
Longitude float64
Accuracy int
Latitude float64
Longitude float64
Accuracy int
MotionSimulationEnabled bool
}
const (
motionActivityType = 63
motionActivityConfidence = 467
)
type patchStats struct {
WiFi int
Cell int
@@ -41,6 +47,8 @@ type wireField struct {
var macPattern = regexp.MustCompile(`^[0-9a-fA-F]{1,2}(:[0-9a-fA-F]{1,2}){5}$`)
var wlocMarker = []byte{0, 0, 0, 1, 0, 0}
func minInt(a, b int) int {
if a < b {
return a
@@ -177,6 +185,7 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
lon := int64(math.Round(c.Longitude * 1e8))
var out []byte
changed := false
hasMotionType, hasMotionConfidence := false, false
for _, f := range fields {
switch {
case f.num == 1 && f.wireType == wireVarint:
@@ -197,10 +206,34 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
changed = true
}
out = append(out, raw...)
case c.MotionSimulationEnabled && f.num == 11 && f.wireType == wireVarint:
hasMotionType = true
raw := append(writeTag(11, wireVarint), writeVarint(motionActivityType)...)
if !bytes.Equal(raw, f.raw) {
changed = true
}
out = append(out, raw...)
case c.MotionSimulationEnabled && f.num == 12 && f.wireType == wireVarint:
hasMotionConfidence = true
raw := append(writeTag(12, wireVarint), writeVarint(motionActivityConfidence)...)
if !bytes.Equal(raw, f.raw) {
changed = true
}
out = append(out, raw...)
default:
out = append(out, f.raw...)
}
}
if c.MotionSimulationEnabled && !hasMotionType {
out = append(out, writeTag(11, wireVarint)...)
out = append(out, writeVarint(motionActivityType)...)
changed = true
}
if c.MotionSimulationEnabled && !hasMotionConfidence {
out = append(out, writeTag(12, wireVarint)...)
out = append(out, writeVarint(motionActivityConfidence)...)
changed = true
}
return out, changed, nil
}
@@ -314,6 +347,99 @@ func patchWlocPayload(payload []byte, c wlocCoords, st *patchStats) ([]byte, boo
return out, changed, nil
}
func parseARPCPayloadBounds(body []byte) (lengthOffset, payloadOffset, payloadEnd int, err error) {
if len(body) < 2 {
return 0, 0, 0, errors.New("ARPC body too short")
}
offset := 2 // version
for range 3 {
if offset+2 > len(body) {
return 0, 0, 0, errors.New("truncated ARPC string length")
}
length := int(binary.BigEndian.Uint16(body[offset : offset+2]))
offset += 2
if length > len(body)-offset {
return 0, 0, 0, errors.New("truncated ARPC string")
}
offset += length
}
const functionAndLengthBytes = 8
if offset+functionAndLengthBytes > len(body) {
return 0, 0, 0, errors.New("truncated ARPC header")
}
lengthOffset = offset + 4
payloadOffset = lengthOffset + 4
payloadLength := uint64(binary.BigEndian.Uint32(body[lengthOffset:payloadOffset]))
if payloadLength == 0 || payloadLength > uint64(len(body)-payloadOffset) {
return 0, 0, 0, errors.New("invalid ARPC payload length")
}
return lengthOffset, payloadOffset, payloadOffset + int(payloadLength), nil
}
func patchARPCFrame(body []byte, c wlocCoords) ([]byte, patchStats, error) {
lengthOffset, payloadOffset, payloadEnd, err := parseARPCPayloadBounds(body)
if err != nil {
return nil, patchStats{}, err
}
var st patchStats
payload := body[payloadOffset:payloadEnd]
newPayload, changed, err := patchWlocPayload(payload, c, &st)
if err != nil {
return nil, patchStats{}, err
}
if !changed || bytes.Equal(newPayload, payload) {
return nil, patchStats{}, errors.New("ARPC envelope has no patchable wloc payload")
}
var lenBytes [4]byte
binary.BigEndian.PutUint32(lenBytes[:], uint32(len(newPayload)))
out := append(cloneBytes(body[:lengthOffset]), lenBytes[:]...)
out = append(out, newPayload...)
out = append(out, body[payloadEnd:]...)
return out, st, nil
}
func patchMarkerFrame(body []byte, c wlocCoords) ([]byte, patchStats, error) {
markerOffset := bytes.Index(body, wlocMarker)
if markerOffset < 0 {
return nil, patchStats{}, errors.New("wloc marker not found")
}
lengthOffset := markerOffset + len(wlocMarker)
payloadOffset := lengthOffset + 2
if payloadOffset > len(body) {
return nil, patchStats{}, errors.New("truncated marker frame")
}
payloadLength := int(binary.BigEndian.Uint16(body[lengthOffset:payloadOffset]))
if payloadLength == 0 || payloadLength > len(body)-payloadOffset {
return nil, patchStats{}, errors.New("invalid marker payload length")
}
payloadEnd := payloadOffset + payloadLength
var st patchStats
payload := body[payloadOffset:payloadEnd]
newPayload, changed, err := patchWlocPayload(payload, c, &st)
if err != nil {
return nil, patchStats{}, err
}
if !changed || bytes.Equal(newPayload, payload) {
return nil, patchStats{}, errors.New("marker frame has no patchable wloc payload")
}
if len(newPayload) > 65535 {
return nil, patchStats{}, errors.New("patched marker payload too large")
}
var lenBytes [2]byte
binary.BigEndian.PutUint16(lenBytes[:], uint16(len(newPayload)))
out := append(cloneBytes(body[:lengthOffset]), lenBytes[:]...)
out = append(out, newPayload...)
out = append(out, body[payloadEnd:]...)
return out, st, nil
}
func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte, patchStats, error) {
if len(body) < offset+10 {
return nil, *st, fmt.Errorf("body too short: %d, base=%d", len(body), offset)
@@ -352,6 +478,13 @@ func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte,
}
func patchWlocBody(body []byte, c wlocCoords) ([]byte, patchStats, error) {
if out, st, err := patchARPCFrame(body, c); err == nil {
return out, st, nil
}
if out, st, err := patchMarkerFrame(body, c); err == nil {
return out, st, nil
}
var st patchStats
offsets := []int{0, 2, 4, 6, 8, 10, 12, 14, 16}
seen := map[int]bool{}
+312 -7
View File
@@ -4,7 +4,13 @@ import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
@@ -19,6 +25,15 @@ func testLocation(lat, lon int64, accuracy uint64) []byte {
return out
}
func testLocationWithMotion(lat, lon int64, accuracy, motionType, motionConfidence uint64) []byte {
out := testLocation(lat, lon, accuracy)
out = append(out, writeTag(11, wireVarint)...)
out = append(out, writeVarint(motionType)...)
out = append(out, writeTag(12, wireVarint)...)
out = append(out, writeVarint(motionConfidence)...)
return out
}
func testWifiDevice(loc []byte) []byte {
mac := []byte("aa:bb:cc:dd:ee:ff")
var out []byte
@@ -38,6 +53,29 @@ func testFrame(payload []byte) []byte {
return out
}
func testARPCFrame(payload, suffix []byte) ([]byte, int) {
var out []byte
var version [2]byte
binary.BigEndian.PutUint16(version[:], 1)
out = append(out, version[:]...)
for _, value := range [][]byte{[]byte("zh_CN"), []byte("com.apple.locationd"), []byte("20A123")} {
var length [2]byte
binary.BigEndian.PutUint16(length[:], uint16(len(value)))
out = append(out, length[:]...)
out = append(out, value...)
}
var functionID [4]byte
binary.BigEndian.PutUint32(functionID[:], 1)
out = append(out, functionID[:]...)
lengthOffset := len(out)
var payloadLength [4]byte
binary.BigEndian.PutUint32(payloadLength[:], uint32(len(payload)))
out = append(out, payloadLength[:]...)
out = append(out, payload...)
out = append(out, suffix...)
return out, lengthOffset
}
func TestPatchWifiLocation(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
body := testFrame(payload)
@@ -63,20 +101,181 @@ func TestPatchWifiLocation(t *testing.T) {
}
func TestPatchCellLocation(t *testing.T) {
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
payload := writeLengthDelimited(22, cell)
body := testFrame(payload)
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
for _, field := range []int{22, 24} {
t.Run(fmt.Sprintf("field_%d", field), func(t *testing.T) {
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
payload := writeLengthDelimited(field, cell)
body := testFrame(payload)
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.Cell != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
if bytes.Equal(patched, body) {
t.Fatal("body was not patched")
}
})
}
}
func TestMotionSimulationDisabledPreservesFields(t *testing.T) {
original := testLocationWithMotion(100, 200, 25, 7, 88)
patched, changed, err := patchLocation(original, wlocCoords{
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
})
if err != nil {
t.Fatal(err)
}
if !changed {
t.Fatal("coordinates were not patched")
}
fields, err := parseFields(patched)
if err != nil {
t.Fatal(err)
}
for _, field := range fields {
if (field.num == 11 || field.num == 12) && !bytes.Contains(original, field.raw) {
t.Fatalf("motion field %d changed while disabled", field.num)
}
}
}
func TestMotionSimulationEnabledReplacesFields(t *testing.T) {
original := testLocationWithMotion(100, 200, 25, 7, 88)
patched, _, err := patchLocation(original, wlocCoords{
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
MotionSimulationEnabled: true,
})
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(patched, append(writeTag(11, wireVarint), writeVarint(motionActivityType)...)) {
t.Fatal("motion activity type was not replaced")
}
if !bytes.Contains(patched, append(writeTag(12, wireVarint), writeVarint(motionActivityConfidence)...)) {
t.Fatal("motion activity confidence was not replaced")
}
}
func TestMotionSimulationEnabledAddsMissingFields(t *testing.T) {
patched, _, err := patchLocation(testLocation(100, 200, 25), wlocCoords{
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
MotionSimulationEnabled: true,
})
if err != nil {
t.Fatal(err)
}
fields, err := parseFields(patched)
if err != nil {
t.Fatal(err)
}
counts := map[int]int{}
for _, field := range fields {
counts[field.num]++
}
if counts[11] != 1 || counts[12] != 1 {
t.Fatalf("expected one inserted motion field each, got %+v", counts)
}
}
func TestPatchARPCFramePreservesEnvelopeAndSuffix(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
suffix := []byte{0xde, 0xad, 0xbe, 0xef}
body, lengthOffset := testARPCFrame(payload, suffix)
originalPrefix := cloneBytes(body[:lengthOffset])
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.Cell != 1 || stats.Locations != 1 {
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
if bytes.Equal(patched, body) {
t.Fatal("body was not patched")
if !bytes.Equal(patched[:lengthOffset], originalPrefix) {
t.Fatal("ARPC metadata changed")
}
newLength := int(binary.BigEndian.Uint32(patched[lengthOffset : lengthOffset+4]))
if newLength == len(payload) {
t.Fatal("ARPC payload length was not updated")
}
if !bytes.Equal(patched[lengthOffset+4+newLength:], suffix) {
t.Fatal("ARPC suffix changed")
}
}
func TestPatchARPCPayloadLargerThanUint16(t *testing.T) {
padding := writeLengthDelimited(99, bytes.Repeat([]byte{0x7f}, 70_000))
location := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
payload := append(padding, location...)
body, lengthOffset := testARPCFrame(payload, nil)
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
newLength := int(binary.BigEndian.Uint32(patched[lengthOffset : lengthOffset+4]))
if newLength <= 65535 {
t.Fatalf("expected 32-bit ARPC payload length, got %d", newLength)
}
if !bytes.Contains(patched[lengthOffset+4:lengthOffset+4+newLength], padding) {
t.Fatal("unknown ARPC payload field changed")
}
}
func TestPatchMarkerFramePreservesPrefixAndSuffix(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
prefix := []byte{0xaa, 0xbb, 0xcc}
suffix := []byte{0xdd, 0xee}
var length [2]byte
binary.BigEndian.PutUint16(length[:], uint16(len(payload)))
body := append(cloneBytes(prefix), wlocMarker...)
body = append(body, length[:]...)
body = append(body, payload...)
body = append(body, suffix...)
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
lengthOffset := len(prefix) + len(wlocMarker)
newLength := int(binary.BigEndian.Uint16(patched[lengthOffset : lengthOffset+2]))
if newLength == len(payload) {
t.Fatal("marker payload length was not updated")
}
if !bytes.Equal(patched[:len(prefix)], prefix) {
t.Fatal("marker prefix changed")
}
if !bytes.Equal(patched[lengthOffset+2+newLength:], suffix) {
t.Fatal("marker suffix changed")
}
}
func TestPatchBareWlocPayload(t *testing.T) {
body := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
if len(patched) > 0 && bytes.HasPrefix(patched, []byte{0, 1, 0, 0}) {
t.Fatal("bare payload was unexpectedly wrapped")
}
}
@@ -108,3 +307,109 @@ func TestTransparentBodyUnchanged(t *testing.T) {
t.Fatal("expected non-patchable body to error")
}
}
func TestPatchWlocResponsePassesThroughOversizedBody(t *testing.T) {
payload := bytes.Repeat([]byte("x"), (1<<20)+1)
req := httptest.NewRequest(http.MethodPost, "https://gs-loc.apple.com/clls/wloc", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Request: req,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(payload)),
ContentLength: int64(len(payload)),
}
patched := patchWlocResponse(resp, nil)
got, err := io.ReadAll(patched.Body)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatal("oversized WLOC response was changed or truncated")
}
}
func TestServeLocalRequestsKeepsUnrelatedRequestBodyStreaming(t *testing.T) {
const secret = "body-must-not-be-buffered-or-logged"
req := httptest.NewRequest(http.MethodPost, "https://example.com/upload", bytes.NewBufferString(secret))
returned, response := serveLocalRequests(req, nil)
if response != nil {
t.Fatalf("unexpected local response: %d", response.StatusCode)
}
got, err := io.ReadAll(returned.Body)
if err != nil {
t.Fatal(err)
}
if string(got) != secret {
t.Fatalf("request body changed: got %q", got)
}
if logs := drainLogs(); bytes.Contains([]byte(logs), []byte(secret)) {
t.Fatal("request body leaked into diagnostic logs")
}
}
func TestCoordsEndpointReturnsAtomicSnapshot(t *testing.T) {
stateMu.Lock()
previousLat, previousLon := currentLat, currentLon
previousEnabled, previousAccuracy := currentEnabled, currentAccuracy
currentLat, currentLon, currentEnabled, currentAccuracy = 0, 0, false, 0
stateMu.Unlock()
t.Cleanup(func() {
stateMu.Lock()
currentLat, currentLon = previousLat, previousLon
currentEnabled, currentAccuracy = previousEnabled, previousAccuracy
stateMu.Unlock()
})
handler := newProxy(nil).NonproxyHandler
const updates = 20_000
const readers = 8
const readsPerReader = 2_500
var writers sync.WaitGroup
writers.Add(1)
go func() {
defer writers.Done()
for i := 1; i <= updates; i++ {
stateMu.Lock()
currentLat = float64(i)
currentLon = -float64(i)
currentEnabled = i%2 == 0
currentAccuracy = i
stateMu.Unlock()
}
}()
errs := make(chan error, readers)
var readersGroup sync.WaitGroup
for range readers {
readersGroup.Add(1)
go func() {
defer readersGroup.Done()
for i := 0; i < readsPerReader; i++ {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://proxy.local/coords", nil))
var snapshot struct {
Enabled bool `json:"enabled"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Accuracy int `json:"accuracy"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &snapshot); err != nil {
errs <- err
return
}
if snapshot.Lat != 0 && (snapshot.Lon != -snapshot.Lat || snapshot.Accuracy != int(snapshot.Lat)) {
errs <- fmt.Errorf("torn coordinate snapshot: %+v", snapshot)
return
}
}
}()
}
readersGroup.Wait()
writers.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
}
+404 -108
View File
@@ -2,162 +2,458 @@
# 📍 Location Spoofer
### iOS Location Spoofer · DingTalk · WeChat · Apple Watch Region Unlock · Fake GPS
### iOS Location Service Research & Testing Framework
**No VPN, no jailbreak — run a local HTTP proxy on your iPhone to rewrite Apple location responses.**<br>
Works with DingTalk check-in, WeChat location sharing, and any app that uses system location. Map selection, real-time location, environment verification, certificate setup, and runtime logs in a single app.
An open-source project for **iOS location-service research, software development testing, and QA validation**.
The project uses either an on-device proxy or a third-party proxy client to simulate selected Apple location-service
responses in a controlled test environment.
[![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)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.5-2563EB)](docs/CHANGELOG.md)
[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md)
<img src="images/主界面.jpg" alt="Location Spoofer iOS Fake GPS main interface" width="380">
[Features](#feature-overview) ·
[How It Works](#how-it-works) ·
[Quick Start](#quick-start) ·
[Build](#building-the-project) ·
[中文](README.md)
</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.
> Use this project only for education, research, testing on devices you own, software development, and QA validation.
>
> Use it only on devices, networks, and software environments that you own or are authorized to test. Follow applicable
> laws, network policies, and service terms.
>
> The project does not guarantee compatibility with every iOS version or third-party app, and it does not promise to
> bypass third-party security controls, business restrictions, or service rules.
## Credits
## Project Scope
The core location-response rewriting approach and Go implementation are based on [Yu9191/wloc](https://github.com/Yu9191/wloc). This project adds a SwiftUI interface, MapKit selection, certificate and proxy guidance, environment verification, favorites, and diagnostics.
Location Spoofer is a tool for studying iOS location-service behavior and testing location-dependent software.
## Why Location Spoofer?
It provides:
Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailbroken device, Location Spoofer keeps the control flow on the iPhone itself.
- Native map selection and location-scenario switching;
- Controlled simulation of selected Apple location-service responses;
- App Mode and Third-party Proxy Mode;
- Map coordinate detection with paired WGS-84 and GCJ-02 values;
- Environment checks, runtime logs, and diagnostics;
- Favorite locations and restoration of the previous map state.
| Feature | Description |
|---|---|
| 🚫 **No VPN** | No VPN tunnel — uses only location permission, no background refresh or notification access required. |
| 📱 **No jailbreak** | Can be installed through self-signing; minimum deployment target is iOS 15. |
| 🗺️ **Native Maps experience** | The same blue dot and selection gestures as Apple Maps — search, tap, and drag. |
| 📍 **System-level location spoofing** | Works with DingTalk, WeChat, Apple Maps, Amap, and other apps for real-time fake GPS. |
| 🔍 **Visible map scale** | Left-side controls show the current visible range; place name adapts to zoom level. |
| 🧪 **Environment verification** | Checks proxy, CA trust, WiFi interception, coordinate write, and response rewrite. |
| 🧾 **Diagnostics** | Per-entry copy for easy sharing and debugging. |
The project does not modify the target app's source code and does not provide telemetry, remote control, or data
collection services.
## Screenshots
## Feature Overview
| Main Interface | Apple Maps | Amap | Apple Watch |
|---|---|---|---|
| ![Location Spoofer main interface](images/主界面.jpg) | ![Apple Maps result](images/Apple%20Map.jpg) | ![Amap result](images/高德地图.jpg) | ![Apple Watch region feature](images/高血压.jpg) |
- **Native map interaction**
- Uses MapKit for the map and system blue dot;
- Supports search, map taps, center-point dragging, and zooming;
- Supports favorites and restoration of the latest selection;
- Shows both domestic and international coordinate representations with separate copy actions.
## Key Features
- **Location-service response simulation**
- Processes only the Apple location-service requests defined by the project;
- Returns the selected coordinates in a controlled test environment;
- Does not require changes to the target app.
- **iOS Location Spoofer / Fake GPS**: Apply the selected coordinate to the local proxy that rewrites location responses, compatible with DingTalk check-in, WeChat location sharing, and more.
- **Native real-time location**: The map displays MapKit's own blue dot — no extra "fake real-time" overlay.
- **Concurrency-safe selection**: Pan, tap, search, favorites, and async location respect the user's latest intent; stale results won't overwrite newer selections.
- **Hierarchical place names**: POI, street, or road at close zoom; neighborhood, district, city, or province at wider zoom.
- **Map scale display**: Zoom controls show the current visible range.
- **Favorites with quick switch**: Save frequent coordinates and see which location is about to be applied.
- **Setup guide**: Certificate download, installation, full trust, WiFi HTTP proxy, activation, and deactivation instructions.
- **Built-in diagnostics**: Verification flow and structured runtime logs.
- **Two runtime modes**
- App Mode: runs the Go proxy on-device and covers only the current Wi-Fi network;
- Third-party Proxy Mode: uses a supported proxy client and may cover Wi-Fi, 4G, or 5G depending on that client.
- **Environment checks**
- App Mode checks the local proxy, CA trust, and request path;
- Third-party Proxy Mode checks the WLOC configuration API and module response;
- Failures route to the relevant setup or diagnostics screen.
- **Development diagnostics**
- Runtime logs;
- Log copy and cleanup;
- Map coordinate-system change records;
- Sanitized issue-report generation.
## How It Works
### App Mode
App Mode runs the local Go proxy inside the app. A manual HTTP proxy on the current Wi-Fi routes the selected requests
through that on-device proxy.
```text
iOS location request
│ Manual HTTP proxy on the current Wi-Fi
On-device wloccore Go proxy
│ Handle selected Apple location-service requests
Apple location-service response
│ Test coordinate response
The system and apps read the location result
```
App Mode:
- Does not create a Network Extension;
- Does not display or occupy the system VPN slot;
- Covers only the current Wi-Fi network;
- Requires a manual HTTP proxy on that Wi-Fi network;
- Requires installation and trust of the CA generated by the app;
- Handles only the Apple location-service and environment-verification traffic defined by the project. It is not a
general-purpose packet capture tool.
### Third-party Proxy Mode
Third-party Proxy Mode does not start the app's Go proxy and does not use the CA generated by the app.
```text
Map selection
│ WGS-84 coordinates
WLOC configuration API
Third-party proxy client stores the configuration
Third-party client processes location-service requests
```
In this mode:
- The app owns map selection, favorites, coordinate synchronization, and coordinate clearing;
- The third-party client owns proxy/VPN, MITM, certificates, and rule execution;
- The third-party client owns coordinate persistence;
- Wi-Fi, 4G, and 5G support depends on the client;
- The configuration may remain active after Location Spoofer closes.
#### Configuration API and Client Integration
The app does not upload coordinates to a project server. It sends the following GET request, which the third-party
client must intercept locally on the device:
```text
https://gs-loc.apple.com/wloc-settings/save
```
| Action | Query parameters | Purpose |
|---|---|---|
| Query | `action=query` | Verify module connectivity and read the stored coordinate |
| Save | `lon=<WGS-84 longitude>&lat=<WGS-84 latitude>&acc=<accuracy>` | Store the selected coordinate |
| Clear | `action=clear` | Remove the stored test coordinate |
The interception script must return HTTP 200 with JSON:
```json
{
"success": true,
"longitude": 113.0,
"latitude": 22.0,
"accuracy": 25
}
```
Failures use:
```json
{
"success": false,
"error": "Error description"
}
```
When no coordinate is stored, a query may return `{"success":false,"error":"无已保存的坐标"}`. The app interprets
that response as “module connected, virtual location inactive.” A successful save response must echo the requested
WGS-84 longitude and latitude.
To integrate another third-party client:
1. Add an HTTP request script for `gs-loc.apple.com/wloc-settings/save` that parses the parameters and returns the
specified JSON;
2. Store coordinates, accuracy, and optional state in the client's persistent storage;
3. Enable HTTPS decryption for `gs-loc.apple.com` and `gs-loc-cn.apple.com`;
4. Intercept `gs-loc(-cn).apple.com/clls/wloc` responses, read the same persistent data, and modify the WLOC response;
5. Publish an importable module and verify query, save, clear, and location restoration on a real device.
The app validates only the configuration endpoint's HTTP status, JSON shape, and coordinate round trip. It does not
manage the third-party client's certificate, MITM, VPN, or proxy state.
Do not enable App Mode interception and Third-party Proxy Mode interception at the same time.
## Runtime Modes
### App Mode
Suitable for:
- Wi-Fi-only device testing;
- Local testing without a third-party proxy client;
- Workflows that need in-app proxy, certificate, and environment guidance.
Requirements:
- An iOS device;
- A Wi-Fi network that permits manual HTTP proxy configuration;
- Installation and trust of the local CA;
- Completion of the in-app proxy and environment checks.
### Third-party Proxy Mode
Suitable for:
- Tests that need Wi-Fi, 4G, or 5G coverage;
- Existing supported proxy-client workflows;
- Cases where the third-party client should keep the proxy configuration active.
Current client status:
| Client | Status | Community configuration |
|---|---|---|
| Shadowrocket | Currently used for on-device testing | Built-in App guide |
| Surge | Configuration provided, not fully verified | Contributions wanted |
| Quantumult X | Configuration provided, not fully verified | Contributions wanted |
| Loon | Configuration provided, not fully verified | Contributions wanted |
| Stash | Configuration provided, not fully verified | Contributions wanted |
| Egern | Uses the Surge module, not fully verified | Contributions wanted |
Community configurations are reviewed by client. Accepted submissions are linked in this table with attribution unless
the contributor requests anonymous inclusion.
Module snapshots and provenance:
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
The selected client owns its certificates, MITM configuration, and proxy switches. Review third-party modules and
scripts before importing them.
## Quick Start
### 1. Install the App
- Download a build from [Releases](https://github.com/xweiba/location-spoofer/releases) and self-sign; or
- Build from source on macOS with Xcode — see the [build guide](docs/BUILD.md).
You can use:
Detailed steps in the [self-signing guide](docs/SELF-SIGNING.md).
- Your own Apple Developer signing environment;
- A self-signing tool suitable for personal testing;
- The unsigned IPA published in Releases;
- A source build produced on macOS using the [build guide](docs/BUILD.md).
### 2. Install & Trust the CA
Free self-signing environments may not provide Network Extension capabilities. App Mode therefore uses an on-device
proxy plus a manual Wi-Fi HTTP proxy and does not depend on a VPN component.
Follow the first-setup wizard to download the profile, then:
#### Self-Signing Instructions
Release assets are unsigned IPA files and must be installed on an iPhone with a self-signing tool:
1. **Enable sideloading support**: On iOS 16 or newer, open Settings → Privacy & Security → Developer Mode, enable it,
then restart and confirm when prompted. iOS 15 does not have this switch, so skip this step.
2. **Download the IPA**: Open this project's [Releases](https://github.com/xweiba/location-spoofer/releases) and download
the latest `PaopaoLocationSpoofer-unsigned.ipa`.
3. **Prepare signing software**: Download the appropriate Impactor build from
[Impactor Releases](https://github.com/claration/Impactor/releases). Other tools that support self-signing and installing
IPA files, such as Aisi Assistant, may also be used.
4. **Connect and install**: Connect the iPhone to the computer with a USB cable, choose “Trust This Computer” on the
phone, select the downloaded IPA in the signing software, and follow that tool's instructions to sign and install it.
Impactor supports Windows, macOS, and Linux. If Windows cannot detect the device, install the Apple device drivers supplied
with iTunes first. Aisi Assistant is third-party software; obtain it from its official channel and evaluate its account,
certificate, and privacy risks yourself.
After installation, if iOS blocks the app from opening, go to Settings → General → VPN & Device Management and trust the
corresponding developer app. A free Apple ID signature normally expires after seven days and must then be renewed.
### 2. First Launch
1. Choose App Mode or Third-party Proxy Mode;
2. Complete the corresponding in-app setup;
3. Run the environment check;
4. Search, tap, or drag on the map to select a test location;
5. Enable the test location and verify the result in the authorized test environment.
### 3. Restore the Real Location
App Mode:
1. Stop the test location;
2. Disable the manual HTTP proxy on the current Wi-Fi network;
3. Follow the in-app instructions to refresh the location environment.
Third-party Proxy Mode:
1. Clear the WLOC coordinates from the app;
2. Disable the corresponding module or proxy in the third-party client;
3. Restore HTTPS decryption and proxy settings according to the client documentation.
Location caches may take time to refresh. Restart the device if the system or target app continues to show an old
location.
## Coordinate Handling
The project stores two coordinate representations:
- WGS-84: the international standard used for WLOC writes;
- GCJ-02: the domestic map representation used where required.
MapKit does not expose a public API that reports whether its current runtime output uses GCJ-02 or WGS-84. The project
uses a fixed-anchor query to resolve the active representation and performs controlled refreshes after blue-dot changes
and explicit user actions.
Each write boundary stores a complete WGS-84/GCJ-02 pair. Rendering selects the field matching the confirmed map
representation instead of repeatedly converting an already typed value.
## Project Structure
```text
Settings → General → VPN & Device Management → install WLOC CA
Settings → General → About → Certificate Trust Settings → enable full trust
App/ SwiftUI interface, MapKit, location, and runtime flow
Core/ Go proxy, certificate server, and location-response handling
Shared/ Coordinates, favorites, logs, configuration, and shared models
Resources/ Info.plist, Entitlements, and resources
Config/ Build configuration
Scripts/ Build, packaging, and validation scripts
Tests/ XCTest and Shell contract tests
docs/ Build, module, and release documentation
```
### 3. Configure the Current WiFi Proxy
## Building the Project
On the current WiFi's proxy settings, choose "Manual":
Source builds require:
```text
Server: 127.0.0.1
Port: 8888
Authentication: off
```
- macOS;
- Xcode;
- Xcode Command Line Tools;
- XcodeGen;
- Go 1.23 or newer.
### 4. Select a Location & Enable
1. Search, tap, or drag the map to pick a location; tap the real-time location button to jump to the MapKit blue dot.
2. Tap "Start Spoofing" and wait for the environment check to pass.
3. Follow the inapp activation instructions to refresh airplane mode, WiFi, and location services.
4. Open Apple Maps or your target app to verify.
### 5. Restore Your Real Location
Stop spoofing, remove the manual proxy from the current WiFi, and follow the inapp deactivation instructions to refresh the system location cache. If stale cache persists, restart your device.
## Why No VPN?
```text
iPhone location request
│ WiFi HTTP proxy: 127.0.0.1:8888
Local wloccore Go proxy
│ Handles only the targeted Apple location-service traffic
Apple location service response
│ The selected coordinate is written into the response
The system and applications receive the modified result
```
The project does not use Network Extension to create a VPN tunnel — there is no VPN icon and no VPN slot occupied. **However, you still need to configure the current WiFi HTTP proxy and install & trust the locally generated CA.** Recheck proxy settings after switching WiFi networks; remove the manual proxy when you stop using the app.
## Compatibility
| Item | Requirement |
|---|---|
| iOS | 15.0+ |
| Build | macOS, Xcode, XcodeGen |
| Swift | 5.9 |
| Go | 1.23+ |
| Network | WiFi with manual HTTP proxy support |
| Installation | Self-sign or use release builds |
Actual behavior may vary with iOS version, network conditions, system location cache, device model, and the target app's own location strategy. Compatibility with every iOS version or third-party app is not guaranteed.
## Build & Project Structure
Building the iOS app directly on Windows is not supported.
```bash
git clone https://github.com/xweiba/location-spoofer.git
cd location-spoofer
./build.sh
```
The unsigned IPA is at:
Build and run Simulator tests:
```bash
./build.sh --test
```
The build script generates an unsigned IPA:
```text
dist/PaopaoLocationSpoofer-unsigned.ipa
```
```text
App/ SwiftUI, MapKit, location and setup flow
Core/ Go local proxy and location response rewriting
Shared/ Favorites, settings, logs, and shared models
Resources/ Info.plist, Entitlements, and icons
Scripts/ Build, signing, and verification scripts
Tests/ XCTest and Bash contract tests
docs/ Build, self-signing, and changelog documentation
```
Deploy it to a test device using your own signing and installation process.
## Documentation & Feedback
## Privacy and Security Boundaries
- The project contains no telemetry or remote-control service;
- The project does not automatically upload location data;
- Runtime logs remain in the device App Group container and retain only the latest three days;
- Issue reports are copied by the user before being submitted to GitHub;
- App Mode accesses the local proxy and the environment-verification URL;
- Third-party Proxy Mode may access the upstream module URL and the WLOC configuration endpoint;
- The CA private key generated by the app is stored in the device Keychain;
- Third-party MITM, certificates, and proxy behavior are owned by the selected client.
Do not post real locations, authentication information, CA private keys, or complete sensitive logs in public issues.
## Limitations
- iOS updates may change location-service behavior;
- MapKit coordinate output can vary with the system, region, and location environment;
- System location caches may delay visible changes;
- Each third-party proxy client requires separate compatibility testing;
- Not every app uses the same location API;
- Not every app or service accepts test coordinates;
- Behavior is not guaranteed across every network, device model, or iOS version.
## Contributing
Contributions are welcome for:
- [Bug reports](https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml);
- [Feature requests](https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE);
- [Usage help and compatibility results](https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9);
- [Third-party client configurations and sanitized screenshots](https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB);
- Performance improvements;
- Documentation improvements;
- Additional tests.
For bugs, generate a report from **Settings → Support → Report Bug** in the App whenever possible. The report includes:
- iOS version;
- App version;
- Runtime mode;
- Selected third-party client;
- Whether the issue is reproducible;
- Problem description;
- Sanitized runtime logs;
Paste it into the **App-generated diagnostic report** field in the GitHub Issue Form.
## Documentation
- [Build guide](docs/BUILD.md)
- [Self-signing guide](docs/SELF-SIGNING.md)
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
- [Changelog](docs/CHANGELOG.md)
- [中文文档](README.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
When reporting issues, please include reproduction steps, iOS version, device model, and sanitized runtime logs.
## Feature Preview
## Links
These screenshots show the main interface and selected on-device test scenarios. Actual results depend on the iOS
version, network environment, system caches, and the target app's location strategy; they are not a compatibility
guarantee for every app or release.
**LinuxDo** — [https://linux.do](https://linux.do/)
<table>
<tr>
<th>Main interface</th>
<th>Apple Maps test</th>
<th>Amap test</th>
</tr>
<tr>
<td><img src="images/主界面.jpg" alt="Location Spoofer map selection interface" width="220"></td>
<td><img src="images/Apple%20Map.jpg" alt="Apple Maps location test scenario" width="220"></td>
<td><img src="images/高德地图.jpg" alt="Amap location test scenario" width="220"></td>
</tr>
<tr>
<th>WeChat test</th>
<th>DingTalk test</th>
<th>Apple Watch scenario test</th>
</tr>
<tr>
<td><img src="images/微信.jpg" alt="WeChat location test scenario" width="220"></td>
<td><img src="images/钉钉.jpg" alt="DingTalk location test scenario" width="220"></td>
<td><img src="images/高血压.jpg" alt="Apple Watch region-feature test scenario" width="220"></td>
</tr>
</table>
## Acknowledgements and Links
The core location-response handling approach, Go implementation, and third-party modules are based on:
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
- [ios-location-spoofer](https://github.com/mekos2772/ios-location-spoofer)
Thanks to the following LINUX DO users for their contributions:
- Bug fixes: [Chen Ze](https://linux.do/u/lixiaobaivv)
- Ideas and suggestions: [Alex](https://linux.do/u/_alex), [ye4241](https://linux.do/u/ye4241)
Links:
- [LINUX DO](https://linux.do/)
- [iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)
Thanks to the open-source contributors working on iOS location-service research, network proxies, and mobile testing
tools.
+394 -122
View File
@@ -2,173 +2,445 @@
# 📍 Location Spoofer
### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS
### iOS Location Service Research & Testing Framework
**无需 VPN、无需越狱,在 iPhone 本机通过 WiFi HTTP 代理改写 Apple 定位响应。**<br>
可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。
一个用于 **iOS 定位服务研究、软件开发测试和 QA 验证** 的开源项目。
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
不同地理位置和定位场景下的行为。
[![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)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.5-2563EB)](docs/CHANGELOG.md)
[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md)
<img src="images/主界面.jpg" alt="Location Spoofer iOS 虚拟定位 Fake GPS 主界面" width="380">
[功能概览](#功能概览) ·
[工作原理](#工作原理) ·
[快速开始](#快速开始) ·
[构建项目](#构建项目) ·
[English](README.en.md)
</div>
> [!IMPORTANT]
> 本项目用于学习、安全研究自有设备测试。它会安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款
> 本项目用于学习研究自有设备测试、软件开发和 QA 验证
>
> 请仅在你拥有或获得授权的设备、网络和软件环境中使用,并遵守当地法律法规、网络管理规定以及相关服务条
> 款。
>
> 本项目不保证兼容所有 iOS 版本或第三方应用,也不承诺绕过第三方应用的安全策略、业务限制或服务规则。
## 致谢
## 项目定位
核心定位响应改写思路与 Go 实现来源于 [Yu9191/wloc](https://github.com/Yu9191/wloc)。本项目在此基础上增加 SwiftUI 界面、MapKit 选点、证书与代理引导、环境验证、收藏和诊断能力
Location Spoofer 是一个面向 iOS 定位服务行为研究和开发测试的工具
## 为什么选择 Location Spoofer
它提供:
很多 iOS 虚拟定位工具依赖电脑常驻、开发者调试、VPN 或越狱。本项目采用不同路线:在 iPhone 本机运行 Go 代理,仅对 Apple 定位服务目标请求进行处理。
- 原生地图选点和位置场景切换;
- Apple 定位服务响应的测试环境模拟;
- 本地代理和第三方代理两种运行模式;
- 坐标标准识别与 WGS-84 / GCJ-02 双坐标管理;
- 环境检测、运行日志和问题诊断;
- 收藏位置和上次地图状态恢复。
| 特性 | 说明 |
|---|---|
| 🚫 **无 VPN** | 不创建 VPN 隧道,仅使用定位权限,无需后台刷新、通知等额外权限 |
| 📱 **无需越狱** | 支持自行签名安装,最低部署目标为 iOS 15 |
| 🗺️ **原生地图体验** | 使用 Apple 地图同款蓝点,搜索、点击、拖动选点体验与 Apple 地图一致 |
| 📍 **系统级虚拟定位** | 支持钉钉、微信、Apple 地图、高德等 App 的虚拟实时定位 |
| 🔍 **可见缩放范围** | 左侧缩放控件显示当前可视范围,地点名称随级别自动适配 |
| 🧪 **环境检测** | 检查代理、CA 信任、Wi‑Fi 接管、坐标写入与响应改写 |
| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 |
项目不修改目标 App 的源代码,也不提供远程控制或数据采集服务。
## 效果预
## 功能概
<table>
<tr>
<th>应用主界面</th>
<th>Apple 地图</th>
<th>高德地图</th>
<th>Apple Watch</th>
</tr>
<tr>
<td><img src="images/主界面.jpg" alt="iOS 虚拟定位应用主界面" width="210"></td>
<td><img src="images/Apple%20Map.jpg" alt="iPhone Location Spoofer Apple Maps 效果" width="210"></td>
<td><img src="images/高德地图.jpg" alt="Fake GPS 高德地图定位效果" width="210"></td>
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能验证" width="210"></td>
</tr>
</table>
- **原生地图交互**
- 使用 MapKit 显示地图和系统蓝点;
- 支持搜索、点击选点、拖动地图中心和缩放;
- 支持收藏位置和恢复上次选点;
- 当前选点同时显示国内坐标和国际坐标,可分别复制。
## 核心功能
- **定位服务响应模拟**
- 通过代理层处理指定的 Apple 定位服务请求;
- 在测试环境中返回选定的坐标数据;
- 不需要修改目标 App 代码。
- **iOS 虚拟定位 / Fake GPS**:将当前地图选点应用到本机定位响应改写代理,适配钉钉打卡、微信位置共享等场景。
- **原生实时位置**:地图显示 MapKit 自带蓝点,不再由 App 额外绘制实时位置标记。
- **并发安全选点**:拖动、点击、搜索、收藏和异步定位按用户最新意图处理,旧结果不会覆盖新选点
- **地点名称分级**:近距离显示 POI、门牌或道路;拉远后显示社区、区县、城市或省份。
- **地图范围显示**:缩放控件中显示 `180 m``2.5 km``126 km` 等当前可视范围。
- **收藏与快速切换**:保存常用坐标,并明确显示当前准备应用的位置。
- **配置引导**:提供证书安装、完全信任、Wi‑Fi HTTP 代理、生效与恢复说明。
- **问题诊断**:内置验证流程和结构化运行日志
- **双运行模式**
- APP 模式:在设备内运行 Go 代理,仅支持当前 Wi-Fi 网络;
- 第三方代理模式:通过支持的代理客户端覆盖 Wi-Fi、4G 或 5G,具体能力取决于客户端
- **环境检测**
- APP 模式检测本地代理、证书信任和请求链路;
- 第三方代理模式检测 WLOC 配置接口和模块响应;
- 失败时提供对应的配置或诊断入口
- **开发调试**
- 运行日志;
- 日志复制和清理;
- 坐标标准变化记录;
- 脱敏问题报告生成。
## 工作原理
### APP 模式
APP 模式在设备内运行本地 Go 代理,并通过当前 Wi-Fi 的手动 HTTP 代理让指定请求经过本地代理。
```text
iOS 定位请求
│ 当前 Wi-Fi 手动 HTTP 代理
设备内 wloccore Go 代理
│ 处理指定 Apple 定位服务请求
Apple 定位服务响应
│ 测试坐标响应
系统和应用读取定位结果
```
APP 模式:
- 不创建 Network Extension
- 不显示或占用系统 VPN
- 只覆盖当前 Wi-Fi 网络;
- 需要配置当前 Wi-Fi 的手动 HTTP 代理;
- 需要安装并信任 App 生成的本机 CA;
- 代理只处理项目定义的 Apple 定位服务和环境验证请求,不是通用网络抓包工具。
### 第三方代理模式
第三方代理模式不启动 App 内置 Go 代理,也不使用 App 生成的 CA。
```text
地图选点
│ WGS-84 坐标
WLOC 配置接口
第三方代理客户端保存配置
第三方代理客户端处理定位服务请求
```
在该模式下:
- App 负责地图选点、收藏、坐标同步和清除;
- 第三方客户端负责代理/VPN、MITM、证书和规则执行;
- 坐标持久化由第三方客户端负责;
- 是否支持 Wi-Fi、4G 或 5G 取决于客户端;
- App 关闭后,第三方客户端中的配置可能继续生效。
#### 配置接口与客户端适配
App 不会把坐标上传到项目服务器。它会发起以下 GET 请求,第三方客户端必须在设备本地拦截:
```text
https://gs-loc.apple.com/wloc-settings/save
```
| 操作 | 查询参数 | 用途 |
|---|---|---|
| 查询 | `action=query` | 检查模块是否连接,并读取当前保存的坐标 |
| 保存 | `lon=<WGS-84 经度>&lat=<WGS-84 纬度>&acc=<精度>` | 保存当前选点 |
| 清除 | `action=clear` | 删除已保存的测试坐标 |
拦截脚本必须返回 HTTP 200 和 JSON
```json
{
"success": true,
"longitude": 113.0,
"latitude": 22.0,
"accuracy": 25
}
```
失败时返回:
```json
{
"success": false,
"error": "错误说明"
}
```
查询时没有已保存坐标,可以返回 `{"success":false,"error":"无已保存的坐标"}`。App 会把它识别为
“模块已连接,但虚拟定位未开启”。保存成功时,响应中的经纬度必须与请求中的 WGS-84 坐标一致。
要适配新的第三方客户端,需要:
1.`gs-loc.apple.com/wloc-settings/save` 添加 HTTP 请求脚本,解析上述参数并返回约定 JSON;
2. 使用客户端的持久化存储保存坐标、精度和可选状态;
3.`gs-loc.apple.com``gs-loc-cn.apple.com` 配置 HTTPS 解密;
4. 拦截 `gs-loc(-cn).apple.com/clls/wloc` 响应,读取同一份持久化数据并修改 WLOC 响应;
5. 提供可订阅的模块文件,并确认查询、保存、清除和定位恢复都能在真机完成。
App 只验证配置接口的 HTTP 状态、JSON 格式和坐标回读,不管理第三方客户端的证书、MITM、VPN 或代理状态。
不要同时启用 APP 模式代理和第三方代理模式,避免两个代理链路互相干扰。
## 运行模式
### APP 模式
适用于:
- 只使用 Wi-Fi 的设备测试;
- 不依赖第三方代理客户端的本地验证;
- 需要在 App 内完成代理、证书和环境检测的场景。
使用条件:
- iOS 设备;
- 当前 Wi-Fi 支持手动 HTTP 代理;
- 安装并信任本机 CA
- 在 App 内完成代理配置和环境检测。
### 第三方代理模式
适用于:
- 需要 Wi-Fi、4G 或 5G 网络覆盖的测试;
- 已经使用支持模块或脚本的代理客户端;
- 希望由第三方客户端继续保持代理配置的场景。
当前客户端状态:
| 客户端 | 状态 | 社区配置 |
|---|---|---|
| Shadowrocket | 当前用于真机测试 | App 内置教程 |
| Surge | 已提供配置,尚未完整验证 | 待征集 |
| Quantumult X | 已提供配置,尚未完整验证 | 待征集 |
| Loon | 已提供配置,尚未完整验证 | 待征集 |
| Stash | 已提供配置,尚未完整验证 | 待征集 |
| Egern | 使用 Surge 模块,尚未完整验证 | 待征集 |
社区配置按客户端分区审核;采纳后会在上表链接教程和投稿者,投稿者也可以选择匿名收录。
相关模块快照和来源记录:
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
第三方客户端、证书、MITM 和代理开关由客户端自身负责。导入任何第三方模块前,请先审查其配置和脚本内容。
## 快速开始
### 1. 安装 App
### 1. 安装应用
- 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 获取构建产物并自行签名;或
- 在 macOS + Xcode 环境按[构建说明](docs/BUILD.md)编译。
你可以使用:
详细步骤见[自签安装说明](docs/SELF-SIGNING.md)。
- 自己的 Apple Developer 签名环境;
- 适合个人测试的自签工具;
- 项目 Releases 中的未签名 IPA
- 在 macOS 上按[构建说明](docs/BUILD.md)自行构建。
### 2. 安装并信任 CA
免费自签环境可能无法使用 Network Extension,因此 APP 模式采用设备内本地代理和 Wi-Fi 手动代理,不依赖
VPN 组件。
按首次引导下载描述文件,然后完成:
#### 自签安装说明
Release 附件是未签名 IPA,需要使用自签工具安装到 iPhone:
1. **开启自签支持**:iOS 16 及以上版本前往“设置 → 隐私与安全性 → 开发者模式”,开启后按系统提示重启并
确认;iOS 15 没有此开关,可跳过本步。
2. **下载 IPA**:前往本项目的 [Releases](https://github.com/xweiba/location-spoofer/releases),下载最新的
`PaopaoLocationSpoofer-unsigned.ipa`
3. **准备自签软件**:前往 [Impactor Releases](https://github.com/claration/Impactor/releases) 下载对应系统
版本的 Impactor;也可以使用爱思助手等支持 IPA 自签安装的软件。
4. **连接并安装**:使用 USB 数据线连接 iPhone 与电脑,在手机上选择“信任此电脑”,然后在自签软件中选择
刚下载的 IPA,根据软件提示完成签名与安装。
Impactor 支持 Windows、macOS 和 LinuxWindows 若无法识别设备,请先安装 iTunes 提供的 Apple 设备驱动。
爱思助手属于第三方软件,请从其官方渠道获取,并自行评估账号、证书和隐私风险。
安装完成后,若 iOS 阻止打开 App,请前往“设置 → 通用 → VPN 与设备管理”信任对应的开发者 App。免费
Apple ID 自签通常只有 7 天有效期,到期后需要重新签名安装。
### 2. 首次启动
首次启动时:
1. 选择 APP 模式或第三方代理模式;
2. 按照 App 内引导完成对应配置;
3. 执行环境检测;
4. 在地图中搜索、点击或拖动选择测试位置;
5. 启用测试位置并在目标测试环境中验证结果。
### 3. 恢复真实位置
APP 模式:
1. 停止测试位置;
2. 关闭当前 Wi-Fi 的手动 HTTP 代理;
3. 按 App 内提示刷新定位环境。
第三方代理模式:
1. 使用 App 清除 WLOC 坐标;
2. 在第三方客户端中关闭对应模块或代理;
3. 按客户端要求恢复 HTTPS 解密和代理设置。
如果系统或目标应用仍显示旧位置,可能需要等待定位缓存刷新,必要时重启设备后再次验证。
## 坐标处理
项目内部保存两种坐标表示:
- WGS-84:国际标准,WLOC 写入使用此坐标;
- GCJ-02:国内地图标准,用于需要国内地图坐标的场景。
MapKit 不提供公开 API 直接返回当前是否使用 GCJ-02 或 WGS-84。项目通过固定锚点查询判断 MapKit 当前返回
标准,并在运行期间根据蓝点变化和用户操作进行受控刷新。
坐标写入边界会保存完整的 WGS-84 / GCJ-02 坐标对,使用时根据当前地图标准选择对应字段,避免重复转换造成
位置偏移。
## 项目结构
```text
设置 → 通用 → VPN 与设备管理 → 安装 WLOC CA
设置 → 通用 → 关于本机 → 证书信任设置 → 完全信任
App/ SwiftUI 界面、MapKit、定位和运行流程
Core/ Go 代理、证书服务和定位响应处理
Shared/ 坐标、收藏、日志、配置和共享模型
Resources/ Info.plist、Entitlements 和资源文件
Config/ 构建配置
Scripts/ 构建、打包和验证脚本
Tests/ XCTest 和 Shell contract tests
docs/ 构建、模块和版本文档
```
### 3. 配置当前 WiFi 代理
## 构建项目
在当前 WiFi 的"配置代理"中选择"手动"
源码构建需要 macOS 环境
```text
服务器:127.0.0.1
端口:8888
鉴定:关闭
```
- macOS
- Xcode
- Xcode Command Line Tools
- XcodeGen
- Go 1.23 或更高版本。
### 4. 选点并启用
1. 搜索、点击或拖动地图选择位置;点击实时位置按钮可回到 MapKit 蓝点。
2. 点击"开始虚拟定位",等待环境检测通过。
3. 按 App 内"生效说明"刷新飞行模式、Wi‑Fi 和定位服务状态。
4. 打开 Apple 地图或目标 App 验证结果。
### 5. 恢复真实位置
停止虚拟定位,关闭当前 Wi‑Fi 的手动代理,并按 App 内"失效说明"刷新系统定位缓存。若系统仍保留旧缓存,请重启设备后再检查。
## 为什么不需要 VPN
```text
iPhone 定位请求
│ 当前 WiFi HTTP 代理:127.0.0.1:8888
本机 wloccoreGo
│ 仅处理目标 Apple 定位服务请求
├──────────────► Apple 定位服务
◄──────────────┘
│ 改写目标响应中的坐标
系统与应用读取定位结果
```
项目不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**但它仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 切换 Wi‑Fi 后需要重新检查代理设置;停止使用后应及时关闭手动代理。
## 兼容性
| 项目 | 要求 |
|---|---|
| iOS | 15.0+ |
| 构建 | macOS、Xcode、XcodeGen |
| Swift | 5.9 |
| Go | 1.23+ |
| 网络 | 可手动配置 HTTP 代理的 WiFi |
| 安装 | 自行签名或使用 Releases 构建产物 |
效果会受到 iOS 版本、网络、系统定位缓存和目标 App 自身策略影响,不承诺兼容所有系统或第三方 App。
## 构建与项目结构
当前项目不支持在 Windows 上直接构建 iOS 应用。
```bash
git clone https://github.com/xweiba/location-spoofer.git
cd location-spoofer
./build.sh
```
构建产物默认位于
运行构建并执行 Simulator 测试
```bash
./build.sh --test
```
构建脚本会生成未签名 IPA
```text
dist/PaopaoLocationSpoofer-unsigned.ipa
```
```text
App/ SwiftUI、MapKit、定位和配置流程
Core/ Go 本机代理与定位响应改写
Shared/ 收藏、设置、日志和共享模型
Resources/ Info.plist、Entitlements 与图标
Scripts/ 构建、签名和检查脚本
Tests/ XCTest 与 Bash 契约测试
docs/ 构建、自签和版本更新文档
```
之后需要使用你自己的签名和安装流程部署到测试设备。
## 文档与反馈
## 隐私与安全边界
- 项目不包含遥测或远程控制服务;
- 项目不会自动上传用户位置数据;
- 运行日志保存在设备 App Group 容器中,并自动保留近三天;
- 问题报告需要用户主动复制后提交到 GitHub;
- APP 模式会访问本机代理和环境验证地址;
- 第三方代理模式可能访问上游模块地址和 WLOC 配置接口;
- App 生成的 CA 私钥保存在设备 Keychain 中;
- 第三方客户端模块、MITM 和证书链路由用户选择的客户端负责。
请不要把真实位置、认证信息、证书私钥或完整敏感日志提交到公开 Issue。
## 限制
- iOS 系统版本变化可能影响定位服务行为;
- MapKit 的坐标返回标准可能随系统、地区和定位环境变化;
- 系统定位存在缓存,切换位置后不一定立即生效;
- 第三方代理客户端的兼容性和规则行为需要分别验证;
- 不保证所有应用都使用同一种定位 API;
- 不保证所有应用或服务都接受测试坐标;
- 不保证在所有网络环境、设备型号和 iOS 版本上表现一致。
## 贡献
欢迎提交:
- [Bug Report](https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml)
- [功能建议](https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE)
- [使用帮助与兼容性测试](https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9)
- [第三方客户端配置和脱敏原始截图](https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB)
- 性能改进;
- 文档改进;
- 测试补充。
提交 Bug 时建议优先通过 App 的“设置 → 支持 → 报告 Bug”生成报告。报告包含:
- iOS 版本;
- App 版本;
- 使用的运行模式;
- 当前第三方客户端;
- 是否可以稳定复现;
- 问题描述;
- 脱敏后的运行日志;
GitHub Issue Form 中的“App 生成的诊断报告”字段与 App 复制内容一一对应。
## 文档
- [构建说明](docs/BUILD.md)
- [自签安装](docs/SELF-SIGNING.md)
- [v1.0.0 更新日志](docs/CHANGELOG.md)
- [English README](README.en.md)
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
- [社区客户端教程与截图提交](docs/COMMUNITY_TUTORIALS.md)
- [更新日志](docs/CHANGELOG.md)
- [英文文档](README.en.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
反馈问题时,请附上复现步骤、iOS 版本、设备型号及已脱敏的运行日志。
## 功能预览
## 友链
以下截图用于展示主界面和部分真机测试场景。实际结果会受到 iOS 版本、网络环境、系统缓存和目标应用定位策略
影响,不代表对所有应用或版本作出兼容性保证。
**LinuxDo** — [https://linux.do](https://linux.do/)
<table>
<tr>
<th>应用主界面</th>
<th>Apple 地图测试</th>
<th>高德地图测试</th>
</tr>
<tr>
<td><img src="images/主界面.jpg" alt="Location Spoofer 地图选点主界面" width="220"></td>
<td><img src="images/Apple%20Map.jpg" alt="Apple 地图定位测试场景" width="220"></td>
<td><img src="images/高德地图.jpg" alt="高德地图定位测试场景" width="220"></td>
</tr>
<tr>
<th>微信测试</th>
<th>钉钉测试</th>
<th>Apple Watch 场景测试</th>
</tr>
<tr>
<td><img src="images/微信.jpg" alt="微信定位测试场景" width="220"></td>
<td><img src="images/钉钉.jpg" alt="钉钉定位测试场景" width="220"></td>
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能测试场景" width="220"></td>
</tr>
</table>
## 致谢与友链
核心定位响应处理思路、Go 实现和第三方模块参考自:
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
- [ios-location-spoofer](https://github.com/mekos2772/ios-location-spoofer)
感谢以下 LINUX DO 用户对项目的贡献:
- 功能修复:[陈泽](https://linux.do/u/lixiaobaivv)
- 思路及建议:[Alex](https://linux.do/u/_alex)、[ye4241](https://linux.do/u/ye4241)
友链:
- [LINUX DO](https://linux.do/)
- [iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)
感谢开源社区中参与 iOS 定位服务研究、网络代理和移动端测试工具建设的贡献者。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "app-mode-certificate-install.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "app-mode-certificate-trust.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "app-mode-wifi-proxy.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "shadowrocket-config-details.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "shadowrocket-https-ca.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "shadowrocket-https-decryption.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "shadowrocket-module-import.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+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>
@@ -6,5 +6,7 @@
<array>
<string>group.com.paopaolabs.location-spoofer</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
</dict>
</plist>
@@ -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=xweiba
#!homepage=https://github.com/xweiba/location-spoofer
[rewrite_local]
^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc url script-response-body https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version) url script-echo-response https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
[mitm]
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
+18
View File
@@ -0,0 +1,18 @@
#!name=Apple WLOC 定位修改
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f
#!author=xweiba
#!homepage=https://github.com/xweiba/location-spoofer
#!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://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version) script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js, timeout=10, tag=WLOC Settings
[MITM]
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
@@ -0,0 +1,12 @@
#!name=Apple WLOC 定位修改
#!desc=修改 Apple 网络定位返回坐标 (Shadowrocket 小火箭) | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
#!author=xweiba
#!homepage=https://github.com/xweiba/location-spoofer
#!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://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version),requires-body=0,max-size=0,timeout=10,script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/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=xweiba
#!homepage=https://github.com/xweiba/location-spoofer
#!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://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version)", requires-body=0, max-size=0, timeout=10, script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
[MITM]
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
@@ -0,0 +1,31 @@
name: Apple WLOC 定位修改
desc: "修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/"
author: xweiba
homepage: https://github.com/xweiba/location-spoofer
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
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version)
name: WLOC.Settings
type: request
require-body: false
timeout: 10
script-providers:
WLOC.Location:
url: https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
interval: 86400
WLOC.Settings:
url: https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
interval: 86400
+23 -12
View File
@@ -4,21 +4,32 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CORE="$ROOT/Core"
BUILD="$CORE/build"
IOS_SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
MIN_IOS_VERSION="15.0"
export CGO_ENABLED=1
export CGO_CFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
export CGO_LDFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
export GOOS="ios"
export GOARCH="arm64"
build_archive() {
local sdk="$1"
local min_flag="$2"
local output="$3"
local sdk_path
sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path)"
mkdir -p "$BUILD"
CGO_ENABLED=1 \
CGO_CFLAGS="-arch arm64 -isysroot $sdk_path $min_flag" \
CGO_LDFLAGS="-arch arm64 -isysroot $sdk_path $min_flag" \
GOOS=ios GOARCH=arm64 \
go build -buildmode=c-archive -ldflags="-s -w" -o "$output" .
}
rm -rf "$BUILD"
mkdir -p "$BUILD/iphoneos" "$BUILD/iphonesimulator"
cd "$CORE"
go mod download
go build -buildmode=c-archive -ldflags="-s -w" -o "$BUILD/libwloccore.a" .
cp "$BUILD/libwloccore.h" "$ROOT/Core/wloccore.h"
test -s "$ROOT/Core/wloccore.h"
echo "Built $BUILD/libwloccore.a"
build_archive iphoneos "-miphoneos-version-min=$MIN_IOS_VERSION" "$BUILD/iphoneos/libwloccore.a"
build_archive iphonesimulator "-mios-simulator-version-min=$MIN_IOS_VERSION" "$BUILD/iphonesimulator/libwloccore.a"
cp "$BUILD/iphoneos/libwloccore.h" "$ROOT/Core/wloccore.h"
test -s "$BUILD/iphoneos/libwloccore.a"
test -s "$BUILD/iphonesimulator/libwloccore.a"
test -s "$ROOT/Core/wloccore.h"
echo "Built device and simulator Core archives"
+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 和 Impactor 完成签名安装,无需付费开发者账号。
- 签名时请保留 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"
+2
View File
@@ -14,5 +14,7 @@
</array>
<key>get-task-allow</key>
<true/>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
</dict>
</plist>
+153
View File
@@ -43,3 +43,156 @@ enum WlocSettingsStore {
save(WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false))
}
}
@MainActor
final class MotionSimulationStore: ObservableObject {
static let shared = MotionSimulationStore()
private enum Key {
static let enabled = "motionSimulation.enabled"
}
@Published private(set) var isEnabled: Bool
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
isEnabled = defaults.bool(forKey: Key.enabled)
}
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
defaults.set(enabled, forKey: Key.enabled)
}
}
@MainActor
final class ThirdPartyModuleSourceStore: ObservableObject {
static let shared = ThirdPartyModuleSourceStore()
private enum Key {
static let useMirror = "thirdPartyModule.useMirror"
}
@Published private(set) var useMirror: Bool
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
useMirror = defaults.object(forKey: Key.useMirror) as? Bool ?? true
}
func setUseMirror(_ enabled: Bool) {
useMirror = enabled
defaults.set(enabled, forKey: Key.useMirror)
}
}
enum VirtualLocationTipKind: Equatable {
case activation
case deactivation
}
/// Owns the persistent counters and suppression flags for automatic operation tips.
/// Manual help sheets do not consult or mutate this store.
struct VirtualLocationTipPreferences {
static let minimumCountForSuppression = 3
private enum Key {
static let activationCount = "virtualLocationTip.activationCount"
static let deactivationCount = "virtualLocationTip.deactivationCount"
static let activationSuppressed = "virtualLocationTip.activationSuppressed"
static let deactivationSuppressed = "virtualLocationTip.deactivationSuppressed"
static let legacyActivationSuppressed = "activationTipDisabled"
}
private let defaults: UserDefaults
private let legacyDefaults: UserDefaults
init(
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) {
self.defaults = defaults
self.legacyDefaults = legacyDefaults
}
@discardableResult
func recordSuccessfulOperation(_ kind: VirtualLocationTipKind) -> Int {
let key = countKey(for: kind)
let next = defaults.integer(forKey: key) + 1
defaults.set(next, forKey: key)
return next
}
func shouldPresentAutomaticTip(_ kind: VirtualLocationTipKind) -> Bool {
!isSuppressed(kind)
}
func canSuppress(_ kind: VirtualLocationTipKind) -> Bool {
defaults.integer(forKey: countKey(for: kind)) >= Self.minimumCountForSuppression
}
func suppress(_ kind: VirtualLocationTipKind) {
guard canSuppress(kind) else { return }
defaults.set(true, forKey: suppressionKey(for: kind))
}
private func isSuppressed(_ kind: VirtualLocationTipKind) -> Bool {
if kind == .activation,
legacyDefaults.bool(forKey: Key.legacyActivationSuppressed) {
return true
}
return defaults.bool(forKey: suppressionKey(for: kind))
}
private func countKey(for kind: VirtualLocationTipKind) -> String {
switch kind {
case .activation: return Key.activationCount
case .deactivation: return Key.deactivationCount
}
}
private func suppressionKey(for kind: VirtualLocationTipKind) -> String {
switch kind {
case .activation: return Key.activationSuppressed
case .deactivation: return Key.deactivationSuppressed
}
}
}
/// Controls the optional prompt asking users to share a verified third-party setup.
struct ThirdPartyCommunityPromptPreferences {
static let minimumCountForSuppression = 3
private enum Key {
static let presentationCount = "thirdPartyCommunityPrompt.presentationCount"
static let suppressed = "thirdPartyCommunityPrompt.suppressed"
}
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
@discardableResult
func recordPresentation() -> Int {
let next = defaults.integer(forKey: Key.presentationCount) + 1
defaults.set(next, forKey: Key.presentationCount)
return next
}
func shouldPresent() -> Bool {
!defaults.bool(forKey: Key.suppressed)
}
func canSuppress() -> Bool {
defaults.integer(forKey: Key.presentationCount) >= Self.minimumCountForSuppression
}
func suppress() {
guard canSuppress() else { return }
defaults.set(true, forKey: Key.suppressed)
}
}
+253
View File
@@ -0,0 +1,253 @@
import Foundation
struct AppUpdatePrompt: Identifiable, Equatable {
enum Requirement: String {
case recommended
case required
}
let currentVersion: String
let latestVersion: String
let minimumSupportedVersion: String
let requirement: Requirement
let releaseNotes: String?
var id: String {
"\(requirement.rawValue)-\(latestVersion)-\(minimumSupportedVersion)"
}
}
struct AppRemoteConfiguration: Decodable, Equatable {
let latestVersion: String
let minimumSupportedVersion: String
let communityPromptClients: [String]
static let fallback = AppRemoteConfiguration(
latestVersion: "1.0.5",
minimumSupportedVersion: "1.0.0",
communityPromptClients: [
ThirdPartyProxyClient.surge.rawValue,
ThirdPartyProxyClient.quantumultX.rawValue,
ThirdPartyProxyClient.loon.rawValue,
ThirdPartyProxyClient.stash.rawValue,
ThirdPartyProxyClient.egern.rawValue
]
)
init(
latestVersion: String,
minimumSupportedVersion: String,
communityPromptClients: [String]
) {
self.latestVersion = latestVersion
self.minimumSupportedVersion = minimumSupportedVersion
self.communityPromptClients = communityPromptClients
}
static func decode(_ data: Data) throws -> AppRemoteConfiguration {
let configuration = try JSONDecoder().decode(AppRemoteConfiguration.self, from: data)
guard let latest = NumericVersion(configuration.latestVersion),
let minimum = NumericVersion(configuration.minimumSupportedVersion),
minimum <= latest else {
throw AppRemoteConfigurationError.invalidVersion
}
guard configuration.communityPromptClients.allSatisfy({
ThirdPartyProxyClient(rawValue: $0) != nil
}) else {
throw AppRemoteConfigurationError.invalidClient
}
return configuration
}
func updatePrompt(currentVersion: String, releaseNotes: String? = nil) -> AppUpdatePrompt? {
guard let current = NumericVersion(currentVersion),
let latest = NumericVersion(latestVersion),
let minimum = NumericVersion(minimumSupportedVersion) else {
return nil
}
if current < minimum {
return AppUpdatePrompt(
currentVersion: currentVersion,
latestVersion: latestVersion,
minimumSupportedVersion: minimumSupportedVersion,
requirement: .required,
releaseNotes: releaseNotes
)
}
if current < latest {
return AppUpdatePrompt(
currentVersion: currentVersion,
latestVersion: latestVersion,
minimumSupportedVersion: minimumSupportedVersion,
requirement: .recommended,
releaseNotes: releaseNotes
)
}
return nil
}
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
client != .shadowrocket && communityPromptClients.contains(client.rawValue)
}
}
@MainActor
final class AppRemoteConfigurationStore: ObservableObject {
static let shared = AppRemoteConfigurationStore()
@Published private(set) var configuration = AppRemoteConfiguration.fallback
func apply(_ configuration: AppRemoteConfiguration) {
self.configuration = configuration
}
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
configuration.requestsCommunityPrompt(for: client)
}
}
enum AppRemoteConfigurationService {
static let configurationURLs = [
URL(
string: "https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
)!,
URL(
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
)!
]
static let releasesURL = URL(
string: "https://github.com/xweiba/location-spoofer/releases/latest"
)!
static func fetch() async -> AppRemoteConfiguration? {
let session = makeSession()
defer { session.finishTasksAndInvalidate() }
for url in configurationURLs {
var request = URLRequest(url: url)
request.timeoutInterval = 1.5
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
continue
}
let configuration = try AppRemoteConfiguration.decode(data)
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
"来源": url.host ?? "未知",
"最新版本": configuration.latestVersion,
"最低版本": configuration.minimumSupportedVersion,
"社区征集客户端数": String(configuration.communityPromptClients.count)
])
return configuration
} catch {
RuntimeLogger.info("APP", "Update", "版本配置源不可用,尝试下一地址", details: [
"来源": url.host ?? "未知",
"错误": error.localizedDescription
])
}
}
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置")
return nil
}
static func fetchReleaseNotes(version: String) async -> String? {
let session = makeSession()
defer { session.finishTasksAndInvalidate() }
for url in releaseNotesURLs(version: version) {
var request = URLRequest(url: url)
request.timeoutInterval = 1.5
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let markdown = String(data: data, encoding: .utf8),
let summary = releaseNotesSummary(markdown) else {
continue
}
return summary
} catch {
RuntimeLogger.info("APP", "Update", "版本说明源不可用,尝试下一地址", details: [
"来源": url.host ?? "未知",
"版本": version,
"错误": error.localizedDescription
])
}
}
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
"版本": version
])
return nil
}
static func releaseNotesURLs(version: String) -> [URL] {
let path = "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
return [
URL(string: "https://gh-proxy.org/\(path)")!,
URL(string: path)!
]
}
private static func makeSession() -> URLSession {
let sessionConfiguration = URLSessionConfiguration.ephemeral
sessionConfiguration.timeoutIntervalForRequest = 1.5
sessionConfiguration.timeoutIntervalForResource = 2
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
sessionConfiguration.waitsForConnectivity = false
return URLSession(configuration: sessionConfiguration)
}
private static func releaseNotesSummary(_ markdown: String) -> String? {
var items: [String] = []
var inMainUpdates = false
for rawLine in markdown.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
if line == "## 主要更新" {
inMainUpdates = true
continue
}
if inMainUpdates, line.hasPrefix("## ") {
break
}
guard inMainUpdates, line.hasPrefix("- ") else { continue }
items.append("" + line.dropFirst(2))
}
guard !items.isEmpty else { return nil }
return items.joined(separator: "\n")
}
}
private enum AppRemoteConfigurationError: Error {
case invalidVersion
case invalidClient
}
private struct NumericVersion: Comparable {
let components: [Int]
init?(_ value: String) {
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
guard !parts.isEmpty else { return nil }
var parsed: [Int] = []
for part in parts {
guard !part.isEmpty, let number = Int(part), number >= 0 else { return nil }
parsed.append(number)
}
components = parsed
}
static func < (lhs: NumericVersion, rhs: NumericVersion) -> Bool {
let count = max(lhs.components.count, rhs.components.count)
for index in 0..<count {
let left = index < lhs.components.count ? lhs.components[index] : 0
let right = index < rhs.components.count ? rhs.components[index] : 0
if left != right { return left < right }
}
return false
}
}
+23 -4
View File
@@ -17,7 +17,7 @@ final class BackgroundKeepAlive {
guard isActive, let info = notification.userInfo,
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
type == AVAudioSession.InterruptionType.ended.rawValue else { return }
start()
restartAfterInterruption()
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
}
@@ -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()
@@ -50,6 +59,16 @@ final class BackgroundKeepAlive {
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
}
private func restartAfterInterruption() {
guard isActive else { return }
playerNode?.stop()
engine?.stop()
playerNode = nil
engine = nil
isActive = false
start()
}
func stop() {
guard isActive else { return }
isActive = false
+163 -21
View File
@@ -1,46 +1,188 @@
import Foundation
import Security
protocol CertificateAuthorityKeychain {
func load() throws -> CertificateAuthority?
func save(_ authority: CertificateAuthority) throws
func remove() throws
}
enum CertificateAuthorityStoreError: LocalizedError {
case invalidAuthority
case keychain(OSStatus)
var errorDescription: String? {
switch self {
case .invalidAuthority: return "本地 CA 证书或私钥无效"
case let .keychain(status): return "无法写入设备钥匙串(\(status)"
}
}
}
final class DeviceCertificateAuthorityKeychain: CertificateAuthorityKeychain {
private enum Item {
static let service = "com.paopaolabs.location-spoofer.certificate-authority"
static let certificateAccount = "root-ca-certificate"
static let keyAccount = "root-ca-private-key"
}
func load() throws -> CertificateAuthority? {
guard let certPEM = try load(account: Item.certificateAccount),
let keyPEM = try load(account: Item.keyAccount) else {
return nil
}
return CertificateAuthority(certPEM: certPEM, keyPEM: keyPEM)
}
func save(_ authority: CertificateAuthority) throws {
try remove()
do {
try save(authority.certPEM, account: Item.certificateAccount)
try save(authority.keyPEM, account: Item.keyAccount)
} catch {
try? remove()
throw error
}
}
func remove() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Item.service,
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw CertificateAuthorityStoreError.keychain(status)
}
}
private func load(account: String) throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Item.service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
throw CertificateAuthorityStoreError.keychain(status)
}
return value
}
private func save(_ value: String, account: String) throws {
guard let data = value.data(using: .utf8) else {
throw CocoaError(.fileWriteInapplicableStringEncoding)
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Item.service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
kSecValueData as String: data,
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw CertificateAuthorityStoreError.keychain(status) }
}
}
final class CertificateAuthorityStore {
private let directory: URL
private let generator: () throws -> CertificateAuthority
private let validator: (CertificateAuthority) -> Bool
private let keychain: CertificateAuthorityKeychain
private let certificateURL: URL
private let keyURL: URL
init(directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true), generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority) {
init(
directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true),
keychain: CertificateAuthorityKeychain = DeviceCertificateAuthorityKeychain(),
generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority,
validator: @escaping (CertificateAuthority) -> Bool = CoreBridge.isValidCertificateAuthority
) {
self.directory = directory
self.keychain = keychain
self.generator = generator
self.validator = validator
self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
self.keyURL = directory.appendingPathComponent("ca-key.pem")
}
func ensure() throws -> CertificateAuthority {
if let current = try load() {
RuntimeLogger.debug("SHARED", "Certificate.store", "读取已有 CA 文件", details: ["directory": directory.path])
return current
if let authority = try loadValidKeychainAuthority() {
removeLegacyFilesBestEffort()
RuntimeLogger.debug("SHARED", "Certificate.store", "复用设备钥匙串中的 CA")
return authority
}
RuntimeLogger.info("SHARED", "Certificate.store", "未找到 CA 文件,开始生成", details: ["directory": directory.path])
if let legacy = try loadLegacyAuthority(), validator(legacy) {
try keychain.save(legacy)
removeLegacyFilesBestEffort()
RuntimeLogger.info("SHARED", "Certificate.store", "旧 CA 已迁移到设备钥匙串")
return legacy
}
let authority = try generator()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
guard let certificateData = authority.certPEM.data(using: .utf8),
let keyData = authority.keyPEM.data(using: .utf8) else {
throw CocoaError(.fileWriteInapplicableStringEncoding)
}
try certificateData.write(to: certificateURL, options: .atomic)
try keyData.write(to: keyURL, options: .atomic)
applyCompleteProtection(to: certificateURL)
applyCompleteProtection(to: keyURL)
RuntimeLogger.info("SHARED", "Certificate.store", "CA 文件写入完成", details: ["directory": directory.path])
guard validator(authority) else { throw CertificateAuthorityStoreError.invalidAuthority }
try keychain.save(authority)
removeLegacyFilesBestEffort()
RuntimeLogger.info("SHARED", "Certificate.store", "已生成并保存设备专属 CA")
return authority
}
func load() throws -> CertificateAuthority? {
guard FileManager.default.fileExists(atPath: certificateURL.path), FileManager.default.fileExists(atPath: keyURL.path) else { return nil }
return CertificateAuthority(certPEM: try String(contentsOf: certificateURL, encoding: .utf8), keyPEM: try String(contentsOf: keyURL, encoding: .utf8))
try loadValidKeychainAuthority()
}
private func applyCompleteProtection(to url: URL) {
#if os(iOS)
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path)
#endif
func reset() throws {
try removeLegacyFiles()
try keychain.remove()
RuntimeLogger.info("SHARED", "Certificate.store", "已删除设备 CA,等待重新生成")
}
private func loadValidKeychainAuthority() throws -> CertificateAuthority? {
guard let authority = try keychain.load() else { return nil }
guard validator(authority) else {
RuntimeLogger.warning("SHARED", "Certificate.store", "钥匙串中的 CA 无效,准备回退")
try? keychain.remove()
return nil
}
return authority
}
private func loadLegacyAuthority() throws -> CertificateAuthority? {
guard FileManager.default.fileExists(atPath: certificateURL.path),
FileManager.default.fileExists(atPath: keyURL.path) else {
return nil
}
return CertificateAuthority(
certPEM: try String(contentsOf: certificateURL, encoding: .utf8),
keyPEM: try String(contentsOf: keyURL, encoding: .utf8)
)
}
private func removeLegacyFilesBestEffort() {
do {
try removeLegacyFiles()
} catch {
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
}
}
private func removeLegacyFiles() throws {
let fileManager = FileManager.default
for url in [keyURL, certificateURL] where fileManager.fileExists(atPath: url.path) {
try fileManager.removeItem(at: url)
}
if fileManager.fileExists(atPath: directory.path) {
try fileManager.removeItem(at: directory)
}
}
}
+13 -2
View File
@@ -5,8 +5,9 @@ final class CertificateTrustVerifier {
/// Check whether a CA certificate (given as PEM data) is installed and fully trusted
/// by the system. Uses SecTrust evaluation against system anchors only no network needed.
static func isCACertificateTrusted(certPEM: String) -> Bool {
guard let certData = certPEM.data(using: .utf8),
let cert = SecCertificateCreateWithData(nil, certData as CFData) else {
guard let pemData = certPEM.data(using: .utf8),
let block = pemData.pemCertificateBlock,
let cert = SecCertificateCreateWithData(nil, block as CFData) else {
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
return false
}
@@ -38,3 +39,13 @@ final class CertificateTrustVerifier {
return result
}
}
private extension Data {
var pemCertificateBlock: Data? {
guard let text = String(data: self, encoding: .utf8),
let begin = text.range(of: "-----BEGIN CERTIFICATE-----"),
let end = text.range(of: "-----END CERTIFICATE-----") else { return nil }
let body = text[begin.upperBound..<end.lowerBound].components(separatedBy: .whitespacesAndNewlines).joined()
return Data(base64Encoded: body)
}
}
+443 -11
View File
@@ -1,27 +1,387 @@
import Foundation
import CoreLocation
import MapKit
struct CoordinatePair: Codable, Equatable {
static let currentConversionVersion = 1
struct Value: Codable, Equatable {
let latitude: Double
let longitude: Double
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
}
let wgs84: Value
let gcj02: Value
let conversionVersion: Int
init(wgs84: Value, gcj02: Value, conversionVersion: Int = CoordinatePair.currentConversionVersion) {
self.wgs84 = wgs84
self.gcj02 = gcj02
self.conversionVersion = conversionVersion
}
init(mapCoordinate: CLLocationCoordinate2D, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) {
self = CoordinateConverter.coordinatePair(
lat: mapCoordinate.latitude,
lon: mapCoordinate.longitude,
mapCoordinateSystem: mapCoordinateSystem
)
}
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
switch mapCoordinateSystem {
case .wgs84: return wgs84.coordinate
case .gcj02: return gcj02.coordinate
}
}
func matchesWGS84(latitude: Double, longitude: Double, tolerance: Double = 0.0001) -> Bool {
abs(wgs84.latitude - latitude) <= tolerance
&& abs(wgs84.longitude - longitude) <= tolerance
}
}
/// GCJ-02 () WGS-84
///
/// MKMapView 使 (AutoNavi) GCJ-02
/// MKMapView `centerCoordinate``convert(point:toCoordinateFrom:)`
/// GCJ-02 CoreLocation / CLLocationManager
/// Apple wloc 使 WGS-84
///
///
/// - UI GCJ-02
/// - wloc WGS-84
///
/// UI GCJ-02 WGS-84
/// MapKit does not expose a public API for its active coordinate reference system.
/// The app resolves it with a bounded heuristic, then stores both WGS-84 and
/// GCJ-02 representations at each write boundary so replay does not convert again.
enum CoordinateConverter {
// (Krasovsky 1940)
private static let a = 6378245.0
private static let ee = 0.00669342162296594323
///
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: -
struct MapCoordinateSystemChange: Equatable {
let previous: MapCoordinateSystem
let current: MapCoordinateSystem
}
enum RuntimeMapCoordinateSystemRefreshResult: Equatable {
case unchanged(MapCoordinateSystem)
case changed(MapCoordinateSystemChange)
case unavailable(reason: String)
case cancelled
}
/// Apple 使 GCJ-02
@MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02
@MainActor private static var mapCoordinateSystemCheckPending = false
@MainActor private(set) static var initialMapCoordinateSystemUsedFallback = true
/// Startup gate: only request realtime location if the public MapKit probe
/// cannot resolve a coordinate type. This guarantees a finite answer before
/// persisted map positions are replayed.
@MainActor
static func resolveInitialMapCoordinateSystem() async -> MapCoordinateSystem {
guard !mapCoordinateSystemCheckPending else {
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测已有请求进行中")
return currentMapCoordinateSystem
}
mapCoordinateSystemCheckPending = true
defer { mapCoordinateSystemCheckPending = false }
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测开始", details: [
"锚点": "22.283819,114.158439",
"判定规则": "首条名称=林士街→GCJ-02,否则→WGS-84",
"缓存": "false"
])
let nextType: MapCoordinateSystem
switch await fixedAnchorCoordinateSystemProbe() {
case let .response(name, count):
nextType = mapCoordinateSystem(forFixedAnchorFirstResultName: name)
initialMapCoordinateSystemUsedFallback = false
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [
"首条名称": name,
"结果数": String(count),
"命中林士街": String(name == "林士街"),
"最终标准": nextType.rawValue,
"结果来源": "固定锚点"
])
case .unavailable(let reason), .timedOut(let reason):
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测不可用,开始实时定位兜底", details: [
"原因": reason
])
let realtime = await RealtimeLocationManager.shared.requestLocation()
if let realtime,
CLLocationCoordinate2DIsValid(realtime),
!usesGCJ02ServiceArea(lat: realtime.latitude, lon: realtime.longitude) {
nextType = .wgs84
} else {
nextType = .gcj02
}
initialMapCoordinateSystemUsedFallback = true
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测使用兜底结果", details: [
"探测失败原因": reason,
"实时定位存在": String(realtime != nil),
"最终标准": nextType.rawValue,
"结果来源": realtime == nil ? "默认国内标准" : "实时定位服务区域"
])
case .cancelled:
initialMapCoordinateSystemUsedFallback = true
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测被取消,保留默认国内标准", details: [
"最终标准": currentMapCoordinateSystem.rawValue
])
return currentMapCoordinateSystem
}
currentMapCoordinateSystem = nextType
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准已确定,允许创建地图", details: [
"最终标准": nextType.rawValue,
"使用兜底": String(initialMapCoordinateSystemUsedFallback),
"缓存": "false"
])
return nextType
}
/// Re-runs the fixed-anchor MapKit behavior probe while the map is alive.
/// Runtime failures preserve the last confirmed type: a potentially spoofed
/// Core Location sample is not authoritative for MapKit's representation.
@MainActor
static func refreshRuntimeMapCoordinateSystem(reason: String) async -> RuntimeMapCoordinateSystemRefreshResult {
guard !mapCoordinateSystemCheckPending else {
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测合并到进行中请求", details: [
"触发原因": reason,
"当前标准": currentMapCoordinateSystem.rawValue
])
return .unchanged(currentMapCoordinateSystem)
}
mapCoordinateSystemCheckPending = true
defer { mapCoordinateSystemCheckPending = false }
let previous = currentMapCoordinateSystem
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测开始", details: [
"触发原因": reason,
"检测前标准": previous.rawValue,
"锚点": "22.283819,114.158439",
"缓存": "false"
])
switch await fixedAnchorCoordinateSystemProbe() {
case let .response(name, count):
guard !Task.isCancelled else {
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测结果已过期,取消写入", details: [
"触发原因": reason,
"保留标准": previous.rawValue
])
return .cancelled
}
let detected = mapCoordinateSystem(forFixedAnchorFirstResultName: name)
initialMapCoordinateSystemUsedFallback = false
guard detected != previous else {
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测完成,标准未变化", details: [
"触发原因": reason,
"首条名称": name,
"结果数": String(count),
"确认标准": detected.rawValue
])
return .unchanged(detected)
}
let change = MapCoordinateSystemChange(previous: previous, current: detected)
currentMapCoordinateSystem = detected
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测发现切换", details: [
"触发原因": reason,
"首条名称": name,
"结果数": String(count),
"from": previous.rawValue,
"to": detected.rawValue
])
return .changed(change)
case .unavailable(let failureReason), .timedOut(let failureReason):
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测失败,保留当前标准", details: [
"触发原因": reason,
"原因": failureReason,
"保留标准": previous.rawValue
])
return .unavailable(reason: failureReason)
case .cancelled:
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测已取消", details: [
"触发原因": reason,
"保留标准": previous.rawValue
])
return .cancelled
}
}
static func mapCoordinateSystem(forFixedAnchorFirstResultName name: String) -> MapCoordinateSystem {
name == "林士街" ? .gcj02 : .wgs84
}
/// A user-requested realtime sample is WGS-84 and can correct a provisional
/// startup map coordinate system without altering persisted coordinate pairs.
@MainActor
static func correctMapCoordinateSystemUsingRealtime(_ coordinate: CLLocationCoordinate2D) -> MapCoordinateSystemChange? {
guard CLLocationCoordinate2DIsValid(coordinate) else { return nil }
guard initialMapCoordinateSystemUsedFallback else {
RuntimeLogger.info("APP", "坐标转换", "实时定位不覆盖固定锚点的明确检测结果", details: [
"当前标准": currentMapCoordinateSystem.rawValue
])
return nil
}
let next: MapCoordinateSystem = usesGCJ02ServiceArea(lat: coordinate.latitude, lon: coordinate.longitude) ? .gcj02 : .wgs84
guard next != currentMapCoordinateSystem else {
RuntimeLogger.info("APP", "坐标转换", "实时定位确认兜底地图坐标标准无需修正", details: [
"当前标准": currentMapCoordinateSystem.rawValue
])
return nil
}
let change = MapCoordinateSystemChange(previous: currentMapCoordinateSystem, current: next)
currentMapCoordinateSystem = next
RuntimeLogger.warning("APP", "坐标转换", "实时定位修正启动兜底地图坐标标准", details: [
"from": change.previous.rawValue,
"to": change.current.rawValue
])
return change
}
private static func fixedAnchorCoordinateSystemProbe() async -> MapCoordinateSystemProbeResult {
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = "22.283819, 114.158439"
let search = MKLocalSearch(request: request)
let resolver = MapCoordinateSystemProbeResolver()
return await withTaskCancellationHandler(operation: {
await withCheckedContinuation { continuation in
let timeout = DispatchWorkItem {
search.cancel()
resolver.resolve(.timedOut(reason: "固定锚点查询超过5秒"))
}
resolver.install(continuation, timeout: timeout)
guard !resolver.isResolved else { return }
search.start { response, error in
if let error {
let nsError = error as NSError
resolver.resolve(.unavailable(
reason: "\(nsError.domain)(\(nsError.code)): \(nsError.localizedDescription)"
))
return
}
let items = response?.mapItems ?? []
guard let first = items.first,
let name = first.name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty else {
resolver.resolve(.unavailable(reason: "固定锚点查询返回空结果"))
return
}
resolver.resolve(.response(name: name, count: items.count))
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout)
}
}, onCancel: {
search.cancel()
resolver.resolve(.cancelled)
})
}
/// Creates the complete persisted pair once at the map input boundary.
static func coordinatePair(lat: Double, lon: Double, mapCoordinateSystem: MapCoordinateSystem) -> CoordinatePair {
let raw = CoordinatePair.Value(latitude: lat, longitude: lon)
switch mapCoordinateSystem {
case .wgs84:
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
return CoordinatePair(
wgs84: raw,
gcj02: .init(latitude: gcj.lat, longitude: gcj.lon)
)
case .gcj02:
let wgs = gcj02ToWgs84(lat: lat, lon: lon)
return CoordinatePair(
wgs84: .init(latitude: wgs.lat, longitude: wgs.lon),
gcj02: raw
)
}
}
/// Legacy raw domestic map values were historically displayed as GCJ-02;
/// overseas values were WGS-84 and stay identity coordinates.
static func legacyCoordinatePair(lat: Double, lon: Double) -> CoordinatePair {
let type: MapCoordinateSystem = usesGCJ02ServiceArea(lat: lat, lon: lon) ? .gcj02 : .wgs84
return coordinatePair(lat: lat, lon: lon, mapCoordinateSystem: type)
}
// MARK: -
/// Haversine
static func distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
let r = 6371000.0
let dLat = (lat2 - lat1) * .pi / 180.0
let dLon = (lon2 - lon1) * .pi / 180.0
let a = sin(dLat / 2) * sin(dLat / 2)
+ cos(lat1 * .pi / 180.0) * cos(lat2 * .pi / 180.0)
* sin(dLon / 2) * sin(dLon / 2)
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
static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
var wgsLat = lat
var wgsLon = lon
//
for _ in 0..<2 {
let d = delta(lat: wgsLat, lon: wgsLon)
wgsLat = lat - d.lat
@@ -30,6 +390,25 @@ enum CoordinateConverter {
return (wgsLat, wgsLon)
}
/// WGS-84 GCJ-02
static func wgs84ToGcj02(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
let d = delta(lat: lat, lon: lon)
return (lat + d.lat, lon + d.lon)
}
/// Keep the GCJ-02 service region explicit. Outside this region, conversion
/// is identity so the domestic fallback can never shift an overseas value.
static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool {
let mainland = lat >= 0.8293 && lat <= 55.8271 && lon >= 72.004 && lon <= 137.8347
let hongKong = lat >= 22.13 && lat <= 22.57 && lon >= 113.82 && lon <= 114.45
let macao = lat >= 22.05 && lat <= 22.25 && lon >= 113.52 && lon <= 113.65
let taiwan = lat >= 21.75 && lat <= 25.35 && lon >= 119.30 && lon <= 122.10
return mainland || hongKong || macao || taiwan
}
// MARK: -
/// (WGS-84 GCJ-02 )
private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
let dLat = transformLat(x: lon - 105.0, y: lat - 35.0)
@@ -60,3 +439,56 @@ enum CoordinateConverter {
return ret
}
}
private enum MapCoordinateSystemProbeResult {
case response(name: String, count: Int)
case unavailable(reason: String)
case timedOut(reason: String)
case cancelled
}
private final class MapCoordinateSystemProbeResolver: @unchecked Sendable {
private let lock = NSLock()
private var result: MapCoordinateSystemProbeResult?
private var continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>?
private var timeout: DispatchWorkItem?
var isResolved: Bool {
lock.lock()
defer { lock.unlock() }
return result != nil
}
func install(
_ continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>,
timeout: DispatchWorkItem
) {
lock.lock()
if let result {
lock.unlock()
timeout.cancel()
continuation.resume(returning: result)
return
}
self.continuation = continuation
self.timeout = timeout
lock.unlock()
}
func resolve(_ nextResult: MapCoordinateSystemProbeResult) {
lock.lock()
guard result == nil else {
lock.unlock()
return
}
result = nextResult
let continuation = continuation
let timeout = timeout
self.continuation = nil
self.timeout = nil
lock.unlock()
timeout?.cancel()
continuation?.resume(returning: nextResult)
}
}
+8 -15
View File
@@ -19,6 +19,14 @@ enum CoreBridgeError: LocalizedError {
}
enum CoreBridge {
static func isValidCertificateAuthority(_ authority: CertificateAuthority) -> Bool {
authority.certPEM.withCString { certificate in
authority.keyPEM.withCString { key in
wloccore_validateca(UnsafeMutablePointer(mutating: certificate), UnsafeMutablePointer(mutating: key)) != 0
}
}
}
static func generateCertificateAuthority() throws -> CertificateAuthority {
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
let result = wloccore_generateca()
@@ -41,21 +49,6 @@ enum CoreBridge {
return String(cString: ptr)
}
/// wloc WiFi AP +
static func testWlocRequestData() -> Data {
guard let ptr = wloccore_testrequesthex() else { return Data([0x0a, 0x02, 0x08, 0x01]) }
defer { free(ptr) }
let hex = String(cString: ptr)
var data = Data()
var idx = hex.startIndex
while idx < hex.endIndex {
let end = hex.index(idx, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
if let b = UInt8(hex[idx..<end], radix: 16) { data.append(b) }
idx = end
}
return data
}
static func flushLogs(category: String) {
guard let pointer = wloccore_drainlogs() else { return }
defer { free(pointer) }
+130 -15
View File
@@ -1,21 +1,79 @@
import CoreLocation
import Foundation
struct FavoriteLocation: Codable, Identifiable, Equatable {
let id: UUID
var name: String
var latitude: Double
var longitude: Double
var coordinatePair: CoordinatePair
var accuracy: Int
var createdAt: Date
private var wasDecodedFromLegacyCoordinates = false
init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double, accuracy: Int, createdAt: Date = Date()) {
/// WGS-84 compatibility accessor. WLOC consumers must use this value.
var latitude: Double { coordinatePair.wgs84.latitude }
var longitude: Double { coordinatePair.wgs84.longitude }
var isLegacyCoordinateRecord: Bool { wasDecodedFromLegacyCoordinates }
init(
id: UUID = UUID(),
name: String,
coordinatePair: CoordinatePair,
accuracy: Int,
createdAt: Date = Date()
) {
self.id = id
self.name = name
self.latitude = latitude
self.longitude = longitude
self.coordinatePair = coordinatePair
self.accuracy = accuracy
self.createdAt = createdAt
}
init(
id: UUID = UUID(),
name: String,
latitude: Double,
longitude: Double,
accuracy: Int,
createdAt: Date = Date(),
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02
) {
self.init(
id: id,
name: name,
coordinatePair: CoordinateConverter.coordinatePair(lat: latitude, lon: longitude, mapCoordinateSystem: mapCoordinateSystem),
accuracy: accuracy,
createdAt: createdAt
)
}
private enum CodingKeys: String, CodingKey {
case id, name, coordinatePair, accuracy, createdAt, latitude, longitude
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
accuracy = try container.decode(Int.self, forKey: .accuracy)
createdAt = try container.decode(Date.self, forKey: .createdAt)
if let pair = try container.decodeIfPresent(CoordinatePair.self, forKey: .coordinatePair) {
coordinatePair = pair
} else {
let latitude = try container.decode(Double.self, forKey: .latitude)
let longitude = try container.decode(Double.self, forKey: .longitude)
coordinatePair = CoordinateConverter.legacyCoordinatePair(lat: latitude, lon: longitude)
wasDecodedFromLegacyCoordinates = true
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(coordinatePair, forKey: .coordinatePair)
try container.encode(accuracy, forKey: .accuracy)
try container.encode(createdAt, forKey: .createdAt)
}
}
struct MapConfiguration: Equatable {
@@ -52,27 +110,72 @@ final class FavoriteLocationStore: ObservableObject {
}
@discardableResult
func save(name: String, latitude: Double, longitude: Double, accuracy: Int) -> FavoriteLocation {
let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy)
//
func save(
name: String,
mapCoordinate: CLLocationCoordinate2D,
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
accuracy: Int
) -> FavoriteLocation {
save(
FavoriteLocation(
name: name,
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
accuracy: accuracy
)
)
}
/// Saves a coordinate pair whose source representation was already typed
/// before an asynchronous map-coordinate-system refresh.
@discardableResult
func save(name: String, coordinatePair: CoordinatePair, accuracy: Int) -> FavoriteLocation {
save(FavoriteLocation(name: name, coordinatePair: coordinatePair, accuracy: accuracy))
}
@discardableResult
private func save(_ favorite: FavoriteLocation) -> FavoriteLocation {
favorites.removeAll {
abs($0.latitude - favorite.latitude) < 0.000001 && abs($0.longitude - favorite.longitude) < 0.000001
abs($0.coordinatePair.wgs84.latitude - favorite.coordinatePair.wgs84.latitude) < 0.000001
&& abs($0.coordinatePair.wgs84.longitude - favorite.coordinatePair.wgs84.longitude) < 0.000001
}
favorites.insert(favorite, at: 0)
select(favorite.id)
persist()
persistIgnoringFailure()
return favorite
}
/// Compatibility entry point for callers that already own raw map values.
@discardableResult
func save(name: String, latitude: Double, longitude: Double, accuracy: Int, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02) -> FavoriteLocation {
save(
name: name,
mapCoordinate: .init(latitude: latitude, longitude: longitude),
mapCoordinateSystem: mapCoordinateSystem,
accuracy: accuracy
)
}
func select(_ id: UUID?) {
selectedFavoriteID = id
defaults.set(id?.uuidString, forKey: Keys.selectedID)
}
@discardableResult
func selectMatching(coordinatePair: CoordinatePair) -> FavoriteLocation? {
let matchingFavorite = favorites.first {
$0.coordinatePair.matchesWGS84(
latitude: coordinatePair.wgs84.latitude,
longitude: coordinatePair.wgs84.longitude
)
}
select(matchingFavorite?.id)
return matchingFavorite
}
func rename(_ id: UUID, to name: String) {
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
favorites[idx].name = name
persist()
persistIgnoringFailure()
}
func delete(_ favorite: FavoriteLocation) {
@@ -80,11 +183,23 @@ final class FavoriteLocationStore: ObservableObject {
if selectedFavoriteID == favorite.id {
select(favorites.first?.id)
}
persist()
persistIgnoringFailure()
}
private func persist() {
guard let data = try? JSONEncoder().encode(favorites) else { return }
defaults.set(data, forKey: Keys.favorites)
func migrateLegacyCoordinates() throws {
guard favorites.contains(where: \.isLegacyCoordinateRecord) else { return }
try persist()
}
private func persistIgnoringFailure() {
do {
try persist()
} catch {
RuntimeLogger.error("APP", "收藏", "保存收藏失败", error: error)
}
}
private func persist() throws {
defaults.set(try JSONEncoder().encode(favorites), forKey: Keys.favorites)
}
}
+41
View File
@@ -0,0 +1,41 @@
import Foundation
enum GitHubSubmission {
static let communityContributionURL = URL(
string: "https://github.com/xweiba/location-spoofer/discussions/new?category=%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB"
)!
static let usageHelpURL = URL(
string: "https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9"
)!
static let featureRequestURL = URL(
string: "https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE"
)!
static let bugReportURL = URL(
string: "https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml"
)!
static func communityContributionTemplate(
for client: ThirdPartyProxyClient,
systemVersion: String
) -> String {
"""
## 第三方客户端
\(client.name)
## 第三方客户端版本
请填写当前使用的第三方客户端版本。
## iOS 版本
iOS \(systemVersion)
## 配置步骤
请描述模块导入、证书或解密设置、代理连接和最终验证过程。
## 截图与补充说明
请附上脱敏原图,并说明每张图片对应的步骤。
## README 收录署名
- [ ] 匿名收录,不在 README 展示投稿账号
"""
}
}
+89 -3
View File
@@ -1,5 +1,12 @@
import Network
import Foundation
import SystemConfiguration.CaptiveNetwork
enum WiFiChangeReason: String {
case reconnected = "Wi-Fi 恢复连接"
case interfaceChanged = "网络接口切换到 Wi-Fi"
case ssidChanged = "SSID 发生变化"
}
@MainActor
final class NetworkMonitor: ObservableObject {
@@ -7,20 +14,99 @@ final class NetworkMonitor: ObservableObject {
@Published private(set) var isSatisfied = true
@Published private(set) var isWiFiEnabled = true
@Published private(set) var currentSSID: String?
/// Wi-Fi SSID
private var wifiChangeHandlers: [UUID: @MainActor (WiFiChangeReason) -> Void] = [:]
private let monitor = NWPathMonitor()
private var ssidTimer: Timer?
private var wasSatisfied = true
private var wasWiFiEnabled = true
private var hasReceivedInitialPath = false
private var lastKnownSSID: String?
private init() {
let initialSSID = Self.fetchSSID()
currentSSID = initialSSID
lastKnownSSID = initialSSID
monitor.pathUpdateHandler = { [weak self] path in
let satisfied = path.status == .satisfied
let wifi = path.usesInterfaceType(.wifi)
Task { @MainActor in
self?.isSatisfied = satisfied
self?.isWiFiEnabled = wifi
guard let self else { return }
let reason: WiFiChangeReason?
if !self.hasReceivedInitialPath {
// NWPathMonitor 线
self.hasReceivedInitialPath = true
reason = nil
} else if satisfied && wifi && !self.wasSatisfied {
reason = .reconnected
} else if satisfied && wifi && !self.wasWiFiEnabled {
// Wi-Fi satisfied status
reason = .interfaceChanged
} else {
reason = nil
}
self.wasSatisfied = satisfied
self.wasWiFiEnabled = wifi
self.isSatisfied = satisfied
self.isWiFiEnabled = wifi
if let reason {
self.notifyWiFiChanged(reason: reason)
}
}
}
monitor.start(queue: .main)
startSSIDPolling()
}
var isAirplaneMode: Bool { !isSatisfied }
/// Registers a Wi-Fi-change observer and returns a token that must be removed.
@discardableResult
func observeWiFiChanges(_ handler: @escaping @MainActor (WiFiChangeReason) -> Void) -> UUID {
let token = UUID()
wifiChangeHandlers[token] = handler
return token
}
func removeWiFiChangeObserver(_ token: UUID) {
wifiChangeHandlers.removeValue(forKey: token)
}
private func notifyWiFiChanged(reason: WiFiChangeReason) {
for handler in wifiChangeHandlers.values {
handler(reason)
}
}
private func startSSIDPolling() {
ssidTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
let ssid = Self.fetchSSID()
self.currentSSID = ssid
guard let ssid else { return }
guard let previousSSID = self.lastKnownSSID else {
// SSID 线 Wi-Fi
self.lastKnownSSID = ssid
return
}
if ssid != previousSSID {
self.lastKnownSSID = ssid
self.notifyWiFiChanged(reason: .ssidChanged)
}
}
}
}
static func fetchSSID() -> String? {
guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil }
for iface in interfaces {
if let info = CNCopyCurrentNetworkInfo(iface as CFString) as? [String: Any],
let ssid = info[kCNNetworkInfoKeySSID as String] as? String {
return ssid
}
}
return nil
}
}
+104
View File
@@ -0,0 +1,104 @@
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"
static let localWiFiInitialized = "localWiFiRuntimeModeInitialized"
static let thirdPartyInitialized = "thirdPartyRuntimeModeInitialized"
static let initializationMigrationCompleted = "runtimeModeInitializationMigrationCompleted"
static let legacySetupCompleted = "setupCompleted"
}
@Published private(set) var mode: ProxyRuntimeMode
@Published private(set) var hasSelectedMode: Bool
@Published private(set) var localWiFiInitialized: Bool
@Published private(set) var thirdPartyInitialized: Bool
private let defaults: UserDefaults
private let legacyDefaults: UserDefaults
init(
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) {
self.defaults = defaults
self.legacyDefaults = legacyDefaults
self.mode = defaults.string(forKey: Key.runtimeMode)
.flatMap(ProxyRuntimeMode.init(rawValue:)) ?? .localWiFi
self.hasSelectedMode = defaults.bool(forKey: Key.hasSelectedRuntimeMode)
self.localWiFiInitialized = defaults.bool(forKey: Key.localWiFiInitialized)
self.thirdPartyInitialized = defaults.bool(forKey: Key.thirdPartyInitialized)
migrateLegacyInitializationIfNeeded()
}
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)
migrateLegacyInitializationIfNeeded()
if changed {
RuntimeLogger.info("APP", "Mode", "代理运行模式已切换", details: [
"模式": mode.displayName
])
}
}
func isInitialized(_ mode: ProxyRuntimeMode) -> Bool {
switch mode {
case .localWiFi: return localWiFiInitialized
case .thirdParty: return thirdPartyInitialized
}
}
func markInitialized(_ mode: ProxyRuntimeMode) {
setInitialized(true, for: mode)
}
func resetInitialization(_ mode: ProxyRuntimeMode) {
setInitialized(false, for: mode)
}
private func setInitialized(_ initialized: Bool, for mode: ProxyRuntimeMode) {
switch mode {
case .localWiFi:
localWiFiInitialized = initialized
defaults.set(initialized, forKey: Key.localWiFiInitialized)
case .thirdParty:
thirdPartyInitialized = initialized
defaults.set(initialized, forKey: Key.thirdPartyInitialized)
}
}
private func migrateLegacyInitializationIfNeeded() {
guard hasSelectedMode,
!defaults.bool(forKey: Key.initializationMigrationCompleted) else {
return
}
if legacyDefaults.bool(forKey: Key.legacySetupCompleted) {
setInitialized(true, for: mode)
RuntimeLogger.info("APP", "Mode", "已迁移旧版模式初始化状态", details: [
"模式": mode.displayName
])
}
defaults.set(true, forKey: Key.initializationMigrationCompleted)
}
}
+126 -4
View File
@@ -1,3 +1,4 @@
import CoreLocation
import Foundation
struct RuntimeLogEntry: Codable, Identifiable, Equatable {
@@ -49,11 +50,17 @@ enum RuntimeLogStore {
private static let decoder = JSONDecoder()
private static let encoder = JSONEncoder()
private static let maximumBytes: UInt64 = 1_500_000
static let retentionInterval: TimeInterval = 3 * 24 * 60 * 60
private static let pruningInterval: TimeInterval = 60 * 60
private static var lastPrunedAt: Date?
static func append(_ entry: RuntimeLogEntry) {
lock.lock()
defer { lock.unlock() }
do {
let now = Date()
try pruneExpiredLogsIfNeeded(now: now)
guard entry.timestamp >= retentionCutoff(now: now) else { return }
let url = try logURL(for: entry.source)
try rotateIfNeeded(url)
var data = try encoder.encode(entry)
@@ -75,7 +82,8 @@ enum RuntimeLogStore {
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
lock.lock()
defer { lock.unlock() }
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
let directory = logDirectory
try? pruneExpiredLogs(in: directory, now: Date())
guard let urls = try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
@@ -90,12 +98,12 @@ enum RuntimeLogStore {
static func clearAll() {
lock.lock()
defer { lock.unlock() }
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
try? FileManager.default.removeItem(at: directory)
try? FileManager.default.removeItem(at: logDirectory)
lastPrunedAt = nil
}
private static func logURL(for source: String) throws -> URL {
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
let directory = logDirectory
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
.replacingOccurrences(of: "/", with: "-")
@@ -103,6 +111,56 @@ enum RuntimeLogStore {
return directory.appendingPathComponent("\(process)-\(safeSource).jsonl")
}
private static var logDirectory: URL {
AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
}
static func retentionCutoff(now: Date) -> Date {
now.addingTimeInterval(-retentionInterval)
}
private static func pruneExpiredLogsIfNeeded(now: Date) throws {
if let lastPrunedAt,
now >= lastPrunedAt,
now.timeIntervalSince(lastPrunedAt) < pruningInterval {
return
}
try pruneExpiredLogs(in: logDirectory, now: now)
}
private static func pruneExpiredLogs(in directory: URL, now: Date) throws {
guard FileManager.default.fileExists(atPath: directory.path) else {
lastPrunedAt = now
return
}
let urls = try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
).filter { $0.pathExtension == "jsonl" }
for url in urls {
let entries = readEntries(url)
let retained = retainedEntries(entries, now: now)
guard retained.count != entries.count else { continue }
guard !retained.isEmpty else {
try FileManager.default.removeItem(at: url)
continue
}
var data = Data()
for entry in retained {
data.append(try encoder.encode(entry))
data.append(0x0A)
}
try data.write(to: url, options: .atomic)
}
lastPrunedAt = now
}
static func retainedEntries(_ entries: [RuntimeLogEntry], now: Date) -> [RuntimeLogEntry] {
let cutoff = retentionCutoff(now: now)
return entries.filter { $0.timestamp >= cutoff }
}
private static func rotateIfNeeded(_ url: URL) throws {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attributes[.size] as? NSNumber,
@@ -162,3 +220,67 @@ enum RuntimeLogger {
))
}
}
/// Realtime-location diagnostics keep precise coordinates out of the persisted,
/// exportable log. Exact values are printed only by DEBUG builds for local Xcode
/// debugging.
enum RealtimeLocationTrace {
static func log(
_ message: String,
location: CLLocation,
details: [String: String] = [:],
level: RuntimeLogEntry.Level = .info
) {
var metadata = details
metadata["样本时间"] = ISO8601DateFormatter().string(from: location.timestamp)
metadata["样本年龄秒"] = format(Date().timeIntervalSince(location.timestamp))
metadata["水平精度米"] = format(location.horizontalAccuracy)
metadata["垂直精度米"] = format(location.verticalAccuracy)
metadata["海拔米"] = format(location.altitude)
metadata["坐标有效"] = String(CLLocationCoordinate2DIsValid(location.coordinate))
persist(level, message: message, details: metadata)
debugCoordinate(message, location: location, details: metadata)
}
static func coordinate(
_ message: String,
coordinate: CLLocationCoordinate2D,
details: [String: String] = [:]
) {
#if DEBUG
let suffix = details.sorted { $0.key < $1.key }
.map { "\($0.key)=\($0.value)" }
.joined(separator: " ")
let latitude = String(format: "%.8f", coordinate.latitude)
let longitude = String(format: "%.8f", coordinate.longitude)
let metadata = suffix.isEmpty ? "" : " \(suffix)"
print("[RealtimeLocation] \(message) latitude=\(latitude) longitude=\(longitude)\(metadata)")
#endif
}
private static func persist(
_ level: RuntimeLogEntry.Level,
message: String,
details: [String: String]
) {
switch level {
case .debug: RuntimeLogger.debug("APP", "实时定位", message, details: details)
case .info: RuntimeLogger.info("APP", "实时定位", message, details: details)
case .warning: RuntimeLogger.warning("APP", "实时定位", message, details: details)
case .error: RuntimeLogger.error("APP", "实时定位", message, details: details)
}
}
private static func debugCoordinate(
_ message: String,
location: CLLocation,
details: [String: String]
) {
coordinate(message, coordinate: location.coordinate, details: details)
}
private static func format(_ value: Double) -> String {
guard value.isFinite else { return String(value) }
return String(format: "%.3f", value)
}
}
+402
View File
@@ -0,0 +1,402 @@
import Foundation
struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
let success: Bool
let longitude: Double?
let latitude: Double?
let accuracy: Int?
let error: String?
let motionSimulationEnabled: Bool?
}
struct ThirdPartyProxyVersionResponse: Decodable, Equatable {
let success: Bool
let moduleVersion: String
let protocolVersion: Int
let capabilities: Set<String>
static let requiredCapabilities: Set<String> = [
"wifi", "cellTower", "arpc", "marker", "synthetic", "bare", "motionSimulation"
]
var isCompatible: Bool {
success && protocolVersion >= 1 && capabilities.isSuperset(of: Self.requiredCapabilities)
}
}
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)
case moduleOutdated
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)"
case .moduleOutdated:
return "模块版本过低,请重新导入模块"
}
}
var recoverySuggestion: String {
switch self {
case .moduleOutdated:
return "删除旧模块后,重新复制并导入最新模块"
default:
return "检查模块、MITM、证书和代理/VPN连接"
}
}
static func recoverySuggestion(for error: Error) -> String {
(error as? Self)?.recoverySuggestion
?? "检查模块、MITM、证书和代理/VPN连接"
}
}
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 interceptionHostnames = [
"gs-loc.apple.com",
"gs-loc-cn.apple.com"
]
static let interceptionHostnamesText = interceptionHostnames.joined(separator: ", ")
static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
static let versionEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/version")!
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
@Published private(set) var moduleUpdateRecommended = false
@Published private(set) var isRequesting = false
private let requester: any ThirdPartyProxyRequesting
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)
let active = try validatedQueryState(response)
if active {
activeSettings = response
connectionState = .connected(active: true)
} else {
activeSettings = nil
connectionState = .connected(active: false)
}
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,
motionEnabled: MotionSimulationStore.shared.isEnabled
))
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 updateMotionSimulation(_ enabled: Bool) async throws -> ThirdPartyProxySettingsResponse {
guard let current = activeSettings,
let latitude = current.latitude,
let longitude = current.longitude else {
throw ThirdPartyProxyError.rejected("第三方虚拟定位尚未开启")
}
guard await refreshAdvancedFeatureAvailability() else {
throw ThirdPartyProxyError.moduleOutdated
}
let response = try await perform(action: .save(
latitude: latitude,
longitude: longitude,
accuracy: current.accuracy ?? 25,
motionEnabled: enabled
))
guard response.success else {
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝更新运动状态")
}
activeSettings = response
return response
}
func validateConnection() async throws -> ThirdPartyProxySettingsResponse {
try await query()
}
func validateVersion() async throws -> ThirdPartyProxyVersionResponse {
guard !isRequesting else {
throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行")
}
isRequesting = true
defer { isRequesting = false }
var request = URLRequest(url: Self.versionEndpoint)
request.httpMethod = "GET"
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
request.timeoutInterval = 8
do {
let (data, response) = try await requester.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
let version = try? JSONDecoder().decode(ThirdPartyProxyVersionResponse.self, from: data),
version.isCompatible else {
throw ThirdPartyProxyError.moduleOutdated
}
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方模块版本检测通过", details: [
"模块版本": version.moduleVersion,
"协议版本": String(version.protocolVersion),
"能力": version.capabilities.sorted().joined(separator: ",")
])
return version
} catch let error as ThirdPartyProxyError {
throw error
} catch {
throw ThirdPartyProxyError.network(error.localizedDescription)
}
}
@discardableResult
func refreshAdvancedFeatureAvailability() async -> Bool {
do {
_ = try await validateVersion()
moduleUpdateRecommended = false
return true
} catch {
moduleUpdateRecommended = true
RuntimeLogger.warning(
"APP",
"ThirdPartyProxy",
"第三方模块不支持高级功能",
details: [
"版本检测": error.localizedDescription,
"处理建议": "基础坐标功能可继续使用;更新模块后可使用运动状态模拟"
]
)
return false
}
}
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 func validatedQueryState(_ response: ThirdPartyProxySettingsResponse) throws -> Bool {
if response.success,
response.latitude != nil,
response.longitude != nil {
return true
}
if response.error?.contains("无已保存") == true {
return false
}
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理查询失败")
}
private enum Action {
case query
case save(latitude: Double, longitude: Double, accuracy: Int, motionEnabled: Bool)
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: Self.configurationEndpoint, 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, let motionEnabled):
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)),
URLQueryItem(
name: "motion",
value: motionEnabled ? "1" : "0"
)
]
}
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 {
static let moduleSubscriptionVersion = "1.0.0"
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 ? nil : "配置已提供,尚未验证"
}
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"
}
}
@MainActor
var subscriptionURL: URL {
let url: String
let directory = ThirdPartyModuleSourceStore.shared.useMirror
? "Resources/ThirdPartyProxyModules"
: "ThirdParty/WlocScripts/modules/direct"
let prefix = ThirdPartyModuleSourceStore.shared.useMirror
? "https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/"
: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/"
switch self {
case .surge, .egern:
url = "\(prefix)\(directory)/wloc.sgmodule"
case .quantumultX:
url = "\(prefix)\(directory)/wloc.conf"
case .loon:
url = "\(prefix)\(directory)/wloc.lpx"
case .stash:
url = "\(prefix)\(directory)/wloc.stoverride"
case .shadowrocket:
url = "\(prefix)\(directory)/wloc.module"
}
return URL(string: "\(url)?v=\(Self.moduleSubscriptionVersion)")!
}
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)
}
}
+1 -11
View File
@@ -1,6 +1,6 @@
import Foundation
/// UI
/// SetupCoordinator
enum VerificationResult: Equatable, Identifiable {
case success
case proxyNotRunning
@@ -26,14 +26,4 @@ enum VerificationResult: Equatable, Identifiable {
var isSuccess: Bool { self == .success }
///
var tipKind: TipKind? {
switch self {
case .success: return nil
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
case .certNotTrusted: return nil // tip
case .wifiProxyNotConfigured: return .proxySetup
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
}
}
}
@@ -0,0 +1,84 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class AppRemoteConfigurationTests: XCTestCase {
func testDecodesReadableJSONAndClientPromptSwitches() throws {
let data = """
{
"latestVersion": "1.2.0",
"minimumSupportedVersion": "1.1.0",
"communityPromptClients": ["surge", "loon"]
}
""".data(using: .utf8)!
let configuration = try AppRemoteConfiguration.decode(data)
XCTAssertEqual(configuration.latestVersion, "1.2.0")
XCTAssertTrue(configuration.requestsCommunityPrompt(for: .surge))
XCTAssertTrue(configuration.requestsCommunityPrompt(for: .loon))
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .stash))
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
}
func testVersionPolicyDistinguishesRequiredRecommendedAndCurrent() throws {
let configuration = AppRemoteConfiguration(
latestVersion: "1.2.0",
minimumSupportedVersion: "1.1.0",
communityPromptClients: []
)
XCTAssertEqual(
configuration.updatePrompt(currentVersion: "1.0.9")?.requirement,
.required
)
XCTAssertEqual(
configuration.updatePrompt(currentVersion: "1.1.0")?.requirement,
.recommended
)
XCTAssertNil(configuration.updatePrompt(currentVersion: "1.2.0"))
XCTAssertNil(configuration.updatePrompt(currentVersion: "1.2.0.0"))
}
func testRejectsInvalidVersionsAndUnknownClients() {
let invalidVersion = """
{
"latestVersion": "1.0.0",
"minimumSupportedVersion": "2.0.0",
"communityPromptClients": []
}
""".data(using: .utf8)!
XCTAssertThrowsError(try AppRemoteConfiguration.decode(invalidVersion))
let invalidClient = """
{
"latestVersion": "2.0.0",
"minimumSupportedVersion": "1.0.0",
"communityPromptClients": ["unknown"]
}
""".data(using: .utf8)!
XCTAssertThrowsError(try AppRemoteConfiguration.decode(invalidClient))
}
func testFallbackMatchesCurrentProjectPolicy() {
let configuration = AppRemoteConfiguration.fallback
XCTAssertEqual(configuration.latestVersion, "1.0.5")
XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0")
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
for client in ThirdPartyProxyClient.allCases where client != .shadowrocket {
XCTAssertTrue(configuration.requestsCommunityPrompt(for: client))
}
}
func testUpdateResourcesPreferDomesticMirrorAndRetainOfficialFallback() {
XCTAssertEqual(AppRemoteConfigurationService.configurationURLs.first?.host, "gh-proxy.org")
XCTAssertEqual(
AppRemoteConfigurationService.configurationURLs.last?.host,
"raw.githubusercontent.com"
)
let releaseNotesURLs = AppRemoteConfigurationService.releaseNotesURLs(version: "1.0.5")
XCTAssertEqual(releaseNotesURLs.first?.host, "gh-proxy.org")
XCTAssertEqual(releaseNotesURLs.last?.host, "raw.githubusercontent.com")
}
}
@@ -2,16 +2,164 @@ import XCTest
@testable import PaopaoLocationSpoofer
final class CertificateAuthorityStoreTests: XCTestCase {
func testEnsureCreatesOnceAndThenReusesExistingPair() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
private let validAuthority = CertificateAuthority(certPEM: "valid-cert", keyPEM: "valid-key")
func testEnsureCreatesOnceThenReusesValidKeychainPair() throws {
let keychain = InMemoryCertificateAuthorityKeychain()
var generations = 0
let store = CertificateAuthorityStore(directory: directory) {
let store = makeStore(keychain: keychain) {
generations += 1
return CertificateAuthority(certPEM: "cert", keyPEM: "key")
return self.validAuthority
}
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
XCTAssertEqual(try store.ensure(), validAuthority)
XCTAssertEqual(try store.ensure(), validAuthority)
XCTAssertEqual(keychain.stored, validAuthority)
XCTAssertEqual(generations, 1)
}
func testEnsureMigratesValidLegacyPairThenDeletesLegacyFiles() throws {
let directory = try makeLegacyDirectory(authority: validAuthority)
defer { try? FileManager.default.removeItem(at: directory) }
let keychain = InMemoryCertificateAuthorityKeychain()
let store = makeStore(directory: directory, keychain: keychain) {
XCTFail("A valid legacy pair must be migrated instead of regenerated")
return self.validAuthority
}
XCTAssertEqual(try store.ensure(), validAuthority)
XCTAssertEqual(keychain.stored, validAuthority)
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
}
func testFailedKeychainMigrationPreservesLegacyFiles() throws {
let directory = try makeLegacyDirectory(authority: validAuthority)
defer { try? FileManager.default.removeItem(at: directory) }
let keychain = InMemoryCertificateAuthorityKeychain()
keychain.shouldFailSave = true
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
XCTAssertThrowsError(try store.ensure())
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
}
func testInvalidKeychainPairFallsBackToLegacyPair() throws {
let directory = try makeLegacyDirectory(authority: validAuthority)
defer { try? FileManager.default.removeItem(at: directory) }
let keychain = InMemoryCertificateAuthorityKeychain()
keychain.stored = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
XCTAssertEqual(try store.ensure(), validAuthority)
XCTAssertEqual(keychain.stored, validAuthority)
}
func testValidKeychainPairRetriesCleanupOfLegacyFiles() throws {
let directory = try makeLegacyDirectory(authority: validAuthority)
defer { try? FileManager.default.removeItem(at: directory) }
let keychain = InMemoryCertificateAuthorityKeychain()
keychain.stored = validAuthority
let store = makeStore(directory: directory, keychain: keychain) {
XCTFail("A valid Keychain pair must not be regenerated")
return self.validAuthority
}
XCTAssertEqual(try store.ensure(), validAuthority)
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
}
func testInvalidLegacyFilesAreRemovedOnlyAfterReplacementIsPersisted() throws {
let invalidAuthority = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
let directory = try makeLegacyDirectory(authority: invalidAuthority)
defer { try? FileManager.default.removeItem(at: directory) }
let keychain = InMemoryCertificateAuthorityKeychain()
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
XCTAssertEqual(try store.ensure(), validAuthority)
XCTAssertEqual(keychain.stored, validAuthority)
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
}
func testFailedReplacementPersistencePreservesInvalidLegacyFiles() throws {
let invalidAuthority = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
let directory = try makeLegacyDirectory(authority: invalidAuthority)
defer { try? FileManager.default.removeItem(at: directory) }
let keychain = InMemoryCertificateAuthorityKeychain()
keychain.shouldFailSave = true
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
XCTAssertThrowsError(try store.ensure())
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
}
func testResetRemovesKeychainAndLegacyAuthority() throws {
let directory = try makeLegacyDirectory(authority: validAuthority)
let keychain = InMemoryCertificateAuthorityKeychain()
keychain.stored = validAuthority
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
try store.reset()
XCTAssertNil(keychain.stored)
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path))
}
func testResetFailurePreservesKeychainAuthority() {
let keychain = InMemoryCertificateAuthorityKeychain()
keychain.stored = validAuthority
keychain.shouldFailRemove = true
let store = makeStore(keychain: keychain) { self.validAuthority }
XCTAssertThrowsError(try store.reset())
XCTAssertEqual(keychain.stored, validAuthority)
}
private func makeStore(
directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),
keychain: InMemoryCertificateAuthorityKeychain,
generator: @escaping () throws -> CertificateAuthority
) -> CertificateAuthorityStore {
CertificateAuthorityStore(
directory: directory,
keychain: keychain,
generator: generator,
validator: { $0 == self.validAuthority }
)
}
private func makeLegacyDirectory(authority: CertificateAuthority) throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try authority.certPEM.write(to: directory.appendingPathComponent("ca-cert.pem"), atomically: true, encoding: .utf8)
try authority.keyPEM.write(to: directory.appendingPathComponent("ca-key.pem"), atomically: true, encoding: .utf8)
return directory
}
}
private enum CertificateAuthorityStoreTestError: Error {
case saveFailed
case removeFailed
}
private final class InMemoryCertificateAuthorityKeychain: CertificateAuthorityKeychain {
var stored: CertificateAuthority?
var shouldFailSave = false
var shouldFailRemove = false
func load() throws -> CertificateAuthority? { stored }
func save(_ authority: CertificateAuthority) throws {
guard !shouldFailSave else { throw CertificateAuthorityStoreTestError.saveFailed }
stored = authority
}
func remove() throws {
guard !shouldFailRemove else { throw CertificateAuthorityStoreTestError.removeFailed }
stored = nil
}
}
@@ -2,8 +2,7 @@ import XCTest
@testable import PaopaoLocationSpoofer
final class CertificateTrustVerifierTests: XCTestCase {
func testVerifierMapsFailedProbeToUnavailable() async {
let verifier = CertificateTrustVerifier(probe: { _, _ in false })
XCTAssertEqual(await verifier.verify(url: URL(string: "https://127.0.0.1:1/health")!, leafHash: "x"), .unavailable)
func testVerifierRejectsMalformedPEM() {
XCTAssertFalse(CertificateTrustVerifier.isCACertificateTrusted(certPEM: "not a certificate"))
}
}
@@ -1,4 +1,5 @@
import XCTest
import CoreLocation
@testable import PaopaoLocationSpoofer
final class FavoriteLocationStoreTests: XCTestCase {
@@ -7,14 +8,195 @@ final class FavoriteLocationStoreTests: XCTestCase {
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let store = FavoriteLocationStore(defaults: defaults)
let favorite = store.save(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
let favorite = store.save(
name: "深圳湾",
mapCoordinate: .init(latitude: 22.494, longitude: 113.951),
mapCoordinateSystem: .gcj02,
accuracy: 20
)
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾")
}
func testSelectingMatchingCoordinatePairRestoresFavoriteSelection() {
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let store = FavoriteLocationStore(defaults: defaults)
let favorite = store.save(
name: "深圳湾",
coordinatePair: CoordinateConverter.coordinatePair(
lat: 22.491_438,
lon: 113.945_702,
mapCoordinateSystem: .wgs84
),
accuracy: 20
)
store.select(nil)
let nearbyRealtimePair = CoordinateConverter.coordinatePair(
lat: 22.491_488,
lon: 113.945_752,
mapCoordinateSystem: .wgs84
)
XCTAssertEqual(store.selectMatching(coordinatePair: nearbyRealtimePair)?.id, favorite.id)
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
let unrelatedPair = CoordinateConverter.coordinatePair(
lat: 31.2304,
lon: 121.4737,
mapCoordinateSystem: .wgs84
)
XCTAssertNil(store.selectMatching(coordinatePair: unrelatedPair))
XCTAssertNil(store.selectedFavoriteID)
}
func testFavoriteStoresBothFormsAndSelectsMatchingPairWithoutReadConversion() {
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let wgs = CLLocationCoordinate2D(latitude: 22.491_438, longitude: 113.945_702)
let favorite = FavoriteLocation(
name: "深圳湾",
coordinatePair: .init(mapCoordinate: wgs, mapCoordinateSystem: .wgs84),
accuracy: 20
)
XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).latitude, wgs.latitude, accuracy: 0.000_000_1)
XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).longitude, wgs.longitude, accuracy: 0.000_000_1)
XCTAssertNotEqual(favorite.coordinatePair.gcj02.latitude, wgs.latitude)
XCTAssertNotEqual(favorite.coordinatePair.gcj02.longitude, wgs.longitude)
}
func testSavingPrecomputedPairDoesNotReinterpretItAfterMapTypeRefresh() {
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let pair = CoordinateConverter.coordinatePair(
lat: 22.296_642,
lon: 114.172_175,
mapCoordinateSystem: .wgs84
)
let favorite = FavoriteLocationStore(defaults: defaults).save(
name: "香港天文台",
coordinatePair: pair,
accuracy: 25
)
XCTAssertEqual(favorite.coordinatePair, pair)
}
func testLegacyFavoriteIsUpgradedAsDomesticGCJAndRewritten() throws {
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let id = UUID()
let createdAt = Date(timeIntervalSince1970: 1_700_000_000)
let payload = LegacyFavoritePayload(
id: id,
name: "旧收藏",
latitude: 22.544_577,
longitude: 113.941_14,
accuracy: 25,
createdAt: createdAt
)
defaults.set(try JSONEncoder().encode([payload]), forKey: "favorite_locations")
let store = FavoriteLocationStore(defaults: defaults)
XCTAssertTrue(store.favorites[0].isLegacyCoordinateRecord)
XCTAssertEqual(store.favorites[0].coordinatePair.gcj02.latitude, payload.latitude, accuracy: 0.000_000_1)
XCTAssertNotEqual(store.favorites[0].coordinatePair.wgs84.longitude, payload.longitude)
try store.migrateLegacyCoordinates()
let reloaded = FavoriteLocationStore(defaults: defaults)
XCTAssertEqual(reloaded.favorites[0].id, id)
XCTAssertEqual(reloaded.favorites[0].name, "旧收藏")
XCTAssertFalse(reloaded.favorites[0].isLegacyCoordinateRecord)
}
func testOverseasPairUsesIdentityConversion() {
let eiffelTower = CoordinateConverter.coordinatePair(lat: 48.858_37, lon: 2.294_481, mapCoordinateSystem: .wgs84)
XCTAssertEqual(eiffelTower.wgs84.latitude, eiffelTower.gcj02.latitude, accuracy: 0.000_000_1)
XCTAssertEqual(eiffelTower.wgs84.longitude, eiffelTower.gcj02.longitude, accuracy: 0.000_000_1)
}
func testDomesticMapCoordinateMatchesPreviouslyActivatedWGS84Value() {
let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14)
let pair = CoordinatePair(mapCoordinate: gcj, mapCoordinateSystem: .gcj02)
XCTAssertTrue(pair.matchesWGS84(
latitude: pair.wgs84.latitude,
longitude: pair.wgs84.longitude
))
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 testFixedAnchorResultNameUsesOneSharedMapTypeRule() {
XCTAssertEqual(
CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "林士街"),
.gcj02
)
XCTAssertEqual(
CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "Connaught Road West"),
.wgs84
)
}
func testMapConfigurationNeverRequestsRealUserLocation() {
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
}
}
private struct LegacyFavoritePayload: Encodable {
let id: UUID
let name: String
let latitude: Double
let longitude: Double
let accuracy: Int
let createdAt: Date
}
@@ -3,136 +3,85 @@ import XCTest
@MainActor
final class LocationActionCoordinatorTests: XCTestCase {
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async {
let events = EventLog()
let trust = FakeTrust(canModify: true, events: events)
let proxy = FakeProxy(activeForClear: false, events: events)
let settings = FakeSettings(events: events)
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
let coordinator = LocationActionCoordinator()
func testApplyStartsProxyAndWritesTheFavoriteWGS84Pair() async {
let proxy = FakeLocationActionProxy()
let settings = FakeLocationActionSettingsStore()
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
let favorite = FavoriteLocation(
name: "深圳湾",
latitude: 22.494,
longitude: 113.951,
accuracy: 20,
mapCoordinateSystem: .gcj02
)
let applied = await coordinator.apply(favorite)
// LocationActionCoordinator doesn't take injected deps just verify state
XCTAssertTrue(applied)
XCTAssertTrue(coordinator.virtualLocationEnabled)
XCTAssertTrue(proxy.isRunning)
XCTAssertEqual(proxy.lastCoordinates?.latitude, favorite.coordinatePair.wgs84.latitude)
XCTAssertEqual(proxy.lastCoordinates?.longitude, favorite.coordinatePair.wgs84.longitude)
XCTAssertEqual(settings.saved?.latitude, favorite.coordinatePair.wgs84.latitude)
XCTAssertEqual(settings.saved?.longitude, favorite.coordinatePair.wgs84.longitude)
XCTAssertTrue(settings.saved?.enabled == true)
}
func testClearDoesNotConnectAnInactiveProxy() async {
let events = EventLog()
let coordinator = LocationActionCoordinator()
func testClearWritesDisabledCoordinatesAndClearsSettings() {
let proxy = FakeLocationActionProxy(isRunning: true)
let settings = FakeLocationActionSettingsStore()
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
coordinator.clear()
XCTAssertEqual(proxy.lastCoordinates?.latitude, 0)
XCTAssertEqual(proxy.lastCoordinates?.longitude, 0)
XCTAssertFalse(proxy.lastCoordinates?.enabled ?? true)
XCTAssertFalse(settings.saved?.enabled ?? true)
XCTAssertFalse(coordinator.virtualLocationEnabled)
}
func testBusyApplyRejectsASecondRequest() async {
let coordinator = LocationActionCoordinator()
func testApplyVerifiedRejectsInactiveProxyWithoutWritingCoordinates() {
let proxy = FakeLocationActionProxy()
let settings = FakeLocationActionSettingsStore()
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
let first = Task { await coordinator.apply(favorite) }
let secondApplied = await coordinator.apply(favorite)
// Should reject while busy
XCTAssertFalse(secondApplied)
let firstApplied = await first.value
// First one might succeed or fail depending on proxy state; just check no crash
_ = firstApplied
XCTAssertFalse(coordinator.applyVerified(favorite))
XCTAssertNil(proxy.lastCoordinates)
XCTAssertNil(settings.saved)
}
}
@MainActor
private final class FakeTrust {
var canModify: Bool
let events: EventLog
init(canModify: Bool, events: EventLog) {
self.canModify = canModify
self.events = events
private final class FakeLocationActionProxy: LocationActionProxying {
struct Coordinates: Equatable {
let latitude: Double
let longitude: Double
let enabled: Bool
let accuracy: Int
}
func refreshTrust() async {
events.append("trust.refresh")
var isRunning: Bool
private(set) var lastCoordinates: Coordinates?
init(isRunning: Bool = false) {
self.isRunning = isRunning
}
func start() async throws {
isRunning = true
}
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64 {
lastCoordinates = Coordinates(latitude: lat, longitude: lon, enabled: enabled, accuracy: accuracy)
return 1
}
}
@MainActor
private final class FakeProxy {
let activeForClear: Bool
let events: EventLog
let connectGate: AsyncGate?
init(activeForClear: Bool, events: EventLog, connectGate: AsyncGate? = nil) {
self.activeForClear = activeForClear
self.events = events
self.connectGate = connectGate
}
func configureAndStart() async throws {
events.append("proxy.connect")
await connectGate?.blockUntilOpened()
}
func stopAndWait() async throws {
events.append("proxy.stop")
}
func send(_ message: String) async throws -> String {
events.append("proxy.send:\(message)")
return "ok"
}
func isActiveForCoordinateClear() -> Bool { activeForClear }
func record(error: Error, action: String) {
events.append("proxy.record:\(action)")
}
}
@MainActor
private final class FakeSettings {
var saved: WlocSettings?
let events: EventLog
init(events: EventLog) { self.events = events }
private final class FakeLocationActionSettingsStore: LocationActionSettingsStoring {
private(set) var saved: WlocSettings?
func load() -> WlocSettings? { saved }
func save(_ settings: WlocSettings) {
saved = settings
events.append("settings.save.\(settings.enabled ? "enabled" : "disabled")")
}
func clear() {
saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false)
events.append("settings.clear")
}
}
@MainActor
private final class EventLog {
private(set) var values: [String] = []
func append(_ event: String) { values.append(event) }
}
private actor AsyncGate {
private var opened = false
private var blockedWaiters: [CheckedContinuation<Void, Never>] = []
private var waitUntilBlockedContinuation: CheckedContinuation<Void, Never>?
func blockUntilOpened() async {
guard !opened else { return }
waitUntilBlockedContinuation?.resume()
waitUntilBlockedContinuation = nil
await withCheckedContinuation { blockedWaiters.append($0) }
}
func waitUntilBlocked() async {
guard !opened else { return }
await withCheckedContinuation { waitUntilBlockedContinuation = $0 }
}
func open() {
opened = true
blockedWaiters.forEach { $0.resume() }
blockedWaiters.removeAll()
}
func save(_ settings: WlocSettings) { saved = settings }
func clear() { saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false) }
}
@@ -14,8 +14,7 @@ final class MapLocationStateTests: XCTestCase {
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
let accepted = state.acceptRealtimeLocation(
.init(latitude: 39.90, longitude: 116.40),
intent: request,
focus: true
intent: request
)
XCTAssertFalse(accepted)
@@ -53,7 +52,7 @@ final class MapLocationStateTests: XCTestCase {
XCTAssertEqual(state.selection.source, .userPan)
let intent = state.beginRealtimeIntent()
XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent, focus: true))
XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent))
XCTAssertEqual(state.selection.source, .realtime)
}
@@ -174,20 +173,48 @@ final class MapLocationStateTests: XCTestCase {
XCTAssertEqual(state.selection.source, .search)
}
func testMapCoordinateSystemChangeClearsSupersededRealtimeSampleWithoutMovingSelection() {
let state = MapLocationState(initialCoordinate: initial)
state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩")
let selection = state.selection
state.updateRealtimeLocation(CLLocation(latitude: 30.42, longitude: 114.25))
state.clearRealtimeLocationForMapCoordinateSystemChange()
XCTAssertNil(state.realtimeLocation)
XCTAssertNil(state.realtimeCoordinate)
XCTAssertEqual(state.selection, selection)
}
func testRealtimeIntentCanImmediatelyAcceptNativeLocation() {
let state = MapLocationState(initialCoordinate: initial)
let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25)
state.updateRealtimeLocation(nativeLocation)
let intent = state.beginRealtimeIntent()
XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, focus: true))
XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent))
XCTAssertEqual(state.selection.source, .realtime)
XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001)
guard case let .focus(coordinate, distance) = state.cameraCommand?.kind else {
return XCTFail("Expected realtime focus command")
XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it")
}
func testMapCoordinateSystemReprojectionPreservesSelectionIdentityAndIssuesFocus() {
let state = MapLocationState(initialCoordinate: initial)
let favoriteID = UUID()
state.selectFavorite(.init(latitude: 22.55, longitude: 113.95), id: favoriteID, name: "测试收藏")
let revision = state.selection.revision
state.reprojectSelectionForMapCoordinateSystemChange(.init(latitude: 22.54, longitude: 113.94))
XCTAssertEqual(state.selection.source, .favorite(favoriteID))
XCTAssertEqual(state.selection.explicitName, "测试收藏")
XCTAssertEqual(state.selection.revision, revision)
XCTAssertEqual(state.selection.coordinate.latitude, 22.54, accuracy: 0.000001)
guard case let .focus(coordinate, distanceMeters) = state.cameraCommand?.kind else {
return XCTFail("Expected a focus command after map coordinate-system reprojection")
}
XCTAssertEqual(coordinate.latitude, 30.42, accuracy: 0.000001)
XCTAssertEqual(distance, 200)
XCTAssertEqual(coordinate.latitude, 22.54, accuracy: 0.000001)
XCTAssertEqual(distanceMeters, state.viewportMeters)
}
func testZoomMathScalesBothAxesInTheSameDirection() {
@@ -207,4 +234,71 @@ final class MapLocationStateTests: XCTestCase {
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km")
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 km")
}
func testLastCoordinateStoreKeepsBothFormsAndZoom() {
let suite = "MapLocationStateTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14)
LastCoordinateStore.save(mapCoordinate: gcj, mapCoordinateSystem: .gcj02, zoomMeters: 1_250, defaults: defaults)
guard let restored = LastCoordinateStore.load(defaults: defaults) else {
return XCTFail("Expected a persisted current map pin")
}
XCTAssertEqual(restored.coordinate(for: .gcj02).latitude, gcj.latitude, accuracy: 0.000_000_1)
XCTAssertEqual(restored.coordinate(for: .gcj02).longitude, gcj.longitude, accuracy: 0.000_000_1)
XCTAssertNotEqual(restored.coordinate(for: .wgs84).longitude, gcj.longitude)
XCTAssertEqual(restored.zoomMeters, 1_250)
}
func testCoordinateMigrationUpgradesLegacyCurrentPinAndFavoritesBeforeSettingVersion() throws {
let suite = "MapLocationStateTests.\(UUID().uuidString)"
let legacySuite = "MapLocationStateTests.Legacy.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let legacyDefaults = UserDefaults(suiteName: legacySuite)!
defer { defaults.removePersistentDomain(forName: suite) }
defer { legacyDefaults.removePersistentDomain(forName: legacySuite) }
let firstID = UUID()
let secondID = UUID()
let records = [
CoordinateMigrationLegacyFavorite(id: firstID, name: "第一个", latitude: 22.544_577, longitude: 113.941_14, accuracy: 15, createdAt: .distantPast),
CoordinateMigrationLegacyFavorite(id: secondID, name: "海外", latitude: 48.858_37, longitude: 2.294_481, accuracy: 30, createdAt: .distantFuture),
]
legacyDefaults.set(22.544_577, forKey: "lastMapLat")
legacyDefaults.set(113.941_14, forKey: "lastMapLon")
legacyDefaults.set(2_000.0, forKey: "mapViewportMeters")
defaults.set(try JSONEncoder().encode(records), forKey: "favorite_locations")
defaults.set(secondID.uuidString, forKey: "favorite_locations_selected_id")
let favorites = FavoriteLocationStore(defaults: defaults)
try CoordinateStorageMigration.migrateIfNeeded(
favorites: favorites,
defaults: defaults,
legacyDefaults: legacyDefaults
)
XCTAssertEqual(defaults.integer(forKey: "coordinateStorageMigrationVersion"), CoordinateStorageMigration.currentVersion)
guard let current = LastCoordinateStore.load(defaults: defaults) else {
return XCTFail("Expected migrated current map pin")
}
XCTAssertEqual(current.coordinate(for: .gcj02).latitude, 22.544_577, accuracy: 0.000_000_1)
XCTAssertEqual(current.zoomMeters, 2_000)
let reloaded = FavoriteLocationStore(defaults: defaults)
XCTAssertEqual(reloaded.favorites.map(\.id), [firstID, secondID])
XCTAssertEqual(reloaded.favorites.map(\.name), ["第一个", "海外"])
XCTAssertEqual(reloaded.selectedFavoriteID, secondID)
XCTAssertFalse(reloaded.favorites.contains(where: \.isLegacyCoordinateRecord))
XCTAssertEqual(reloaded.favorites[1].coordinatePair.wgs84.latitude, reloaded.favorites[1].coordinatePair.gcj02.latitude, accuracy: 0.000_000_1)
}
}
private struct CoordinateMigrationLegacyFavorite: Encodable {
let id: UUID
let name: String
let latitude: Double
let longitude: Double
let accuracy: Int
let createdAt: Date
}
@@ -0,0 +1,17 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class MotionSimulationStoreTests: XCTestCase {
func testDefaultsToDisabledAndPersistsChanges() {
let suite = "MotionSimulationStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let store = MotionSimulationStore(defaults: defaults)
XCTAssertFalse(store.isEnabled)
store.setEnabled(true)
XCTAssertTrue(MotionSimulationStore(defaults: defaults).isEnabled)
}
}
@@ -0,0 +1,56 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class ProxyRuntimeModeTests: XCTestCase {
func testDefaultsToLocalWiFiAndPersistsThirdPartyMode() {
let suiteName = "ProxyRuntimeModeTests.\(UUID().uuidString)"
let legacySuiteName = "ProxyRuntimeModeLegacyTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let legacyDefaults = UserDefaults(suiteName: legacySuiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
legacyDefaults.removePersistentDomain(forName: legacySuiteName)
}
let initial = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
XCTAssertEqual(initial.mode, .localWiFi)
XCTAssertFalse(initial.hasSelectedMode)
XCTAssertFalse(initial.isInitialized(.localWiFi))
XCTAssertFalse(initial.isInitialized(.thirdParty))
initial.setMode(.thirdParty)
initial.markInitialized(.thirdParty)
let restored = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
XCTAssertEqual(restored.mode, .thirdParty)
XCTAssertTrue(restored.hasSelectedMode)
XCTAssertFalse(restored.isInitialized(.localWiFi))
XCTAssertTrue(restored.isInitialized(.thirdParty))
restored.resetInitialization(.thirdParty)
XCTAssertFalse(restored.isInitialized(.thirdParty))
}
func testMigratesOnlyCurrentLegacyCompletedMode() {
let suiteName = "ProxyRuntimeModeMigrationTests.\(UUID().uuidString)"
let legacySuiteName = "ProxyRuntimeModeMigrationLegacyTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let legacyDefaults = UserDefaults(suiteName: legacySuiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
legacyDefaults.removePersistentDomain(forName: legacySuiteName)
}
defaults.set(ProxyRuntimeMode.thirdParty.rawValue, forKey: "proxyRuntimeMode")
defaults.set(true, forKey: "hasSelectedProxyRuntimeMode")
legacyDefaults.set(true, forKey: "setupCompleted")
let migrated = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
XCTAssertFalse(migrated.isInitialized(.localWiFi))
XCTAssertTrue(migrated.isInitialized(.thirdParty))
migrated.setMode(.localWiFi)
XCTAssertFalse(migrated.isInitialized(.localWiFi))
XCTAssertTrue(migrated.isInitialized(.thirdParty))
}
}
@@ -72,7 +72,7 @@ final class RealtimeLocationManagerTests: XCTestCase {
func testOneShotTimeoutTransitionsToContinuousFallback() async {
let driver = FakeRealtimeLocationDriver()
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 5_000_000)
let manager = RealtimeLocationManager(driver: driver, oneShotTimeoutNanoseconds: 5_000_000, fallbackTimeoutNanoseconds: 1_000_000_000)
let request = Task { await manager.requestLocation() }
try? await Task.sleep(nanoseconds: 20_000_000)
@@ -0,0 +1,26 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class RuntimeLogStoreTests: XCTestCase {
func testRetentionCutoffIsExactlyThreeDays() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
XCTAssertEqual(
RuntimeLogStore.retentionCutoff(now: now),
now.addingTimeInterval(-3 * 24 * 60 * 60)
)
}
func testRetentionKeepsCutoffAndNewerEntriesOnly() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let cutoff = RuntimeLogStore.retentionCutoff(now: now)
let expired = RuntimeLogEntry(timestamp: cutoff.addingTimeInterval(-0.001), source: "APP", level: .info, category: "Test", message: "expired")
let boundary = RuntimeLogEntry(timestamp: cutoff, source: "APP", level: .info, category: "Test", message: "boundary")
let recent = RuntimeLogEntry(timestamp: now, source: "CORE", level: .warning, category: "Proxy", message: "recent")
XCTAssertEqual(
RuntimeLogStore.retainedEntries([expired, boundary, recent], now: now),
[boundary, recent]
)
}
}
@@ -0,0 +1,82 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class SetupCoordinatorTests: XCTestCase {
func testSuccessfulVerificationDismissesSetup() {
let coordinator = SetupCoordinator()
coordinator.requestSetup()
coordinator.applyVerificationResult(.success)
XCTAssertEqual(coordinator.trustState, .trusted)
XCTAssertFalse(coordinator.needsSetup)
}
func testCertificateFailureRoutesDirectlyToCertificateStep() {
let coordinator = SetupCoordinator()
coordinator.applyVerificationResult(.certNotTrusted)
XCTAssertEqual(coordinator.trustState, .unavailable)
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .cert)
}
func testProxyFailureRoutesBackToProxyStep() {
let coordinator = SetupCoordinator()
coordinator.applyVerificationResult(.certNotTrusted)
coordinator.applyVerificationResult(.wifiProxyNotConfigured)
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .proxy)
}
func testExplicitCertificateRequestRoutesToCertificateStep() {
let coordinator = SetupCoordinator()
coordinator.requestCertificateSetup()
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .cert)
}
func testThirdPartyFailureRequestPreservesErrorAndRoutesToImportGuide() {
let coordinator = SetupCoordinator()
coordinator.requestThirdPartySetup(message: "模块未连接")
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .thirdPartyImport)
XCTAssertEqual(coordinator.message, "模块未连接")
}
func testThirdPartyOnboardingStartsWithClientSelection() {
let coordinator = SetupCoordinator()
coordinator.requestThirdPartyOnboarding()
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .thirdPartyClient)
XCTAssertTrue(coordinator.message.isEmpty)
}
func testProxyFailurePreservesResultForPresentedGuide() {
let coordinator = SetupCoordinator()
coordinator.applyVerificationResult(.proxyNotRunning)
XCTAssertEqual(coordinator.lastVerificationResult, .proxyNotRunning)
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .proxy)
}
func testConcurrentVerificationDoesNotOpenGuide() {
let coordinator = SetupCoordinator()
coordinator.applyVerificationResult(.verificationInProgress)
XCTAssertFalse(coordinator.needsSetup)
}
}
@@ -0,0 +1,50 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class ThirdPartyCommunityPromptPreferencesTests: XCTestCase {
private var suites: [String] = []
override func tearDown() {
for suite in suites {
UserDefaults.standard.removePersistentDomain(forName: suite)
}
suites.removeAll()
super.tearDown()
}
func testSuppressionBecomesAvailableOnThirdPresentation() {
let preferences = ThirdPartyCommunityPromptPreferences(defaults: makeDefaults())
XCTAssertEqual(preferences.recordPresentation(), 1)
XCTAssertFalse(preferences.canSuppress())
XCTAssertEqual(preferences.recordPresentation(), 2)
XCTAssertFalse(preferences.canSuppress())
XCTAssertEqual(preferences.recordPresentation(), 3)
XCTAssertTrue(preferences.canSuppress())
}
func testEarlySuppressionIsIgnoredAndThirdPresentationCanPersistIt() {
let defaults = makeDefaults()
let preferences = ThirdPartyCommunityPromptPreferences(defaults: defaults)
preferences.recordPresentation()
preferences.suppress()
XCTAssertTrue(preferences.shouldPresent())
preferences.recordPresentation()
preferences.recordPresentation()
preferences.suppress()
XCTAssertFalse(preferences.shouldPresent())
let restored = ThirdPartyCommunityPromptPreferences(defaults: defaults)
XCTAssertFalse(restored.shouldPresent())
}
private func makeDefaults() -> UserDefaults {
let suite = "ThirdPartyCommunityPromptPreferencesTests.\(UUID().uuidString)"
suites.append(suite)
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
}
@@ -0,0 +1,17 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class ThirdPartyModuleSourceStoreTests: XCTestCase {
func testMirrorDefaultsToEnabledAndPersists() {
let suite = "ThirdPartyModuleSourceStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let store = ThirdPartyModuleSourceStore(defaults: defaults)
XCTAssertTrue(store.useMirror)
store.setUseMirror(false)
XCTAssertFalse(ThirdPartyModuleSourceStore(defaults: defaults).useMirror)
}
}
@@ -0,0 +1,216 @@
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(values["motion"], "0")
XCTAssertEqual(manager.connectionState, .connected(active: true))
}
func testVersionEndpointRequiresProtocolAndCapabilities() async throws {
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":""}"#)
let manager = ThirdPartyProxyManager(requester: requester)
let version = try await manager.validateVersion()
XCTAssertEqual(requester.lastURL?.path, "/wloc-settings/version")
XCTAssertEqual(version.moduleVersion, "1.0.0")
XCTAssertTrue(version.isCompatible)
}
func testConnectionUsesLegacySaveQueryEndpoint() async throws {
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":""}"#)
let manager = ThirdPartyProxyManager(requester: requester)
let response = try await manager.validateConnection()
XCTAssertFalse(response.success)
XCTAssertFalse(manager.moduleUpdateRecommended)
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
XCTAssertEqual(requester.requestedURLs.first?.query, "action=query")
}
func testMissingVersionDisablesOnlyAdvancedFeatures() async {
let requester = FakeThirdPartyRequester(
body: #"{"success":false,"error":""}"#,
versionBody: "not-json"
)
let manager = ThirdPartyProxyManager(requester: requester)
let isAvailable = await manager.refreshAdvancedFeatureAvailability()
XCTAssertFalse(isAvailable)
XCTAssertTrue(manager.moduleUpdateRecommended)
XCTAssertEqual(manager.connectionState, .unknown)
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/version"])
}
func testLegacyModuleCanStillSaveBasicCoordinates() 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, versionBody: "not-json")
let manager = ThirdPartyProxyManager(requester: requester)
let response = try await manager.save(favorite)
XCTAssertTrue(response.success)
XCTAssertFalse(manager.moduleUpdateRecommended)
XCTAssertEqual(manager.connectionState, .connected(active: true))
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
XCTAssertNil(requester.requestedURLs.first?.query)
}
func testBrokenSaveQueryFailsWithoutCheckingVersion() async {
let requester = FakeThirdPartyRequester(body: "not-json")
let manager = ThirdPartyProxyManager(requester: requester)
do {
_ = try await manager.validateConnection()
XCTFail("expected interception failure")
} catch {
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
}
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
}
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 testClientLinksUseProjectOwnedMirrorModulesAndVerificationLabels() {
XCTAssertEqual(
ThirdPartyProxyManager.interceptionHostnamesText,
"gs-loc.apple.com, gs-loc-cn.apple.com"
)
XCTAssertNil(ThirdPartyProxyClient.shadowrocket.verificationText)
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText?.contains("尚未验证") == true)
XCTAssertEqual(ThirdPartyProxyClient.egern.subscriptionURL, ThirdPartyProxyClient.surge.subscriptionURL)
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasPrefix(
"https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/"
))
let stashComponents = URLComponents(
url: ThirdPartyProxyClient.stash.subscriptionURL,
resolvingAgainstBaseURL: false
)
let shadowrocketComponents = URLComponents(
url: ThirdPartyProxyClient.shadowrocket.subscriptionURL,
resolvingAgainstBaseURL: false
)
XCTAssertEqual(stashComponents?.path, "/https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/ThirdPartyProxyModules/wloc.stoverride")
XCTAssertEqual(shadowrocketComponents?.path, "/https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/ThirdPartyProxyModules/wloc.module")
XCTAssertEqual(stashComponents?.queryItems?.first?.value, ThirdPartyProxyClient.moduleSubscriptionVersion)
XCTAssertEqual(shadowrocketComponents?.queryItems?.first?.value, ThirdPartyProxyClient.moduleSubscriptionVersion)
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 let versionData: Data
private(set) var lastURL: URL?
private(set) var requestedURLs: [URL] = []
init(
body: String,
versionBody: String = #"{"success":true,"moduleVersion":"1.0.0","protocolVersion":1,"capabilities":["wifi","cellTower","arpc","marker","synthetic","bare","motionSimulation"]}"#
) {
data = Data(body.utf8)
versionData = Data(versionBody.utf8)
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
lastURL = request.url
requestedURLs.append(request.url!)
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (request.url?.path == "/wloc-settings/version" ? versionData : data, response)
}
}
@@ -0,0 +1,85 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class VirtualLocationTipPreferencesTests: XCTestCase {
private var suites: [String] = []
override func tearDown() {
for suite in suites {
UserDefaults.standard.removePersistentDomain(forName: suite)
}
suites.removeAll()
super.tearDown()
}
func testSuppressionAppearsOnlyAfterThirdSuccessfulOperation() {
let defaults = makeDefaults()
let legacyDefaults = makeDefaults()
let preferences = VirtualLocationTipPreferences(
defaults: defaults,
legacyDefaults: legacyDefaults
)
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 1)
XCTAssertFalse(preferences.canSuppress(.activation))
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 2)
XCTAssertFalse(preferences.canSuppress(.activation))
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 3)
XCTAssertTrue(preferences.canSuppress(.activation))
}
func testActivationAndDeactivationCountersAndSuppressionAreIndependent() {
let defaults = makeDefaults()
let preferences = VirtualLocationTipPreferences(
defaults: defaults,
legacyDefaults: makeDefaults()
)
for _ in 0..<3 {
preferences.recordSuccessfulOperation(.activation)
}
preferences.suppress(.activation)
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.activation))
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
XCTAssertFalse(preferences.canSuppress(.deactivation))
for _ in 0..<3 {
preferences.recordSuccessfulOperation(.deactivation)
}
preferences.suppress(.deactivation)
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.deactivation))
}
func testSuppressionBeforeThirdOperationIsIgnored() {
let preferences = VirtualLocationTipPreferences(
defaults: makeDefaults(),
legacyDefaults: makeDefaults()
)
preferences.recordSuccessfulOperation(.deactivation)
preferences.suppress(.deactivation)
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
}
func testLegacyActivationSuppressionRemainsEffective() {
let legacyDefaults = makeDefaults()
legacyDefaults.set(true, forKey: "activationTipDisabled")
let preferences = VirtualLocationTipPreferences(
defaults: makeDefaults(),
legacyDefaults: legacyDefaults
)
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.activation))
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
}
private func makeDefaults() -> UserDefaults {
let suite = "VirtualLocationTipPreferencesTests.\(UUID().uuidString)"
suites.append(suite)
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
}
+28
View File
@@ -8,6 +8,34 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
test -f "$ROOT/App/ProxyManager.swift" || fail "ProxyManager must exist"
test -f "$ROOT/Shared/RuntimeLog.swift" || fail "RuntimeLog must exist"
test -f "$ROOT/App/RealtimeLocationManager.swift" || fail "RealtimeLocationManager must exist"
grep -q 'Location Spoofer CA' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must use the stable app-branded CA name"
! grep -q 'WLOC CA' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must not show the legacy dated CA name"
grep -q 'localWiFiRuntimeModeInitialized' "$ROOT/Shared/ProxyRuntimeMode.swift" || fail "APP mode initialization must persist independently"
grep -q 'thirdPartyRuntimeModeInitialized' "$ROOT/Shared/ProxyRuntimeMode.swift" || fail "third-party mode initialization must persist independently"
! grep -q 'runVerificationTest' "$ROOT/App/ContentView.swift" || fail "normal app startup must not run the environment verification test"
grep -q '重置证书' "$ROOT/App/SettingsView.swift" || fail "APP mode settings must expose certificate reset"
grep -q 'certificateStore.reset()' "$ROOT/App/SettingsView.swift" || fail "certificate reset must remove the persisted app CA"
grep -q 'requestCertificateSetup()' "$ROOT/App/SettingsView.swift" || fail "certificate reset must open the certificate setup flow"
grep -q 'requestThirdPartySetup(message:' "$ROOT/App/SettingsView.swift" || fail "third-party settings failures must open the setup guide"
grep -q 'SFSafariViewController' "$ROOT/App/SafariView.swift" || fail "certificate download must use an in-app Safari service"
grep -q 'prepareCertificateDownloadURL' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must prepare an in-app download URL"
! grep -q 'UIApplication.shared.open(url' "$ROOT/App/ProxyManager.swift" || fail "certificate download must not force an external browser"
grep -q 'AppModeWiFiProxy' "$ROOT/App/FirstSetupView.swift" || fail "APP proxy setup must show the Wi-Fi screenshot"
grep -q 'AppModeCertificateInstall' "$ROOT/App/FirstSetupView.swift" || fail "certificate install setup must show its screenshot"
grep -q 'AppModeCertificateTrust' "$ROOT/App/FirstSetupView.swift" || fail "certificate trust setup must show its screenshot"
grep -q 'UIImage(named: assetName)' "$ROOT/App/FirstSetupView.swift" || fail "missing screenshots must fall back to text without breaking setup"
for asset in AppModeWiFiProxy AppModeCertificateInstall AppModeCertificateTrust; do
test -s "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|| fail "missing APP onboarding image asset: $asset"
grep -q '\.jpg' "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|| fail "APP onboarding derivatives must use an Asset Catalog-supported JPEG: $asset"
done
test -s "$ROOT/docs/onboarding-screenshots/app-mode/app-mode-wifi-proxy.jpg" \
|| fail "unannotated APP-mode source screenshots must be retained by mode"
test -s "$ROOT/docs/app-icon-source.svg" || fail "the optimized app icon must retain an editable vector source"
test -s "$ROOT/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png" \
|| fail "the optimized 1024px app icon is missing"
# VPNManager and Tunnel must NOT exist
test ! -f "$ROOT/App/VPNManager.swift" || fail "VPNManager must be removed"
+62 -7
View File
@@ -4,6 +4,12 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
TIP_VIEWS="$ROOT/App/TipViews.swift"
grep -Fq 'Label("还是无法生效?"' "$TIP_VIEWS" || fail "activation help must use the requested retry heading"
grep -Fq 'Label("还是无法取消?"' "$TIP_VIEWS" || fail "deactivation help must use a mode-specific retry heading"
grep -q 'frame(maxWidth: .infinity, alignment: .leading)' "$TIP_VIEWS" \
|| fail "tip detail copy must align to the leading edge"
MAP_HOME="$ROOT/App/MapHomeView.swift"
MAP_STATE="$ROOT/App/MapLocationState.swift"
MAP_BRIDGE="$ROOT/App/MapViewRepresentable.swift"
@@ -12,8 +18,11 @@ SETUP="$ROOT/App/SetupCoordinator.swift"
PROXY="$ROOT/App/ProxyManager.swift"
SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift"
DIAGNOSTICS="$ROOT/App/DiagnosticsView.swift"
CONTENT="$ROOT/App/ContentView.swift"
CONVERTER="$ROOT/Shared/CoordinateConverter.swift"
NETWORK_MONITOR="$ROOT/Shared/NetworkMonitor.swift"
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS"; do
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS" "$CONTENT" "$CONVERTER" "$NETWORK_MONITOR"; do
test -f "$file" || fail "missing required refactor file: $file"
done
@@ -22,7 +31,11 @@ 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"
grep -q 'activeCameraCommandID' "$MAP_BRIDGE" || fail "programmatic map callbacks must be associated with the active camera command"
grep -q 'UIPanGestureRecognizer' "$MAP_BRIDGE" || fail "map panning must be recognized explicitly"
grep -q 'UIPinchGestureRecognizer' "$MAP_BRIDGE" || fail "pinch zoom must not be treated as a selected-center pan"
@@ -40,19 +53,61 @@ grep -q 'case awaitingAuthorization' "$REALTIME" || fail "location requests must
grep -q 'var location: CLLocation?' "$REALTIME" || fail "Core Location driver must expose its cached native sample"
grep -q 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent"
! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed"
grep -q 'defer' "$SETUP" || fail "verification must restore temporary state with defer"
grep -q 'restoreCoords' "$SETUP" || fail "verification must use revision-aware coordinate restoration"
grep -q 'coordinateRevision' "$PROXY" || fail "proxy coordinate writes must be revisioned"
grep -q 'setCoordsIfUnchanged' "$SETUP" || fail "verification must not overwrite a newer coordinate before its test write"
grep -q 'applyVerified' "$MAP_HOME" || fail "verified location commits must be synchronous after revision validation"
grep -q 'func applyVerificationResult' "$SETUP" || fail "verification results must have one setup-state reducer"
grep -q 'case .success:' "$SETUP" || fail "successful verification must converge setup state"
grep -q 'case .certNotTrusted:' "$SETUP" || fail "certificate failure must converge setup state"
grep -q 'setupStep = .proxy' "$SETUP" || fail "proxy failure must converge setup state"
grep -q 'realtimeRequestTask' "$MAP_HOME" || fail "realtime button requests must be synchronously serialized"
grep -q 'RealtimeLocationRequestContext' "$MAP_HOME" || fail "a realtime button tap must retarget an in-flight startup request instead of being ignored"
grep -q 'CLError.network' "$MAP_HOME" || fail "reverse geocoding network failures must use bounded retry"
grep -q 'SystemSettingsNavigator' "$MAP_HOME" || fail "settings actions must use the shared navigator"
grep -q '复制全部日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone copy button"
grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear button"
grep -q '日志自动清理,仅保留近 3 天' "$DIAGNOSTICS" || fail "diagnostics must disclose the three-day retention policy"
grep -q 'retentionInterval: TimeInterval = 3 \* 24 \* 60 \* 60' "$ROOT/Shared/RuntimeLog.swift" || fail "runtime logs must retain only three days"
if grep -q 'logEvent("CONNECT " + host + " -> passthrough")' "$ROOT/Core/proxy.go"; then
fail "proxy diagnostics must not log unrelated passthrough CONNECT hosts"
fi
grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing"
grep -q 'MARKETING_VERSION: "0.0.4"' "$ROOT/project.yml" || fail "marketing version must be 0.0.4"
grep -q '## \[0.0.4\] — 待发布' "$ROOT/docs/CHANGELOG.md" || fail "0.0.4 pending changelog section is missing"
grep -q 'await CoordinateConverter.resolveInitialMapCoordinateSystem()' "$CONTENT" || fail "map type must resolve before MapHomeView construction"
grep -q 'refreshRuntimeMapCoordinateSystem(reason:' "$CONVERTER" || fail "fixed-anchor map type must support runtime refresh"
grep -q 'scheduleBluePointMapCoordinateSystemRefresh()' "$MAP_HOME" || fail "native blue-point samples must trigger runtime map-type refresh while spoofing"
! grep -A3 'private func scheduleBluePointMapCoordinateSystemRefresh' "$MAP_HOME" | grep -q 'spoofState == .active' || fail "blue-point map-type refresh must also detect the return to physical location"
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "点击实时定位")' "$MAP_HOME" || fail "realtime button must await the coordinated map-type refresh"
grep -q 'favorites.selectMatching(coordinatePair: pair)' "$MAP_HOME" || fail "realtime selection must restore a matching favorite selection"
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "保存收藏")' "$MAP_HOME" || fail "favorite save must await the coordinated map-type refresh"
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "App回到前台")' "$MAP_HOME" || fail "foreground recovery must reuse the coordinated map-type refresh"
grep -q '地图坐标标准运行期检测结果已过期,取消写入' "$CONVERTER" || fail "cancelled runtime probes must not mutate the global map type"
! grep -q 'source == .coreLocation.*correctMapCoordinateSystemUsingRealtime' "$MAP_HOME" || fail "runtime Core Location samples must not infer MapKit type"
grep -q 'clearRealtimeLocationForMapCoordinateSystemChange' "$MAP_HOME" || fail "map-type changes must discard superseded blue-point samples"
grep -q '地图坐标类型已变化' "$MAP_HOME" || fail "map-type changes must emit an explicit searchable business log"
grep -q '图钉已按新类型重设' "$MAP_HOME" || fail "map-type change log must report pin reprojection"
grep -q 'coordinateRow(label: "GCJ-02(国内)", system: .gcj02)' "$MAP_HOME" || fail "current selection panel must label the domestic coordinate as GCJ-02"
grep -q 'coordinateRow(label: "WGS-84(国际)", system: .wgs84)' "$MAP_HOME" || fail "current selection panel must label the international coordinate as WGS-84"
grep -q 'fixedSize(horizontal: true, vertical: false)' "$MAP_HOME" || fail "coordinate labels must keep their natural single-line width"
grep -q 'minimumScaleFactor(0.72)' "$MAP_HOME" || fail "coordinate values must shrink to remain on one line"
grep -q 'phase = .map' "$CONTENT" || fail "ContentView must explicitly gate MapHomeView construction"
! grep -q 'startTileProbe' "$MAP_HOME" || fail "MapHomeView must not start a second fixed-anchor coordinate-system probe"
! grep -q 'initializeMap()' "$MAP_HOME" || fail "MapHomeView must not replay a second map initialization from onAppear"
grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime position must resolve before map construction"
! grep -q 'lastTileCheck' "$CONVERTER" || fail "map coordinate-system detection must not use a time cache"
! grep -q '跳过(缓存' "$CONVERTER" || fail "map coordinate-system detection must not skip using a cached result"
! 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 = 3_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait three seconds before environment verification"
! grep -q 'wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi failures must not use a duplicate reminder mapping"
grep -q 'setup.requestSetup(message:' "$MAP_HOME" || fail "missing Wi-Fi must enter the reusable proxy setup guide"
test "$(grep -c 'setup.applyVerificationResult(result)' "$MAP_HOME")" -ge 2 \
|| fail "activation and Wi-Fi-change verification failures must use the shared setup reducer"
! grep -q 'activeTip = \.proxySetup' "$MAP_HOME" || fail "proxy failures must not use a duplicate tip sheet"
! grep -q 'case certificate' "$ROOT/App/TipViews.swift" || fail "generic certificate tip must not coexist with certificate setup"
! grep -q 'CertificateTipContent' "$ROOT/App/TipViews.swift" || fail "certificate failures must use the complete setup flow"
! grep -q 'case proxySetup' "$ROOT/App/TipViews.swift" || fail "proxy failures must use the complete setup flow"
! grep -q 'case rewriteFailed' "$ROOT/App/TipViews.swift" || fail "rewrite failures must use the complete setup flow"
! grep -q 'onChange(of: net.isAirplaneMode)' "$MAP_HOME" || fail "airplane recovery must not race the Wi-Fi change verifier"
grep -q 'hasReceivedInitialPath' "$NETWORK_MONITOR" || fail "initial network path must not be reported as a Wi-Fi switch"
grep -q 'lastKnownSSID' "$NETWORK_MONITOR" || fail "SSID polling must preserve a baseline across temporary nil readings"
echo "PASS: map location state refactor contract"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ZH="$ROOT/README.md"
EN="$ROOT/README.en.md"
fail() { echo "FAIL: $*" >&2; exit 1; }
grep -q 'iOS Location Service Research & Testing Framework' "$ZH" || fail "Chinese README positioning is missing"
grep -q 'iOS Location Service Research & Testing Framework' "$EN" || fail "English README positioning is missing"
grep -q '#### 自签安装说明' "$ZH" || fail "Chinese self-signing instructions are missing"
grep -q '#### Self-Signing Instructions' "$EN" || fail "English self-signing instructions are missing"
grep -q 'Impactor Releases' "$ZH" || fail "Chinese README must link the requested Impactor releases"
grep -q 'Impactor Releases' "$EN" || fail "English README must link the requested Impactor releases"
grep -q '开发者模式' "$ZH" || fail "Chinese README must explain iOS 16 Developer Mode"
grep -q 'Developer Mode' "$EN" || fail "English README must explain iOS 16 Developer Mode"
grep -q '7 天有效期' "$ZH" || fail "Chinese README must disclose free-signing expiry"
grep -q 'seven days' "$EN" || fail "English README must disclose free-signing expiry"
grep -q '^## 功能预览$' "$ZH" || fail "Chinese feature preview is missing"
grep -q '^## Feature Preview$' "$EN" || fail "English feature preview is missing"
images=(
'主界面.jpg'
'Apple%20Map.jpg'
'高德地图.jpg'
'微信.jpg'
'钉钉.jpg'
'高血压.jpg'
)
for image in "${images[@]}"; do
grep -q "images/$image" "$ZH" || fail "Chinese README is missing image: $image"
grep -q "images/$image" "$EN" || fail "English README is missing image: $image"
done
image_files=(
'主界面.jpg'
'Apple Map.jpg'
'高德地图.jpg'
'微信.jpg'
'钉钉.jpg'
'高血压.jpg'
)
for image in "${image_files[@]}"; do
test -f "$ROOT/images/$image" || fail "referenced preview image is missing: $image"
done
test "$(grep -c '^## ' "$ZH")" -eq "$(grep -c '^## ' "$EN")" \
|| fail "Chinese and English README section counts must stay aligned"
! grep -Eq '^## (许可证|License)$' "$ZH" "$EN" || fail "README must not claim a repository license"
grep -q '当前项目不支持在 Windows 上直接构建 iOS 应用' "$ZH" || fail "Chinese README must reject Windows source builds"
grep -q 'Building the iOS app directly on Windows is not supported' "$EN" || fail "English README must reject Windows source builds"
grep -q 'docs/COMMUNITY_TUTORIALS.md' "$ZH" || fail "Chinese README must link the community tutorial submission guide"
grep -q '^#### 配置接口与客户端适配$' "$ZH" \
|| fail "Chinese README must document the third-party integration contract"
grep -q '^#### Configuration API and Client Integration$' "$EN" \
|| fail "English README must document the third-party integration contract"
for contract in 'action=query' 'action=clear' 'lon=<WGS-84'; do
grep -q "$contract" "$ZH" || fail "Chinese README is missing third-party contract: $contract"
grep -q "$contract" "$EN" || fail "English README is missing third-party contract: $contract"
done
grep -q '除敏感信息遮挡外,不要自行添加箭头、编号、边框、说明文字或其他标注' \
"$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|| fail "tutorial submissions must keep source screenshots free of non-privacy annotations"
grep -q '同一张截图可以对应多个步骤' "$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|| fail "tutorial submissions must explain multi-step screenshot handling"
grep -q '不得覆盖上述原图' "$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|| fail "annotated app assets must not replace categorized source screenshots"
if grep -Rnw --include='*.md' --include='*.sh' \
"$ROOT/build.sh" "$ROOT/README.md" "$ROOT/README.en.md" "$ROOT/docs" "$ROOT/Scripts" \
-e 'Impact'; then
fail "documentation and build output must use the correct Impactor name"
fi
echo "PASS: README contract"
+161
View File
@@ -0,0 +1,161 @@
#!/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 must be part of the import page"
! grep -q '生成并导入配置文件' "$SETUP" || fail "setup must not offer file generation/import"
grep -q '复制模块订阅地址' "$SETUP" || fail "module subscription URL copy action is missing"
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" || fail "setup must expose a client launch action"
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" \
|| fail "the import page must expose the selected 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"
grep -q 'static let configurationEndpoint' "$MANAGER" \
|| fail "the third-party configuration endpoint must have one shared owner"
grep -q 'DisclosureGroup("第三方客户端适配说明")' "$SETUP" \
|| fail "client selection must expose the third-party integration contract"
grep -Fq '查询:GET ?action=query' "$SETUP" \
|| fail "client integration guidance must document the query action"
grep -Fq '保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>' "$SETUP" \
|| fail "client integration guidance must document WGS-84 coordinate saving"
grep -Fq '清除:GET ?action=clear' "$SETUP" \
|| fail "client integration guidance must document the clear action"
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
test -s "$MODULES/$file" || fail "missing bundled module: $file"
grep -q 'gs-loc.apple.com' "$MODULES/$file" \
|| fail "$file must include gs-loc.apple.com in its MITM hostnames"
grep -q 'gs-loc-cn.apple.com' "$MODULES/$file" \
|| fail "$file must include gs-loc-cn.apple.com in its MITM hostnames"
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 "all clients must expose both MITM hostnames"
grep -q 'gs-loc.apple.com 和 gs-loc-cn.apple.com' "$SETUP" \
|| fail "setup guidance must name both Apple location hostnames"
grep -q 'ThirdPartyProxyManager.interceptionHostnamesText' "$SETUP" \
|| fail "setup hostname copy actions must use the shared two-host value"
grep -q 'ThirdPartyProxyManager.interceptionHostnamesText' "$SETTINGS" \
|| fail "Settings must expose the shared two-host copy action"
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
! grep -q '当前可测试' "$SETUP" || fail "Shadowrocket must not show the obsolete current-test label"
! grep -q '当前可测试' "$SETTINGS" || fail "Settings must not show the obsolete current-test label"
grep -q 'thirdPartyTestFailure = ThirdPartyConnectionTestFailure' "$SETUP" \
|| fail "third-party connection failures must render inline on the import page"
grep -q 'testResultView(' "$SETUP" || fail "APP and third-party tests must share the same result component"
grep -q 'showsVerificationResult = false' "$SETUP" \
|| fail "switching setup pages must hide the shared APP verification result"
grep -q 'showsThirdPartyFailureLog = false' "$SETUP" \
|| fail "switching setup pages must hide the shared third-party test log"
grep -q 'Label("查看诊断日志"' "$SETUP" \
|| fail "third-party connection failure must expose the diagnostics action"
! grep -q 'setupActionError = error.localizedDescription' "$SETUP" \
|| fail "third-party connection failure must not use the generic failure alert"
grep -q 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|| fail "the import page must validate the legacy save/query endpoint"
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
| grep -q 'refreshAdvancedFeatureAvailability()' \
|| fail "the import page must also probe advanced module capabilities"
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
| grep -q 'motionSimulation.setEnabled(false)' \
|| fail "an incompatible module must turn motion simulation off without failing basic setup"
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" | grep -q 'onComplete()' \
|| fail "a successful third-party connection test must close setup immediately"
grep -q 'components.queryItems = \[URLQueryItem(name: "action", value: "query")\]' "$MANAGER" \
|| fail "the connection test must preserve the established save?action=query contract"
grep -q 'thirdPartyProxy.moduleUpdateRecommended' "$SETTINGS" \
|| fail "Settings must react to legacy module compatibility mode"
grep -q '当前模块版本较旧,基础坐标功能仍可继续使用' "$SETTINGS" \
|| fail "Settings must explain that legacy modules retain basic coordinate support"
! grep -A8 'Toggle("运动状态模拟"' "$SETTINGS" | grep -q 'thirdPartyProxy.moduleUpdateRecommended' \
|| fail "legacy module compatibility must turn motion simulation off without disabling its toggle"
grep -q 'refreshAdvancedFeatureAvailability' "$SETTINGS" \
|| fail "Settings must probe advanced module availability independently"
grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \
| grep -q 'refreshAdvancedFeatureAvailability()' \
|| fail "enabling inactive third-party motion simulation must validate the version endpoint first"
grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \
| grep -q 'motionSimulation.setEnabled(true)' \
|| fail "inactive third-party motion simulation may enable only after version validation succeeds"
grep -q 'proxyOperationAlertTitle = "无法开启运动状态模拟"' "$SETTINGS" \
|| fail "motion simulation must show a dedicated failure alert when version validation fails"
grep -q '请重新导入最新模块脚本后再开启' "$SETTINGS" \
|| fail "motion simulation failure must tell the user to update the module script"
test "$(grep -c 'presentMotionSimulationModuleUpdateAlert()' "$SETTINGS")" -ge 3 \
|| fail "inactive and active motion simulation paths must share the module-update alert"
grep -q 'onChange(of: thirdPartyProxy.moduleUpdateRecommended)' "$SETTINGS" \
|| fail "Settings must react when an installed module becomes incompatible"
grep -A5 'private func disableUnsupportedThirdPartyMotionSimulation()' "$SETTINGS" \
| grep -q 'motionSimulation.setEnabled(false)' \
|| fail "unsupported third-party modules must force motion simulation off before disabling the toggle"
! grep -q 'thirdPartyTestResult?.success' "$SETUP" \
|| fail "a successful third-party test must not leave a separate completion state"
grep -q 'setupStep = \.thirdPartyImport' "$ROOT/App/SetupCoordinator.swift" \
|| fail "third-party runtime failures must route directly to the import guide"
grep -q 'setup.requestThirdPartySetup(message: error.localizedDescription)' "$ROOT/App/MapHomeView.swift" \
|| fail "third-party coordinate sync failures must open the import guide"
grep -q '检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测' "$SETUP" \
|| fail "runtime repair must explain why the import guide opened"
test "$(grep -c 'title: \"接口连接失败\"' "$SETUP")" -eq 1 \
|| fail "third-party failure details must render in one shared result area"
grep -Fq '当前客户端:\(client.name)' "$SETUP" \
|| fail "third-party failure logs must identify the selected client"
grep -q 'HStack(spacing: 12)' "$SETUP" || fail "setup footer actions must share one horizontal row"
grep -q 'Spacer(minLength: 12)' "$SETUP" || fail "setup footer actions must stay at opposite edges"
grep -q 'ScrollViewReader' "$SETUP" || fail "setup must keep failure results reachable after insertion"
grep -q 'scrollProxy.scrollTo("thirdPartyFailureLog"' "$SETUP" \
|| fail "third-party failure must scroll to the detailed log"
grep -q '======== 第三方代理连接检测 ========' "$SETUP" \
|| fail "third-party inline diagnostics must include a structured test log"
grep -Fq 'Label("第 2 步:完成 \(client.name) 配置"' "$SETUP" \
|| fail "unverified clients must use a two-step import and configuration guide"
grep -Fq 'Text("请在 \(client.name) 中完成相应配置。")' "$SETUP" \
|| fail "unverified client configuration guidance must avoid unverified menu details"
! grep -q '进入模块、重写或覆写订阅入口' "$SETUP" \
|| fail "unverified clients must not claim untested menu locations"
grep -q '"当前客户端": client.name' "$SETUP" \
|| fail "third-party runtime diagnostics must identify the selected client"
for asset in ShadowrocketModuleImport ShadowrocketConfigDetails ShadowrocketHTTPSDecryption ShadowrocketHTTPSCA; do
test -s "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|| fail "missing Shadowrocket onboarding image asset: $asset"
grep -q '\.jpg' "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|| fail "Shadowrocket onboarding derivatives must use an Asset Catalog-supported JPEG: $asset"
grep -q "$asset" "$SETUP" || fail "setup does not reference onboarding image asset: $asset"
done
config_line="$(grep -n 'assetName: "ShadowrocketConfigDetails"' "$SETUP" | head -n 1 | cut -d: -f1)"
module_line="$(grep -n 'assetName: "ShadowrocketModuleImport"' "$SETUP" | head -n 1 | cut -d: -f1)"
test "$config_line" -lt "$module_line" || fail "Shadowrocket setup must show the configuration page before the module page"
! grep -q 'GeometryReader' "$SETUP" || fail "onboarding image markers must be baked into assets, not positioned at runtime"
test -s "$ROOT/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-import.jpg" \
|| fail "unannotated Shadowrocket source screenshots must be retained by client"
! 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.5"' "$ROOT/project.yml" || fail "marketing version must be 1.0.5"
grep -q 'CURRENT_PROJECT_VERSION: "6"' "$ROOT/project.yml" || fail "build version must be 6"
echo "PASS: third-party proxy mode contract"
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG="$ROOT/Shared/AppRemoteConfiguration.swift"
CONTENT="$ROOT/App/ContentView.swift"
MAP="$ROOT/App/MapHomeView.swift"
SETUP="$ROOT/App/FirstSetupView.swift"
SETTINGS="$ROOT/App/SettingsView.swift"
BUG_REPORT="$ROOT/App/BugReportView.swift"
GITHUB_SUBMISSION="$ROOT/Shared/GitHubSubmission.swift"
DISCUSSION_FORM="$ROOT/.github/DISCUSSION_TEMPLATE/第三方配置分享.yml"
ISSUE_FORM="$ROOT/.github/ISSUE_TEMPLATE/bug-report.yml"
ISSUE_CONFIG="$ROOT/.github/ISSUE_TEMPLATE/config.yml"
fail() {
echo "FAIL: $1" >&2
exit 1
}
python3 - "$ROOT/version.txt" <<'PY' || exit 1
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
config = json.load(handle)
assert config["latestVersion"] == "1.0.5"
assert config["minimumSupportedVersion"] == "1.0.0"
assert "shadowrocket" not in config["communityPromptClients"]
assert set(config["communityPromptClients"]) == {
"surge", "quantumultX", "loon", "stash", "egern"
}
PY
grep -q 'static let fallback = AppRemoteConfiguration' "$CONFIG" \
|| fail "the app must ship a built-in remote-configuration fallback"
grep -q 'timeoutIntervalForRequest = 1.5' "$CONFIG" \
|| fail "remote configuration requests must use a short timeout"
grep -q 'timeoutIntervalForResource = 2' "$CONFIG" \
|| fail "remote configuration resource loading must use a short timeout"
grep -q 'gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt' "$CONFIG" \
|| fail "update detection must prefer the domestic GitHub Raw mirror"
grep -q 'static let configurationURLs = \[' "$CONFIG" \
|| fail "update detection must retain multiple configuration sources"
! grep -q 'data.count' "$CONFIG" \
|| fail "the client must not impose a remote configuration file-size limit"
grep -q 'docs/releases/v\\(version).md' "$CONFIG" \
|| fail "update notes must come from the archived release document"
grep -q '/releases/latest' "$CONFIG" \
|| fail "missing release notes must fall back to the latest Release page"
grep -q '.task { await checkForUpdates() }' "$CONTENT" \
|| fail "update detection must run asynchronously outside bootstrap"
! grep -A90 'private func bootstrap() async' "$CONTENT" | grep -q 'fetch()' \
|| fail "remote configuration must not block the startup gate"
grep -Fq 'Label("检查更新"' "$SETTINGS" \
|| fail "Settings must expose a manual update check"
grep -Fq 'title: Text("已是最新版本")' "$SETTINGS" \
|| fail "manual update checks must report the current-version result"
grep -Fq 'title: Text("检查更新失败")' "$SETTINGS" \
|| fail "manual update checks must report network failures"
grep -q '社区分享成功配置?' "$MAP" \
|| fail "non-Shadowrocket success must offer community contribution"
grep -q 'Button("去提交")' "$MAP" \
|| fail "community contribution prompt must expose the submit action"
grep -q 'Button("复制模板")' "$MAP" \
|| fail "community contribution prompt must expose an explicit copy action"
grep -q 'Button("取消", role: .cancel)' "$MAP" \
|| fail "the first community prompts must expose cancel"
grep -q 'Button("不再提示", role: .cancel)' "$MAP" \
|| fail "the third community prompt must allow permanent suppression"
grep -q '匿名收录,不在 README 展示投稿账号' "$GITHUB_SUBMISSION" \
|| fail "the contribution template must offer anonymous README attribution"
grep -Fq 'https://github.com/xweiba/location-spoofer/discussions/new?category=%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB' "$GITHUB_SUBMISSION" \
|| fail "community submission must use one fixed Discussions address"
grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' "$MAP" \
|| fail "the explicit copy action must copy the contribution template"
grep -A8 'Button("去提交")' "$MAP" | grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' \
|| fail "the submit action must copy the template before opening GitHub"
grep -A8 'Button("去提交")' "$MAP" | grep -q 'openCommunityContributionPage()' \
|| fail "the submit action must open the fixed Discussions page after copying"
! grep -A12 'private func openCommunityContributionPage' "$MAP" | grep -q 'URLComponents' \
|| fail "the fixed community address must not be rebuilt from dynamic query items"
grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'SafariDestination' \
|| fail "community submission must open with the shared in-App Safari destination"
! grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'UIApplication.shared.open' \
|| fail "community submission must not be intercepted by an external GitHub client"
test -s "$DISCUSSION_FORM" \
|| fail "the fixed Discussions category must provide a maintained submission form"
grep -q 'label: 第三方客户端' "$DISCUSSION_FORM" \
|| fail "the Discussions form must classify submissions by client"
grep -q '匿名收录,不在 README 展示投稿账号' "$DISCUSSION_FORM" \
|| fail "the Discussions form must retain anonymous README attribution"
grep -q '请先移除账号、订阅、密码、设备标识、真实位置等敏感信息' "$DISCUSSION_FORM" \
|| fail "the Discussions form must remind users to remove sensitive information"
grep -q '截图除敏感信息遮挡外不要添加箭头、编号或说明文字' "$DISCUSSION_FORM" \
|| fail "the Discussions form must explain the screenshot annotation rule"
! grep -q 'label: 隐私确认' "$DISCUSSION_FORM" \
|| fail "the Discussions form must not require a separate privacy confirmation"
for field in \
'## 第三方客户端' \
'## 第三方客户端版本' \
'## iOS 版本' \
'## 配置步骤' \
'## 截图与补充说明' \
'## README 收录署名'; do
grep -Fq "$field" "$GITHUB_SUBMISSION" \
|| fail "the App contribution template must match Discussions field: $field"
done
grep -Fq 'label: 第三方客户端版本' "$DISCUSSION_FORM" \
|| fail "the Discussions form must clearly request the third-party client version"
grep -Fq 'https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml' "$GITHUB_SUBMISSION" \
|| fail "the App bug report must open the dedicated Issue form"
grep -Fq 'GitHubSubmission.bugReportURL' "$BUG_REPORT" \
|| fail "the App bug report must use the shared GitHub destination"
grep -Fq 'SafariView(url: destination.url)' "$BUG_REPORT" \
|| fail "the App bug report must open in the shared in-App Safari view"
! grep -q 'UIApplication.shared.open' "$BUG_REPORT" \
|| fail "the App bug report must not be handed to an external GitHub client"
grep -Fq 'App 生成的诊断报告' "$BUG_REPORT" \
|| fail "the App must tell users where to paste the generated report"
grep -Fq '第三方客户端:' "$BUG_REPORT" \
|| fail "the generated report must identify the selected third-party client"
grep -Fq 'Label("报告 Bug"' "$SETTINGS" \
|| fail "Settings must identify the support action as a bug report"
for action in '使用帮助' '功能建议' '分享第三方配置'; do
grep -Fq "Label(\"$action\"" "$SETTINGS" \
|| fail "Settings support must expose: $action"
done
grep -A8 'Label("分享第三方配置"' "$SETTINGS" >/dev/null \
|| fail "Settings must retain the manual third-party sharing action"
grep -q 'GitHubSubmission.communityContributionTemplate' "$SETTINGS" \
|| fail "Settings manual sharing must copy the shared contribution template"
grep -q 'SafariView(url: destination.url)' "$SETTINGS" \
|| fail "Settings GitHub support actions must use the in-App Safari view"
test -s "$ISSUE_FORM" \
|| fail "the repository must provide a bug report Issue form"
grep -Fq 'label: App 生成的诊断报告' "$ISSUE_FORM" \
|| fail "the Issue form must expose the field named by the App"
grep -Fq '设置 → 支持 → 报告 Bug' "$ISSUE_FORM" \
|| fail "the Issue form must point users to the matching App workflow"
grep -Fq 'label: 我已移除真实位置、认证信息、订阅地址、证书私钥和其他敏感信息' "$ISSUE_FORM" \
|| fail "the Issue form must require privacy confirmation"
grep -Fq 'blank_issues_enabled: false' "$ISSUE_CONFIG" \
|| fail "blank Issues must be disabled so reports use the maintained form"
for destination in '使用帮助' '功能建议' '第三方配置分享'; do
grep -Fq "name: $destination" "$ISSUE_CONFIG" \
|| fail "Issue chooser must route non-bug request to $destination"
done
for obsolete in \
'我已配置,开始检测' \
'确认完成,重新检测' \
'下一步:导入配置' \
'我已导入,检测接口连接'; do
! grep -q "$obsolete" "$SETUP" \
|| fail "setup footer must not retain dynamic label: $obsolete"
done
test "$(grep -c 'actionLabel("完成")' "$SETUP")" -eq 4 \
|| fail "all setup footer primary actions must use the fixed 完成 label"
application_line="$(grep -n 'Section("应用")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
certificate_line="$(grep -n 'Section("证书")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
test "$application_line" -lt "$certificate_line" \
|| fail "the APP certificate reset section must appear below the application section"
echo "PASS: update and community contribution contract"
+6
View File
@@ -0,0 +1,6 @@
# Third-Party Notices
The generated proxy scripts bundle:
- `pako` 2.1.0, Copyright (C) 2014-2017 Vitaly Puzrin and contributors, MIT License.
- `esbuild` is used only as a development/build dependency and is distributed under the MIT License.
+19
View File
@@ -0,0 +1,19 @@
import { build } from "esbuild";
import { mkdir } from "node:fs/promises";
await mkdir(new URL("./dist/v1/", import.meta.url), { recursive: true });
for (const [entry, outfile] of [
["src/response-entry.js", "dist/v1/wloc.js"],
["src/settings-entry.js", "dist/v1/wloc-settings.js"]
]) {
await build({
entryPoints: [entry],
outfile,
bundle: true,
format: "iife",
target: ["es2017"],
minify: true,
legalComments: "eof"
});
}
+1
View File
@@ -0,0 +1 @@
(()=>{var y="1.0.0";var m=["wifi","cellTower","arpc","marker","synthetic","bare","motionSimulation"],c="locationSpoofer.settings.v1";function u(){return typeof $task!="undefined"?"quantumultX":typeof $loon!="undefined"?"loon":typeof $rocket!="undefined"?"shadowrocket":typeof Egern!="undefined"?"egern":typeof $environment!="undefined"&&$environment["stash-version"]?"stash":typeof $environment!="undefined"&&$environment["surge-version"]?"surge":"unknown"}function h(e){let t=u()==="quantumultX"?$prefs.valueForKey(e):$persistentStore.read(e);if(!t)return null;try{return JSON.parse(t)}catch(n){return null}}function l(e,t){let n=t==null?"":JSON.stringify(t);return u()==="quantumultX"?$prefs.setValueForKey(n,e):$persistentStore.write(n,e)}function i(e){let t={status:200,headers:{"Content-Type":"application/json; charset=utf-8"},body:JSON.stringify(e)};u()==="quantumultX"?$done(Object.assign({},t,{status:"HTTP/1.1 200 OK"})):u()==="stash"?$done(t):$done({response:t})}function g(e){let t=e.split("?")[1]||"",n={};return t.split("&").forEach(s=>{if(!s)return;let o=s.indexOf("="),f=o<0?s:s.slice(0,o),d=o<0?"":s.slice(o+1),a=f,p=d;try{a=decodeURIComponent(f.replace(/\+/g," "))}catch($){}try{p=decodeURIComponent(d.replace(/\+/g," "))}catch($){}Object.prototype.hasOwnProperty.call(n,a)||(n[a]=p)}),n}function O(e){let t=e.split("?")[0],n=t.indexOf("://");if(n<0)return t;let s=t.indexOf("/",n+3);return s<0?"/":t.slice(s)}var b=typeof $request=="undefined"?"":$request.url||"",E=O(b),r=g(b);if(E==="/wloc-settings/version")i({success:!0,moduleVersion:y,protocolVersion:1,capabilities:m});else if(r.action==="query"){let e=h(c);i(e&&e.enabled?{success:!0,longitude:e.longitude,latitude:e.latitude,accuracy:e.accuracy,motionSimulationEnabled:e.motionSimulationEnabled===!0}:{success:!1,error:"\u65E0\u5DF2\u4FDD\u5B58\u7684\u5750\u6807"})}else if(r.action==="clear")l(c,null),i({success:!0});else{let e=Number(r.lon!=null?r.lon:r.longitude),t=Number(r.lat!=null?r.lat:r.latitude),n=Number(r.acc!=null?r.acc:r.accuracy!=null?r.accuracy:25);if(!Number.isFinite(e)||!Number.isFinite(t))i({success:!1,error:"\u7F3A\u5C11 lon/lat \u53C2\u6570"});else{let s={enabled:!0,longitude:e,latitude:t,accuracy:n,motionSimulationEnabled:r.motion==="1"},o=l(c,s);i(o?{success:!0,longitude:e,latitude:t,accuracy:n,motionSimulationEnabled:s.motionSimulationEnabled}:{success:!1,error:"\u4FDD\u5B58\u914D\u7F6E\u5931\u8D25"})}}})();
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
#!name=Apple WLOC 定位修改
#!desc=Location Spoofer 第三方代理模块
#!author=xweiba
#!homepage=https://github.com/xweiba/location-spoofer
[rewrite_local]
^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc url script-response-body https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version) url script-echo-response https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
[mitm]
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
+11
View File
@@ -0,0 +1,11 @@
#!name=Apple WLOC 定位修改
#!desc=Location Spoofer 第三方代理模块
#!author=xweiba
#!homepage=https://github.com/xweiba/location-spoofer
[Script]
http-response ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version) script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js, timeout=10, tag=WLOC Settings
[MITM]
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
+12
View File
@@ -0,0 +1,12 @@
#!name=Apple WLOC 定位修改
#!desc=Location Spoofer 第三方代理模块
#!author=xweiba
#!homepage=https://github.com/xweiba/location-spoofer
#!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/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version),requires-body=0,max-size=0,timeout=10,script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
[MITM]
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
+12
View File
@@ -0,0 +1,12 @@
#!name=Apple WLOC 定位修改
#!desc=Location Spoofer 第三方代理模块
#!author=xweiba
#!homepage=https://github.com/xweiba/location-spoofer
#!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/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version)", requires-body=0, max-size=0, timeout=10, script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
[MITM]
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
+31
View File
@@ -0,0 +1,31 @@
name: Apple WLOC 定位修改
desc: Location Spoofer 第三方代理模块
author: xweiba
homepage: https://github.com/xweiba/location-spoofer
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
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version)
name: WLOC.Settings
type: request
require-body: false
timeout: 10
script-providers:
WLOC.Location:
url: https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
interval: 86400
WLOC.Settings:
url: https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
interval: 86400
+508
View File
@@ -0,0 +1,508 @@
{
"name": "@location-spoofer/wloc-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@location-spoofer/wloc-scripts",
"version": "1.0.0",
"dependencies": {
"pako": "2.1.0"
},
"devDependencies": {
"esbuild": "0.25.8"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
"integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
"integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
"integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
"integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
"integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
"integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
"integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
"integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
"integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
"integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
"integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
"integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
"integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
"integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
"integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
"integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
"integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
"integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
"integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
"integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
"integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
"integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
"integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
"integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
"integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.25.8",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.8.tgz",
"integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.8",
"@esbuild/android-arm": "0.25.8",
"@esbuild/android-arm64": "0.25.8",
"@esbuild/android-x64": "0.25.8",
"@esbuild/darwin-arm64": "0.25.8",
"@esbuild/darwin-x64": "0.25.8",
"@esbuild/freebsd-arm64": "0.25.8",
"@esbuild/freebsd-x64": "0.25.8",
"@esbuild/linux-arm": "0.25.8",
"@esbuild/linux-arm64": "0.25.8",
"@esbuild/linux-ia32": "0.25.8",
"@esbuild/linux-loong64": "0.25.8",
"@esbuild/linux-mips64el": "0.25.8",
"@esbuild/linux-ppc64": "0.25.8",
"@esbuild/linux-riscv64": "0.25.8",
"@esbuild/linux-s390x": "0.25.8",
"@esbuild/linux-x64": "0.25.8",
"@esbuild/netbsd-arm64": "0.25.8",
"@esbuild/netbsd-x64": "0.25.8",
"@esbuild/openbsd-arm64": "0.25.8",
"@esbuild/openbsd-x64": "0.25.8",
"@esbuild/openharmony-arm64": "0.25.8",
"@esbuild/sunos-x64": "0.25.8",
"@esbuild/win32-arm64": "0.25.8",
"@esbuild/win32-ia32": "0.25.8",
"@esbuild/win32-x64": "0.25.8"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@location-spoofer/wloc-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs",
"test": "node --test"
},
"dependencies": {
"pako": "2.1.0"
},
"devDependencies": {
"esbuild": "0.25.8"
}
}
+263
View File
@@ -0,0 +1,263 @@
export const MOTION_ACTIVITY_TYPE = 63;
export const MOTION_ACTIVITY_CONFIDENCE = 467;
export const WLOC_MARKER = Uint8Array.from([0, 0, 0, 1, 0, 0]);
const UINT32_RANGE = 0x100000000;
const concat = (...parts) => {
const length = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
};
const equal = (left, right) =>
left.length === right.length && left.every((value, index) => value === right[index]);
function readVarint(data, offset) {
let value = 0;
let multiplier = 1;
for (let index = 0; index < 10 && offset + index < data.length; index += 1) {
const byte = data[offset + index];
const chunk = byte & 0x7f;
if (value !== null && chunk <= Math.floor((Number.MAX_SAFE_INTEGER - value) / multiplier)) {
value += chunk * multiplier;
} else {
value = null;
}
if ((byte & 0x80) === 0) return { value, next: offset + index + 1 };
multiplier *= 0x80;
}
throw new Error("invalid varint");
}
function writeVarint(input) {
const value = Math.trunc(Number(input));
if (!Number.isSafeInteger(value)) throw new Error("varint value is not a safe integer");
let low = value >>> 0;
let high = Math.floor(value / UINT32_RANGE) >>> 0;
const bytes = [];
do {
const byte = low & 0x7f;
low = ((low >>> 7) | ((high & 0x7f) << 25)) >>> 0;
high >>>= 7;
const hasMore = high !== 0 || low !== 0;
bytes.push(byte | (hasMore ? 0x80 : 0));
} while (high !== 0 || low !== 0);
return Uint8Array.from(bytes);
}
const writeTag = (number, wireType) => writeVarint((number << 3) | wireType);
function writeLengthDelimited(number, value) {
return concat(writeTag(number, 2), writeVarint(value.length), value);
}
export function parseFields(data) {
const fields = [];
let offset = 0;
while (offset < data.length) {
const start = offset;
const tag = readVarint(data, offset);
offset = tag.next;
if (!Number.isSafeInteger(tag.value)) throw new Error("protobuf tag is too large");
const number = Math.floor(tag.value / 8);
const wireType = tag.value & 7;
if (number === 0) throw new Error("invalid protobuf field 0");
let value;
if (wireType === 0) {
const item = readVarint(data, offset);
value = data.slice(offset, item.next);
offset = item.next;
} else if (wireType === 1) {
if (offset + 8 > data.length) throw new Error("truncated fixed64");
value = data.slice(offset, offset + 8);
offset += 8;
} else if (wireType === 2) {
const length = readVarint(data, offset);
offset = length.next;
const size = length.value;
if (!Number.isSafeInteger(size) || offset + size > data.length) {
throw new Error("truncated length-delimited field");
}
value = data.slice(offset, offset + size);
offset += size;
} else if (wireType === 5) {
if (offset + 4 > data.length) throw new Error("truncated fixed32");
value = data.slice(offset, offset + 4);
offset += 4;
} else {
throw new Error(`unsupported wire type ${wireType}`);
}
fields.push({ number, wireType, value, raw: data.slice(start, offset) });
}
return fields;
}
function patchLocation(data, config, stats) {
const fields = parseFields(data);
if (!fields.some((field) => field.number === 1 && field.wireType === 0) ||
!fields.some((field) => field.number === 2 && field.wireType === 0)) {
return data;
}
let hasMotionType = false;
let hasMotionConfidence = false;
const parts = fields.map((field) => {
if (field.wireType !== 0) return field.raw;
if (field.number === 1) return concat(writeTag(1, 0), writeVarint(Math.round(config.latitude * 1e8)));
if (field.number === 2) return concat(writeTag(2, 0), writeVarint(Math.round(config.longitude * 1e8)));
if (field.number === 3) return concat(writeTag(3, 0), writeVarint(config.accuracy));
if (config.motionSimulationEnabled && field.number === 11) {
hasMotionType = true;
return concat(writeTag(11, 0), writeVarint(MOTION_ACTIVITY_TYPE));
}
if (config.motionSimulationEnabled && field.number === 12) {
hasMotionConfidence = true;
return concat(writeTag(12, 0), writeVarint(MOTION_ACTIVITY_CONFIDENCE));
}
return field.raw;
});
if (config.motionSimulationEnabled && !hasMotionType) {
parts.push(concat(writeTag(11, 0), writeVarint(MOTION_ACTIVITY_TYPE)));
}
if (config.motionSimulationEnabled && !hasMotionConfidence) {
parts.push(concat(writeTag(12, 0), writeVarint(MOTION_ACTIVITY_CONFIDENCE)));
}
const out = concat(...parts);
if (!equal(out, data)) stats.locations += 1;
return out;
}
function patchWifi(data, config, stats) {
const fields = parseFields(data);
const hasMac = fields.some((field) =>
field.number === 1 && field.wireType === 2 &&
/^[0-9a-fA-F]{1,2}(:[0-9a-fA-F]{1,2}){5}$/.test(
Array.from(field.value, (byte) => String.fromCharCode(byte)).join("")
)
);
if (!hasMac) return data;
let changed = false;
const parts = fields.map((field) => {
if (field.number !== 2 || field.wireType !== 2) return field.raw;
const value = patchLocation(field.value, config, stats);
changed ||= !equal(value, field.value);
return writeLengthDelimited(2, value);
});
if (changed) stats.wifi += 1;
return concat(...parts);
}
function patchCell(data, config, stats) {
let changed = false;
const parts = parseFields(data).map((field) => {
if (field.number !== 5 || field.wireType !== 2) return field.raw;
const value = patchLocation(field.value, config, stats);
changed ||= !equal(value, field.value);
return writeLengthDelimited(5, value);
});
if (changed) stats.cell += 1;
return concat(...parts);
}
export function patchPayload(data, config, stats = { wifi: 0, cell: 0, locations: 0 }) {
const parts = parseFields(data).map((field) => {
if (field.number === 2 && field.wireType === 2) {
return writeLengthDelimited(2, patchWifi(field.value, config, stats));
}
if ((field.number === 22 || field.number === 24) && field.wireType === 2) {
return writeLengthDelimited(field.number, patchCell(field.value, config, stats));
}
return field.raw;
});
return { data: concat(...parts), stats };
}
const uint16 = (data, offset) => (data[offset] << 8) | data[offset + 1];
const uint32 = (data, offset) =>
((data[offset] * 0x1000000) + (data[offset + 1] << 16) +
(data[offset + 2] << 8) + data[offset + 3]) >>> 0;
const writeUint16 = (value) => Uint8Array.from([(value >>> 8) & 0xff, value & 0xff]);
const writeUint32 = (value) => Uint8Array.from([
(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff
]);
function findBytes(data, marker) {
outer: for (let offset = 0; offset <= data.length - marker.length; offset += 1) {
for (let index = 0; index < marker.length; index += 1) {
if (data[offset + index] !== marker[index]) continue outer;
}
return offset;
}
return -1;
}
function patchARPC(body, config) {
if (body.length < 2) throw new Error("short ARPC");
let offset = 2;
for (let index = 0; index < 3; index += 1) {
if (offset + 2 > body.length) throw new Error("truncated ARPC string");
offset += 2 + uint16(body, offset);
}
const lengthOffset = offset + 4;
const payloadOffset = lengthOffset + 4;
if (payloadOffset > body.length) throw new Error("truncated ARPC header");
const length = uint32(body, lengthOffset);
if (!length || payloadOffset + length > body.length) throw new Error("invalid ARPC length");
const patched = patchPayload(body.slice(payloadOffset, payloadOffset + length), config);
if (equal(patched.data, body.slice(payloadOffset, payloadOffset + length))) throw new Error("unchanged ARPC");
return { data: concat(body.slice(0, lengthOffset), writeUint32(patched.data.length),
patched.data, body.slice(payloadOffset + length)), stats: patched.stats };
}
function patchMarker(body, config) {
const markerOffset = findBytes(body, WLOC_MARKER);
if (markerOffset < 0) throw new Error("marker not found");
const lengthOffset = markerOffset + WLOC_MARKER.length;
const payloadOffset = lengthOffset + 2;
const length = uint16(body, lengthOffset);
if (!length || payloadOffset + length > body.length) throw new Error("invalid marker length");
const patched = patchPayload(body.slice(payloadOffset, payloadOffset + length), config);
if (patched.data.length > 65535 || equal(patched.data, body.slice(payloadOffset, payloadOffset + length))) {
throw new Error("unchanged marker");
}
return { data: concat(body.slice(0, lengthOffset), writeUint16(patched.data.length),
patched.data, body.slice(payloadOffset + length)), stats: patched.stats };
}
function patchSynthetic(body, offset, config) {
if (offset + 10 > body.length) throw new Error("short frame");
const length = uint16(body, offset + 8);
if (!length || offset + 10 + length > body.length) throw new Error("invalid frame");
const patched = patchPayload(body.slice(offset + 10, offset + 10 + length), config);
if (patched.data.length > 65535 || equal(patched.data, body.slice(offset + 10, offset + 10 + length))) {
throw new Error("unchanged frame");
}
return { data: concat(body.slice(0, offset + 8), writeUint16(patched.data.length),
patched.data, body.slice(offset + 10 + length)), stats: patched.stats };
}
export function patchWlocBody(body, config) {
for (const patcher of [patchARPC, patchMarker]) {
try { return patcher(body, config); } catch {}
}
const offsets = [...new Set([0, 2, 4, 6, 8, 10, 12, 14, 16,
...Array.from({ length: Math.min(96, Math.max(0, body.length - 10)) + 1 }, (_, index) => index)])];
for (const offset of offsets) {
try { return patchSynthetic(body, offset, config); } catch {}
}
for (let offset = 0; offset <= Math.min(256, body.length); offset += 1) {
try {
const patched = patchPayload(body.slice(offset), config);
if (!equal(patched.data, body.slice(offset))) {
return { data: concat(body.slice(0, offset), patched.data), stats: patched.stats };
}
} catch {}
}
throw new Error("no patchable wloc payload found");
}
export const internals = { concat, writeVarint, writeTag, writeLengthDelimited, equal };
+26
View File
@@ -0,0 +1,26 @@
import { ungzip } from "pako";
import { patchWlocBody } from "./core.js";
import {
STORAGE_KEY, finishBinary, finishPassthrough, readPersistent, responseBytes
} from "./runtime.js";
try {
const settings = readPersistent(STORAGE_KEY);
const input = responseBytes();
if (!settings || !settings.enabled || !input.length) {
finishPassthrough();
} else {
const isGzip = input.length >= 2 && input[0] === 0x1f && input[1] === 0x8b;
const body = isGzip ? ungzip(input) : input;
const patched = patchWlocBody(body, {
latitude: Number(settings.latitude),
longitude: Number(settings.longitude),
accuracy: Number(settings.accuracy != null ? settings.accuracy : 25),
motionSimulationEnabled: settings.motionSimulationEnabled === true
});
finishBinary(patched.data);
}
} catch (error) {
console.log(`[Location Spoofer] ${error && error.message ? error.message : error}`);
finishPassthrough();
}

Some files were not shown because too many files have changed in this diff Show More