64 Commits
Author SHA1 Message Date
xweiba 4aa4c6aa6c fix(update): 按语言加载三语更新日志并重发 v1.0.8 2026-09-24 23:36:23 +08:00
xweiba ca239411ee docs(release): 补充 v1.0.8 更新说明 2026-09-24 23:19:55 +08:00
xweiba 5b9fedfe9e fix(i18n): 修复首选语言回退并准备 v1.0.8 2026-09-24 23:19:21 +08:00
xweiba 228b67b210 docs: 英文 README 设为默认并添加 AGPL 许可 2026-09-24 15:31:47 +08:00
xweiba f4712f7fb2 ci: 契约测试接入 CI 与 make test 2026-09-11 14:20:50 +08:00
xweiba 393c7cc215 fix(version): 修正内置兜底版本号漂移并补版本一致性校验 2026-09-11 14:20:50 +08:00
xweiba 46c623ad81 chore: 准备 v1.0.7 发布 2026-09-11 11:55:03 +08:00
xweiba 68cd5fd31c fix(proxy): 恢复上游脚本与逐项域名复制 2026-09-11 11:41:30 +08:00
xweiba 27f6029f9e merge: 合并 PR #11 英文本地化 2026-09-11 02:39:10 +08:00
xweiba 05949703af fix(i18n): 完善国际化并迁移 WLOC 脚本 2026-09-11 02:39:00 +08:00
Syntax-Error-1337 908ac8f734 Add English localization (device-language based)
Add English as an overlay localization following the device language,
keeping Simplified Chinese as the base/development language. Untranslated
strings fall back to Chinese automatically.

- project.yml: set developmentLanguage zh-Hans and knownRegions (zh-Hans/en/Base)
- Resources/en.lproj/{Localizable,InfoPlist}.strings: ~230 English strings
- Resources/zh-Hans.lproj/InfoPlist.strings: Chinese permission prompts
- Restructure ternary Text/Label and String-typed UI messages so they
  localize (String(localized:), LocalizedStringKey helper params); replace
  a .contains("通过") success check with a dedicated flag
- Localize user-facing model/coordinator/error messages across App and Shared

Internal RuntimeLogger logs and diagnostic/report bodies remain in Chinese.
2026-09-10 01:05:45 +08:00
xweiba eadeca636d chore: prepare v1.0.6 release 2026-09-01 18:17:34 +08:00
xweiba b530d52add feat: drop script version detection; split motion vs perturbation by mode
Third-party mode no longer probes /wloc-settings/version or capability
metadata since the upstream scripts are unmaintained. Settings now show:
- APP mode: motion-state simulation toggle only
- third-party mode: random-perturbation (randomRadius) toggle only

save() sends randomRadius so upstream wloc.js applies the offset.
2026-09-01 18:10:01 +08:00
xweiba 135e26ea7a feat: point third-party mode at upstream Yu9191 modules
- subscription URLs now resolve directly to upstream modules (gh-proxy
  mirror / raw), dropping project-owned module and script copies
- remove Resources/ThirdPartyProxyModules and ThirdParty/WlocScripts,
  their pbxproj references, and the contract-test module checks
- third-party save sends lon/lat/acc matching the upstream settings
  script; motion-state simulation is unavailable (upstream lacks it)
- surface the iOS 27 beta 6 gs-loc MITM limitation in the app setup and
  README (zh/en)
2026-09-01 17:57:27 +08:00
xweiba d35d5c788a feat: vendor upstream modules verbatim, mirror via gh-proxy
All five module files now match Yu9191/wloc exactly (author, description,
icon, wloc-settings/save pattern, argument config). The only difference is
the mirror variant wraps GitHub Raw URLs in gh-proxy for regions where raw
is unreachable.
2026-09-01 17:42:34 +08:00
xweiba cce2aef58f feat: wire randomRadius perturbation argument into all modules
The upstream wloc.js already applies randomRadius. Surf/Loon/Stash/
Shadowrocket modules now pass the perturbation argument through so users
can configure it in the proxy client UI (QX rewrite cannot carry args).
2026-09-01 17:41:23 +08:00
xweiba 9cea68392e fix: point third-party modules at upstream wloc scripts
Modules now load wloc.js and wloc-settings.js directly from Yu9191/wloc
(raw for direct, gh-proxy for the mirror path) instead of the project's
own dist copies, so proxy clients run the battle-tested upstream scripts.
The Shadowrocket module also carries the upstream argument config and icon.
2026-09-01 17:40:26 +08:00
xweiba fb0ca961a4 fix: sync third-party wloc scripts with upstream
Vendor the Yu9191/wloc dist scripts so the third-party modules run the
battle-tested response/settings handlers instead of the rewritten ones.
The upstream scripts read the wloc_settings key, module $argument, and
handle the save/query/clear settings endpoints.
2026-09-01 17:33:15 +08:00
xweiba a666f177e6 fix: intercept gsp-ssl.ls.apple.com and bluedot wloc endpoints
Devices in China can send clls/wloc requests to gsp-ssl.ls.apple.com or
bluedot.is.autonavi.com instead of gs-loc.apple.com, so the third-party
modules only MITM'd gs-loc hosts and silently failed to spoof location.

- Add gsp-ssl.ls.apple.com, bluedot.is.autonavi.com and
  bluedot.is.autonavi.com.gds.alibabadns.com to all 10 module files
  (pattern + MITM hostname) and to the built-in proxy isWlocHost
- Update app interceptionHostnames and the copy-hostnames guidance
- Bump module subscription version to 1.0.1 to bust proxy client cache
2026-09-01 17:08:34 +08:00
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
152 changed files with 13348 additions and 1575 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: 分享已验证的第三方客户端模块、配置步骤和脱敏截图
+23
View File
@@ -0,0 +1,23 @@
name: Contract Tests
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
contract-tests:
runs-on: macos-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run contract tests
run: ./Tests/run_all_contract_tests.sh
+23 -12
View File
@@ -28,27 +28,38 @@ jobs:
- name: Install XcodeGen - name: Install XcodeGen
run: brew install xcodegen run: brew install xcodegen
- name: Run contract tests
run: ./Tests/run_all_contract_tests.sh
- name: Build unsigned IPA - name: Build unsigned IPA
run: ./build.sh run: ./build.sh
- name: Rename IPA for release - name: Rename IPA for release
run: mv dist/PaopaoLocationSpoofer-unsigned.ipa dist/Location-Spoofer-unsigned.ipa run: mv dist/PaopaoLocationSpoofer-unsigned.ipa dist/Location-Spoofer-unsigned.ipa
- name: Read release notes - name: Validate archived release notes
id: changelog
run: | run: |
VERSION="${{ github.ref_name }}" VERSION="${{ github.ref_name }}"
NOTES_FILE="docs/releases/${VERSION}.md" for NOTES_FILE in \
if [ -f "$NOTES_FILE" ]; then "docs/releases/${VERSION}.en.md" \
cat "$NOTES_FILE" > /tmp/release_notes.md "docs/releases/${VERSION}.md" \
else "docs/releases/${VERSION}.zh-Hant.md"; do
echo "⚠️ $NOTES_FILE 未找到,请创建该文件。" > /tmp/release_notes.md if [ ! -s "$NOTES_FILE" ]; then
fi echo "::error::Missing localized release notes: $NOTES_FILE"
exit 1
fi
if ! grep -Fq "# ${VERSION}" "$NOTES_FILE"; then
echo "::error::$NOTES_FILE must contain the # ${VERSION} heading"
exit 1
fi
done
{ {
echo "notes<<EOF" cat "docs/releases/${VERSION}.en.md"
cat /tmp/release_notes.md printf '\n---\n\n'
echo "EOF" cat "docs/releases/${VERSION}.md"
} >> $GITHUB_OUTPUT printf '\n---\n\n'
cat "docs/releases/${VERSION}.zh-Hant.md"
} > /tmp/release_notes.md
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
+7
View File
@@ -2,6 +2,9 @@
.superpowers/ .superpowers/
build/ build/
dist/ dist/
!ThirdParty/WlocScripts/dist/
!ThirdParty/WlocScripts/dist/**
node_modules/
DerivedData/ DerivedData/
xcuserdata/ xcuserdata/
*.xcuserstate *.xcuserstate
@@ -23,5 +26,9 @@ Wloc.xcodeproj/
.github/* .github/*
!.github/workflows/ !.github/workflows/
!.github/workflows/** !.github/workflows/**
!.github/DISCUSSION_TEMPLATE/
!.github/DISCUSSION_TEMPLATE/**
!.github/ISSUE_TEMPLATE/
!.github/ISSUE_TEMPLATE/**
Core/core Core/core
docs/superpowers/ docs/superpowers/
+57 -26
View File
@@ -7,13 +7,17 @@ struct BugReportView: View {
@State private var isReproducible = true @State private var isReproducible = true
@State private var isRunning = false @State private var isRunning = false
@State private var showCopiedAlert = 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// 说明 // 说明
Text("遇到问题时,在这里生成 Issue 报告。系统会运行一次诊断测试,将测试结果和你的描述一起复制到剪切板,然后跳转到 GitHub 提交。") Text("遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板。跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段。")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -52,7 +56,7 @@ struct BugReportView: View {
if isRunning { if isRunning {
ProgressView().tint(.white) ProgressView().tint(.white)
} }
Text(isRunning ? "正在生成报告…" : "生成 Issue 报告") (isRunning ? Text("正在生成报告…") : Text("生成 Bug 报告"))
.font(.body.weight(.medium)) .font(.body.weight(.medium))
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -65,29 +69,41 @@ struct BugReportView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.bottom, 12) .padding(.bottom, 12)
} }
.navigationTitle("报告 Issue") .navigationTitle("报告 Bug")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.alert("已生成", isPresented: $showCopiedAlert) { .alert("已生成", isPresented: $showCopiedAlert) {
Button("跳转到 GitHub") { Button("打开 GitHub 表单") {
dismiss() githubDestination = SafariDestination(url: GitHubSubmission.bugReportURL)
if let url = URL(string: "https://github.com/xweiba/location-spoofer/issues/new") {
UIApplication.shared.open(url)
}
} }
Button("稍后再说", role: .cancel) { Button("稍后再说", role: .cancel) {
dismiss() dismiss()
} }
} message: { } message: {
Text("Issue 报告已复制到剪切板。请在 GitHub Issues 页面粘贴并提交。") Text("Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。")
}
.sheet(item: $githubDestination) { destination in
SafariView(url: destination.url)
.ignoresSafeArea()
} }
} }
private func generateReport() { private func generateReport() {
isRunning = true isRunning = true
Task { Task {
// 跑测试 let testLog: String
_ = await setup.runVerificationTest(testLat: 22.543099, testLon: 113.934576) if runtimeMode.mode == .thirdParty {
let testLog = setup.testLog do {
let response = try await thirdPartyProxy.query()
let active = response.success && response.latitude != nil && response.longitude != nil
let savedCoordinate = active ? AppLocalization.string("是") : AppLocalization.string("否")
testLog = AppLocalization.string("第三方代理测试模式:模块连接成功;已保存坐标=\(savedCoordinate)")
} catch {
testLog = AppLocalization.string("第三方代理测试模式:模块连接失败;\(error.localizedDescription)")
}
} else {
_ = await setup.runVerificationTest()
testLog = setup.testLog
}
// 获取版本信息 // 获取版本信息
let appVersion: String = { let appVersion: String = {
@@ -98,20 +114,11 @@ struct BugReportView: View {
let systemVersion = UIDevice.current.systemVersion let systemVersion = UIDevice.current.systemVersion
// 拼接报告 // 拼接报告
let report = """ let report = bugReport(
### 环境信息 appVersion: appVersion,
App 版本: \(appVersion) systemVersion: systemVersion,
系统版本: iOS \(systemVersion) testLog: testLog
可复现环境: \(isReproducible ? "是" : "否") )
### 问题描述
\(description.trimmingCharacters(in: .whitespacesAndNewlines))
### 诊断日志
```
\(testLog.isEmpty ? "(无诊断数据)" : testLog)
```
"""
// 复制到剪切板 // 复制到剪切板
UIPasteboard.general.string = report UIPasteboard.general.string = report
@@ -121,4 +128,28 @@ struct BugReportView: View {
showCopiedAlert = true showCopiedAlert = true
} }
} }
private func bugReport(appVersion: String, systemVersion: String, testLog: String) -> String {
let client = runtimeMode.mode == .thirdParty
? thirdPartyClient.selectedClient.name
: AppLocalization.string("不适用")
let reproducible = isReproducible ? AppLocalization.string("是") : AppLocalization.string("否")
let diagnostics = testLog.isEmpty ? AppLocalization.string("(无诊断数据)") : testLog
return """
### \(AppLocalization.string("环境信息"))
\(AppLocalization.string("App 版本")): \(appVersion)
\(AppLocalization.string("系统版本")): iOS \(systemVersion)
\(AppLocalization.string("运行模式")): \(runtimeMode.mode.displayName)
\(AppLocalization.string("第三方客户端")): \(client)
\(AppLocalization.string("可复现环境")): \(reproducible)
### \(AppLocalization.string("问题描述"))
\(description.trimmingCharacters(in: .whitespacesAndNewlines))
### \(AppLocalization.string("诊断日志"))
```
\(diagnostics)
```
"""
}
} }
-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 SwiftUI
import UIKit
struct ContentView: View { struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var setup = SetupCoordinator() @StateObject private var setup = SetupCoordinator()
@ObservedObject private var net = NetworkMonitor.shared @ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@State private var showSetup = false @ObservedObject private var remoteConfiguration = AppRemoteConfigurationStore.shared
@State private var showEnableTip = false @State private var phase: AppPhase = .splash
@AppStorage("setupCompleted") private var setupCompleted = false @State private var updatePrompt: AppUpdatePrompt?
@State private var requiredUpdatePrompt: AppUpdatePrompt?
enum AppPhase { case splash, setup, map }
var body: some View { var body: some View {
NavigationView { Group {
MapHomeView(setup: setup) switch phase {
} case .splash:
.task { VStack(spacing: 16) {
// 首次打开无标记:必须进引导页 Image(systemName: "location.fill")
if !setupCompleted { .font(.system(size: 48)).foregroundStyle(.blue)
showSetup = true ProgressView()
return (runtimeMode.hasSelectedMode && runtimeMode.mode == .localWiFi
? Text("正在初始化地图与本地代理…")
: Text("正在初始化地图…"))
.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 .task { await bootstrap() }
guard setupCompleted else { return } .task { await checkForUpdates() }
if airplane { .onChange(of: scenePhase) { newPhase in
showEnableTip = true 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 { } else {
showEnableTip = false ProxyManager.shared.stop()
Task { await setup.refreshTrust() } BackgroundKeepAlive.shared.stop()
setup.requestThirdPartyOnboarding()
} }
phase = .setup
return
} }
.fullScreenCover(isPresented: $showSetup) {
FirstSetupView(setup: setup, onComplete: { if launchMode == .localWiFi {
setupCompleted = true await setup.prepareLocalServices()
setup.completeSetup() } else {
showSetup = false ProxyManager.shared.stop()
}) BackgroundKeepAlive.shared.stop()
RuntimeLogger.info("APP", "Startup", "第三方代理测试模式:跳过本地 CA、代理和环境检测")
} }
// 设置页「进入引导页」入口联动 do {
.onChange(of: setup.needsSetup) { needs in try CoordinateStorageMigration.migrateIfNeeded(favorites: FavoriteLocationStore())
if needs { showSetup = true } } catch {
RuntimeLogger.error("APP", "Startup", "旧坐标数据迁移失败,将在下次启动重试", error: error)
} }
.sheet(isPresented: $showEnableTip) {
NavigationView { // MapHomeView is intentionally constructed only after this required
VStack(spacing: 20) { // coordinate-system gate resolves, so cached pins are never replayed
Image(systemName: "airplane") // into an unknown Apple Maps coordinate system.
.font(.system(size: 48)) let mapCoordinateSystem = await CoordinateConverter.resolveInitialMapCoordinateSystem()
.foregroundStyle(.orange) guard !Task.isCancelled else { return }
Text("飞行模式已开启") RuntimeLogger.info("APP", "Startup", "地图坐标标准初始化完成,开始后续启动流程", details: [
.font(.title3.weight(.semibold)) "地图标准": mapCoordinateSystem.rawValue,
Text("Wi‑Fi 和蜂窝数据已关闭,虚拟定位无法生效。请关闭飞行模式后重试。") "使用兜底": String(CoordinateConverter.initialMapCoordinateSystemUsedFallback)
.font(.body) ])
.foregroundStyle(.secondary)
.multilineTextAlignment(.center) // Resolve the first map center before constructing MapHomeView. This
Button("知道了") { // prevents a Shenzhen/cache frame followed by a second realtime frame.
showEnableTip = false if LastCoordinateStore.load() == nil {
} RuntimeLogger.info("APP", "Startup", "没有持久化图钉,地图创建前请求实时定位")
.buttonStyle(.borderedProminent) if let realtime = await RealtimeLocationManager.shared.requestLocation() {
.frame(maxWidth: .infinity) let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(realtime)
} let pair = CoordinateConverter.coordinatePair(
.padding(30) lat: realtime.latitude,
.navigationTitle("提示") lon: realtime.longitude,
.navigationBarTitleDisplayMode(.inline) mapCoordinateSystem: .wgs84
.toolbar { )
ToolbarItem(placement: .navigationBarTrailing) { LastCoordinateStore.save(coordinatePair: pair, zoomMeters: 1_000)
Button("完成") { showEnableTip = false } 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
?? AppLocalization.string("更新说明暂时无法加载,请前往最新 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
?? AppLocalization.string("更新说明暂时无法加载,请前往最新 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
} }
} }
} }
+87 -17
View File
@@ -7,33 +7,62 @@ struct RuntimeLogsView: View {
let testFavorite: FavoriteLocation let testFavorite: FavoriteLocation
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ObservedObject private var proxy = ProxyManager.shared @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 entries: [RuntimeLogEntry] = []
@State private var isTesting = false @State private var isTesting = false
@State private var testResult = "" @State private var testResult = ""
@State private var testSucceeded = false
@State private var testMessage = "" @State private var testMessage = ""
@State private var showClearConfirm = false @State private var showClearConfirm = false
@State private var copiedEntryID: UUID? @State private var copiedEntryID: UUID?
@State private var copyLogsConfirmed = false @State private var copyLogsConfirmed = false
@State private var testLogCopied = 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.localizedMessage.localizedCaseInsensitiveContains(q)
|| $0.localizedCategory.localizedCaseInsensitiveContains(q)
}
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
testPanel 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() Divider()
if entries.isEmpty { if filteredEntries.isEmpty {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary) Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
Text("暂无运行日志").foregroundStyle(.secondary) (entries.isEmpty ? Text("暂无运行日志") : Text("无匹配日志")).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, maxHeight: .infinity) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 10) { LazyVStack(alignment: .leading, spacing: 10) {
ForEach(entries.reversed()) { entry in logRow(entry) } ForEach(filteredEntries.reversed()) { entry in logRow(entry) }
}.padding(12) }.padding(12)
} }
} }
} }
.navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline) .navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
Text("日志自动清理,仅保留近 3 天")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
}
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } } ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@@ -74,10 +103,17 @@ struct RuntimeLogsView: View {
Button { Button {
isTesting = true; testResult = ""; testLogCopied = false isTesting = true; testResult = ""; testLogCopied = false
Task { Task {
let result = await setup.runVerificationTest(testLat: testFavorite.latitude, testLon: testFavorite.longitude) if runtimeMode.mode == .thirdParty {
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)" await runThirdPartyConnectionTest()
if !result.isSuccess { testResult += ",查看下方日志" } } else {
testMessage = setup.testLog let result = await setup.runVerificationTest()
testSucceeded = result.isSuccess
testResult = result.isSuccess
? AppLocalization.string("环境检测通过")
: AppLocalization.string("环境检测失败: \(result.localizedTitle)")
if !result.isSuccess { testResult += AppLocalization.string(",查看下方日志") }
testMessage = setup.testLog
}
isTesting = false; refresh() isTesting = false; refresh()
} }
} label: { } label: {
@@ -87,7 +123,7 @@ struct RuntimeLogsView: View {
} else { } else {
Image(systemName: "play.fill").font(.system(size: 13, weight: .bold)) Image(systemName: "play.fill").font(.system(size: 13, weight: .bold))
} }
Text(isTesting ? "正在测试…" : "虚拟定位测试").font(.subheadline.weight(.semibold)) (isTesting ? Text("正在检测…") : Text("环境检测")).font(.subheadline.weight(.semibold))
Spacer() Spacer()
Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).opacity(0.5) Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).opacity(0.5)
} }
@@ -102,7 +138,9 @@ struct RuntimeLogsView: View {
.fill(isTesting ? Color.gray : Color.blue) .fill(isTesting ? Color.gray : Color.blue)
) )
.disabled(isTesting || actions.state.isBusy) .disabled(isTesting || actions.state.isBusy)
Text("依次执行:代理 → 证书 → WiFi 代理 → 坐标写入 → 数据改写验证。") (runtimeMode.mode == .thirdParty
? Text("检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。")
: Text("依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。"))
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
if !testMessage.isEmpty { if !testMessage.isEmpty {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -147,13 +185,15 @@ struct RuntimeLogsView: View {
}.frame(maxHeight: 180) }.frame(maxHeight: 180)
} }
} }
HStack(spacing: 14) { if runtimeMode.mode == .localWiFi {
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle") HStack(spacing: 14) {
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield") (proxy.isRunning ? Label("代理运行中", systemImage: "play.circle") : Label("代理未运行", systemImage: "stop.circle"))
}.font(.caption).foregroundStyle(.secondary) (setup.canModify ? Label("可修改", systemImage: "checkmark.shield.fill") : Label("不可修改", systemImage: "xmark.shield"))
}.font(.caption).foregroundStyle(.secondary)
}
if !testResult.isEmpty { if !testResult.isEmpty {
Text(testResult).font(.footnote.weight(.medium)) Text(testResult).font(.footnote.weight(.medium))
.foregroundStyle(testResult.contains("通过") ? .green : .red) .foregroundStyle(testSucceeded ? .green : .red)
} }
}.padding(14).background(Color(.secondarySystemBackground)) }.padding(14).background(Color(.secondarySystemBackground))
} }
@@ -164,7 +204,7 @@ struct RuntimeLogsView: View {
.foregroundStyle(entry.level == .error ? .red : entry.level == .warning ? .orange : .blue).frame(width: 18) .foregroundStyle(entry.level == .error ? .red : entry.level == .warning ? .orange : .blue).frame(width: 18)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text("\(entry.source) \(entry.category)").font(.caption.weight(.semibold)) Text("\(entry.source) \(entry.localizedCategory)").font(.caption.weight(.semibold))
Spacer() Spacer()
Button { Button {
UIPasteboard.general.string = entry.renderedText UIPasteboard.general.string = entry.renderedText
@@ -188,9 +228,9 @@ struct RuntimeLogsView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
Text(entry.message).font(.caption.monospaced()).textSelection(.enabled) Text(entry.localizedMessage).font(.caption.monospaced()).textSelection(.enabled)
if !entry.details.isEmpty { if !entry.details.isEmpty {
Text(entry.details.sorted(by: { $0.key < $1.key }).map { "\($0.key): \($0.value)" }.joined(separator: "\n")) Text(entry.localizedDetailsText)
.font(.caption2.monospaced()).foregroundStyle(.secondary).textSelection(.enabled) .font(.caption2.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
} }
} }
@@ -201,4 +241,34 @@ struct RuntimeLogsView: View {
} }
private func refresh() { entries = RuntimeLogStore.loadAll() } 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
testSucceeded = true
testResult = active ? AppLocalization.string("第三方模块连接通过,已有坐标") : AppLocalization.string("第三方模块连接通过,暂无坐标")
testMessage = thirdPartyTestLog(active: active)
} catch {
testSucceeded = false
testResult = AppLocalization.string("第三方模块连接失败")
testMessage = thirdPartyTestLog(error: error)
}
}
private func thirdPartyTestLog(active: Bool? = nil, error: Error? = nil) -> String {
var lines = [
AppLocalization.string("======== 第三方代理连接检测 ========"),
AppLocalization.string("模式: 测试模式"),
AppLocalization.string("请求: wloc-settings/save?action=query")
]
if let active {
lines.append(AppLocalization.string("拦截响应: 有效 JSON"))
lines.append(AppLocalization.string("已保存坐标: \(active ? AppLocalization.string("是") : AppLocalization.string("否"))"))
} else if let error {
lines.append(AppLocalization.string("结果: \(error.localizedDescription)"))
}
return lines.joined(separator: "\n")
}
} }
+905 -220
View File
File diff suppressed because it is too large Load Diff
+53 -14
View File
@@ -1,15 +1,47 @@
import Foundation 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 @MainActor
final class LocationActionCoordinator: ObservableObject { final class LocationActionCoordinator: ObservableObject {
@Published private(set) var state: LocationActionState = .idle @Published private(set) var state: LocationActionState = .idle
@Published private(set) var virtualLocationEnabled = false @Published private(set) var virtualLocationEnabled = false
@Published private(set) var message = "" @Published private(set) var message = ""
private let proxy = ProxyManager.shared private let proxy: any LocationActionProxying
private let settings: any LocationActionSettingsStoring
init() { 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 { func apply(_ favorite: FavoriteLocation) async -> Bool {
@@ -41,8 +73,8 @@ final class LocationActionCoordinator: ObservableObject {
func clear() { func clear() {
guard !state.isBusy else { return } guard !state.isBusy else { return }
proxy.setCoords(lat: 0, lon: 0, enabled: false) _ = proxy.setCoords(lat: 0, lon: 0, enabled: false, accuracy: 25)
WlocSettingsStore.clear() settings.clear()
state = .idle state = .idle
virtualLocationEnabled = false virtualLocationEnabled = false
message = "已恢复真实定位" message = "已恢复真实定位"
@@ -56,21 +88,28 @@ final class LocationActionCoordinator: ObservableObject {
} }
private func commit(_ favorite: FavoriteLocation) -> Bool { private func commit(_ favorite: FavoriteLocation) -> Bool {
// MKMapView 在中国地区使用高德瓦片(GCJ-02),返回的坐标是 GCJ-02。 // WLOC 合约固定使用持久化的 WGS-84 值,不依赖当前地图地图坐标标准。
// 但 Apple wloc 定位服务使用 WGS-84,因此写入代理前需要转换为 WGS-84。 let wgs = favorite.coordinatePair.wgs84
let wgs = CoordinateConverter.gcj02ToWgs84(lat: favorite.latitude, lon: favorite.longitude) let value = WlocSettings(
WlocSettingsStore.save(WlocSettings( longitude: wgs.longitude,
longitude: wgs.lon, latitude: wgs.latitude,
latitude: wgs.lat,
accuracy: favorite.accuracy, accuracy: favorite.accuracy,
enabled: true enabled: true
)) )
proxy.setCoords( settings.save(value)
lat: wgs.lat, _ = proxy.setCoords(
lon: wgs.lon, lat: wgs.latitude,
lon: wgs.longitude,
enabled: true, enabled: true,
accuracy: favorite.accuracy 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 state = .idle
virtualLocationEnabled = true virtualLocationEnabled = true
message = "虚拟定位已开启" message = "虚拟定位已开启"
+976 -211
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 nextRealtimeIntentID: UInt64 = 0
private var latestRealtimeIntentID: 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 viewportMeters = initialViewportMeters
selection = MapSelection( selection = MapSelection(
coordinate: initialCoordinate, coordinate: initialCoordinate,
source: .initial, source: initialSource,
explicitName: nil, explicitName: initialName?.nonEmpty,
revision: nextSelectionRevision revision: nextSelectionRevision
) )
} }
@@ -217,6 +222,13 @@ final class MapLocationState: ObservableObject {
if coordinate == nil { realtimeLocation = nil } 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) { func updateExplicitName(_ name: String, forFavoriteID favoriteID: UUID) {
guard selection.source == .favorite(favoriteID) else { return } guard selection.source == .favorite(favoriteID) else { return }
selection = MapSelection( selection = MapSelection(
@@ -231,6 +243,20 @@ final class MapLocationState: ObservableObject {
viewportMeters = max(50, distanceMeters) 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 @discardableResult
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool { func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false } guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
@@ -288,45 +314,132 @@ extension CLLocationCoordinate2D {
// MARK: - 持久化存储 // 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 { enum ViewportStore {
private static let key = "mapViewportMeters" 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? { static func load(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance? {
let v = UserDefaults.standard.double(forKey: key) let value = defaults.double(forKey: key)
return v > 0 ? v : nil return value > 0 ? value : nil
} }
/// 取持久化缩放值,取不到返回默认 1km 并立即存储
static func loadOrDefault() -> CLLocationDistance { static func loadOrDefault(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance {
if let v = load() { return v } if let value = load(defaults: defaults) { return value }
let fallback: CLLocationDistance = 1_000 let fallback: CLLocationDistance = 1_000
save(fallback) save(fallback, defaults: defaults)
return fallback return fallback
} }
} }
struct LastCoordinate { enum CoordinateStorageMigration {
let latitude: Double private static let versionKey = "coordinateStorageMigrationVersion"
let longitude: Double static let currentVersion = 1
var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) }
var isValid: Bool { CLLocationCoordinate2DIsValid(coordinate) && (latitude != 0 || longitude != 0) }
}
enum LastCoordinateStore { static func migrateIfNeeded(
private static let latKey = "lastMapLat" favorites: FavoriteLocationStore,
private static let lonKey = "lastMapLon" defaults: UserDefaults = AppGroup.defaults,
static func save(lat: Double, lon: Double) { legacyDefaults: UserDefaults = .standard
UserDefaults.standard.set(lat, forKey: latKey) ) throws {
UserDefaults.standard.set(lon, forKey: lonKey) guard defaults.integer(forKey: versionKey) < currentVersion else { return }
} // Pre-migration map pin and viewport values lived in UserDefaults.standard;
/// 取持久化坐标,未存过或无效返回 nil // favorites already lived in App Group defaults.
static func load() -> LastCoordinate? { try LastCoordinateStore.migrateLegacyCoordinate(
let c = LastCoordinate( defaults: defaults,
latitude: UserDefaults.standard.double(forKey: latKey), legacyDefaults: legacyDefaults
longitude: UserDefaults.standard.double(forKey: lonKey)
) )
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 { struct MapViewRepresentable: UIViewRepresentable {
let selection: MapSelection let selection: MapSelection
let initialViewportMeters: CLLocationDistance
let cameraCommand: MapCameraCommand? let cameraCommand: MapCameraCommand?
let onRealtimeLocationChanged: (CLLocation) -> Void let onRealtimeLocationChanged: (CLLocation) -> Void
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
let onViewportChanged: (CLLocationDistance) -> Void let onViewportChanged: (CLLocationDistance) -> Void
let onMapTap: (CLLocationCoordinate2D) -> Void let onMapTap: (CLLocationCoordinate2D) -> Void
let onUserZoomChanged: ((CLLocationDistance) -> Void)? let onUserZoomChanged: ((CLLocationDistance) -> Void)?
var onZoomIn: (() -> Void)?
var onZoomOut: (() -> Void)?
func makeCoordinator() -> Coordinator { Coordinator(parent: self) } func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
@@ -45,7 +48,12 @@ struct MapViewRepresentable: UIViewRepresentable {
let map = MKMapView() let map = MKMapView()
map.delegate = context.coordinator map.delegate = context.coordinator
map.showsUserLocation = true 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( map.setRegion(
MKCoordinateRegion( MKCoordinateRegion(
center: selection.coordinate, center: selection.coordinate,
@@ -55,10 +63,83 @@ struct MapViewRepresentable: UIViewRepresentable {
animated: false 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(_:))) let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
tap.cancelsTouchesInView = false tap.cancelsTouchesInView = false
map.addGestureRecognizer(tap) map.addGestureRecognizer(tap)
context.coordinator.map = map context.coordinator.map = map
context.coordinator.setupKeyboardObservers()
return map return map
} }
@@ -76,11 +157,48 @@ struct MapViewRepresentable: UIViewRepresentable {
private var activeCommandIsZoom = false private var activeCommandIsZoom = false
private var regionChangeWasUserDriven = false private var regionChangeWasUserDriven = false
private var isPinchZoom = 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) { init(parent: MapViewRepresentable) {
self.parent = parent 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) { func consume(_ command: MapCameraCommand?, on map: MKMapView) {
guard let command, command.id != lastConsumedCommandID else { return } guard let command, command.id != lastConsumedCommandID else { return }
lastConsumedCommandID = command.id lastConsumedCommandID = command.id
@@ -92,8 +210,7 @@ struct MapViewRepresentable: UIViewRepresentable {
let region: MKCoordinateRegion let region: MKCoordinateRegion
switch command.kind { switch command.kind {
case let .focus(coordinate, _): case let .focus(coordinate, _):
// 保持当前 span 不变,只移动中心点,避免 latitudinalMeters // 保持当前 span 不变,避免正方形 region 在竖屏 inflate
// 与 visibleVerticalDistance 之间因屏幕宽高比引入 2x 漂移
region = MKCoordinateRegion(center: coordinate, span: map.region.span) region = MKCoordinateRegion(center: coordinate, span: map.region.span)
case let .zoom(factor): case let .zoom(factor):
region = MKCoordinateRegion( region = MKCoordinateRegion(
@@ -111,11 +228,36 @@ struct MapViewRepresentable: UIViewRepresentable {
parent.onMapTap(map.convert(point, toCoordinateFrom: map)) 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) { func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard let location = userLocation.location, guard let location = visibleUserLocationSample(userLocation) else {
CLLocationCoordinate2DIsValid(location.coordinate), RuntimeLogger.warning("APP", "实时定位", "MapKit 蓝点更新但 location 为空", details: [
location.horizontalAccuracy >= 0 else { return } "来源": "MKMapView.didUpdate"
parent.onRealtimeLocationChanged(location) ])
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) { func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
@@ -143,6 +285,15 @@ struct MapViewRepresentable: UIViewRepresentable {
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
let distance = visibleVerticalDistance(in: mapView) let distance = visibleVerticalDistance(in: mapView)
parent.onViewportChanged(distance) 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 let userZoomed: Bool
if activeCameraCommandID != nil { if activeCameraCommandID != nil {
@@ -165,6 +316,28 @@ struct MapViewRepresentable: UIViewRepresentable {
regionChangeWasUserDriven = false 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 { private func visibleVerticalDistance(in map: MKMapView) -> CLLocationDistance {
let centerX = map.bounds.midX let centerX = map.bounds.midX
let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map) let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map)
+1
View File
@@ -9,6 +9,7 @@ struct PaopaoLocationSpooferApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(\.locale, AppLocalization.locale)
} }
} }
} }
+48 -11
View File
@@ -25,9 +25,21 @@ final class ProxyManager: ObservableObject {
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0 let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0) let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
let accuracy = CInt(settings?.accuracy ?? 25) 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 let result: UInt = authority.certPEM.withCString { cp in
authority.keyPEM.withCString { kp 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 } guard result != 0 else { CoreBridge.flushLogs(category: "Proxy"); throw ProxyError.startFailed }
@@ -55,12 +67,17 @@ final class ProxyManager: ObservableObject {
@discardableResult @discardableResult
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int = 25) -> UInt64 { func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int = 25) -> UInt64 {
coordinateRevision &+= 1 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: [ RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
"revision": String(coordinateRevision), "revision": String(coordinateRevision),
"enabled": String(enabled), "enabled": String(enabled),
"lat": String(lat), "accuracy": String(accuracy)
"lon": String(lon)
]) ])
return coordinateRevision return coordinateRevision
} }
@@ -117,15 +134,35 @@ final class ProxyManager: ObservableObject {
return (Double(r.r0), Double(r.r1), r.r2 != 0) 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 { do {
if !isRunning { try await start() } if !isRunning { try await start() }
// 本地代理直接提供 CA 证书下载 guard let url = URL(string: "http://127.0.0.1:8888/cert") else {
guard let url = URL(string: "http://127.0.0.1:8888/cert") else { return } error = AppLocalization.string("证书下载地址无效")
await MainActor.run { UIApplication.shared.open(url) } return nil
}
error = nil
return url
} catch { } catch {
self.error = "启动代理失败: \(error.localizedDescription)" self.error = AppLocalization.string("启动代理失败: \(error.localizedDescription)")
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error) RuntimeLogger.error("APP", "Certificate", "准备证书下载失败", error: error)
return nil
} }
} }
@@ -145,5 +182,5 @@ struct ProxyCoordinateSnapshot: Equatable {
enum ProxyError: LocalizedError { enum ProxyError: LocalizedError {
case startFailed case startFailed
var errorDescription: String? { "Go proxy 启动失败" } var errorDescription: String? { AppLocalization.string("Go proxy 启动失败") }
} }
+111 -17
View File
@@ -61,7 +61,7 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
private let driver: RealtimeLocationDriving private let driver: RealtimeLocationDriving
private let oneShotTimeoutNanoseconds: UInt64 private let oneShotTimeoutNanoseconds: UInt64
private let fallbackTimeoutNanoseconds: UInt64 private let fallbackTimeoutNanoseconds: UInt64
private let cacheMaxAge: TimeInterval private let cacheMaxAge: TimeInterval = 20
private var nextRequestID: UInt64 = 0 private var nextRequestID: UInt64 = 0
private var activeRequest: ActiveRequest? private var activeRequest: ActiveRequest?
private var timeoutTask: Task<Void, Never>? private var timeoutTask: Task<Void, Never>?
@@ -69,13 +69,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
init( init(
driver: RealtimeLocationDriving, driver: RealtimeLocationDriving,
oneShotTimeoutNanoseconds: UInt64 = 1_500_000_000, oneShotTimeoutNanoseconds: UInt64 = 1_500_000_000,
fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000, fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000
cacheMaxAge: TimeInterval = 20
) { ) {
self.driver = driver self.driver = driver
self.oneShotTimeoutNanoseconds = oneShotTimeoutNanoseconds self.oneShotTimeoutNanoseconds = oneShotTimeoutNanoseconds
self.fallbackTimeoutNanoseconds = fallbackTimeoutNanoseconds self.fallbackTimeoutNanoseconds = fallbackTimeoutNanoseconds
self.cacheMaxAge = cacheMaxAge
authorizationStatus = driver.authorizationStatus authorizationStatus = driver.authorizationStatus
super.init() super.init()
driver.delegate = self driver.delegate = self
@@ -94,21 +92,31 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
func requestLocation() async -> CLLocationCoordinate2D? { func requestLocation() async -> CLLocationCoordinate2D? {
guard activeRequest == nil else { 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 return nil
} }
authorizationStatus = driver.authorizationStatus authorizationStatus = driver.authorizationStatus
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求入口", details: [
"授权状态": authorizationName(authorizationStatus),
"内存缓存存在": String(location != nil),
"系统缓存存在": String(driver.location != nil)
])
guard authorizationStatus != .denied, authorizationStatus != .restricted else { guard authorizationStatus != .denied, authorizationStatus != .restricted else {
RuntimeLogger.warning("APP", "实时定位", "授权状态不允许定位", details: [
"授权状态": authorizationName(authorizationStatus)
])
return nil return nil
} }
if let cached = freshestCachedLocation() { if let cached = freshestCachedLocation() {
location = cached location = cached
RuntimeLogger.info("APP", "定位", "使用系统缓存实时定位", details: [ RealtimeLocationTrace.log("使用 CLLocationManager 新鲜缓存", location: cached, details: [
"age": String(format: "%.2f", max(0, -cached.timestamp.timeIntervalSinceNow)), "来源": "manager-memory-or-system",
"lat": String(cached.coordinate.latitude), "缓存上限秒": String(Int(cacheMaxAge))
"lon": String(cached.coordinate.longitude)
]) ])
return cached.coordinate return cached.coordinate
} }
@@ -116,7 +124,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
nextRequestID &+= 1 nextRequestID &+= 1
let requestID = nextRequestID let requestID = nextRequestID
isRequesting = true isRequesting = true
RuntimeLogger.info("APP", "定位", "请求实时定位…", details: ["requestID": String(requestID)]) RuntimeLogger.info("APP", "实时定位", "创建 CLLocationManager 请求", details: [
"requestID": String(requestID),
"授权状态": authorizationName(authorizationStatus),
"初始阶段": "awaitingAuthorization"
])
return await withTaskCancellationHandler { return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
@@ -128,6 +140,9 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
) )
if authorizationStatus == .notDetermined { if authorizationStatus == .notDetermined {
RuntimeLogger.info("APP", "实时定位", "请求前台定位授权", details: [
"requestID": String(requestID)
])
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds) scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
driver.requestWhenInUseAuthorization() driver.requestWhenInUseAuthorization()
} else { } else {
@@ -155,6 +170,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
MainActor.assumeIsolated { MainActor.assumeIsolated {
authorizationStatus = driver.authorizationStatus authorizationStatus = driver.authorizationStatus
RuntimeLogger.info("APP", "实时定位", "定位授权状态变化", details: [
"授权状态": authorizationName(authorizationStatus),
"requestID": activeRequest.map { String($0.id) } ?? "nil",
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
])
switch authorizationStatus { switch authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorizedWhenInUse:
if let request = activeRequest, request.phase == .awaitingAuthorization { 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]) { nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
MainActor.assumeIsolated { 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) let validLocations = locations.filter(Self.isValid)
if let latestValid = validLocations.last { if let latestValid = validLocations.last {
location = latestValid location = latestValid
@@ -183,8 +224,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
let latest = validLocations.last(where: { let latest = validLocations.last(where: {
$0.timestamp >= startedAt.addingTimeInterval(-1) $0.timestamp >= startedAt.addingTimeInterval(-1)
}) else { }) else {
if let request = activeRequest, request.phase != .awaitingAuthorization {
RuntimeLogger.warning("APP", "实时定位", "本批次没有可完成当前请求的样本", details: [
"requestID": String(request.id),
"阶段": phaseName(request.phase),
"有效样本数": String(validLocations.count)
])
}
return return
} }
RealtimeLocationTrace.log("接受 CLLocationManager 样本并完成请求", location: latest, details: [
"requestID": String(request.id),
"阶段": phaseName(request.phase)
])
finishRequest(id: request.id, coordinate: latest.coordinate) finishRequest(id: request.id, coordinate: latest.coordinate)
} }
} }
@@ -213,8 +265,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
} }
private func freshestCachedLocation(now: Date = Date()) -> CLLocation? { private func freshestCachedLocation(now: Date = Date()) -> CLLocation? {
[location, driver.location] let candidates = [location, driver.location].compactMap { $0 }
.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(Self.isValid)
.filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge } .filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge }
.max(by: { $0.timestamp < $1.timestamp }) .max(by: { $0.timestamp < $1.timestamp })
@@ -260,6 +323,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
request.phase = .oneShot request.phase = .oneShot
request.startedAt = Date() request.startedAt = Date()
activeRequest = request activeRequest = request
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 单次定位", details: [
"requestID": String(requestID),
"超时毫秒": String(oneShotTimeoutNanoseconds / 1_000_000)
])
scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds) scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds)
driver.requestLocation() driver.requestLocation()
} }
@@ -270,6 +337,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
request.phase == .oneShot else { return } request.phase == .oneShot else { return }
request.phase = .continuousFallback request.phase = .continuousFallback
activeRequest = request activeRequest = request
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 持续定位兜底", details: [
"requestID": String(requestID),
"超时毫秒": String(fallbackTimeoutNanoseconds / 1_000_000)
])
driver.startUpdatingLocation() driver.startUpdatingLocation()
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds) scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
} }
@@ -287,15 +358,38 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
activeRequest = nil activeRequest = nil
isRequesting = false isRequesting = false
if let coordinate { if coordinate != nil {
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [ RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求完成", details: [
"requestID": String(requestID), "requestID": String(requestID),
"lat": String(coordinate.latitude), "最终阶段": phaseName(request.phase),
"lon": String(coordinate.longitude) "有坐标": "true"
]) ])
} else { } 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) 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) {}
}
+553 -35
View File
@@ -1,70 +1,209 @@
import SwiftUI 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 { struct SettingsView: View {
@ObservedObject var setup: SetupCoordinator @ObservedObject var setup: SetupCoordinator
@ObservedObject var actions: LocationActionCoordinator @ObservedObject var actions: LocationActionCoordinator
@ObservedObject private var proxy = ProxyManager.shared @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 randomRadius = RandomRadiusStore.shared
@ObservedObject private var moduleSource = ThirdPartyModuleSourceStore.shared
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var activeTip: TipKind? @State private var activeTip: TipKind?
@State private var proxyOperationError = ""
@State private var proxyOperationAlertTitle = AppLocalization.string("代理操作失败")
@State private var modeOperationRunning = false
@State private var copiedClient: ThirdPartyProxyClient?
@State private var copiedMITMHostname: String?
@State private var showCertificateResetConfirmation = false
@State private var githubDestination: SafariDestination?
@State private var isCheckingForUpdates = false
@State private var updateCheckResult: UpdateCheckResult?
var body: some View { var body: some View {
Form { 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("状态") { Section("状态") {
HStack { if runtimeMode.mode == .localWiFi {
Label("代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle") HStack {
Spacer() Label("本机代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
Toggle("", isOn: proxyBinding).labelsHidden() Spacer()
.tint(.blue) 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 { HStack {
Label("虚拟定位", systemImage: actions.virtualLocationEnabled ? "location.fill" : "location.slash") Label("虚拟定位", systemImage: virtualLocationIsActive ? "location.fill" : "location.slash")
Spacer() Spacer()
Text(actions.virtualLocationEnabled ? "已开启" : "已关闭").foregroundStyle(.secondary) Text(virtualLocationStatusText).foregroundStyle(.secondary)
} }
} }
Section("说明") { Section("定位模拟") {
Button { if runtimeMode.mode == .localWiFi {
activeTip = .activation Toggle("运动状态模拟", isOn: motionSimulationBinding)
} label: { .disabled(
Label("生效说明", systemImage: "checklist") modeOperationRunning ||
actions.state.isBusy ||
thirdPartyProxy.isRequesting
)
Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。")
.font(.footnote)
.foregroundStyle(.secondary)
} else if runtimeMode.mode == .thirdParty {
Toggle("随机扰动", isOn: randomRadiusBinding)
.disabled(modeOperationRunning || actions.state.isBusy)
Text("开启后,下次同步坐标时会给目标点添加随机偏移(默认半径 50 米),避免位置固定在同一点。")
.font(.footnote)
.foregroundStyle(.secondary)
} }
Button { }
activeTip = .deactivation
} label: { if runtimeMode.mode == .thirdParty {
Label("失效说明", systemImage: "arrow.uturn.backward.circle") thirdPartyConfigurationSection
} } else {
Button { Section("说明") {
activeTip = .removeProxy Button {
} label: { activeTip = .activation
Label("关闭 WiFi 代理", systemImage: "wifi.slash") } label: {
Label("生效说明", systemImage: "checklist")
}
Button {
activeTip = .deactivation
} label: {
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
}
Button {
activeTip = .removeProxy
} label: {
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
}
} }
} }
Section("工作原理") { Section("工作原理") {
Text(""" Text(workflowDescription)
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
""")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Section("应用") { Section("应用") {
Button { if runtimeMode.mode == .localWiFi {
setup.needsSetup = true Button {
} label: { setup.requestSetup()
Label("进入引导页", systemImage: "arrow.clockwise.circle") } 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) 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("支持") { Section("支持") {
NavigationLink { NavigationLink {
BugReportView(setup: setup) BugReportView(setup: setup)
} label: { } 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")
}
} }
} }
@@ -83,11 +222,11 @@ struct SettingsView: View {
Section("致谢") { Section("致谢") {
Button { Button {
if let url = URL(string: "https://github.com/Yu9191/wloc") { if let url = URL(string: "https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md") {
UIApplication.shared.open(url) UIApplication.shared.open(url)
} }
} label: { } label: {
Label("核心定位改写逻辑移植自 Yu9191/wloc", systemImage: "heart.fill") Label("定位改写脚本源自 Yu9191/wloc,现由本项目维护", systemImage: "heart.fill")
.foregroundStyle(.pink) .foregroundStyle(.pink)
} }
} }
@@ -100,9 +239,36 @@ struct SettingsView: View {
.sheet(item: $activeTip) { kind in .sheet(item: $activeTip) { kind in
TipSheetView(kind: kind) 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 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。")
}
} }
private func valueRow(_ title: String, value: String) -> some View { private func valueRow(_ title: LocalizedStringKey, value: String) -> some View {
HStack { Text(title); Spacer(); Text(value).font(.footnote.monospaced()).foregroundStyle(.secondary) } HStack { Text(title); Spacer(); Text(value).font(.footnote.monospaced()).foregroundStyle(.secondary) }
} }
@@ -112,15 +278,367 @@ struct SettingsView: View {
return "\(v) (\(b))" 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
?? AppLocalization.string("更新说明暂时无法加载,请前往最新 Release 页面查看。")
let message: String
if prompt.requirement == .required {
message = AppLocalization.string("当前版本 \(prompt.currentVersion) 已停止支持,请更新到 \(prompt.latestVersion) 后继续使用。\n\n\(details)")
} else {
message = AppLocalization.string("当前版本 \(prompt.currentVersion),最新版本 \(prompt.latestVersion)。\n\n\(details)")
}
return Alert(
title: prompt.requirement == .required ? Text("需要更新") : Text("发现新版本"),
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> { private var proxyBinding: Binding<Bool> {
Binding(get: { proxy.isRunning }, set: { on in Binding(get: { proxy.isRunning }, set: { on in
Task { Task {
if on { if on {
do { try await proxy.start() } catch { proxy.error = error.localizedDescription } do {
try await proxy.start()
} catch {
proxy.error = error.localizedDescription
proxyOperationAlertTitle = AppLocalization.string("代理操作失败")
proxyOperationError = error.localizedDescription
}
} else { } else {
if actions.virtualLocationEnabled {
actions.clear()
RuntimeLogger.info("APP", "Settings", "关闭代理前已同步关闭虚拟定位")
}
proxy.stop() 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
proxy.applyMotionSimulation(enabled)
}
)
}
private var randomRadiusBinding: Binding<Bool> {
Binding(
get: { randomRadius.isEnabled },
set: { randomRadius.setEnabled($0) }
)
}
@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 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: {
(copiedClient == thirdPartyClient.selectedClient ? Label("已复制模块订阅地址", systemImage: "doc.on.doc") : Label("复制模块订阅地址", systemImage: "doc.on.doc"))
}
ForEach(ThirdPartyProxyManager.interceptionHostnames, id: \.self) { hostname in
HStack(spacing: 8) {
Text(hostname)
.font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
Button {
UIPasteboard.general.string = hostname
copiedMITMHostname = hostname
} label: {
Image(systemName: copiedMITMHostname == hostname ? "checkmark" : "doc.on.doc")
.frame(width: 28, height: 28)
}
.buttonStyle(.borderless)
.accessibilityLabel(
copiedMITMHostname == hostname
? Text("已复制 \(hostname)")
: Text("复制 \(hostname)")
)
}
}
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("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.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 AppLocalization.string("未检测")
case .connected(let active): return active ? AppLocalization.string("已连接,有坐标") : AppLocalization.string("已连接,无坐标")
case .failed: return AppLocalization.string("连接失败")
}
}
private var virtualLocationStatusText: String {
if runtimeMode.mode == .localWiFi {
return actions.virtualLocationEnabled ? AppLocalization.string("已开启") : AppLocalization.string("已关闭")
}
if case .connected(let active) = thirdPartyProxy.connectionState {
return active ? AppLocalization.string("第三方已保存") : AppLocalization.string("未保存")
}
return AppLocalization.string("未知")
}
private var workflowDescription: String {
if runtimeMode.mode == .thirdParty {
return AppLocalization.string("App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。")
}
return AppLocalization.string("""
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
通过 WiFi 手动代理配置,让系统发往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.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.query()
proxyOperationAlertTitle = AppLocalization.string("模式已切换")
proxyOperationError = AppLocalization.string("第三方代理模式检测通过。请关闭 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 = AppLocalization.string("模式已切换")
proxyOperationError = AppLocalization.string("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.query()
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 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 = AppLocalization.string("证书重置失败")
proxyOperationError = setup.message
return
}
setup.requestCertificateSetup()
dismiss()
} catch {
proxyOperationAlertTitle = AppLocalization.string("证书重置失败")
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 = AppLocalization.string("无法打开客户端")
proxyOperationError = AppLocalization.string("无法打开 \(client.name),请确认客户端已安装后手动打开。")
}
}
}
private var virtualLocationIsActive: Bool {
if runtimeMode.mode == .localWiFi {
return actions.virtualLocationEnabled
}
if case .connected(let active) = thirdPartyProxy.connectionState {
return active
}
return false
}
} }
+106 -130
View File
@@ -7,8 +7,10 @@ final class SetupCoordinator: ObservableObject {
@Published var message = "" @Published var message = ""
@Published var isBrowsingWithoutTrust = false @Published var isBrowsingWithoutTrust = false
@Published var testLog = "" @Published var testLog = ""
@Published private(set) var lastVerificationResult: VerificationResult?
// 启动检测失败才弹引导页;检测通过则保持 false // 启动检测失败才弹引导页;检测通过则保持 false
@Published var needsSetup = false @Published var needsSetup = false
@Published private(set) var setupStep: SetupStep = .proxy
let certificateStore = CertificateAuthorityStore() let certificateStore = CertificateAuthorityStore()
let proxy = ProxyManager.shared let proxy = ProxyManager.shared
@@ -24,79 +26,81 @@ final class SetupCoordinator: ObservableObject {
var canModify: Bool { proxy.isRunning && trustState == .trusted } var canModify: Bool { proxy.isRunning && trustState == .trusted }
func refreshTrust() async { /// Local services are prepared before the setup UI so the environment test
trustState = .checking /// can distinguish Wi-Fi proxy configuration from CA trust failures.
message = "正在检测…" @discardableResult
testLog = "" func prepareLocalServices() async -> Bool {
do { do {
_ = try certificateStore.ensure() _ = try certificateStore.ensure()
if !proxy.isRunning { try await proxy.start() } if !proxy.isRunning { try await proxy.start() }
// 强制走代理 POST 到 Apple 定位接口:TLS 成功 = CA 信任 message = ""
let config = URLSessionConfiguration.ephemeral return true
config.timeoutIntervalForRequest = 5
config.timeoutIntervalForResource = 8
config.connectionProxyDictionary = [
kCFNetworkProxiesHTTPEnable as String: true,
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
kCFNetworkProxiesHTTPPort as String: 8888,
]
// 用当前保存的虚拟定位坐标做测试;没有则用默认坐标
let saved = WlocSettingsStore.load()
let testLat = saved?.latitude ?? 22.543099
let testLon = saved?.longitude ?? 113.934576
let testAccuracy = saved?.accuracy ?? 25
let req = makeWlocRequest()
let (_, resp) = try await URLSession(configuration: config).data(for: req)
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
// 400 = Apple 拒绝了测试请求体,但 TLS 握手成功 = 证书已信任
if status == 0 {
trustState = .unavailable
message = "代理链路异常,未收到响应"
return
}
// 走同一套改写验证:Go Core 内模拟 Apple 响应并确认坐标被改写
let patchResult = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: testAccuracy)
if patchResult.hasPrefix("ok:") {
trustState = .trusted
message = "✓ 定位环境正常(返回 \(status))"
} else {
trustState = .unavailable
message = "定位数据改写验证失败:\(patchResult)"
}
} catch { } catch {
message = AppLocalization.string("本地代理初始化失败:\(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 = AppLocalization.string("✓ 定位环境正常")
case .certNotTrusted:
trustState = .unavailable trustState = .unavailable
let ns = error as NSError setupStep = .cert
if ns.domain == NSURLErrorDomain && ns.code == -1202 { needsSetup = true
message = "CA 证书未信任,请去「设置→通用→关于→证书信任设置」开启或重新安装" message = AppLocalization.string("CA 证书未安装或未信任")
} else if ns.domain == NSURLErrorDomain && ns.code == -1001 { case .verificationInProgress, .verificationSuperseded:
message = "检测超时,请检查代理是否正常" break
} else if ns.domain == NSURLErrorDomain && ns.code == -1200 { default:
message = "代理未启动或无法连接" trustState = .unavailable
} else { setupStep = .proxy
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)" needsSetup = true
} message = AppLocalization.string("Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888")
} }
needsSetup = !canModify
} }
func sceneDidBecomeActive() {} func sceneDidBecomeActive() {}
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false } func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
func completeSetup() { 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 // MARK: - Step-by-step verification test
private func makeWlocRequest() -> URLRequest { func runVerificationTest() async -> VerificationResult {
var req = URLRequest(url: URL(string: "https://gs-loc.apple.com/clls/wloc")!)
req.httpMethod = "POST"
req.httpBody = CoreBridge.testWlocRequestData()
req.setValue("application/x-protobuf", forHTTPHeaderField: "Content-Type")
req.setValue("wloc/1.0", forHTTPHeaderField: "User-Agent")
req.setValue("application/x-protobuf", forHTTPHeaderField: "Accept")
return req
}
func runVerificationTest(testLat: Double = 22.543099, testLon: Double = 113.934576) async -> VerificationResult {
guard !isVerificationRunning else { return .verificationInProgress } guard !isVerificationRunning else { return .verificationInProgress }
isVerificationRunning = true isVerificationRunning = true
defer { isVerificationRunning = false } defer { isVerificationRunning = false }
@@ -104,49 +108,36 @@ final class SetupCoordinator: ObservableObject {
testLog = "" testLog = ""
let log = { (msg: String) in self.testLog += msg + "\n" } let log = { (msg: String) in self.testLog += msg + "\n" }
log("======== 代理验证测试 ========") log(AppLocalization.string("======== 代理验证测试 ========"))
log("App 版本: \(appVersion)") log(AppLocalization.string("App 版本: \(appVersion)"))
log("系统版本: iOS \(UIDevice.current.systemVersion)") log(AppLocalization.string("系统版本: iOS \(UIDevice.current.systemVersion)"))
log("测试目标: lat=\(testLat), lon=\(testLon)")
log("") log("")
// Step A: Proxy running // Step A: Proxy running
log("[步骤 A] 检查代理是否运行…") log(AppLocalization.string("[步骤 A] 检查代理是否运行…"))
log(" 端口: 127.0.0.1:8888") log(AppLocalization.string(" 端口: 127.0.0.1:8888"))
let stepAStart = Date() let stepAStart = Date()
if !proxy.isRunning { if !proxy.isRunning {
log(" ⚠ 代理未运行,尝试启动…") log(AppLocalization.string(" ⚠ 代理未运行,尝试启动…"))
do { try await proxy.start() } catch { do { try await proxy.start() } catch {
log(" ✗ 启动失败: \(error.localizedDescription)") log(AppLocalization.string(" ✗ 启动失败: \(error.localizedDescription)"))
return .proxyNotRunning return .proxyNotRunning
} }
log(" ✓ 代理启动成功") log(AppLocalization.string(" ✓ 代理启动成功"))
} else { } else {
log(" ✓ 代理已在运行中") log(AppLocalization.string(" ✓ 代理已在运行中"))
} }
collectProxyLogs(since: stepAStart, to: log) collectProxyLogs(since: stepAStart, to: log)
// Verification must never overwrite a newer location action.
let previousCoordinates = proxy.coordinateSnapshot(
accuracy: WlocSettingsStore.load()?.accuracy ?? 25
)
var verificationCoordinateRevision: UInt64?
defer {
if let revision = verificationCoordinateRevision {
let restored = proxy.restoreCoords(previousCoordinates, ifUnchangedSince: revision)
log(restored ? " ↩ 已恢复验证前的代理坐标" : " ↩ 检测到更新位置,跳过旧坐标恢复")
}
}
// Step B: Combined CA + WiFi proxy check (single request) // Step B: Combined CA + WiFi proxy check (single request)
log("") log("")
log("[步骤 B] 检测证书与 WiFi 代理…") log(AppLocalization.string("[步骤 B] 检测证书与 WiFi 代理…"))
log(" 方式: 请求 baidu.com/paopao-verify-<token>") log(AppLocalization.string(" 方式: 请求 baidu.com/paopao-verify-<token>"))
log(" 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过") log(AppLocalization.string(" 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过"))
let stepBStart = Date() let stepBStart = Date()
let verifyToken = CoreBridge.refreshVerifyToken() let verifyToken = CoreBridge.refreshVerifyToken()
guard !verifyToken.isEmpty else { guard !verifyToken.isEmpty else {
log(" ✗ 无法生成验证 token") log(AppLocalization.string(" ✗ 无法生成验证 token"))
return .certNotTrusted return .certNotTrusted
} }
do { do {
@@ -159,65 +150,50 @@ final class SetupCoordinator: ObservableObject {
let statusCode = (resp as? HTTPURLResponse)?.statusCode ?? 0 let statusCode = (resp as? HTTPURLResponse)?.statusCode ?? 0
let body = String(data: data, encoding: .utf8) ?? "" let body = String(data: data, encoding: .utf8) ?? ""
if body == verifyToken { if body == verifyToken {
log(" ✓ 证书已信任,WiFi 代理已配置") log(AppLocalization.string(" ✓ 证书已信任,WiFi 代理已配置"))
} else { } else {
log(" ✗ 响应不匹配 (HTTP \(statusCode)),WiFi 代理未配置") log(AppLocalization.string(" ✗ 响应不匹配: HTTP \(statusCode), \(data.count) bytes,WiFi 代理未配置"))
log(" 收到: \(body.prefix(100))")
return .wifiProxyNotConfigured return .wifiProxyNotConfigured
} }
} catch { } catch {
let ns = error as NSError let ns = error as NSError
let msg = error.localizedDescription let msg = error.localizedDescription
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)") log(AppLocalization.string(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)"))
if ns.domain == NSURLErrorDomain && ns.code == -1202 { if isCertificateTrustError(nsError: ns, message: msg) {
log(" TLS 握手被拒,CA 证书未信任") log(AppLocalization.string(" TLS/证书校验失败,CA 证书未信任"))
return .certNotTrusted
}
if msg.contains("TLS") {
log(" 包含 TLS → 证书问题")
return .certNotTrusted return .certNotTrusted
} }
return .wifiProxyNotConfigured return .wifiProxyNotConfigured
} }
collectProxyLogs(since: stepBStart, to: log) 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("")
log("======== 环境检测通过 ✓ ========") log(AppLocalization.string("======== 环境检测通过 ✓ ========"))
return .success 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 /// 拉取 Go 代理的详细日志(CONNECT/请求/上游响应/改写结果)到 testLog
private func collectProxyLogs(since date: Date, to log: (String) -> Void) { private func collectProxyLogs(since date: Date, to log: (String) -> Void) {
CoreBridge.flushLogs(category: "Proxy") CoreBridge.flushLogs(category: "Proxy")
@@ -226,9 +202,9 @@ final class SetupCoordinator: ObservableObject {
$0.source == "CORE" && $0.category == "Proxy" && $0.timestamp >= date $0.source == "CORE" && $0.category == "Proxy" && $0.timestamp >= date
} }
guard !proxyEntries.isEmpty else { return } guard !proxyEntries.isEmpty else { return }
log(" --- 代理日志 ---") log(AppLocalization.string(" --- 代理日志 ---"))
for e in proxyEntries { for e in proxyEntries {
log(" " + e.message) log(" " + e.localizedMessage)
} }
} }
} }
+4 -4
View File
@@ -25,13 +25,13 @@ enum SystemSettingsDestination {
var manualPath: String { var manualPath: String {
switch self { switch self {
case .appPermissions: case .appPermissions:
return "请手动打开「设置」,找到本 App 后检查定位权限。" return AppLocalization.string("请手动打开「设置」,找到本 App 后检查定位权限。")
case .general: case .general:
return "请手动打开「设置 → 通用」。" return AppLocalization.string("请手动打开「设置 → 通用」。")
case .wifi: case .wifi:
return "请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。" return AppLocalization.string("请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。")
case .locationServices: case .locationServices:
return "请手动打开「设置 → 隐私与安全性 → 定位服务」。" return AppLocalization.string("请手动打开「设置 → 隐私与安全性 → 定位服务」。")
} }
} }
} }
+32 -95
View File
@@ -4,14 +4,12 @@ enum TipKind: String, Identifiable {
case activation = "生效说明" case activation = "生效说明"
case deactivation = "失效说明" case deactivation = "失效说明"
case removeProxy = "关闭 WiFi 代理" case removeProxy = "关闭 WiFi 代理"
case certificate = "证书问题"
case proxySetup = "配置代理"
case rewriteFailed = "改写失败"
var id: String { rawValue } var id: String { rawValue }
} }
struct TipSheetView: View { struct TipSheetView: View {
let kind: TipKind let kind: TipKind
var runtimeMode: ProxyRuntimeMode = .localWiFi
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -19,16 +17,13 @@ struct TipSheetView: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
switch kind { switch kind {
case .activation: ActivationTipContent(dismiss: { dismiss() }) case .activation: ActivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
case .deactivation: DeactivationTipContent(dismiss: { dismiss() }) case .deactivation: DeactivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() }) case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
case .certificate: CertificateTipContent(dismiss: { dismiss() })
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
} }
}.padding(16) }.padding(16)
} }
.navigationTitle(kind.rawValue).navigationBarTitleDisplayMode(.inline) .navigationTitle(LocalizedStringKey(kind.rawValue)).navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
Button { dismiss() } label: { Button { dismiss() } label: {
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12) Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
@@ -53,42 +48,39 @@ 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: - 生效说明 // MARK: - 生效说明
struct ActivationTipContent: View { struct ActivationTipContent: View {
var runtimeMode: ProxyRuntimeMode = .localWiFi
let dismiss: () -> Void let dismiss: () -> Void
var body: some View { var body: some View {
GroupBox(label: Label("让虚拟定位生效", systemImage: "checklist")) { GroupBox(label: Label("让虚拟定位生效", systemImage: "checklist")) {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if runtimeMode == .thirdParty {
step(0, "确认第三方代理已开启", "保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。")
}
step(1, "开启飞行模式", "从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。") step(1, "开启飞行模式", "从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。")
step(2, "关闭 Wi‑Fi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。") step(2, "关闭 Wi‑Fi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。") systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。")
step(4, "打开 Wi‑Fi,启动虚拟定位", "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。") step(4, "打开 Wi‑Fi,启动虚拟定位", runtimeMode == .thirdParty ? "从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" : "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。") step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。") systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。")
}.padding(.vertical, 4) }.padding(.vertical, 4)
} }
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) { GroupBox(label: Label("还是无法生效?", systemImage: "exclamationmark.triangle")) {
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。") Text("操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。")
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 4) .font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
} }
} }
private func step(_ n: Int, _ title: String, _ detail: String) -> some View { private func step(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Text("\(n)").font(.caption2.bold()) Text("\(n)").font(.caption2.bold())
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
@@ -100,7 +92,7 @@ struct ActivationTipContent: View {
} }
} }
private func systemStep(_ n: Int, _ title: String, _ detail: String) -> some View { private func systemStep(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Text("\(n)").font(.caption2.bold()) Text("\(n)").font(.caption2.bold())
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
@@ -119,6 +111,7 @@ struct ActivationTipContent: View {
// MARK: - 失效说明 // MARK: - 失效说明
struct DeactivationTipContent: View { struct DeactivationTipContent: View {
var runtimeMode: ProxyRuntimeMode = .localWiFi
let dismiss: () -> Void let dismiss: () -> Void
var body: some View { var body: some View {
@@ -127,19 +120,28 @@ struct DeactivationTipContent: View {
step(1, "开启飞行模式", "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。") step(1, "开启飞行模式", "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。")
step(2, "关闭 Wi‑Fi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。") step(2, "关闭 Wi‑Fi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。") systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。") if runtimeMode == .thirdParty {
step(4, "确认坐标已清除", "App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。")
} else {
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
}
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。") step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。") systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。")
}.padding(.vertical, 4) }.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) Text("操作到第 3 步时关机重启,开机后从第 4 步继续。")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
} }
} }
private func step(_ n: Int, _ title: String, _ detail: String) -> some View { private func step(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Text("\(n)").font(.caption2.bold()) Text("\(n)").font(.caption2.bold())
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
@@ -151,7 +153,7 @@ struct DeactivationTipContent: View {
} }
} }
private func systemStep(_ n: Int, _ title: String, _ detail: String) -> some View { private func systemStep(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Text("\(n)").font(.caption2.bold()) Text("\(n)").font(.caption2.bold())
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
@@ -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)) 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 //export wloccore_startproxy
func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t { 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 { if certData == nil || keyData == nil {
return 0 return 0
} }
@@ -53,6 +70,7 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
float64(lon), float64(lon),
enabled != 0, enabled != 0,
int(accuracy), int(accuracy),
motionEnabled != 0,
) )
if err != nil { if err != nil {
logEvent("startproxy failed: " + err.Error()) 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 //export wloccore_stopproxy
func wloccore_stopproxy(h C.uintptr_t) C.int { func wloccore_stopproxy(h C.uintptr_t) C.int {
logEvent("stopproxy requested") logEvent("stopproxy requested")
handle := cgo.Handle(h) srv, handle, ok := proxyForHandle(h)
srv, ok := handle.Value().(*http.Server)
handle.Delete()
if !ok { if !ok {
logEvent("stopproxy failed: invalid handle") logEvent("stopproxy failed: invalid handle")
return 1 return 1
} }
handle.Delete()
if err := stopProxy(srv); err != nil { if err := stopProxy(srv); err != nil {
logEvent("stopproxy failed: " + err.Error()) logEvent("stopproxy failed: " + err.Error())
return 2 return 2
@@ -79,15 +96,37 @@ func wloccore_stopproxy(h C.uintptr_t) C.int {
return 0 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 //export wloccore_setcoords
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) { 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() stateMu.Lock()
currentLat = float64(lat) currentLat = float64(lat)
currentLon = float64(lon) currentLon = float64(lon)
currentEnabled = enabled != 0 currentEnabled = enabled != 0
currentAccuracy = int(accuracy) currentAccuracy = int(accuracy)
currentMotionSimulationEnabled = motionEnabled != 0
stateMu.Unlock() 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 //export wloccore_getcoords
@@ -127,12 +166,17 @@ func wloccore_startcertserver(certData, keyData *C.char) C.uintptr_t {
return C.uintptr_t(cgo.NewHandle(server)) 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 { if h == 0 {
return nil, 0, false return nil, 0, false
} }
handle := cgo.Handle(h) defer func() {
server, ok := handle.Value().(*certificateServer) if recover() != nil {
server, handle, ok = nil, 0, false
}
}()
handle = cgo.Handle(h)
server, ok = handle.Value().(*certificateServer)
return server, handle, ok return server, handle, ok
} }
+15 -14
View File
@@ -1,37 +1,38 @@
package main package main
import ( import (
"crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/binary"
"encoding/pem" "encoding/pem"
"io"
"math/big" "math/big"
"math/rand"
"time" "time"
) )
func deterministicReader() io.Reader { func randomSerialNumber() (*big.Int, error) {
seed := sha256.Sum256([]byte("paopao-location-spoofer-ca-v1")) // Keep the serial positive and within the RFC 5280 recommended 20-octet bound.
src := rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8]))) limit := new(big.Int).Lsh(big.NewInt(1), 159)
return rand.New(src) return rand.Int(rand.Reader, limit)
} }
func generateCA() (certPEM, keyPEM []byte, err error) { func generateCA() (certPEM, keyPEM []byte, err error) {
rng := deterministicReader() privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
privateKey, err := rsa.GenerateKey(rng, 2048) if err != nil {
return nil, nil, err
}
serialNumber, err := randomSerialNumber()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
template := x509.Certificate{ template := x509.Certificate{
SerialNumber: big.NewInt(1), SerialNumber: serialNumber,
Subject: pkix.Name{ Subject: pkix.Name{
Organization: []string{"WLOC"}, Organization: []string{"Location Spoofer"},
CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"), CommonName: "Location Spoofer CA",
}, },
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2045, 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, 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
+39 -8
View File
@@ -15,17 +15,16 @@ func TestGenerateCA(t *testing.T) {
if len(certPEM) == 0 || len(keyPEM) == 0 { if len(certPEM) == 0 || len(keyPEM) == 0 {
t.Fatal("empty CA output") t.Fatal("empty CA output")
} }
block, _ := pem.Decode(certPEM) cert := parseCertificatePEM(t, certPEM)
if block == nil {
t.Fatal("invalid cert PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
if !cert.IsCA { if !cert.IsCA {
t.Fatal("certificate is not a CA") 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 { if time.Until(cert.NotAfter).Hours() < 360*24 {
t.Fatal("CA validity is too short") t.Fatal("CA validity is too short")
} }
@@ -33,3 +32,35 @@ func TestGenerateCA(t *testing.T) {
t.Fatal(err) 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
}
+50 -69
View File
@@ -11,7 +11,6 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -23,13 +22,14 @@ import (
const proxyPort = 8888 const proxyPort = 8888
var ( var (
stateMu sync.Mutex stateMu sync.Mutex
currentLat float64 currentLat float64
currentLon float64 currentLon float64
currentEnabled bool currentEnabled bool
currentAccuracy int currentAccuracy int
globalCACert *tls.Certificate currentMotionSimulationEnabled bool
verifyToken string globalCACert *tls.Certificate
verifyToken string
logMu sync.Mutex logMu sync.Mutex
logEntries []string logEntries []string
@@ -64,7 +64,13 @@ func isWlocHost(host string) bool {
host = h host = h
} }
} }
return host == "gs-loc.apple.com" || host == "gs-loc-cn.apple.com" switch host {
case "gs-loc.apple.com", "gs-loc-cn.apple.com",
"gsp-ssl.ls.apple.com", "bluedot.is.autonavi.com",
"bluedot.is.autonavi.com.gds.alibabadns.com":
return true
}
return false
} }
func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer { func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
@@ -95,10 +101,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
} }
if r.URL.Path == "/coords" { if r.URL.Path == "/coords" {
stateMu.Lock() stateMu.Lock()
enabled, lat, lon := currentEnabled, currentLat, currentLon enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
stateMu.Unlock() stateMu.Unlock()
w.Header().Set("Content-Type", "application/json") 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 return
} }
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" { if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
@@ -146,20 +152,16 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
logEvent("CONNECT " + host + " -> MITM (verify)") logEvent("CONNECT " + host + " -> MITM (verify)")
return mitmAction, host 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 return goproxy.OkConnect, host
}) })
} }
proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
body := []byte(nil) // Do not buffer or log arbitrary global-proxy traffic. Keep requests streaming
if req.Body != nil { // and do not persist unrelated request content in diagnostics.
body, _ = io.ReadAll(req.Body)
req.Body.Close()
req.Body = io.NopCloser(bytes.NewReader(body))
}
logEvent(fmt.Sprintf("proxy request target=%s method=%s path=%s size=%d body_prefix=%s",
req.Host, req.Method, req.URL.Path, len(body), hexPrefix(body, 64)))
return serveLocalRequests(req, ctx) return serveLocalRequests(req, ctx)
}) })
proxy.OnResponse().DoFunc(patchWlocResponse) proxy.OnResponse().DoFunc(patchWlocResponse)
@@ -176,7 +178,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-") { if (h == "baidu.com" || h == "www.baidu.com") && strings.HasPrefix(req.URL.Path, "/paopao-verify-") {
token := strings.TrimPrefix(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) { if checkVerifyToken(token) {
resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token) resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token)
resp.Header.Set("Cache-Control", "no-store") resp.Header.Set("Cache-Control", "no-store")
@@ -226,44 +228,42 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
} }
stateMu.Lock() stateMu.Lock()
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
stateMu.Unlock() 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 originalBody := resp.Body
body, err := io.ReadAll(originalBody) body, err := io.ReadAll(io.LimitReader(originalBody, maxPatchBodyBytes+1))
originalBody.Close()
if err != nil { if err != nil {
logEvent("wloc response read failed: " + err.Error()) 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 return resp
} }
logEvent(fmt.Sprintf("wloc upstream response status=%d size=%d headers=[%s] body_prefix=%s", if int64(len(body)) > maxPatchBodyBytes {
resp.StatusCode, len(body), summarizeHeaders(resp.Header), hexPrefix(body, 64))) 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 { if !enabled || resp.StatusCode != http.StatusOK || len(body) == 0 {
logEvent("wloc upstream response passed through (spoofing disabled)")
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
if resp.StatusCode != http.StatusOK {
logEvent(fmt.Sprintf("wloc upstream response passed through (status %d != 200)", resp.StatusCode))
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
if len(body) == 0 {
logEvent("wloc upstream response empty, passed through")
resp.Body = io.NopCloser(bytes.NewReader(body)) resp.Body = io.NopCloser(bytes.NewReader(body))
return resp return resp
} }
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy}) patched, stats, err := patchResponseBody(body, wlocCoords{
if err != nil { Latitude: lat, Longitude: lon, Accuracy: accuracy,
logEvent("wloc patch skipped: " + err.Error()) MotionSimulationEnabled: motionEnabled,
resp.Body = io.NopCloser(bytes.NewReader(body)) })
return resp if err != nil || bytes.Equal(patched, body) {
} if err != nil {
if bytes.Equal(patched, body) { logEvent("wloc patch skipped: " + err.Error())
logEvent("wloc patch produced identical body, passed through") }
resp.Body = io.NopCloser(bytes.NewReader(body)) resp.Body = io.NopCloser(bytes.NewReader(body))
return resp return resp
} }
@@ -273,30 +273,10 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
resp.Header.Del("Content-Encoding") resp.Header.Del("Content-Encoding")
resp.Header.Del("Transfer-Encoding") resp.Header.Del("Transfer-Encoding")
resp.Header.Set("Content-Length", strconv.Itoa(len(patched))) 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", 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)))
lat, lon, accuracy, stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched), hexPrefix(patched, 64)))
return resp 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 { func generateProxyMobileConfig() string {
return `<?xml version="1.0" encoding="UTF-8"?> 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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -356,7 +336,7 @@ func randomUint32() uint32 {
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) 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) cert, err := parseCA(certPEM, keyPEM)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -365,6 +345,7 @@ func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy
stateMu.Lock() stateMu.Lock()
globalCACert = cert globalCACert = cert
currentLat, currentLon, currentEnabled, currentAccuracy = lat, lon, enabled, accuracy currentLat, currentLon, currentEnabled, currentAccuracy = lat, lon, enabled, accuracy
currentMotionSimulationEnabled = motionEnabled
stateMu.Unlock() stateMu.Unlock()
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort)) 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 { type wlocCoords struct {
Latitude float64 Latitude float64
Longitude float64 Longitude float64
Accuracy int Accuracy int
MotionSimulationEnabled bool
} }
const (
motionActivityType = 63
motionActivityConfidence = 467
)
type patchStats struct { type patchStats struct {
WiFi int WiFi int
Cell 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 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 { func minInt(a, b int) int {
if a < b { if a < b {
return a return a
@@ -177,6 +185,7 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
lon := int64(math.Round(c.Longitude * 1e8)) lon := int64(math.Round(c.Longitude * 1e8))
var out []byte var out []byte
changed := false changed := false
hasMotionType, hasMotionConfidence := false, false
for _, f := range fields { for _, f := range fields {
switch { switch {
case f.num == 1 && f.wireType == wireVarint: case f.num == 1 && f.wireType == wireVarint:
@@ -197,10 +206,34 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
changed = true changed = true
} }
out = append(out, raw...) 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: default:
out = append(out, f.raw...) 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 return out, changed, nil
} }
@@ -314,6 +347,99 @@ func patchWlocPayload(payload []byte, c wlocCoords, st *patchStats) ([]byte, boo
return out, changed, nil 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) { func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte, patchStats, error) {
if len(body) < offset+10 { if len(body) < offset+10 {
return nil, *st, fmt.Errorf("body too short: %d, base=%d", len(body), offset) 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) { 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 var st patchStats
offsets := []int{0, 2, 4, 6, 8, 10, 12, 14, 16} offsets := []int{0, 2, 4, 6, 8, 10, 12, 14, 16}
seen := map[int]bool{} seen := map[int]bool{}
+312 -7
View File
@@ -4,7 +4,13 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"encoding/binary" "encoding/binary"
"encoding/json"
"fmt"
"io"
"math" "math"
"net/http"
"net/http/httptest"
"sync"
"testing" "testing"
) )
@@ -19,6 +25,15 @@ func testLocation(lat, lon int64, accuracy uint64) []byte {
return out 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 { func testWifiDevice(loc []byte) []byte {
mac := []byte("aa:bb:cc:dd:ee:ff") mac := []byte("aa:bb:cc:dd:ee:ff")
var out []byte var out []byte
@@ -38,6 +53,29 @@ func testFrame(payload []byte) []byte {
return out 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) { func TestPatchWifiLocation(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25))) payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
body := testFrame(payload) body := testFrame(payload)
@@ -63,20 +101,181 @@ func TestPatchWifiLocation(t *testing.T) {
} }
func TestPatchCellLocation(t *testing.T) { func TestPatchCellLocation(t *testing.T) {
cell := writeLengthDelimited(5, testLocation(300, 400, 25)) for _, field := range []int{22, 24} {
payload := writeLengthDelimited(22, cell) t.Run(fmt.Sprintf("field_%d", field), func(t *testing.T) {
body := testFrame(payload) cell := writeLengthDelimited(5, testLocation(300, 400, 25))
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 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) patched, stats, err := patchWlocBody(body, c)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if stats.Cell != 1 || stats.Locations != 1 { if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats) t.Fatalf("unexpected stats: %+v", stats)
} }
if bytes.Equal(patched, body) { if !bytes.Equal(patched[:lengthOffset], originalPrefix) {
t.Fatal("body was not patched") 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") 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)
}
}
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: setup core ipa-unsigned verify-ipa .PHONY: setup core ipa-unsigned verify-ipa test
setup: setup:
./Scripts/setup.sh ./Scripts/setup.sh
@@ -12,3 +12,6 @@ ipa-unsigned: core
verify-ipa: verify-ipa:
@test -n "$(IPA)" || (echo "Usage: make verify-ipa IPA=/absolute/path/to/signed.ipa" >&2; exit 2) @test -n "$(IPA)" || (echo "Usage: make verify-ipa IPA=/absolute/path/to/signed.ipa" >&2; exit 2)
./Scripts/verify-ipa.sh --signed "$(IPA)" ./Scripts/verify-ipa.sh --signed "$(IPA)"
test:
./Tests/run_all_contract_tests.sh
-163
View File
@@ -1,163 +0,0 @@
<div align="center">
# 📍 Location Spoofer
### iOS Location Spoofer · DingTalk · WeChat · Apple Watch Region Unlock · Fake GPS
**No VPN, no jailbreak — run a local HTTP proxy on your iPhone to rewrite Apple location responses.**<br>
Works with DingTalk check-in, WeChat location sharing, and any app that uses system location. Map selection, real-time location, environment verification, certificate setup, and runtime logs in a single app.
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138?logo=swift&logoColor=white)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go&logoColor=white)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.0-2563EB)](docs/CHANGELOG.md)
[![No VPN](https://img.shields.io/badge/VPN-Not%20needed-16A34A)](#why-no-vpn)
[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md)
<img src="images/主界面.jpg" alt="Location Spoofer iOS Fake GPS main interface" width="380">
</div>
> [!IMPORTANT]
> This project is intended for education, security research, and testing on your own devices. It installs a locally generated CA certificate and requires a manual HTTP proxy on the current Wi‑Fi network. Please understand the risks and follow applicable laws and service terms.
## Credits
The core location-response rewriting approach and Go implementation are based on [Yu9191/wloc](https://github.com/Yu9191/wloc). This project adds a SwiftUI interface, MapKit selection, certificate and proxy guidance, environment verification, favorites, and diagnostics.
## Why Location Spoofer?
Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailbroken device, Location Spoofer keeps the control flow on the iPhone itself.
| Feature | Description |
|---|---|
| 🚫 **No VPN** | No VPN tunnel — uses only location permission, no background refresh or notification access required. |
| 📱 **No jailbreak** | Can be installed through self-signing; minimum deployment target is iOS 15. |
| 🗺️ **Native Maps experience** | The same blue dot and selection gestures as Apple Maps — search, tap, and drag. |
| 📍 **System-level location spoofing** | Works with DingTalk, WeChat, Apple Maps, Amap, and other apps for real-time fake GPS. |
| 🔍 **Visible map scale** | Left-side controls show the current visible range; place name adapts to zoom level. |
| 🧪 **Environment verification** | Checks proxy, CA trust, Wi‑Fi interception, coordinate write, and response rewrite. |
| 🧾 **Diagnostics** | Per-entry copy for easy sharing and debugging. |
## Screenshots
| Main Interface | Apple Maps | Amap | Apple Watch |
|---|---|---|---|
| ![Location Spoofer main interface](images/主界面.jpg) | ![Apple Maps result](images/Apple%20Map.jpg) | ![Amap result](images/高德地图.jpg) | ![Apple Watch region feature](images/高血压.jpg) |
## Key Features
- **iOS Location Spoofer / Fake GPS**: Apply the selected coordinate to the local proxy that rewrites location responses, compatible with DingTalk check-in, WeChat location sharing, and more.
- **Native real-time location**: The map displays MapKit's own blue dot — no extra "fake real-time" overlay.
- **Concurrency-safe selection**: Pan, tap, search, favorites, and async location respect the user's latest intent; stale results won't overwrite newer selections.
- **Hierarchical place names**: POI, street, or road at close zoom; neighborhood, district, city, or province at wider zoom.
- **Map scale display**: Zoom controls show the current visible range.
- **Favorites with quick switch**: Save frequent coordinates and see which location is about to be applied.
- **Setup guide**: Certificate download, installation, full trust, Wi‑Fi HTTP proxy, activation, and deactivation instructions.
- **Built-in diagnostics**: Verification flow and structured runtime logs.
## Quick Start
### 1. Install the App
- Download a build from [Releases](https://github.com/xweiba/location-spoofer/releases) and self-sign; or
- Build from source on macOS with Xcode — see the [build guide](docs/BUILD.md).
Detailed steps in the [self-signing guide](docs/SELF-SIGNING.md).
### 2. Install & Trust the CA
Follow the first-setup wizard to download the profile, then:
```text
Settings → General → VPN & Device Management → install WLOC CA
Settings → General → About → Certificate Trust Settings → enable full trust
```
### 3. Configure the Current Wi‑Fi Proxy
On the current Wi‑Fi's proxy settings, choose "Manual":
```text
Server: 127.0.0.1
Port: 8888
Authentication: off
```
### 4. Select a Location & Enable
1. Search, tap, or drag the map to pick a location; tap the real-time location button to jump to the MapKit blue dot.
2. Tap "Start Spoofing" and wait for the environment check to pass.
3. Follow the in‑app activation instructions to refresh airplane mode, Wi‑Fi, and location services.
4. Open Apple Maps or your target app to verify.
### 5. Restore Your Real Location
Stop spoofing, remove the manual proxy from the current Wi‑Fi, and follow the in‑app deactivation instructions to refresh the system location cache. If stale cache persists, restart your device.
## Why No VPN?
```text
iPhone location request
│ Wi‑Fi HTTP proxy: 127.0.0.1:8888
▼
Local wloccore Go proxy
│ Handles only the targeted Apple location-service traffic
▼
Apple location service response
│ The selected coordinate is written into the response
▼
The system and applications receive the modified result
```
The project does not use Network Extension to create a VPN tunnel — there is no VPN icon and no VPN slot occupied. **However, you still need to configure the current Wi‑Fi HTTP proxy and install & trust the locally generated CA.** Re‑check proxy settings after switching Wi‑Fi networks; remove the manual proxy when you stop using the app.
## Compatibility
| Item | Requirement |
|---|---|
| iOS | 15.0+ |
| Build | macOS, Xcode, XcodeGen |
| Swift | 5.9 |
| Go | 1.23+ |
| Network | Wi‑Fi with manual HTTP proxy support |
| Installation | Self-sign or use release builds |
Actual behavior may vary with iOS version, network conditions, system location cache, device model, and the target app's own location strategy. Compatibility with every iOS version or third-party app is not guaranteed.
## Build & Project Structure
```bash
./build.sh
```
The unsigned IPA is at:
```text
dist/PaopaoLocationSpoofer-unsigned.ipa
```
```text
App/ SwiftUI, MapKit, location and setup flow
Core/ Go local proxy and location response rewriting
Shared/ Favorites, settings, logs, and shared models
Resources/ Info.plist, Entitlements, and icons
Scripts/ Build, signing, and verification scripts
Tests/ XCTest and Bash contract tests
docs/ Build, self-signing, and changelog documentation
```
## Documentation & Feedback
- [Build guide](docs/BUILD.md)
- [Self-signing guide](docs/SELF-SIGNING.md)
- [Changelog](docs/CHANGELOG.md)
- [中文文档](README.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
When reporting issues, please include reproduction steps, iOS version, device model, and sanitized runtime logs.
## Links
**LinuxDo** — [https://linux.do](https://linux.do/)
+418 -122
View File
@@ -2,173 +2,469 @@
# 📍 Location Spoofer # 📍 Location Spoofer
### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS ### iOS Location Service Research & Testing Framework
**无需 VPN、无需越狱,在 iPhone 本机通过 Wi‑Fi HTTP 代理改写 Apple 定位响应。**<br> An open-source project for **iOS location-service research, software development testing, and QA validation**.
可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。
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.
> ⚠️ **Starting with iOS 27 beta 6, the system blocks MITM interception of `gs-loc.apple.com`.** This project is
> temporarily unusable on that version and later betas until a workaround is found.
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml) [![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) [![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&logoColor=white)](Core/go.mod) [![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.0-2563EB)](docs/CHANGELOG.md) [![Version](https://img.shields.io/badge/version-v1.0.8-2563EB)](docs/CHANGELOG.en.md)
[![No VPN](https://img.shields.io/badge/VPN-不需要-16A34A)](#为什么不需要-vpn)
[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md) [Features](#feature-overview) ·
[How It Works](#how-it-works) ·
<img src="images/主界面.jpg" alt="Location Spoofer iOS 虚拟定位 Fake GPS 主界面" width="380"> [Quick Start](#quick-start) ·
[Build](#building-the-project) ·
[中文](README.zh-CN.md)
</div> </div>
> [!IMPORTANT] > [!IMPORTANT]
> 本项目用于学习、安全研究与自有设备测试。它会安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。 > 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.
## 致谢 ## Project Scope
核心定位响应改写思路与 Go 实现来源于 [Yu9191/wloc](https://github.com/Yu9191/wloc)。本项目在此基础上增加 SwiftUI 界面、MapKit 选点、证书与代理引导、环境验证、收藏和诊断能力。 Location Spoofer is a tool for studying iOS location-service behavior and testing location-dependent software.
## 为什么选择 Location Spoofer? It provides:
很多 iOS 虚拟定位工具依赖电脑常驻、开发者调试、VPN 或越狱。本项目采用不同路线:在 iPhone 本机运行 Go 代理,仅对 Apple 定位服务目标请求进行处理。 - 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.
| 特性 | 说明 | The project does not modify the target app's source code and does not provide telemetry, remote control, or data
|---|---| collection services.
| 🚫 **无 VPN** | 不创建 VPN 隧道,仅使用定位权限,无需后台刷新、通知等额外权限 |
| 📱 **无需越狱** | 支持自行签名安装,最低部署目标为 iOS 15 |
| 🗺️ **原生地图体验** | 使用 Apple 地图同款蓝点,搜索、点击、拖动选点体验与 Apple 地图一致 |
| 📍 **系统级虚拟定位** | 支持钉钉、微信、Apple 地图、高德等 App 的虚拟实时定位 |
| 🔍 **可见缩放范围** | 左侧缩放控件显示当前可视范围,地点名称随级别自动适配 |
| 🧪 **环境检测** | 检查代理、CA 信任、Wi‑Fi 接管、坐标写入与响应改写 |
| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 |
## 效果预览 ## Feature Overview
<table> - **Native map interaction**
<tr> - Uses MapKit for the map and system blue dot;
<th>应用主界面</th> - Supports search, map taps, center-point dragging, and zooming;
<th>Apple 地图</th> - Supports favorites and restoration of the latest selection;
<th>高德地图</th> - Shows both domestic and international coordinate representations with separate copy actions.
<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>
## 核心功能 - **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 虚拟定位 / Fake GPS**:将当前地图选点应用到本机定位响应改写代理,适配钉钉打卡、微信位置共享等场景。 - **Two runtime modes**
- **原生实时位置**:地图显示 MapKit 自带蓝点,不再由 App 额外绘制实时位置标记。 - 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.
- **地点名称分级**:近距离显示 POI、门牌或道路;拉远后显示社区、区县、城市或省份。
- **地图范围显示**:缩放控件中显示 `180 m`、`2.5 km`、`126 km` 等当前可视范围。
- **收藏与快速切换**:保存常用坐标,并明确显示当前准备应用的位置。
- **配置引导**:提供证书安装、完全信任、Wi‑Fi HTTP 代理、生效与恢复说明。
- **问题诊断**:内置验证流程和结构化运行日志。
## 快速开始 - **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.
### 1. 安装 App - **Development diagnostics**
- Runtime logs;
- Log copy and cleanup;
- Map coordinate-system change records;
- Sanitized issue-report generation.
- 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 获取构建产物并自行签名;或 ## How It Works
- 在 macOS + Xcode 环境按[构建说明](docs/BUILD.md)编译。
详细步骤见[自签安装说明](docs/SELF-SIGNING.md)。 ### App Mode
### 2. 安装并信任 CA 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 ```text
设置 → 通用 → VPN 与设备管理 → 安装 WLOC CA iOS location request
设置 → 通用 → 关于本机 → 证书信任设置 → 完全信任 │
``` │ Manual HTTP proxy on the current Wi-Fi
### 3. 配置当前 Wi‑Fi 代理
在当前 Wi‑Fi 的"配置代理"中选择"手动":
```text
服务器:127.0.0.1
端口:8888
鉴定:关闭
```
### 4. 选点并启用
1. 搜索、点击或拖动地图选择位置;点击实时位置按钮可回到 MapKit 蓝点。
2. 点击"开始虚拟定位",等待环境检测通过。
3. 按 App 内"生效说明"刷新飞行模式、Wi‑Fi 和定位服务状态。
4. 打开 Apple 地图或目标 App 验证结果。
### 5. 恢复真实位置
停止虚拟定位,关闭当前 Wi‑Fi 的手动代理,并按 App 内"失效说明"刷新系统定位缓存。若系统仍保留旧缓存,请重启设备后再检查。
## 为什么不需要 VPN?
```text
iPhone 定位请求
│ 当前 Wi‑Fi HTTP 代理:127.0.0.1:8888
▼ ▼
本机 wloccore(Go) On-device wloccore Go proxy
│ 仅处理目标 Apple 定位服务请求 │
├──────────────► Apple 定位服务 │ Handle selected Apple location-service requests
◄──────────────┘
│ 改写目标响应中的坐标
▼ ▼
系统与应用读取定位结果 Apple location-service response
│
│ Test coordinate response
▼
The system and apps read the location result
``` ```
项目不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**但它仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 切换 Wi‑Fi 后需要重新检查代理设置;停止使用后应及时关闭手动代理。 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
|---|---|
| iOS | 15.0+ |
| 构建 | macOS、Xcode、XcodeGen |
| Swift | 5.9 |
| Go | 1.23+ |
| 网络 | 可手动配置 HTTP 代理的 Wi‑Fi |
| 安装 | 自行签名或使用 Releases 构建产物 |
效果会受到 iOS 版本、网络、系统定位缓存和目标 App 自身策略影响,不承诺兼容所有系统或第三方 App。 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.
Modules and scripts are now hosted by this repository. The original
`Yu9191/wloc` repository was deleted and remains attribution only, not a runtime dependency:
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
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
You can use:
- 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).
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.
#### 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
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
```
## Building the Project
Source builds require:
- macOS;
- Xcode;
- Xcode Command Line Tools;
- XcodeGen;
- Go 1.23 or newer.
Building the iOS app directly on Windows is not supported.
```bash ```bash
git clone https://github.com/xweiba/location-spoofer.git
cd location-spoofer
./build.sh ./build.sh
``` ```
构建产物默认位于: Build and run Simulator tests:
```bash
./build.sh --test
```
The build script generates an unsigned IPA:
```text ```text
dist/PaopaoLocationSpoofer-unsigned.ipa dist/PaopaoLocationSpoofer-unsigned.ipa
``` ```
```text Deploy it to a test device using your own signing and installation process.
App/ SwiftUI、MapKit、定位和配置流程
Core/ Go 本机代理与定位响应改写
Shared/ 收藏、设置、日志和共享模型
Resources/ Info.plist、Entitlements 与图标
Scripts/ 构建、签名和检查脚本
Tests/ XCTest 与 Bash 契约测试
docs/ 构建、自签和版本更新文档
```
## 文档与反馈 ## Privacy and Security Boundaries
- [构建说明](docs/BUILD.md) - The project contains no telemetry or remote-control service;
- [自签安装](docs/SELF-SIGNING.md) - The project does not automatically upload location data;
- [v1.0.0 更新日志](docs/CHANGELOG.md) - Runtime logs remain in the device App Group container and retain only the latest three days;
- [English README](README.en.md) - 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 module URLs hosted by this repository 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.
## License
The project code is distributed under the [GNU Affero General Public License v3.0](LICENSE).
Third-party WLOC scripts retain their original copyright notices and AGPL-3.0 license in
[`ThirdParty/WlocScripts/`](ThirdParty/WlocScripts/THIRD_PARTY_NOTICES.md).
Other third-party dependencies remain subject to their respective licenses.
## 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)
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
- [Changelog](docs/CHANGELOG.en.md)
- [中文文档](README.zh-CN.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues) - [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
反馈问题时,请附上复现步骤、iOS 版本、设备型号及已脱敏的运行日志。 ## Feature Preview
## 友链 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
the following projects (`Yu9191/wloc` has since been deleted):
- [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.
+454
View File
@@ -0,0 +1,454 @@
<div align="center">
# 📍 Location Spoofer
### iOS Location Service Research & Testing Framework
一个用于 **iOS 定位服务研究、软件开发测试和 QA 验证** 的开源项目。
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
不同地理位置和定位场景下的行为。
> ⚠️ **iOS 27 beta 6 起,系统已禁止对 `gs-loc.apple.com` 进行 MITM 拦截。** 目前该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。
[![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)](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.8-2563EB)](docs/CHANGELOG.md)
[功能概览](#功能概览) ·
[工作原理](#工作原理) ·
[快速开始](#快速开始) ·
[构建项目](#构建项目) ·
[English](README.md)
</div>
> [!IMPORTANT]
> 本项目用于学习研究、自有设备测试、软件开发和 QA 验证。
>
> 请仅在你拥有或获得授权的设备、网络和软件环境中使用,并遵守当地法律法规、网络管理规定以及相关服务条
> 款。
>
> 本项目不保证兼容所有 iOS 版本或第三方应用,也不承诺绕过第三方应用的安全策略、业务限制或服务规则。
## 项目定位
Location Spoofer 是一个面向 iOS 定位服务行为研究和开发测试的工具。
它提供:
- 原生地图选点和位置场景切换;
- Apple 定位服务响应的测试环境模拟;
- 本地代理和第三方代理两种运行模式;
- 坐标标准识别与 WGS-84 / GCJ-02 双坐标管理;
- 环境检测、运行日志和问题诊断;
- 收藏位置和上次地图状态恢复。
项目不修改目标 App 的源代码,也不提供远程控制或数据采集服务。
## 功能概览
- **原生地图交互**
- 使用 MapKit 显示地图和系统蓝点;
- 支持搜索、点击选点、拖动地图中心和缩放;
- 支持收藏位置和恢复上次选点;
- 当前选点同时显示国内坐标和国际坐标,可分别复制。
- **定位服务响应模拟**
- 通过代理层处理指定的 Apple 定位服务请求;
- 在测试环境中返回选定的坐标数据;
- 不需要修改目标 App 代码。
- **双运行模式**
- 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 模块,尚未完整验证 | 待征集 |
社区配置按客户端分区审核;采纳后会在上表链接教程和投稿者,投稿者也可以选择匿名收录。
模块与脚本现由本仓库托管,原 `Yu9191/wloc` 仓库已删除,仅保留来源致谢,不再作为运行时依赖:
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
第三方客户端、证书、MITM 和代理开关由客户端自身负责。导入任何第三方模块前,请先审查其配置和脚本内容。
## 快速开始
### 1. 安装应用
你可以使用:
- 自己的 Apple Developer 签名环境;
- 适合个人测试的自签工具;
- 项目 Releases 中的未签名 IPA;
- 在 macOS 上按[构建说明](docs/BUILD.md)自行构建。
免费自签环境可能无法使用 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 和 Linux;Windows 若无法识别设备,请先安装 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
App/ SwiftUI 界面、MapKit、定位和运行流程
Core/ Go 代理、证书服务和定位响应处理
Shared/ 坐标、收藏、日志、配置和共享模型
Resources/ Info.plist、Entitlements 和资源文件
Config/ 构建配置
Scripts/ 构建、打包和验证脚本
Tests/ XCTest 和 Shell contract tests
docs/ 构建、模块和版本文档
```
## 构建项目
源码构建需要 macOS 环境:
- macOS;
- Xcode;
- Xcode Command Line Tools;
- XcodeGen;
- Go 1.23 或更高版本。
当前项目不支持在 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
```
之后需要使用你自己的签名和安装流程部署到测试设备。
## 隐私与安全边界
- 项目不包含遥测或远程控制服务;
- 项目不会自动上传用户位置数据;
- 运行日志保存在设备 App Group 容器中,并自动保留近三天;
- 问题报告需要用户主动复制后提交到 GitHub;
- APP 模式会访问本机代理和环境验证地址;
- 第三方代理模式可能访问本仓库托管的模块地址和 WLOC 配置接口;
- App 生成的 CA 私钥保存在设备 Keychain 中;
- 第三方客户端模块、MITM 和证书链路由用户选择的客户端负责。
请不要把真实位置、认证信息、证书私钥或完整敏感日志提交到公开 Issue。
## 限制
- iOS 系统版本变化可能影响定位服务行为;
- MapKit 的坐标返回标准可能随系统、地区和定位环境变化;
- 系统定位存在缓存,切换位置后不一定立即生效;
- 第三方代理客户端的兼容性和规则行为需要分别验证;
- 不保证所有应用都使用同一种定位 API;
- 不保证所有应用或服务都接受测试坐标;
- 不保证在所有网络环境、设备型号和 iOS 版本上表现一致。
## 许可证
项目代码采用 [GNU Affero General Public License v3.0](LICENSE) 授权。
第三方 WLOC 脚本保留原作者版权声明及其 AGPL-3.0 许可,详情见
[`ThirdParty/WlocScripts/`](ThirdParty/WlocScripts/THIRD_PARTY_NOTICES.md)。
其他第三方依赖仍遵循各自的许可证。
## 贡献
欢迎提交:
- [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/THIRD_PARTY_MODULES.md)
- [社区客户端教程与截图提交](docs/COMMUNITY_TUTORIALS.md)
- [更新日志](docs/CHANGELOG.md)
- [英文文档](README.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
## 功能预览
以下截图用于展示主界面和部分真机测试场景。实际结果会受到 iOS 版本、网络环境、系统缓存和目标应用定位策略
影响,不代表对所有应用或版本作出兼容性保证。
<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` 原仓库已删除):
- [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> <key>LSApplicationQueriesSchemes</key>
<array> <array>
<string>App-Prefs</string> <string>App-Prefs</string>
<string>shadowrocket</string>
<string>surge</string>
<string>quantumult-x</string>
<string>loon</string>
<string>stash</string>
<string>egern</string>
</array> </array>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
@@ -33,7 +39,7 @@
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
<true/> <false/>
</dict> </dict>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
@@ -6,5 +6,7 @@
<array> <array>
<string>group.com.paopaolabs.location-spoofer</string> <string>group.com.paopaolabs.location-spoofer</string>
</array> </array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
</dict> </dict>
</plist> </plist>
@@ -0,0 +1,11 @@
#!name=Apple WLOC 定位修改
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
#!author=Yu9191 Rewrite
#!homepage=https://github.com/xweiba/location-spoofer
[rewrite_local]
^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.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?v=1.0.8
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save url script-echo-response https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
[mitm]
hostname = gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
+20
View File
@@ -0,0 +1,20 @@
#!name=Apple WLOC 定位修改
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
#!icon=https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
#!author=Yu9191 Rewrite
#!homepage=https://github.com/xweiba/location-spoofer
[Argument]
longitude = input, "113.94114", tag=经度(在线选点优先)
latitude = input, "22.544577", tag=纬度(在线选点优先)
accuracy = input, "25", tag=精度(米)
randomRadius = input, "0", tag=扰动半径(米,0为关闭)
logLevel = select, "info", "off", "error", "warn", "debug", "all", tag=日志级别
[Script]
http-response ^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.com)?)\/clls\/wloc script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js?v=1.0.8, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC, argument=[{longitude},{latitude},{accuracy},{randomRadius},{logLevel}]
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8, timeout=10, tag=WLOC Settings
[MITM]
hostname = gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
@@ -0,0 +1,13 @@
#!name=Apple WLOC 定位修改
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
#!author=Yu9191 Rewrite
#!homepage=https://github.com/xweiba/location-spoofer
#!icon=https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
#!category=Tools
[Script]
Apple WLOC = type=http-response,pattern=^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.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?v=1.0.8,argument=longitude=113.94114&latitude=22.544577&accuracy=25&randomRadius=0&logLevel=info
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save,requires-body=0,max-size=0,timeout=10,script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
[MITM]
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
@@ -0,0 +1,14 @@
#!name=Apple WLOC 定位修改
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
#!author=Yu9191 Rewrite
#!homepage=https://github.com/xweiba/location-spoofer
#!category=Tools
#!arguments=经度:113.94114, 纬度:22.544577, 精度:25, 扰动半径:0, 日志级别:info
#!arguments-desc=经度/纬度: 默认坐标(在线选点储存后优先)\n精度: GPS精度(米)\n扰动半径: 每次响应在目标点周围随机偏移的最大距离(米),0为关闭\n日志级别: off/error/warn/info/debug/all\n\n使用方法: 打开选点页面 -> 选位置 -> 储存到设备
[Script]
Apple WLOC = type=http-response, pattern="^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.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?v=1.0.8, argument=longitude={{{经度}}}&latitude={{{纬度}}}&accuracy={{{精度}}}&randomRadius={{{扰动半径}}}&logLevel={{{日志级别}}}
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save", requires-body=0, max-size=0, timeout=10, script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
[MITM]
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
@@ -0,0 +1,36 @@
name: Apple WLOC 定位修改
desc: "Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md"
author: Yu9191 Rewrite
homepage: https://github.com/xweiba/location-spoofer
icon: https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
category: Tools
http:
mitm:
- "gs-loc.apple.com"
- "gs-loc-cn.apple.com"
- "gsp-ssl.ls.apple.com"
- "bluedot.is.autonavi.com"
- "bluedot.is.autonavi.com.gds.alibabadns.com"
script:
- match: ^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.com)?)\/clls\/wloc
name: WLOC.Location
type: response
require-body: true
binary-mode: true
max-size: 0
timeout: 30
argument: longitude=113.94114&latitude=22.544577&accuracy=25&randomRadius=0&logLevel=info
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save
name: WLOC.Settings
type: request
require-body: false
timeout: 10
script-providers:
WLOC.Location:
url: https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js?v=1.0.8
interval: 86400
WLOC.Settings:
url: https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
interval: 86400
+6
View File
@@ -0,0 +1,6 @@
/* App display name */
"CFBundleDisplayName" = "Location Spoofer";
/* Location permission prompts */
"NSLocationWhenInUseUsageDescription" = "Used to display and verify whether the simulated location is active.";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "Used to display and verify whether the simulated location is active.";
+705
View File
@@ -0,0 +1,705 @@
/* ============================================================
English localization and development-language fallback.
Keys are the original Simplified-Chinese source strings
All localized keys are mirrored in zh-Hans and zh-Hant tables.
Untranslated keys still display their Chinese source text.
============================================================ */
/* MARK: - Shared models (ProxyRuntimeMode / VerificationResult) */
"APP模式" = "App Mode";
"第三方代理模式" = "Third-party Proxy Mode";
"成功" = "Success";
"已有验证正在进行" = "A verification is already in progress";
"验证已被新位置取代" = "Verification superseded by a new location";
"证书未信任" = "Certificate not trusted";
"WiFi代理未配置" = "Wi-Fi proxy not configured";
"坐标写入失败" = "Failed to write coordinate";
"改写验证失败" = "Rewrite verification failed";
/* MARK: - SettingsView */
"运行模式" = "Runtime Mode";
"模式" = "Mode";
"状态" = "Status";
"本机代理" = "Local Proxy";
"第三方模块" = "Third-party Module";
"检测连接" = "Test Connection";
"虚拟定位" = "Simulated Location";
"定位模拟" = "Location Simulation";
"运动状态模拟" = "Motion State Simulation";
"实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。" = "Experimental, off by default. When enabled, the motion state in the location response is simulated too.";
"随机扰动" = "Random Jitter";
"开启后,下次同步坐标时会给目标点添加随机偏移(默认半径 50 米),避免位置固定在同一点。" = "When enabled, the next coordinate sync adds a random offset to the target point (default radius 50 m) so the location is not fixed at a single point.";
"说明" = "Instructions";
"工作原理" = "How It Works";
"应用" = "App";
"进入引导页" = "Open Setup Guide";
"正在检查…" = "Checking…";
"检查更新" = "Check for Updates";
"版本" = "Version";
"证书" = "Certificate";
"重置证书" = "Reset Certificate";
"仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。" = "Only removes the device CA from the app keychain. Any old certificate already installed in iOS must be removed manually in Settings.";
"支持" = "Support";
"使用帮助" = "Usage Help";
"功能建议" = "Feature Request";
"分享第三方配置" = "Share Third-party Config";
"关于" = "About";
"如果觉得好用,欢迎去 GitHub 给项目点个 Star" = "If you find it useful, please star the project on GitHub";
"致谢" = "Acknowledgements";
"核心定位改写逻辑移植自 Yu9191/wloc" = "Core location-rewrite logic ported from Yu9191/wloc";
"设置" = "Settings";
"完成" = "Done";
"代理操作失败" = "Proxy Operation Failed";
"重置证书?" = "Reset certificate?";
"重置并生成新证书" = "Reset and Generate New Certificate";
"当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。" = "The current simulated location and local proxy will stop. The app deletes the device CA from the keychain, generates a new certificate immediately, and opens the install-and-trust guide. You must also go to iOS Settings → General → VPN & Device Management to manually delete the old certificate, then re-download, install, and fully trust the new one.";
"第三方代理配置" = "Third-party Proxy Configuration";
"客户端" = "Client";
"使用国内镜像下载模块" = "Use domestic mirror to download modules";
"仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。" = "Only affects module URLs you copy or re-import afterward; installed modules must be re-imported to switch source.";
"验证状态" = "Verification Status";
"已复制模块订阅地址" = "Subscription URL copied";
"复制模块订阅地址" = "Copy module subscription URL";
"复制 %@" = "Copy %@";
"已复制 %@" = "Copied %@";
"已复制解密域名" = "Decryption hostnames copied";
"复制解密域名" = "Copy decryption hostnames";
"打开 %@" = "Open %@";
"重新打开配置引导" = "Reopen Setup Guide";
"Egern 直接使用 Surge 的 .sgmodule 模块。" = "Egern uses Surge's .sgmodule module directly.";
"Stash 直接订阅 .stoverride,不要通过 Script Hub 转换。" = "Stash subscribes to .stoverride directly; do not convert it via Script Hub.";
"复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。" = "After copying the subscription URL, add the module/rewrite subscription in your proxy client and enable MITM for all copied hostnames (including gsp-ssl.ls.apple.com and bluedot.is.autonavi.com). Once the third-party client saves the coordinate, it persists and stays in effect even after you close this app.";
"已是最新版本" = "You are on the latest version";
"当前版本 %@,远程最新版本 %@。" = "Current version %1$@, latest remote version %2$@.";
"检查更新失败" = "Update Check Failed";
"无法获取远程版本信息,请检查网络后重试。" = "Could not fetch remote version info. Check your network and try again.";
"未检测" = "Not checked";
"已连接,有坐标" = "Connected, coordinate present";
"已连接,无坐标" = "Connected, no coordinate";
"连接失败" = "Connection failed";
"已开启" = "On";
"已关闭" = "Off";
"第三方已保存" = "Saved by third-party";
"未保存" = "Not saved";
"未知" = "Unknown";
"App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。" = "The app only handles map selection, favorites, and sending WGS-84 coordinates. The third-party proxy client intercepts Apple WLOC requests through its module and persists the current coordinate. This mode does not start the local proxy, does not use the app's CA, and does not require configuring 127.0.0.1:8888.";
"App 在设备本地运行一个代理服务器(127.0.0.1:8888)。\n\n通过 WiFi 手动代理配置,让系统发往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的定位请求经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。" = "The app runs a proxy server locally on the device (127.0.0.1:8888).\n\nA manual Wi-Fi proxy routes location requests sent to Apple location hostnames (gs-loc.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, etc.) through this local proxy. Using the installed CA certificate, the proxy performs man-in-the-middle decryption of the HTTPS traffic, rewrites the location coordinate returned by Apple to the one you set, then re-encrypts and returns it to the system — producing the simulated location.";
"模式已切换" = "Mode Switched";
"第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。" = "Third-party proxy mode check passed. Please turn off the 127.0.0.1:8888 manual proxy in Wi-Fi to avoid double interception.";
"APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。" = "App mode environment check passed. Please disable the third-party WLOC module or proxy connection to avoid double interception.";
"证书重置失败" = "Certificate Reset Failed";
"无法打开客户端" = "Could Not Open Client";
"无法打开 %@,请确认客户端已安装后手动打开。" = "Could not open %@. Make sure the client is installed, then open it manually.";
/* MARK: - ContentView */
"正在初始化地图与本地代理…" = "Initializing map and local proxy…";
"正在初始化地图…" = "Initializing map…";
"更新说明暂时无法加载,请前往最新 Release 页面查看。" = "Release notes could not be loaded. Please check the latest Release page.";
"需要更新" = "Update Required";
"当前版本 %@ 已停止支持,请更新到 %@ 后继续使用。\n\n%@" = "Version %1$@ is no longer supported. Please update to %2$@ to continue.\n\n%3$@";
"立即更新" = "Update Now";
"发现新版本" = "New Version Available";
"当前版本 %@,最新版本 %@。\n\n%@" = "Current version %1$@, latest version %2$@.\n\n%3$@";
"前往更新" = "Update";
"稍后" = "Later";
/* MARK: - BugReportView */
"遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板。跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段。" = "When you run into a problem, generate a bug report here. The app runs a diagnostic test and copies the full report to the clipboard. After opening GitHub, paste it into the “App-generated diagnostic report” field.";
"可复现环境" = "Reproducible";
"当前设备上问题稳定复现,非偶发性。" = "The issue reproduces reliably on this device and is not intermittent.";
"问题描述" = "Problem Description";
"正在生成报告…" = "Generating report…";
"生成 Bug 报告" = "Generate Bug Report";
"报告 Bug" = "Report a Bug";
"已生成" = "Generated";
"打开 GitHub 表单" = "Open GitHub Form";
"稍后再说" = "Later";
"Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。" = "The bug report has been copied to the clipboard. Paste it into the “App-generated diagnostic report” field of the GitHub form and submit.";
/* MARK: - DiagnosticsView */
"过滤日志" = "Filter logs";
"暂无运行日志" = "No runtime logs";
"无匹配日志" = "No matching logs";
"运行日志" = "Runtime Logs";
"日志自动清理,仅保留近 3 天" = "Logs are cleared automatically; only the last 3 days are kept";
"关闭" = "Close";
"复制全部日志" = "Copy all logs";
"清空全部日志" = "Clear all logs";
"清空所有运行日志?" = "Clear all runtime logs?";
"清空日志" = "Clear Logs";
"取消" = "Cancel";
"正在检测…" = "Checking…";
"环境检测" = "Environment Check";
"检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。" = "Checks whether the third-party module can intercept and respond to the query request; no test coordinates are written.";
"依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。" = "Checks in order: local proxy → CA certificate trust → Wi-Fi proxy chain.";
"测试日志" = "Test Log";
"已复制" = "Copied";
"代理运行中" = "Proxy running";
"代理未运行" = "Proxy stopped";
"可修改" = "Modifiable";
"不可修改" = "Not modifiable";
"环境检测通过" = "Environment check passed";
"环境检测失败: %@" = "Environment check failed: %@";
",查看下方日志" = ", see the log below";
"第三方模块连接通过,已有坐标" = "Third-party module connected, coordinate present";
"第三方模块连接通过,暂无坐标" = "Third-party module connected, no coordinate yet";
"第三方模块连接失败" = "Third-party module connection failed";
"======== 第三方代理连接检测 ========" = "======== Third-party Proxy Connection Check ========";
"模式: 测试模式" = "Mode: Test mode";
"请求: wloc-settings/save?action=query" = "Request: wloc-settings/save?action=query";
"拦截响应: 有效 JSON" = "Intercepted response: Valid JSON";
"已保存坐标: %@" = "Saved coordinate: %@";
"结果: %@" = "Result: %@";
/* MARK: - TipViews */
"生效说明" = "How to Activate";
"失效说明" = "How to Deactivate";
"关闭 WiFi 代理" = "Remove Wi-Fi Proxy";
"知道了" = "Got It";
"让虚拟定位生效" = "Make the simulated location take effect";
"确认第三方代理已开启" = "Confirm the third-party proxy is on";
"保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。" = "Keep the imported WLOC module, HTTPS decryption, and the third-party proxy/VPN connection enabled.";
"开启飞行模式" = "Turn on Airplane Mode";
"从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。" = "Open Control Center and enable Airplane Mode (tap the airplane icon); Wi-Fi disconnects automatically. This clears the iOS location cache. Wait 2 seconds.";
"关闭 Wi‑Fi" = "Turn off Wi-Fi";
"从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。" = "Tap the Wi-Fi icon in Control Center again and confirm Wi-Fi is off. Wait 2 seconds.";
"关闭系统定位服务" = "Turn off Location Services";
"打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。" = "Open Settings → Privacy & Security → Location Services and turn off the master switch at the top. Wait 2 seconds.";
"打开 Wi‑Fi,启动虚拟定位" = "Turn on Wi-Fi and start the simulated location";
"从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" = "Turn on Wi-Fi from Control Center (keep Airplane Mode on) and confirm the third-party proxy is connected. The coordinate is already synced to the third-party proxy. Wait 2 seconds.";
"从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。" = "Turn on Wi-Fi from Control Center (keep Airplane Mode on), then open the app and tap “Start Simulated Location” at the bottom. Wait 2 seconds.";
"关闭飞行模式" = "Turn off Airplane Mode";
"从控制中心关闭飞行模式。等待 2 秒。" = "Turn off Airplane Mode from Control Center. Wait 2 seconds.";
"重新开启定位服务" = "Turn Location Services back on";
"再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。" = "Open Settings → Privacy & Security → Location Services again and turn the master switch on. Then open a map app to verify the location has changed.";
"还是无法生效?" = "Still not working?";
"操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。" = "At step 3, power off and restart the device, then continue from step 4. This fully clears the system's cached location data.";
"去设置" = "Open Settings";
"取消虚拟定位" = "Cancel the simulated location";
"从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。" = "Enable Airplane Mode from Control Center; Wi-Fi disconnects automatically. Wait 2 seconds.";
"从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。" = "Confirm Wi-Fi is off from Control Center. Wait 2 seconds.";
"打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。" = "Open Settings → Privacy & Security → Location Services and turn off the master switch. Wait 2 seconds.";
"确认坐标已清除" = "Confirm the coordinate is cleared";
"App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。" = "The app has told the third-party proxy to clear the simulated coordinate. Keep the network available and wait 2 seconds so the system can reacquire the real location.";
"打开 Wi‑Fi,移除代理" = "Turn on Wi-Fi and remove the proxy";
"从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。" = "Turn on Wi-Fi from Control Center. Then go to Settings → Wi-Fi → tap the (i) next to your Wi-Fi → HTTP Proxy, choose Off, and save. Wait 2 seconds.";
"再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。" = "Open Settings → Privacy & Security → Location Services again and turn the master switch on. Open a map app to verify the location is restored.";
"还是无法取消?" = "Still can't cancel?";
"操作到第 3 步时关机重启,开机后从第 4 步继续。" = "At step 3, power off and restart the device, then continue from step 4.";
"移除代理配置" = "Remove the proxy configuration";
"停止虚拟定位后,需要手动移除 WiFi 代理配置,否则可能无法上网。\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前 WiFi 右侧 (i) 图标\n3. 找到「HTTP 代理」\n4. 选择「关闭」\n5. 点右上角「存储」" = "After stopping the simulated location, you must manually remove the Wi-Fi proxy configuration or you may lose internet access.\n\n1. Open Settings → Wi-Fi\n2. Tap the (i) icon next to your current Wi-Fi\n3. Find “HTTP Proxy”\n4. Choose “Off”\n5. Tap “Save” in the top-right corner";
/* MARK: - MapHomeView */
"社区分享成功配置?" = "Share your working config with the community?";
"去提交" = "Submit";
"复制模板" = "Copy Template";
"不再提示" = "Don't Show Again";
"第三方客户端" = "Third-party client";
"你正在使用 %@。点击“去提交”会先复制投稿模板,并在 App 内打开社区页面。采纳后将收录到 README,可选择是否匿名署名。" = "You are using %@. Tapping “Submit” first copies the submission template and opens the community page inside the app. Accepted submissions are added to the README; you can choose to stay anonymous.";
"已复制投稿模板" = "Submission Template Copied";
"如果 GitHub 登录或浏览器跳转后模板没有自动填充,可以直接粘贴。" = "If the template is not filled in automatically after you sign in to GitHub or the browser switches, just paste it manually.";
"无法直接跳转" = "Cannot Open Directly";
"定位失败" = "Location Failed";
"打开设置" = "Open Settings";
"无法获取当前定位,请检查定位服务是否已开启" = "Could not get the current location. Please check whether Location Services is enabled.";
"编辑收藏名称" = "Edit Favorite Name";
"名称" = "Name";
"保存" = "Save";
"修改收藏地点名称" = "Rename the favorite location";
"搜索地点或坐标" = "Search a place or coordinates";
"日志" = "Logs";
"更多" = "More";
"当前选点" = "Current Selection";
"GCJ-02(国内)" = "GCJ-02 (Domestic)";
"WGS-84(国际)" = "WGS-84 (Intl.)";
"无法生效?" = "Not working?";
"无法取消?" = "Can't cancel?";
"已收藏,点击取消收藏" = "Favorited. Tap to remove.";
"收藏当前选点" = "Favorite the current selection";
"搜索或点击地图选点后,保存为收藏。" = "Search or tap the map to pick a point, then save it as a favorite.";
"切换到此处" = "Switch Here";
"同步到第三方代理" = "Sync to Third-party Proxy";
"检测并同步中…" = "Checking and syncing…";
"停止第三方虚拟定位" = "Stop Third-party Simulation";
"开始虚拟定位" = "Start Simulated Location";
"验证环境中…" = "Verifying environment…";
"停止虚拟定位" = "Stop Simulated Location";
"当前未连接可用的 Wi-Fi,请连接 Wi-Fi 后配置 127.0.0.1:8888 手动代理。" = "No usable Wi-Fi is connected. Connect to Wi-Fi, then configure the 127.0.0.1:8888 manual proxy.";
"第三方代理查询失败" = "Third-party proxy query failed";
"未命名" = "Unnamed";
"没有找到相关地点" = "No matching places found";
"虚拟定位已开启" = "Simulated Location On";
"不再提醒" = "Don't Remind Me";
"虚拟定位已关闭" = "Simulated Location Off";
/* MARK: - FirstSetupView */
"选择模式" = "Choose Mode";
"配置 Wi-Fi 代理" = "Configure Wi-Fi Proxy";
"初始化 CA 证书" = "Initialize CA Certificate";
"选择客户端" = "Choose Client";
"导入并检测" = "Import & Test";
"上一步" = "Back";
"开始使用" = "Get Started";
"操作失败" = "Operation Failed";
"选择运行模式" = "Choose a Runtime Mode";
"后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。" = "You can switch later in Settings → Runtime Mode. Do not intercept WLOC requests in both modes at the same time.";
"仅 Wi-Fi" = "Wi-Fi only";
"无外部依赖" = "No external deps";
"App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。" = "The app starts a proxy locally on the device and rewrites location responses through a manual HTTP proxy on the current Wi-Fi. Free self-signed apps cannot use the system VPN's Network Extension, so App Mode does not support cellular; it requires configuring a Wi-Fi proxy and installing the CA the app generates.";
"测试模式" = "Test mode";
"App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。" = "The app handles point selection and queries/syncs coordinates through the WLOC configuration endpoint; the third-party proxy client handles network proxying, module interception, MITM, and persistence. Certificates, VPN, and proxy connections are all handled by the third-party client.";
"正在准备 APP模式本地服务…" = "Preparing App Mode local services…";
"先配置 Wi-Fi 系统代理" = "First configure the Wi-Fi system proxy";
"在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。配置后点击下方「完成」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。" = "On the details page of your current Wi-Fi, set “HTTP Proxy” to “Manual”: server 127.0.0.1, port 8888. After configuring, tap “Done” below. The check automatically detects whether the Wi-Fi proxy or certificate trust is the problem.";
"Wi-Fi 代理设置" = "Wi-Fi Proxy Settings";
"1 选择手动,2 填写服务器 127.0.0.1,3 填写端口 8888。" = "1) Choose Manual, 2) enter server 127.0.0.1, 3) enter port 8888.";
"复制地址" = "Copy Address";
"打开 Wi-Fi 设置" = "Open Wi-Fi Settings";
"第 1 步:下载证书" = "Step 1: Download the Certificate";
"下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。App 会弹出 Safari 下载页;出现配置描述文件下载提示时,选择「允许」。" = "Download the randomly generated CA root certificate for this device. The private key is stored only in this device's keychain and is never exported with the certificate file. The app opens a Safari download page; when prompted to download a configuration profile, choose “Allow”.";
"打开下载页" = "Open Download Page";
"无法准备证书下载页面,请查看诊断日志" = "Could not prepare the certificate download page. Please check the diagnostic log.";
"第 2 步:安装证书" = "Step 2: Install the Certificate";
"下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 Location Spoofer CA 并完成安装。" = "After the download finishes, open Settings. If “Profile Downloaded” appears at the top, tap it to install; otherwise go to General → VPN & Device Management, find Location Spoofer CA, and install it.";
"去安装" = "Install";
"安装证书" = "Install Certificate";
"1 在「VPN 与设备管理」中打开 Location Spoofer CA 描述文件并完成安装。" = "1) In VPN & Device Management, open the Location Spoofer CA profile and install it.";
"第 3 步:信任证书" = "Step 3: Trust the Certificate";
"安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 Location Spoofer CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。" = "After installing, go to Settings → General → About → Certificate Trust Settings, find Location Spoofer CA, and enable full trust. As long as iOS keeps the keychain data, reinstalling the app reuses the same certificate.";
"去信任" = "Trust";
"信任证书" = "Trust Certificate";
"1 在「证书信任设置」中为 Location Spoofer CA 开启完全信任。" = "1) In Certificate Trust Settings, enable full trust for Location Spoofer CA.";
"已完成" = "Done";
"已完成 ✓" = "Done ✓";
"iOS 27 beta 6 起,系统已禁止对 gs-loc.apple.com 进行 MITM 拦截。该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。" = "Starting with iOS 27 beta 6, the system blocks MITM interception of gs-loc.apple.com. This project is temporarily unusable on that and later beta versions until a workaround is available.";
"选择第三方代理客户端" = "Choose a Third-party Proxy Client";
"除 Shadowrocket 外,当前客户端配置尚未完成真机验证,页面只提供模块导入入口和通用配置提醒。" = "Except for Shadowrocket, the current client configurations have not been fully verified on real devices; this page only provides a module import entry and general configuration reminders.";
"第三方客户端适配说明" = "Third-party Client Integration Notes";
"App 不连接远程坐标服务器,而是向 Apple 域名发起一个约定请求。第三方客户端需要在本机拦截该请求、保存 WGS-84 坐标并返回 JSON;定位模块再读取同一份数据,修改 Apple WLOC 响应。" = "The app does not connect to a remote coordinate server; instead it sends an agreed-upon request to an Apple hostname. The third-party client must intercept that request locally, store the WGS-84 coordinate, and return JSON; the location module then reads the same data and modifies the Apple WLOC response.";
"配置接口" = "Configuration Endpoint";
"查询:GET ?action=query\n保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>\n清除:GET ?action=clear" = "Query: GET ?action=query\nSave: GET ?lon=<lon>&lat=<lat>&acc=<accuracy>\nClear: GET ?action=clear";
"返回格式" = "Response Format";
"适配要求" = "Integration Requirements";
"客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。" = "The client must support request scripts, persistent storage, HTTP 200 JSON responses, an Apple WLOC response script, and HTTPS decryption for Apple location hostnames (gs-loc.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, etc.). The save endpoint and the WLOC response script must read the same persistent data.";
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。" = "A third-party proxy connection problem was detected. Check the module, MITM, and proxy connection, then test again.";
"第 1 步:导入 %@ 模块" = "Step 1: Import the %@ module";
"复制 %@ 的模块订阅地址。" = "Copy the module subscription URL for %@.";
"打开 Shadowrocket,进入“配置 → 模块”。" = "Open Shadowrocket and go to Config → Modules.";
"打开 %@。" = "Open %@.";
"进入 Shadowrocket 配置" = "Open Shadowrocket Config";
"1 点击「模块」进入模块列表,2 可打开当前本地配置详情。" = "1) Tap “Modules” to open the module list, 2) open the current local config details.";
"点击右上角“+”,粘贴模块订阅地址并导入,然后确认模块已启用。" = "Tap “+” in the top-right, paste the module subscription URL and import it, then confirm the module is enabled.";
"导入 Shadowrocket 模块" = "Import Shadowrocket Module";
"1 点击右上角加号导入模块,2 确认模块已启用。" = "1) Tap the plus in the top-right to import the module, 2) confirm the module is enabled.";
"在 %@ 中导入刚才复制的模块订阅地址。" = "Import the module subscription URL you just copied into %@.";
"第 2 步:完成 %@ 配置" = "Step 2: Finish configuring %@";
"请在 %@ 中完成相应配置。" = "Complete the corresponding configuration in %@.";
"配置时请复制下方全部解密域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)。" = "When configuring, copy all the decryption hostnames below (including gsp-ssl.ls.apple.com and bluedot.is.autonavi.com).";
"第 2 步:配置 HTTPS 解密" = "Step 2: Configure HTTPS Decryption";
"进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。" = "Go to Config → Local Files, find the config with the yellow dot, and tap the i icon on the right.";
"进入“HTTPS 解密”,开启解密开关。" = "Open “HTTPS Decryption” and turn on the decryption switch.";
"在域名列表中添加下方复制的全部解密域名。" = "Add all the decryption hostnames copied below to the hostname list.";
"配置 HTTPS 解密" = "Configure HTTPS Decryption";
"1 开启 HTTPS 解密,2 添加复制的全部解密域名,3 打开证书设置。" = "1) Turn on HTTPS Decryption, 2) add all copied decryption hostnames, 3) open certificate settings.";
"按 Shadowrocket 提示生成并完成证书授权。" = "Follow Shadowrocket's prompts to generate and complete the certificate authorization.";
"授权 Shadowrocket 证书" = "Authorize Shadowrocket Certificate";
"1 打开 Shadowrocket 证书项并按提示安装、授权。" = "1) Open the Shadowrocket certificate item and install/authorize it as prompted.";
"返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。" = "Return to the HTTPS Decryption page, tap the checkmark in the top-right to save, then enable the proxy.";
"打开 Shadowrocket 继续配置" = "Open Shadowrocket to Continue";
"App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。" = "The app can only launch Shadowrocket; it cannot jump directly to the “Modules” or “HTTPS Decryption” page through a public API.";
"轻点查看大图" = "Tap to view full image";
"证书尚未安装或信任" = "Certificate not installed or trusted";
"Wi-Fi 代理未正确设置" = "Wi-Fi proxy is not set up correctly";
"本地代理未能启动" = "The local proxy could not start";
"检测仍在进行" = "The check is still running";
"检测结果已过期" = "The check result is out of date";
"定位改写检测失败" = "Location-rewrite check failed";
"接口连接失败" = "Endpoint connection failed";
/* MARK: - SetupCoordinator */
"本地代理初始化失败:%@" = "Local proxy initialization failed: %@";
"✓ 定位环境正常" = "✓ Location environment OK";
"CA 证书未安装或未信任" = "CA certificate not installed or not trusted";
"Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888" = "Wi-Fi proxy is not set up correctly. Please check 127.0.0.1:8888";
/* MARK: - SystemSettingsNavigator */
"请手动打开「设置」,找到本 App 后检查定位权限。" = "Please open Settings manually, find this app, and check its location permission.";
"请手动打开「设置 → 通用」。" = "Please open Settings → General manually.";
"请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。" = "Please open Settings → Wi-Fi manually and go to the details page of your current Wi-Fi.";
"请手动打开「设置 → 隐私与安全性 → 定位服务」。" = "Please open Settings → Privacy & Security → Location Services manually.";
/* MARK: - ThirdPartyProxyManager */
"第三方代理返回了无法识别的数据" = "The third-party proxy returned unrecognizable data";
"请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接" = "The request was not intercepted by the third-party proxy module. Check the module, MITM, and proxy connection.";
"第三方代理保存的坐标与当前选点不一致" = "The coordinate saved by the third-party proxy does not match the current selection";
"第三方代理请求失败:%@" = "Third-party proxy request failed: %@";
"检查模块、MITM、证书和代理/VPN连接" = "Check the module, MITM, certificate, and proxy/VPN connection";
"第三方代理拒绝保存坐标" = "The third-party proxy refused to save the coordinate";
"第三方代理清除坐标失败" = "The third-party proxy failed to clear the coordinate";
"已有第三方代理请求正在执行" = "A third-party proxy request is already running";
"配置已提供,尚未验证" = "Configuration provided, not yet verified";
"已连接,有保存坐标" = "Connected, coordinate saved";
"已连接,无保存坐标" = "Connected, no saved coordinate";
"连接失败(%@)" = "Connection failed (%@)";
"当前客户端:%@" = "Current client: %@";
"配置接口:/wloc-settings/save" = "Configuration endpoint: /wloc-settings/save";
"请求动作:WLOC query" = "Request action: WLOC query";
"检查范围:模块拦截、MITM、证书、代理/VPN 连接" = "Check scope: module interception, MITM, certificate, and proxy/VPN connection";
"连接状态:%@" = "Connection status: %@";
"检测结果:失败" = "Check result: Failed";
"耗时:%lld ms" = "Duration: %lld ms";
"错误类型:%@" = "Error type: %@";
"错误详情:%@" = "Error details: %@";
"处理建议:%@。" = "Suggested action: %@.";
"======== 第三方代理运行检测 ========" = "======== Third-party Proxy Runtime Check ========";
"触发来源:地图或设置中的第三方代理操作" = "Triggered by: third-party proxy action from the map or Settings";
"请求动作:WLOC 配置接口" = "Request action: WLOC configuration endpoint";
"处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接。" = "Suggested action: confirm the module is enabled, then check MITM, the certificate, and the proxy/VPN connection.";
/* MARK: - Exported diagnostics */
"环境信息" = "Environment";
"App 版本" = "App version";
"系统版本" = "System version";
"诊断日志" = "Diagnostic Logs";
"不适用" = "Not applicable";
"是" = "Yes";
"否" = "No";
"(无诊断数据)" = "(No diagnostic data)";
"第三方代理测试模式:模块连接成功;已保存坐标=%@" = "Third-party proxy test mode: module connected; saved coordinate=%@";
"第三方代理测试模式:模块连接失败;%@" = "Third-party proxy test mode: module connection failed; %@";
"======== 代理验证测试 ========" = "======== Proxy Verification Test ========";
"App 版本: %@" = "App version: %@";
"系统版本: iOS %@" = "System version: iOS %@";
"[步骤 A] 检查代理是否运行…" = "[Step A] Check whether the proxy is running…";
" 端口: 127.0.0.1:8888" = " Port: 127.0.0.1:8888";
" ⚠ 代理未运行,尝试启动…" = " ⚠ Proxy is not running; attempting to start it…";
" ✗ 启动失败: %@" = " ✗ Failed to start: %@";
" ✓ 代理启动成功" = " ✓ Proxy started";
" ✓ 代理已在运行中" = " ✓ Proxy is already running";
"[步骤 B] 检测证书与 WiFi 代理…" = "[Step B] Check certificate and Wi-Fi proxy…";
" 方式: 请求 baidu.com/paopao-verify-<token>" = " Method: request baidu.com/paopao-verify-<token>";
" 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过" = " Criteria: TLS error=certificate issue / response mismatch=proxy not configured / match=passed";
" ✗ 无法生成验证 token" = " ✗ Could not generate a verification token";
" ✓ 证书已信任,WiFi 代理已配置" = " ✓ Certificate is trusted and the Wi-Fi proxy is configured";
" ✗ 响应不匹配: HTTP %lld, %lld bytes,WiFi 代理未配置" = " ✗ Response mismatch: HTTP %1$lld, %2$lld bytes; Wi-Fi proxy is not configured";
" ✗ 请求失败 [%@ code=%lld]: %@" = " ✗ Request failed [%1$@ code=%2$lld]: %3$@";
" TLS/证书校验失败,CA 证书未信任" = " TLS/certificate validation failed; the CA certificate is not trusted";
"======== 环境检测通过 ✓ ========" = "======== Environment Check Passed ✓ ========";
" --- 代理日志 ---" = " --- Proxy Logs ---";
/* MARK: - Setup examples and acknowledgements */
"成功:{\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\n失败:{\"success\":false,\"error\":\"错误说明\"}" = "Success: {\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\nFailure: {\"success\":false,\"error\":\"Error details\"}";
"定位改写脚本源自 Yu9191/wloc,现由本项目维护" = "Location-rewrite scripts originated from Yu9191/wloc and are now maintained by this project";
/* MARK: - Runtime log categories and messages */
"地图" = "Map";
"坐标转换" = "Coordinate Conversion";
"定位" = "Location";
"实时定位" = "Live Location";
"提醒" = "Reminder";
"搜索" = "Search";
"收藏" = "Favorites";
"缩放" = "Zoom";
"========== App 启动 ==========" = "========== App Launch ==========";
"尚未选择运行模式,跳过本地 CA 和代理初始化" = "No runtime mode selected; skipping local CA and proxy initialization";
"第三方代理测试模式:跳过本地 CA、代理和环境检测" = "Third-party proxy test mode: skipping local CA, proxy, and environment checks";
"旧坐标数据迁移失败,将在下次启动重试" = "Legacy coordinate migration failed; it will be retried on the next launch";
"地图坐标标准初始化完成,开始后续启动流程" = "Map coordinate-system initialization completed; continuing app startup";
"没有持久化图钉,地图创建前请求实时定位" = "No persisted pin; requesting live location before creating the map";
"已使用实时定位准备唯一初始地图状态" = "Prepared the initial map state using live location";
"地图创建前取得的初始实时位置(WGS-84)" = "Initial live location obtained before map creation (WGS-84)";
"地图创建前无法取得实时定位,唯一初始位置使用深圳" = "Could not obtain live location before map creation; using Shenzhen as the initial location";
"已找到持久化图钉,直接准备唯一初始地图状态" = "Found a persisted pin and prepared the initial map state";
"启动门禁全部完成,现在创建 MapHomeView" = "All startup gates completed; creating MapHomeView";
"初始化" = "Initialize";
"启动代理 127.0.0.1:8888" = "Starting proxy at 127.0.0.1:8888";
"启动代理: 恢复上次 WGS-84 定位" = "Starting proxy: restoring the last WGS-84 location";
"启动成功" = "Started successfully";
"启动失败" = "Failed to start";
"写入坐标" = "Write coordinate";
"跳过过期坐标写入" = "Skipped stale coordinate write";
"跳过旧验证坐标恢复" = "Skipped restoring coordinates from an outdated verification";
"运动状态模拟设置已更新" = "Motion simulation setting updated";
"准备证书下载失败" = "Failed to prepare certificate download";
"设置虚拟定位坐标" = "Set simulated-location coordinate";
"apply失败" = "Apply failed";
"保存收藏失败" = "Failed to save favorite";
"本地服务初始化失败" = "Local service initialization failed";
"代理运行模式已切换" = "Proxy runtime mode changed";
"已迁移旧版模式初始化状态" = "Migrated legacy mode initialization state";
"地图坐标标准检测已有请求进行中" = "A map coordinate-system check is already running";
"地图坐标标准检测开始" = "Map coordinate-system check started";
"地图坐标标准检测获得明确结果" = "Map coordinate-system check returned a definitive result";
"地图坐标标准检测不可用,开始实时定位兜底" = "Map coordinate-system check unavailable; starting live-location fallback";
"地图坐标标准检测使用兜底结果" = "Map coordinate-system check used the fallback result";
"地图坐标标准检测被取消,保留默认国内标准" = "Map coordinate-system check was cancelled; keeping the default mainland-China system";
"地图坐标标准已确定,允许创建地图" = "Map coordinate system determined; map creation is now allowed";
"地图坐标标准运行期检测合并到进行中请求" = "Runtime map coordinate-system check joined the request already in progress";
"地图坐标标准运行期检测开始" = "Runtime map coordinate-system check started";
"地图坐标标准运行期检测结果已过期,取消写入" = "Runtime map coordinate-system result became stale; discarding it";
"地图坐标标准运行期检测完成,标准未变化" = "Runtime map coordinate-system check completed with no change";
"地图坐标标准运行期检测发现切换" = "Runtime map coordinate-system check detected a change";
"地图坐标标准运行期检测失败,保留当前标准" = "Runtime map coordinate-system check failed; keeping the current system";
"地图坐标标准运行期检测已取消" = "Runtime map coordinate-system check cancelled";
"实时定位不覆盖固定锚点的明确检测结果" = "Live location did not override the definitive fixed-anchor result";
"实时定位确认兜底地图坐标标准无需修正" = "Live location confirmed that the fallback map coordinate system needs no correction";
"实时定位修正启动兜底地图坐标标准" = "Live location corrected the startup fallback map coordinate system";
"第三方代理已保存 WGS-84 坐标" = "Third-party proxy saved the WGS-84 coordinate";
"第三方代理坐标已清除" = "Third-party proxy coordinate cleared";
"第三方代理请求失败" = "Third-party proxy request failed";
"第三方代理坐标同步成功" = "Third-party proxy coordinate synchronized";
"同步坐标到第三方客户端失败" = "Failed to synchronize the coordinate to the third-party client";
"验证结果" = "Verification result";
"验证失败" = "Verification failed";
"开启前检测失败,进入对应环境引导" = "Pre-start check failed; opening the relevant setup guide";
"清除第三方客户端坐标失败" = "Failed to clear the coordinate from the third-party client";
"累计虚拟定位操作次数" = "Counted simulated-location operations";
"已复制坐标" = "Coordinate copied";
"地图坐标标准切换时未找到当前选点缓存" = "No current-selection cache was found when the map coordinate system changed";
"地图坐标标准切换后已使用缓存坐标对回显当前选点" = "Restored the current selection from cached coordinates after the map coordinate system changed";
"地图坐标类型已变化" = "Map coordinate type changed";
"取消保存收藏:检测期间当前选点已变化" = "Cancelled saving favorite because the current selection changed during detection";
"保存当前选点为收藏" = "Saving the current selection as a favorite";
"第三方代理查询返回失败" = "Third-party proxy query returned a failure";
"启动后第三方代理状态查询失败" = "Failed to query third-party proxy status after startup";
"检测到 Wi-Fi 网络变化" = "Detected a Wi-Fi network change";
"网络仍在变化,重新计算环境检测等待时间" = "Network is still changing; recalculating the environment-check delay";
"等待 Wi-Fi 连接稳定后检测" = "Waiting for the Wi-Fi connection to stabilize before checking";
"延时检测已被更新的网络事件取消" = "Delayed check was cancelled by a newer network event";
"稳定等待结束后仍未连接 Wi-Fi,提示检查代理" = "Wi-Fi is still disconnected after the stability delay; prompting the user to check the proxy";
"开始后台环境检测" = "Starting background environment check";
"已有环境检测运行,1 秒后重试" = "An environment check is already running; retrying in 1 second";
"后台环境检测完成" = "Background environment check completed";
"环境检测连续被占用,本次不重复弹窗" = "Environment check remained busy; suppressing a duplicate alert";
"用户点击实时定位" = "User requested live location";
"MapKit 蓝点尚不可用,启动 CLLocationManager 兜底" = "MapKit user location is unavailable; starting the CLLocationManager fallback";
"MapKit 蓝点抢先完成请求,取消 CLLocationManager 兜底" = "MapKit completed the request first; cancelling the CLLocationManager fallback";
"虚拟定位开启后的 MapKit 蓝点标准判定" = "Determined the MapKit user-location coordinate system after simulated location started";
"登记实时定位请求上下文" = "Registered live-location request context";
"复用进行中的 CLLocationManager 请求并更新意图上下文" = "Reusing the active CLLocationManager request and updating its intent context";
"CLLocationManager 兜底未返回坐标" = "CLLocationManager fallback returned no coordinate";
"丢弃 CLLocationManager 结果:任务已取消或蓝点已抢先完成" = "Discarded CLLocationManager result because the task was cancelled or MapKit completed first";
"实时定位坐标完成标准判断并提交到地图" = "Live-location coordinate system determined and submitted to the map";
"MKLocalSearch 返回地点结果" = "MKLocalSearch returned a place result";
"反向地理编码网络失败,准备重试" = "Reverse geocoding network request failed; preparing to retry";
"反向地理编码失败" = "Reverse geocoding failed";
"获得搜索结果" = "Received search results";
"点击收藏点并回显到地图" = "Selected a favorite and displayed it on the map";
"忽略重复 CLLocationManager 请求" = "Ignored duplicate CLLocationManager request";
"CLLocationManager 请求入口" = "CLLocationManager request entry";
"授权状态不允许定位" = "Authorization status does not permit location access";
"创建 CLLocationManager 请求" = "Created CLLocationManager request";
"请求前台定位授权" = "Requesting foreground location authorization";
"定位授权状态变化" = "Location authorization status changed";
"CLLocationManager 返回样本批次" = "CLLocationManager returned a batch of samples";
"本批次没有可完成当前请求的样本" = "This batch contains no sample that can complete the current request";
"接受 CLLocationManager 样本并完成请求" = "Accepted CLLocationManager sample and completed the request";
"定位回调失败" = "Location callback failed";
"跳过 CLLocationManager 缓存样本" = "Skipped cached CLLocationManager sample";
"等待定位授权超时" = "Timed out waiting for location authorization";
"单次定位超时,切换持续定位" = "One-shot location timed out; switching to continuous updates";
"持续定位超时" = "Continuous location updates timed out";
"开始 CLLocationManager 单次定位" = "Starting one-shot CLLocationManager request";
"开始 CLLocationManager 持续定位兜底" = "Starting continuous CLLocationManager fallback";
"CLLocationManager 请求完成" = "CLLocationManager request completed";
"CLLocationManager 请求结束但没有坐标" = "CLLocationManager request ended without a coordinate";
"使用 CLLocationManager 新鲜缓存" = "Using a fresh CLLocationManager cache entry";
"实时定位按钮直接使用 MapKit 蓝点缓存" = "Live-location action used the MapKit user-location cache directly";
"主页收到待处理请求所需的 MapKit 蓝点回调" = "Home screen received the MapKit user-location callback for the pending request";
"主页收到 CLLocationManager 兜底坐标" = "Home screen received the CLLocationManager fallback coordinate";
"原始实时定位坐标" = "Original live-location coordinate";
"检查 CLLocationManager 样本" = "Checking CLLocationManager sample";
"拒绝 CLLocationManager 样本:坐标或精度无效" = "Rejected CLLocationManager sample because its coordinate or accuracy is invalid";
"MapKit 蓝点更新但 location 为空" = "MapKit user location updated but its location value is empty";
"拒绝 MapKit 蓝点样本:坐标无效" = "Rejected MapKit user-location sample because its coordinate is invalid";
"拒绝 MapKit 蓝点样本:水平精度无效" = "Rejected MapKit user-location sample because horizontal accuracy is invalid";
"音频中断恢复" = "Recovered from audio interruption";
"音频会话失败" = "Audio session failed";
"无法创建静音音频缓冲区" = "Could not create the silent-audio buffer";
"引擎启动失败" = "Audio engine failed to start";
"后台保活已启动(静音音频)" = "Background keep-alive started (silent audio)";
"后台保活已停止" = "Background keep-alive stopped";
"保存当前图钉失败" = "Failed to save the current pin";
"存储缩放" = "Stored zoom level";
"旧坐标数据迁移完成" = "Legacy coordinate migration completed";
"远程版本配置加载成功" = "Remote version configuration loaded";
"版本配置源不可用,尝试下一地址" = "Version configuration source unavailable; trying the next URL";
"版本配置加载失败,继续使用内置配置" = "Failed to load version configuration; continuing with built-in values";
"版本说明源不可用,尝试下一地址" = "Release-notes source unavailable; trying the next URL";
"版本说明加载失败,将使用最新 Release 页面" = "Failed to load release notes; using the latest Release page";
"复用设备钥匙串中的 CA" = "Reusing the CA from the device keychain";
"旧 CA 已迁移到设备钥匙串" = "Legacy CA migrated to the device keychain";
"已生成并保存设备专属 CA" = "Generated and saved a device-specific CA";
"已删除设备 CA,等待重新生成" = "Deleted the device CA; waiting to regenerate it";
"钥匙串中的 CA 无效,准备回退" = "CA in the keychain is invalid; preparing fallback";
"删除旧 CA 文件失败,将在下次启动重试" = "Failed to delete legacy CA files; it will be retried on the next launch";
"无法解析 CA 证书 PEM" = "Could not parse the CA certificate PEM";
"无法创建 SecTrust" = "Could not create SecTrust";
"SecTrust 评估返回错误" = "SecTrust evaluation returned an error";
"CA 证书已被系统信任" = "CA certificate is trusted by the system";
"CA 证书未被系统信任" = "CA certificate is not trusted by the system";
"调用 Go Core 生成 CA" = "Calling Go Core to generate the CA";
"Go Core CA 生成成功" = "Go Core generated the CA";
"本地证书服务已在运行" = "Local certificate server is already running";
"调用 Go Core 启动本地证书服务" = "Calling Go Core to start the local certificate server";
"本地证书服务启动成功" = "Local certificate server started";
"停止本地证书服务" = "Stopping local certificate server";
"开始第三方代理连接检测" = "Starting third-party proxy connection check";
"第三方代理连接检测通过" = "Third-party proxy connection check passed";
"第三方代理连接检测失败" = "Third-party proxy connection check failed";
"关闭代理前已同步关闭虚拟定位" = "Stopped simulated location before stopping the proxy";
"切换 APP 模式前无法清除第三方坐标" = "Could not clear the third-party coordinate before switching to App Mode";
"设置页第三方连接检测通过" = "Third-party connection check passed in Settings";
"设置页第三方连接检测失败" = "Third-party connection check failed in Settings";
"地图实际显示坐标" = "Coordinate displayed on map";
/* MARK: - Runtime log fields and values */
"App回到前台" = "App returned to foreground";
"App已确认地图标准" = "App confirmed the map coordinate system";
"CLLocationManager请求中" = "CLLocationManager request active";
"Go proxy 启动失败" = "Go proxy failed to start";
"MapKit地图蓝点" = "MapKit user location";
"MapKit蓝点新样本" = "New MapKit user-location sample";
"MapKit蓝点缓存" = "MapKit user-location cache";
"SSID可读取" = "SSID readable";
"WLOC写入标准" = "WLOC write coordinate system";
"WLOC目标标准" = "WLOC target coordinate system";
"Wi-Fi接口" = "Wi-Fi interface";
"intent选点revision" = "Intent selection revision";
"manager请求中" = "Manager request active";
"上下文存在" = "Context present";
"事件原因" = "Event reason";
"任务已取消" = "Task cancelled";
"任务已存在" = "Task already present";
"使用兜底" = "Used fallback";
"保存收藏" = "Save favorite";
"保存数据包含" = "Saved data contains";
"保持未启用" = "Kept disabled";
"保留原第三方坐标" = "Kept previous third-party coordinate";
"保留已启用状态" = "Kept enabled state";
"保留标准" = "Retained coordinate system";
"修正兜底标准" = "Corrected fallback coordinate system";
"内存缓存存在" = "In-memory cache present";
"初始坐标来源" = "Initial coordinate source";
"初始来源" = "Initial source";
"初始阶段" = "Initial phase";
"判定规则" = "Decision rule";
"原兜底来源" = "Original fallback source";
"原因" = "Reason";
"取值字段" = "Source field";
"可显示不再提醒" = "Can show suppress option";
"启动代理…" = "Starting proxy…";
"命中林士街" = "Matched Lin Shi Street";
"固定锚点" = "Fixed anchor";
"固定锚点查询超过5秒" = "Fixed-anchor query exceeded 5 seconds";
"固定锚点查询返回空结果" = "Fixed-anchor query returned no results";
"国内标准(GCJ-02)" = "Mainland China system (GCJ-02)";
"国内转换区域" = "Mainland conversion region";
"国外非转换区域" = "Non-conversion region outside mainland China";
"国际标准(WGS-84)" = "International system (WGS-84)";
"国际标准(WGS-84)+国内标准(GCJ-02)" = "International (WGS-84) + mainland China (GCJ-02)";
"图钉已按新类型重设" = "Pin reset for the new type";
"地图取值字段" = "Map source field";
"地图标准" = "Map coordinate system";
"坐标有效" = "Coordinate valid";
"坐标标准" = "Coordinate system";
"垂直精度米" = "Vertical accuracy (m)";
"基础校验通过" = "Basic validation passed";
"处理建议" = "Suggested action";
"失败时提示" = "Show alert on failure";
"实时定位存在" = "Live location present";
"实时定位服务区域" = "Live-location service region";
"客户端模式" = "Client mode";
"尝试" = "Attempt";
"已取消位置更新" = "Location updates cancelled";
"已恢复真实定位" = "Real location restored";
"已清理" = "Cleared";
"已重置" = "Reset";
"开启" = "Enable";
"异步地理编码" = "Asynchronous geocoding";
"当前地图标准" = "Current map coordinate system";
"当前客户端" = "Current client";
"当前标准" = "Current coordinate system";
"当前选点revision" = "Current selection revision";
"恢复状态" = "Restore state";
"批次索引" = "Batch index";
"持久化字段" = "Persisted field";
"授权状态" = "Authorization status";
"授权状态rawValue" = "Authorization status raw value";
"探测失败原因" = "Probe failure reason";
"搜索结果" = "Search results";
"操作" = "Operation";
"新类型" = "New type";
"无已保存" = "None saved";
"无法判定" = "Undetermined";
"无法启动本地证书服务" = "Could not start the local certificate server";
"无法生成本地证书" = "Could not generate the local certificate";
"日志策略" = "Logging policy";
"旧类型" = "Previous type";
"显示坐标字段" = "Displayed coordinate field";
"最低版本" = "Minimum version";
"最新版本" = "Latest version";
"最终标准" = "Final coordinate system";
"最终阶段" = "Final phase";
"有坐标" = "Coordinate present";
"有效样本数" = "Valid sample count";
"有缓存" = "Cache present";
"未知错误" = "Unknown error";
"本地 CA 证书或私钥无效" = "Local CA certificate or private key is invalid";
"无法写入设备钥匙串(%d)" = "Could not write to the device keychain (%d)";
"来源" = "Source";
"来源类型" = "Source type";
"林士街" = "Lin Shi Street";
"查看诊断日志" = "View diagnostic logs";
"样本年龄秒" = "Sample age (s)";
"样本数" = "Sample count";
"样本时间" = "Sample time";
"检查范围" = "Check scope";
"检测前标准" = "Coordinate system before check";
"模块拦截、MITM、代理/VPN连接" = "Module interception, MITM, and proxy/VPN connection";
"次数" = "Count";
"每次开启首次或判定变化" = "First sample after each start or classification change";
"水平精度米" = "Horizontal accuracy (m)";
"活动requestID" = "Active request ID";
"活动阶段" = "Active phase";
"海拔米" = "Altitude (m)";
"满足当前请求时间窗" = "Within current request time window";
"点击实时定位" = "Live-location action";
"目标所在区域" = "Target region";
"确认标准" = "Confirmed coordinate system";
"社区征集客户端数" = "Clients requesting community contributions";
"等待秒数" = "Wait duration (s)";
"系统缓存存在" = "System cache present";
"结果" = "Result";
"结果数" = "Result count";
"结果来源" = "Result source";
"缓存" = "Cache";
"缓存上限秒" = "Cache limit (s)";
"缓存时效通过" = "Cache freshness valid";
"缩放米" = "Zoom (m)";
"网络可用" = "Network available";
"耗时毫秒" = "Duration (ms)";
"蓝点回调更接近" = "User-location callback is closer";
"蓝点缓存" = "User-location cache";
"蓝点缓存存在" = "User-location cache present";
"蓝点距GCJ目标米" = "User-location distance to GCJ target (m)";
"蓝点距WGS目标米" = "User-location distance to WGS target (m)";
"触发原因" = "Trigger reason";
"诊断位置" = "Diagnostic location";
"请求动作" = "Request action";
"超时毫秒" = "Timeout (ms)";
"输入坐标标准" = "Input coordinate system";
"连接状态" = "Connection status";
"选点revision" = "Selection revision";
"选点期间发生变化" = "Selection changed during operation";
"错误" = "Error";
"错误类型" = "Error type";
"锚点" = "Anchor";
"阶段" = "Phase";
"首条名称" = "First result name";
"首条名称=林士街→GCJ-02,否则→WGS-84" = "First name=Lin Shi Street → GCJ-02; otherwise → WGS-84";
"默认国内标准" = "Default mainland-China coordinate system";
/* MARK: - ProxyManager */
"证书下载地址无效" = "Invalid certificate download URL";
"启动代理失败: %@" = "Failed to start proxy: %@";
@@ -0,0 +1,6 @@
/* App display name */
"CFBundleDisplayName" = "Location Spoofer";
/* Location permission prompts */
"NSLocationWhenInUseUsageDescription" = "用于显示和验证虚拟定位是否生效";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "用于显示和验证虚拟定位是否生效";
+668
View File
@@ -0,0 +1,668 @@
/* Generated from en.lproj/Localizable.strings by Scripts/generate-chinese-localizations.swift. */
"APP模式" = "APP模式";
"第三方代理模式" = "第三方代理模式";
"成功" = "成功";
"已有验证正在进行" = "已有验证正在进行";
"验证已被新位置取代" = "验证已被新位置取代";
"证书未信任" = "证书未信任";
"WiFi代理未配置" = "WiFi代理未配置";
"坐标写入失败" = "坐标写入失败";
"改写验证失败" = "改写验证失败";
"运行模式" = "运行模式";
"模式" = "模式";
"状态" = "状态";
"本机代理" = "本机代理";
"第三方模块" = "第三方模块";
"检测连接" = "检测连接";
"虚拟定位" = "虚拟定位";
"定位模拟" = "定位模拟";
"运动状态模拟" = "运动状态模拟";
"实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。" = "实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。";
"随机扰动" = "随机扰动";
"开启后,下次同步坐标时会给目标点添加随机偏移(默认半径 50 米),避免位置固定在同一点。" = "开启后,下次同步坐标时会给目标点添加随机偏移(默认半径 50 米),避免位置固定在同一点。";
"说明" = "说明";
"工作原理" = "工作原理";
"应用" = "应用";
"进入引导页" = "进入引导页";
"正在检查…" = "正在检查…";
"检查更新" = "检查更新";
"版本" = "版本";
"证书" = "证书";
"重置证书" = "重置证书";
"仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。" = "仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。";
"支持" = "支持";
"使用帮助" = "使用帮助";
"功能建议" = "功能建议";
"分享第三方配置" = "分享第三方配置";
"关于" = "关于";
"如果觉得好用,欢迎去 GitHub 给项目点个 Star" = "如果觉得好用,欢迎去 GitHub 给项目点个 Star";
"致谢" = "致谢";
"核心定位改写逻辑移植自 Yu9191/wloc" = "核心定位改写逻辑移植自 Yu9191/wloc";
"设置" = "设置";
"完成" = "完成";
"代理操作失败" = "代理操作失败";
"重置证书?" = "重置证书?";
"重置并生成新证书" = "重置并生成新证书";
"当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。" = "当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。";
"第三方代理配置" = "第三方代理配置";
"客户端" = "客户端";
"使用国内镜像下载模块" = "使用国内镜像下载模块";
"仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。" = "仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。";
"验证状态" = "验证状态";
"已复制模块订阅地址" = "已复制模块订阅地址";
"复制模块订阅地址" = "复制模块订阅地址";
"复制 %@" = "复制 %@";
"已复制 %@" = "已复制 %@";
"已复制解密域名" = "已复制解密域名";
"复制解密域名" = "复制解密域名";
"打开 %@" = "打开 %@";
"重新打开配置引导" = "重新打开配置引导";
"Egern 直接使用 Surge 的 .sgmodule 模块。" = "Egern 直接使用 Surge 的 .sgmodule 模块。";
"Stash 直接订阅 .stoverride,不要通过 Script Hub 转换。" = "Stash 直接订阅 .stoverride,不要通过 Script Hub 转换。";
"复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。" = "复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。";
"已是最新版本" = "已是最新版本";
"当前版本 %@,远程最新版本 %@。" = "当前版本 %@,远程最新版本 %@。";
"检查更新失败" = "检查更新失败";
"无法获取远程版本信息,请检查网络后重试。" = "无法获取远程版本信息,请检查网络后重试。";
"未检测" = "未检测";
"已连接,有坐标" = "已连接,有坐标";
"已连接,无坐标" = "已连接,无坐标";
"连接失败" = "连接失败";
"已开启" = "已开启";
"已关闭" = "已关闭";
"第三方已保存" = "第三方已保存";
"未保存" = "未保存";
"未知" = "未知";
"App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。" = "App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。";
"App 在设备本地运行一个代理服务器(127.0.0.1:8888)。\n\n通过 WiFi 手动代理配置,让系统发往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的定位请求经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。" = "App 在设备本地运行一个代理服务器(127.0.0.1:8888)。\n\n通过 WiFi 手动代理配置,让系统发往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的定位请求经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。";
"模式已切换" = "模式已切换";
"第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。" = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。";
"APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。" = "APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。";
"证书重置失败" = "证书重置失败";
"无法打开客户端" = "无法打开客户端";
"无法打开 %@,请确认客户端已安装后手动打开。" = "无法打开 %@,请确认客户端已安装后手动打开。";
"正在初始化地图与本地代理…" = "正在初始化地图与本地代理…";
"正在初始化地图…" = "正在初始化地图…";
"更新说明暂时无法加载,请前往最新 Release 页面查看。" = "更新说明暂时无法加载,请前往最新 Release 页面查看。";
"需要更新" = "需要更新";
"当前版本 %@ 已停止支持,请更新到 %@ 后继续使用。\n\n%@" = "当前版本 %@ 已停止支持,请更新到 %@ 后继续使用。\n\n%@";
"立即更新" = "立即更新";
"发现新版本" = "发现新版本";
"当前版本 %@,最新版本 %@。\n\n%@" = "当前版本 %@,最新版本 %@。\n\n%@";
"前往更新" = "前往更新";
"稍后" = "稍后";
"遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板。跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段。" = "遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板。跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段。";
"可复现环境" = "可复现环境";
"当前设备上问题稳定复现,非偶发性。" = "当前设备上问题稳定复现,非偶发性。";
"问题描述" = "问题描述";
"正在生成报告…" = "正在生成报告…";
"生成 Bug 报告" = "生成 Bug 报告";
"报告 Bug" = "报告 Bug";
"已生成" = "已生成";
"打开 GitHub 表单" = "打开 GitHub 表单";
"稍后再说" = "稍后再说";
"Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。" = "Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。";
"过滤日志" = "过滤日志";
"暂无运行日志" = "暂无运行日志";
"无匹配日志" = "无匹配日志";
"运行日志" = "运行日志";
"日志自动清理,仅保留近 3 天" = "日志自动清理,仅保留近 3 天";
"关闭" = "关闭";
"复制全部日志" = "复制全部日志";
"清空全部日志" = "清空全部日志";
"清空所有运行日志?" = "清空所有运行日志?";
"清空日志" = "清空日志";
"取消" = "取消";
"正在检测…" = "正在检测…";
"环境检测" = "环境检测";
"检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。" = "检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。";
"依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。" = "依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。";
"测试日志" = "测试日志";
"已复制" = "已复制";
"代理运行中" = "代理运行中";
"代理未运行" = "代理未运行";
"可修改" = "可修改";
"不可修改" = "不可修改";
"环境检测通过" = "环境检测通过";
"环境检测失败: %@" = "环境检测失败: %@";
",查看下方日志" = ",查看下方日志";
"第三方模块连接通过,已有坐标" = "第三方模块连接通过,已有坐标";
"第三方模块连接通过,暂无坐标" = "第三方模块连接通过,暂无坐标";
"第三方模块连接失败" = "第三方模块连接失败";
"======== 第三方代理连接检测 ========" = "======== 第三方代理连接检测 ========";
"模式: 测试模式" = "模式: 测试模式";
"请求: wloc-settings/save?action=query" = "请求: wloc-settings/save?action=query";
"拦截响应: 有效 JSON" = "拦截响应: 有效 JSON";
"已保存坐标: %@" = "已保存坐标: %@";
"结果: %@" = "结果: %@";
"生效说明" = "生效说明";
"失效说明" = "失效说明";
"关闭 WiFi 代理" = "关闭 WiFi 代理";
"知道了" = "知道了";
"让虚拟定位生效" = "让虚拟定位生效";
"确认第三方代理已开启" = "确认第三方代理已开启";
"保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。" = "保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。";
"开启飞行模式" = "开启飞行模式";
"从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。" = "从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。";
"关闭 Wi‑Fi" = "关闭 Wi‑Fi";
"从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。" = "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。";
"关闭系统定位服务" = "关闭系统定位服务";
"打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。" = "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。";
"打开 Wi‑Fi,启动虚拟定位" = "打开 Wi‑Fi,启动虚拟定位";
"从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" = "从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。";
"从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。" = "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。";
"关闭飞行模式" = "关闭飞行模式";
"从控制中心关闭飞行模式。等待 2 秒。" = "从控制中心关闭飞行模式。等待 2 秒。";
"重新开启定位服务" = "重新开启定位服务";
"再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。" = "再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。";
"还是无法生效?" = "还是无法生效?";
"操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。" = "操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。";
"去设置" = "去设置";
"取消虚拟定位" = "取消虚拟定位";
"从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。" = "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。";
"从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。" = "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。";
"打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。" = "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。";
"确认坐标已清除" = "确认坐标已清除";
"App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。" = "App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。";
"打开 Wi‑Fi,移除代理" = "打开 Wi‑Fi,移除代理";
"从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。" = "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。";
"再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。" = "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。";
"还是无法取消?" = "还是无法取消?";
"操作到第 3 步时关机重启,开机后从第 4 步继续。" = "操作到第 3 步时关机重启,开机后从第 4 步继续。";
"移除代理配置" = "移除代理配置";
"停止虚拟定位后,需要手动移除 WiFi 代理配置,否则可能无法上网。\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前 WiFi 右侧 (i) 图标\n3. 找到「HTTP 代理」\n4. 选择「关闭」\n5. 点右上角「存储」" = "停止虚拟定位后,需要手动移除 WiFi 代理配置,否则可能无法上网。\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前 WiFi 右侧 (i) 图标\n3. 找到「HTTP 代理」\n4. 选择「关闭」\n5. 点右上角「存储」";
"社区分享成功配置?" = "社区分享成功配置?";
"去提交" = "去提交";
"复制模板" = "复制模板";
"不再提示" = "不再提示";
"第三方客户端" = "第三方客户端";
"你正在使用 %@。点击“去提交”会先复制投稿模板,并在 App 内打开社区页面。采纳后将收录到 README,可选择是否匿名署名。" = "你正在使用 %@。点击“去提交”会先复制投稿模板,并在 App 内打开社区页面。采纳后将收录到 README,可选择是否匿名署名。";
"已复制投稿模板" = "已复制投稿模板";
"如果 GitHub 登录或浏览器跳转后模板没有自动填充,可以直接粘贴。" = "如果 GitHub 登录或浏览器跳转后模板没有自动填充,可以直接粘贴。";
"无法直接跳转" = "无法直接跳转";
"定位失败" = "定位失败";
"打开设置" = "打开设置";
"无法获取当前定位,请检查定位服务是否已开启" = "无法获取当前定位,请检查定位服务是否已开启";
"编辑收藏名称" = "编辑收藏名称";
"名称" = "名称";
"保存" = "保存";
"修改收藏地点名称" = "修改收藏地点名称";
"搜索地点或坐标" = "搜索地点或坐标";
"日志" = "日志";
"更多" = "更多";
"当前选点" = "当前选点";
"GCJ-02(国内)" = "GCJ-02(国内)";
"WGS-84(国际)" = "WGS-84(国际)";
"无法生效?" = "无法生效?";
"无法取消?" = "无法取消?";
"已收藏,点击取消收藏" = "已收藏,点击取消收藏";
"收藏当前选点" = "收藏当前选点";
"搜索或点击地图选点后,保存为收藏。" = "搜索或点击地图选点后,保存为收藏。";
"切换到此处" = "切换到此处";
"同步到第三方代理" = "同步到第三方代理";
"检测并同步中…" = "检测并同步中…";
"停止第三方虚拟定位" = "停止第三方虚拟定位";
"开始虚拟定位" = "开始虚拟定位";
"验证环境中…" = "验证环境中…";
"停止虚拟定位" = "停止虚拟定位";
"当前未连接可用的 Wi-Fi,请连接 Wi-Fi 后配置 127.0.0.1:8888 手动代理。" = "当前未连接可用的 Wi-Fi,请连接 Wi-Fi 后配置 127.0.0.1:8888 手动代理。";
"第三方代理查询失败" = "第三方代理查询失败";
"未命名" = "未命名";
"没有找到相关地点" = "没有找到相关地点";
"虚拟定位已开启" = "虚拟定位已开启";
"不再提醒" = "不再提醒";
"虚拟定位已关闭" = "虚拟定位已关闭";
"选择模式" = "选择模式";
"配置 Wi-Fi 代理" = "配置 Wi-Fi 代理";
"初始化 CA 证书" = "初始化 CA 证书";
"选择客户端" = "选择客户端";
"导入并检测" = "导入并检测";
"上一步" = "上一步";
"开始使用" = "开始使用";
"操作失败" = "操作失败";
"选择运行模式" = "选择运行模式";
"后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。" = "后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。";
"仅 Wi-Fi" = "仅 Wi-Fi";
"无外部依赖" = "无外部依赖";
"App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。" = "App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。";
"测试模式" = "测试模式";
"App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。" = "App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。";
"正在准备 APP模式本地服务…" = "正在准备 APP模式本地服务…";
"先配置 Wi-Fi 系统代理" = "先配置 Wi-Fi 系统代理";
"在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。配置后点击下方「完成」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。" = "在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。配置后点击下方「完成」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。";
"Wi-Fi 代理设置" = "Wi-Fi 代理设置";
"1 选择手动,2 填写服务器 127.0.0.1,3 填写端口 8888。" = "1 选择手动,2 填写服务器 127.0.0.1,3 填写端口 8888。";
"复制地址" = "复制地址";
"打开 Wi-Fi 设置" = "打开 Wi-Fi 设置";
"第 1 步:下载证书" = "第 1 步:下载证书";
"下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。App 会弹出 Safari 下载页;出现配置描述文件下载提示时,选择「允许」。" = "下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。App 会弹出 Safari 下载页;出现配置描述文件下载提示时,选择「允许」。";
"打开下载页" = "打开下载页";
"无法准备证书下载页面,请查看诊断日志" = "无法准备证书下载页面,请查看诊断日志";
"第 2 步:安装证书" = "第 2 步:安装证书";
"下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 Location Spoofer CA 并完成安装。" = "下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 Location Spoofer CA 并完成安装。";
"去安装" = "去安装";
"安装证书" = "安装证书";
"1 在「VPN 与设备管理」中打开 Location Spoofer CA 描述文件并完成安装。" = "1 在「VPN 与设备管理」中打开 Location Spoofer CA 描述文件并完成安装。";
"第 3 步:信任证书" = "第 3 步:信任证书";
"安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 Location Spoofer CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。" = "安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 Location Spoofer CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。";
"去信任" = "去信任";
"信任证书" = "信任证书";
"1 在「证书信任设置」中为 Location Spoofer CA 开启完全信任。" = "1 在「证书信任设置」中为 Location Spoofer CA 开启完全信任。";
"已完成" = "已完成";
"已完成 ✓" = "已完成 ✓";
"iOS 27 beta 6 起,系统已禁止对 gs-loc.apple.com 进行 MITM 拦截。该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。" = "iOS 27 beta 6 起,系统已禁止对 gs-loc.apple.com 进行 MITM 拦截。该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。";
"选择第三方代理客户端" = "选择第三方代理客户端";
"除 Shadowrocket 外,当前客户端配置尚未完成真机验证,页面只提供模块导入入口和通用配置提醒。" = "除 Shadowrocket 外,当前客户端配置尚未完成真机验证,页面只提供模块导入入口和通用配置提醒。";
"第三方客户端适配说明" = "第三方客户端适配说明";
"App 不连接远程坐标服务器,而是向 Apple 域名发起一个约定请求。第三方客户端需要在本机拦截该请求、保存 WGS-84 坐标并返回 JSON;定位模块再读取同一份数据,修改 Apple WLOC 响应。" = "App 不连接远程坐标服务器,而是向 Apple 域名发起一个约定请求。第三方客户端需要在本机拦截该请求、保存 WGS-84 坐标并返回 JSON;定位模块再读取同一份数据,修改 Apple WLOC 响应。";
"配置接口" = "配置接口";
"查询:GET ?action=query\n保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>\n清除:GET ?action=clear" = "查询:GET ?action=query\n保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>\n清除:GET ?action=clear";
"返回格式" = "返回格式";
"适配要求" = "适配要求";
"客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。" = "客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。";
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。" = "检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。";
"第 1 步:导入 %@ 模块" = "第 1 步:导入 %@ 模块";
"复制 %@ 的模块订阅地址。" = "复制 %@ 的模块订阅地址。";
"打开 Shadowrocket,进入“配置 → 模块”。" = "打开 Shadowrocket,进入“配置 → 模块”。";
"打开 %@。" = "打开 %@。";
"进入 Shadowrocket 配置" = "进入 Shadowrocket 配置";
"1 点击「模块」进入模块列表,2 可打开当前本地配置详情。" = "1 点击「模块」进入模块列表,2 可打开当前本地配置详情。";
"点击右上角“+”,粘贴模块订阅地址并导入,然后确认模块已启用。" = "点击右上角“+”,粘贴模块订阅地址并导入,然后确认模块已启用。";
"导入 Shadowrocket 模块" = "导入 Shadowrocket 模块";
"1 点击右上角加号导入模块,2 确认模块已启用。" = "1 点击右上角加号导入模块,2 确认模块已启用。";
"在 %@ 中导入刚才复制的模块订阅地址。" = "在 %@ 中导入刚才复制的模块订阅地址。";
"第 2 步:完成 %@ 配置" = "第 2 步:完成 %@ 配置";
"请在 %@ 中完成相应配置。" = "请在 %@ 中完成相应配置。";
"配置时请复制下方全部解密域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)。" = "配置时请复制下方全部解密域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)。";
"第 2 步:配置 HTTPS 解密" = "第 2 步:配置 HTTPS 解密";
"进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。" = "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。";
"进入“HTTPS 解密”,开启解密开关。" = "进入“HTTPS 解密”,开启解密开关。";
"在域名列表中添加下方复制的全部解密域名。" = "在域名列表中添加下方复制的全部解密域名。";
"配置 HTTPS 解密" = "配置 HTTPS 解密";
"1 开启 HTTPS 解密,2 添加复制的全部解密域名,3 打开证书设置。" = "1 开启 HTTPS 解密,2 添加复制的全部解密域名,3 打开证书设置。";
"按 Shadowrocket 提示生成并完成证书授权。" = "按 Shadowrocket 提示生成并完成证书授权。";
"授权 Shadowrocket 证书" = "授权 Shadowrocket 证书";
"1 打开 Shadowrocket 证书项并按提示安装、授权。" = "1 打开 Shadowrocket 证书项并按提示安装、授权。";
"返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。" = "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。";
"打开 Shadowrocket 继续配置" = "打开 Shadowrocket 继续配置";
"App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。" = "App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。";
"轻点查看大图" = "轻点查看大图";
"证书尚未安装或信任" = "证书尚未安装或信任";
"Wi-Fi 代理未正确设置" = "Wi-Fi 代理未正确设置";
"本地代理未能启动" = "本地代理未能启动";
"检测仍在进行" = "检测仍在进行";
"检测结果已过期" = "检测结果已过期";
"定位改写检测失败" = "定位改写检测失败";
"接口连接失败" = "接口连接失败";
"本地代理初始化失败:%@" = "本地代理初始化失败:%@";
"✓ 定位环境正常" = "✓ 定位环境正常";
"CA 证书未安装或未信任" = "CA 证书未安装或未信任";
"Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888" = "Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888";
"请手动打开「设置」,找到本 App 后检查定位权限。" = "请手动打开「设置」,找到本 App 后检查定位权限。";
"请手动打开「设置 → 通用」。" = "请手动打开「设置 → 通用」。";
"请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。" = "请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。";
"请手动打开「设置 → 隐私与安全性 → 定位服务」。" = "请手动打开「设置 → 隐私与安全性 → 定位服务」。";
"第三方代理返回了无法识别的数据" = "第三方代理返回了无法识别的数据";
"请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接" = "请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接";
"第三方代理保存的坐标与当前选点不一致" = "第三方代理保存的坐标与当前选点不一致";
"第三方代理请求失败:%@" = "第三方代理请求失败:%@";
"检查模块、MITM、证书和代理/VPN连接" = "检查模块、MITM、证书和代理/VPN连接";
"第三方代理拒绝保存坐标" = "第三方代理拒绝保存坐标";
"第三方代理清除坐标失败" = "第三方代理清除坐标失败";
"已有第三方代理请求正在执行" = "已有第三方代理请求正在执行";
"配置已提供,尚未验证" = "配置已提供,尚未验证";
"已连接,有保存坐标" = "已连接,有保存坐标";
"已连接,无保存坐标" = "已连接,无保存坐标";
"连接失败(%@)" = "连接失败(%@)";
"当前客户端:%@" = "当前客户端:%@";
"配置接口:/wloc-settings/save" = "配置接口:/wloc-settings/save";
"请求动作:WLOC query" = "请求动作:WLOC query";
"检查范围:模块拦截、MITM、证书、代理/VPN 连接" = "检查范围:模块拦截、MITM、证书、代理/VPN 连接";
"连接状态:%@" = "连接状态:%@";
"检测结果:失败" = "检测结果:失败";
"耗时:%lld ms" = "耗时:%lld ms";
"错误类型:%@" = "错误类型:%@";
"错误详情:%@" = "错误详情:%@";
"处理建议:%@。" = "处理建议:%@。";
"======== 第三方代理运行检测 ========" = "======== 第三方代理运行检测 ========";
"触发来源:地图或设置中的第三方代理操作" = "触发来源:地图或设置中的第三方代理操作";
"请求动作:WLOC 配置接口" = "请求动作:WLOC 配置接口";
"处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接。" = "处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接。";
"环境信息" = "环境信息";
"App 版本" = "App 版本";
"系统版本" = "系统版本";
"诊断日志" = "诊断日志";
"不适用" = "不适用";
"是" = "是";
"否" = "否";
"(无诊断数据)" = "(无诊断数据)";
"第三方代理测试模式:模块连接成功;已保存坐标=%@" = "第三方代理测试模式:模块连接成功;已保存坐标=%@";
"第三方代理测试模式:模块连接失败;%@" = "第三方代理测试模式:模块连接失败;%@";
"======== 代理验证测试 ========" = "======== 代理验证测试 ========";
"App 版本: %@" = "App 版本: %@";
"系统版本: iOS %@" = "系统版本: iOS %@";
"[步骤 A] 检查代理是否运行…" = "[步骤 A] 检查代理是否运行…";
" 端口: 127.0.0.1:8888" = " 端口: 127.0.0.1:8888";
" ⚠ 代理未运行,尝试启动…" = " ⚠ 代理未运行,尝试启动…";
" ✗ 启动失败: %@" = " ✗ 启动失败: %@";
" ✓ 代理启动成功" = " ✓ 代理启动成功";
" ✓ 代理已在运行中" = " ✓ 代理已在运行中";
"[步骤 B] 检测证书与 WiFi 代理…" = "[步骤 B] 检测证书与 WiFi 代理…";
" 方式: 请求 baidu.com/paopao-verify-<token>" = " 方式: 请求 baidu.com/paopao-verify-<token>";
" 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过" = " 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过";
" ✗ 无法生成验证 token" = " ✗ 无法生成验证 token";
" ✓ 证书已信任,WiFi 代理已配置" = " ✓ 证书已信任,WiFi 代理已配置";
" ✗ 响应不匹配: HTTP %lld, %lld bytes,WiFi 代理未配置" = " ✗ 响应不匹配: HTTP %lld, %lld bytes,WiFi 代理未配置";
" ✗ 请求失败 [%@ code=%lld]: %@" = " ✗ 请求失败 [%@ code=%lld]: %@";
" TLS/证书校验失败,CA 证书未信任" = " TLS/证书校验失败,CA 证书未信任";
"======== 环境检测通过 ✓ ========" = "======== 环境检测通过 ✓ ========";
" --- 代理日志 ---" = " --- 代理日志 ---";
"成功:{\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\n失败:{\"success\":false,\"error\":\"错误说明\"}" = "成功:{\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\n失败:{\"success\":false,\"error\":\"错误说明\"}";
"定位改写脚本源自 Yu9191/wloc,现由本项目维护" = "定位改写脚本源自 Yu9191/wloc,现由本项目维护";
"地图" = "地图";
"坐标转换" = "坐标转换";
"定位" = "定位";
"实时定位" = "实时定位";
"提醒" = "提醒";
"搜索" = "搜索";
"收藏" = "收藏";
"缩放" = "缩放";
"========== App 启动 ==========" = "========== App 启动 ==========";
"尚未选择运行模式,跳过本地 CA 和代理初始化" = "尚未选择运行模式,跳过本地 CA 和代理初始化";
"第三方代理测试模式:跳过本地 CA、代理和环境检测" = "第三方代理测试模式:跳过本地 CA、代理和环境检测";
"旧坐标数据迁移失败,将在下次启动重试" = "旧坐标数据迁移失败,将在下次启动重试";
"地图坐标标准初始化完成,开始后续启动流程" = "地图坐标标准初始化完成,开始后续启动流程";
"没有持久化图钉,地图创建前请求实时定位" = "没有持久化图钉,地图创建前请求实时定位";
"已使用实时定位准备唯一初始地图状态" = "已使用实时定位准备唯一初始地图状态";
"地图创建前取得的初始实时位置(WGS-84)" = "地图创建前取得的初始实时位置(WGS-84)";
"地图创建前无法取得实时定位,唯一初始位置使用深圳" = "地图创建前无法取得实时定位,唯一初始位置使用深圳";
"已找到持久化图钉,直接准备唯一初始地图状态" = "已找到持久化图钉,直接准备唯一初始地图状态";
"启动门禁全部完成,现在创建 MapHomeView" = "启动门禁全部完成,现在创建 MapHomeView";
"初始化" = "初始化";
"启动代理 127.0.0.1:8888" = "启动代理 127.0.0.1:8888";
"启动代理: 恢复上次 WGS-84 定位" = "启动代理: 恢复上次 WGS-84 定位";
"启动成功" = "启动成功";
"启动失败" = "启动失败";
"写入坐标" = "写入坐标";
"跳过过期坐标写入" = "跳过过期坐标写入";
"跳过旧验证坐标恢复" = "跳过旧验证坐标恢复";
"运动状态模拟设置已更新" = "运动状态模拟设置已更新";
"准备证书下载失败" = "准备证书下载失败";
"设置虚拟定位坐标" = "设置虚拟定位坐标";
"apply失败" = "apply失败";
"保存收藏失败" = "保存收藏失败";
"本地服务初始化失败" = "本地服务初始化失败";
"代理运行模式已切换" = "代理运行模式已切换";
"已迁移旧版模式初始化状态" = "已迁移旧版模式初始化状态";
"地图坐标标准检测已有请求进行中" = "地图坐标标准检测已有请求进行中";
"地图坐标标准检测开始" = "地图坐标标准检测开始";
"地图坐标标准检测获得明确结果" = "地图坐标标准检测获得明确结果";
"地图坐标标准检测不可用,开始实时定位兜底" = "地图坐标标准检测不可用,开始实时定位兜底";
"地图坐标标准检测使用兜底结果" = "地图坐标标准检测使用兜底结果";
"地图坐标标准检测被取消,保留默认国内标准" = "地图坐标标准检测被取消,保留默认国内标准";
"地图坐标标准已确定,允许创建地图" = "地图坐标标准已确定,允许创建地图";
"地图坐标标准运行期检测合并到进行中请求" = "地图坐标标准运行期检测合并到进行中请求";
"地图坐标标准运行期检测开始" = "地图坐标标准运行期检测开始";
"地图坐标标准运行期检测结果已过期,取消写入" = "地图坐标标准运行期检测结果已过期,取消写入";
"地图坐标标准运行期检测完成,标准未变化" = "地图坐标标准运行期检测完成,标准未变化";
"地图坐标标准运行期检测发现切换" = "地图坐标标准运行期检测发现切换";
"地图坐标标准运行期检测失败,保留当前标准" = "地图坐标标准运行期检测失败,保留当前标准";
"地图坐标标准运行期检测已取消" = "地图坐标标准运行期检测已取消";
"实时定位不覆盖固定锚点的明确检测结果" = "实时定位不覆盖固定锚点的明确检测结果";
"实时定位确认兜底地图坐标标准无需修正" = "实时定位确认兜底地图坐标标准无需修正";
"实时定位修正启动兜底地图坐标标准" = "实时定位修正启动兜底地图坐标标准";
"第三方代理已保存 WGS-84 坐标" = "第三方代理已保存 WGS-84 坐标";
"第三方代理坐标已清除" = "第三方代理坐标已清除";
"第三方代理请求失败" = "第三方代理请求失败";
"第三方代理坐标同步成功" = "第三方代理坐标同步成功";
"同步坐标到第三方客户端失败" = "同步坐标到第三方客户端失败";
"验证结果" = "验证结果";
"验证失败" = "验证失败";
"开启前检测失败,进入对应环境引导" = "开启前检测失败,进入对应环境引导";
"清除第三方客户端坐标失败" = "清除第三方客户端坐标失败";
"累计虚拟定位操作次数" = "累计虚拟定位操作次数";
"已复制坐标" = "已复制坐标";
"地图坐标标准切换时未找到当前选点缓存" = "地图坐标标准切换时未找到当前选点缓存";
"地图坐标标准切换后已使用缓存坐标对回显当前选点" = "地图坐标标准切换后已使用缓存坐标对回显当前选点";
"地图坐标类型已变化" = "地图坐标类型已变化";
"取消保存收藏:检测期间当前选点已变化" = "取消保存收藏:检测期间当前选点已变化";
"保存当前选点为收藏" = "保存当前选点为收藏";
"第三方代理查询返回失败" = "第三方代理查询返回失败";
"启动后第三方代理状态查询失败" = "启动后第三方代理状态查询失败";
"检测到 Wi-Fi 网络变化" = "检测到 Wi-Fi 网络变化";
"网络仍在变化,重新计算环境检测等待时间" = "网络仍在变化,重新计算环境检测等待时间";
"等待 Wi-Fi 连接稳定后检测" = "等待 Wi-Fi 连接稳定后检测";
"延时检测已被更新的网络事件取消" = "延时检测已被更新的网络事件取消";
"稳定等待结束后仍未连接 Wi-Fi,提示检查代理" = "稳定等待结束后仍未连接 Wi-Fi,提示检查代理";
"开始后台环境检测" = "开始后台环境检测";
"已有环境检测运行,1 秒后重试" = "已有环境检测运行,1 秒后重试";
"后台环境检测完成" = "后台环境检测完成";
"环境检测连续被占用,本次不重复弹窗" = "环境检测连续被占用,本次不重复弹窗";
"用户点击实时定位" = "用户点击实时定位";
"MapKit 蓝点尚不可用,启动 CLLocationManager 兜底" = "MapKit 蓝点尚不可用,启动 CLLocationManager 兜底";
"MapKit 蓝点抢先完成请求,取消 CLLocationManager 兜底" = "MapKit 蓝点抢先完成请求,取消 CLLocationManager 兜底";
"虚拟定位开启后的 MapKit 蓝点标准判定" = "虚拟定位开启后的 MapKit 蓝点标准判定";
"登记实时定位请求上下文" = "登记实时定位请求上下文";
"复用进行中的 CLLocationManager 请求并更新意图上下文" = "复用进行中的 CLLocationManager 请求并更新意图上下文";
"CLLocationManager 兜底未返回坐标" = "CLLocationManager 兜底未返回坐标";
"丢弃 CLLocationManager 结果:任务已取消或蓝点已抢先完成" = "丢弃 CLLocationManager 结果:任务已取消或蓝点已抢先完成";
"实时定位坐标完成标准判断并提交到地图" = "实时定位坐标完成标准判断并提交到地图";
"MKLocalSearch 返回地点结果" = "MKLocalSearch 返回地点结果";
"反向地理编码网络失败,准备重试" = "反向地理编码网络失败,准备重试";
"反向地理编码失败" = "反向地理编码失败";
"获得搜索结果" = "获得搜索结果";
"点击收藏点并回显到地图" = "点击收藏点并回显到地图";
"忽略重复 CLLocationManager 请求" = "忽略重复 CLLocationManager 请求";
"CLLocationManager 请求入口" = "CLLocationManager 请求入口";
"授权状态不允许定位" = "授权状态不允许定位";
"创建 CLLocationManager 请求" = "创建 CLLocationManager 请求";
"请求前台定位授权" = "请求前台定位授权";
"定位授权状态变化" = "定位授权状态变化";
"CLLocationManager 返回样本批次" = "CLLocationManager 返回样本批次";
"本批次没有可完成当前请求的样本" = "本批次没有可完成当前请求的样本";
"接受 CLLocationManager 样本并完成请求" = "接受 CLLocationManager 样本并完成请求";
"定位回调失败" = "定位回调失败";
"跳过 CLLocationManager 缓存样本" = "跳过 CLLocationManager 缓存样本";
"等待定位授权超时" = "等待定位授权超时";
"单次定位超时,切换持续定位" = "单次定位超时,切换持续定位";
"持续定位超时" = "持续定位超时";
"开始 CLLocationManager 单次定位" = "开始 CLLocationManager 单次定位";
"开始 CLLocationManager 持续定位兜底" = "开始 CLLocationManager 持续定位兜底";
"CLLocationManager 请求完成" = "CLLocationManager 请求完成";
"CLLocationManager 请求结束但没有坐标" = "CLLocationManager 请求结束但没有坐标";
"使用 CLLocationManager 新鲜缓存" = "使用 CLLocationManager 新鲜缓存";
"实时定位按钮直接使用 MapKit 蓝点缓存" = "实时定位按钮直接使用 MapKit 蓝点缓存";
"主页收到待处理请求所需的 MapKit 蓝点回调" = "主页收到待处理请求所需的 MapKit 蓝点回调";
"主页收到 CLLocationManager 兜底坐标" = "主页收到 CLLocationManager 兜底坐标";
"原始实时定位坐标" = "原始实时定位坐标";
"检查 CLLocationManager 样本" = "检查 CLLocationManager 样本";
"拒绝 CLLocationManager 样本:坐标或精度无效" = "拒绝 CLLocationManager 样本:坐标或精度无效";
"MapKit 蓝点更新但 location 为空" = "MapKit 蓝点更新但 location 为空";
"拒绝 MapKit 蓝点样本:坐标无效" = "拒绝 MapKit 蓝点样本:坐标无效";
"拒绝 MapKit 蓝点样本:水平精度无效" = "拒绝 MapKit 蓝点样本:水平精度无效";
"音频中断恢复" = "音频中断恢复";
"音频会话失败" = "音频会话失败";
"无法创建静音音频缓冲区" = "无法创建静音音频缓冲区";
"引擎启动失败" = "引擎启动失败";
"后台保活已启动(静音音频)" = "后台保活已启动(静音音频)";
"后台保活已停止" = "后台保活已停止";
"保存当前图钉失败" = "保存当前图钉失败";
"存储缩放" = "存储缩放";
"旧坐标数据迁移完成" = "旧坐标数据迁移完成";
"远程版本配置加载成功" = "远程版本配置加载成功";
"版本配置源不可用,尝试下一地址" = "版本配置源不可用,尝试下一地址";
"版本配置加载失败,继续使用内置配置" = "版本配置加载失败,继续使用内置配置";
"版本说明源不可用,尝试下一地址" = "版本说明源不可用,尝试下一地址";
"版本说明加载失败,将使用最新 Release 页面" = "版本说明加载失败,将使用最新 Release 页面";
"复用设备钥匙串中的 CA" = "复用设备钥匙串中的 CA";
"旧 CA 已迁移到设备钥匙串" = "旧 CA 已迁移到设备钥匙串";
"已生成并保存设备专属 CA" = "已生成并保存设备专属 CA";
"已删除设备 CA,等待重新生成" = "已删除设备 CA,等待重新生成";
"钥匙串中的 CA 无效,准备回退" = "钥匙串中的 CA 无效,准备回退";
"删除旧 CA 文件失败,将在下次启动重试" = "删除旧 CA 文件失败,将在下次启动重试";
"无法解析 CA 证书 PEM" = "无法解析 CA 证书 PEM";
"无法创建 SecTrust" = "无法创建 SecTrust";
"SecTrust 评估返回错误" = "SecTrust 评估返回错误";
"CA 证书已被系统信任" = "CA 证书已被系统信任";
"CA 证书未被系统信任" = "CA 证书未被系统信任";
"调用 Go Core 生成 CA" = "调用 Go Core 生成 CA";
"Go Core CA 生成成功" = "Go Core CA 生成成功";
"本地证书服务已在运行" = "本地证书服务已在运行";
"调用 Go Core 启动本地证书服务" = "调用 Go Core 启动本地证书服务";
"本地证书服务启动成功" = "本地证书服务启动成功";
"停止本地证书服务" = "停止本地证书服务";
"开始第三方代理连接检测" = "开始第三方代理连接检测";
"第三方代理连接检测通过" = "第三方代理连接检测通过";
"第三方代理连接检测失败" = "第三方代理连接检测失败";
"关闭代理前已同步关闭虚拟定位" = "关闭代理前已同步关闭虚拟定位";
"切换 APP 模式前无法清除第三方坐标" = "切换 APP 模式前无法清除第三方坐标";
"设置页第三方连接检测通过" = "设置页第三方连接检测通过";
"设置页第三方连接检测失败" = "设置页第三方连接检测失败";
"地图实际显示坐标" = "地图实际显示坐标";
"App回到前台" = "App回到前台";
"App已确认地图标准" = "App已确认地图标准";
"CLLocationManager请求中" = "CLLocationManager请求中";
"Go proxy 启动失败" = "Go proxy 启动失败";
"MapKit地图蓝点" = "MapKit地图蓝点";
"MapKit蓝点新样本" = "MapKit蓝点新样本";
"MapKit蓝点缓存" = "MapKit蓝点缓存";
"SSID可读取" = "SSID可读取";
"WLOC写入标准" = "WLOC写入标准";
"WLOC目标标准" = "WLOC目标标准";
"Wi-Fi接口" = "Wi-Fi接口";
"intent选点revision" = "intent选点revision";
"manager请求中" = "manager请求中";
"上下文存在" = "上下文存在";
"事件原因" = "事件原因";
"任务已取消" = "任务已取消";
"任务已存在" = "任务已存在";
"使用兜底" = "使用兜底";
"保存收藏" = "保存收藏";
"保存数据包含" = "保存数据包含";
"保持未启用" = "保持未启用";
"保留原第三方坐标" = "保留原第三方坐标";
"保留已启用状态" = "保留已启用状态";
"保留标准" = "保留标准";
"修正兜底标准" = "修正兜底标准";
"内存缓存存在" = "内存缓存存在";
"初始坐标来源" = "初始坐标来源";
"初始来源" = "初始来源";
"初始阶段" = "初始阶段";
"判定规则" = "判定规则";
"原兜底来源" = "原兜底来源";
"原因" = "原因";
"取值字段" = "取值字段";
"可显示不再提醒" = "可显示不再提醒";
"启动代理…" = "启动代理…";
"命中林士街" = "命中林士街";
"固定锚点" = "固定锚点";
"固定锚点查询超过5秒" = "固定锚点查询超过5秒";
"固定锚点查询返回空结果" = "固定锚点查询返回空结果";
"国内标准(GCJ-02)" = "国内标准(GCJ-02)";
"国内转换区域" = "国内转换区域";
"国外非转换区域" = "国外非转换区域";
"国际标准(WGS-84)" = "国际标准(WGS-84)";
"国际标准(WGS-84)+国内标准(GCJ-02)" = "国际标准(WGS-84)+国内标准(GCJ-02)";
"图钉已按新类型重设" = "图钉已按新类型重设";
"地图取值字段" = "地图取值字段";
"地图标准" = "地图标准";
"坐标有效" = "坐标有效";
"坐标标准" = "坐标标准";
"垂直精度米" = "垂直精度米";
"基础校验通过" = "基础校验通过";
"处理建议" = "处理建议";
"失败时提示" = "失败时提示";
"实时定位存在" = "实时定位存在";
"实时定位服务区域" = "实时定位服务区域";
"客户端模式" = "客户端模式";
"尝试" = "尝试";
"已取消位置更新" = "已取消位置更新";
"已恢复真实定位" = "已恢复真实定位";
"已清理" = "已清理";
"已重置" = "已重置";
"开启" = "开启";
"异步地理编码" = "异步地理编码";
"当前地图标准" = "当前地图标准";
"当前客户端" = "当前客户端";
"当前标准" = "当前标准";
"当前选点revision" = "当前选点revision";
"恢复状态" = "恢复状态";
"批次索引" = "批次索引";
"持久化字段" = "持久化字段";
"授权状态" = "授权状态";
"授权状态rawValue" = "授权状态rawValue";
"探测失败原因" = "探测失败原因";
"搜索结果" = "搜索结果";
"操作" = "操作";
"新类型" = "新类型";
"无已保存" = "无已保存";
"无法判定" = "无法判定";
"无法启动本地证书服务" = "无法启动本地证书服务";
"无法生成本地证书" = "无法生成本地证书";
"日志策略" = "日志策略";
"旧类型" = "旧类型";
"显示坐标字段" = "显示坐标字段";
"最低版本" = "最低版本";
"最新版本" = "最新版本";
"最终标准" = "最终标准";
"最终阶段" = "最终阶段";
"有坐标" = "有坐标";
"有效样本数" = "有效样本数";
"有缓存" = "有缓存";
"未知错误" = "未知错误";
"本地 CA 证书或私钥无效" = "本地 CA 证书或私钥无效";
"无法写入设备钥匙串(%d)" = "无法写入设备钥匙串(%d)";
"来源" = "来源";
"来源类型" = "来源类型";
"林士街" = "林士街";
"查看诊断日志" = "查看诊断日志";
"样本年龄秒" = "样本年龄秒";
"样本数" = "样本数";
"样本时间" = "样本时间";
"检查范围" = "检查范围";
"检测前标准" = "检测前标准";
"模块拦截、MITM、代理/VPN连接" = "模块拦截、MITM、代理/VPN连接";
"次数" = "次数";
"每次开启首次或判定变化" = "每次开启首次或判定变化";
"水平精度米" = "水平精度米";
"活动requestID" = "活动requestID";
"活动阶段" = "活动阶段";
"海拔米" = "海拔米";
"满足当前请求时间窗" = "满足当前请求时间窗";
"点击实时定位" = "点击实时定位";
"目标所在区域" = "目标所在区域";
"确认标准" = "确认标准";
"社区征集客户端数" = "社区征集客户端数";
"等待秒数" = "等待秒数";
"系统缓存存在" = "系统缓存存在";
"结果" = "结果";
"结果数" = "结果数";
"结果来源" = "结果来源";
"缓存" = "缓存";
"缓存上限秒" = "缓存上限秒";
"缓存时效通过" = "缓存时效通过";
"缩放米" = "缩放米";
"网络可用" = "网络可用";
"耗时毫秒" = "耗时毫秒";
"蓝点回调更接近" = "蓝点回调更接近";
"蓝点缓存" = "蓝点缓存";
"蓝点缓存存在" = "蓝点缓存存在";
"蓝点距GCJ目标米" = "蓝点距GCJ目标米";
"蓝点距WGS目标米" = "蓝点距WGS目标米";
"触发原因" = "触发原因";
"诊断位置" = "诊断位置";
"请求动作" = "请求动作";
"超时毫秒" = "超时毫秒";
"输入坐标标准" = "输入坐标标准";
"连接状态" = "连接状态";
"选点revision" = "选点revision";
"选点期间发生变化" = "选点期间发生变化";
"错误" = "错误";
"错误类型" = "错误类型";
"锚点" = "锚点";
"阶段" = "阶段";
"首条名称" = "首条名称";
"首条名称=林士街→GCJ-02,否则→WGS-84" = "首条名称=林士街→GCJ-02,否则→WGS-84";
"默认国内标准" = "默认国内标准";
"证书下载地址无效" = "证书下载地址无效";
"启动代理失败: %@" = "启动代理失败: %@";
@@ -0,0 +1,6 @@
/* App display name */
"CFBundleDisplayName" = "Location Spoofer";
/* Location permission prompts */
"NSLocationWhenInUseUsageDescription" = "用於顯示和驗證虛擬定位是否生效";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "用於顯示和驗證虛擬定位是否生效";
+668
View File
@@ -0,0 +1,668 @@
/* Generated from en.lproj/Localizable.strings by Scripts/generate-chinese-localizations.swift. */
"APP模式" = "APP模式";
"第三方代理模式" = "第三方代理模式";
"成功" = "成功";
"已有验证正在进行" = "已有驗證正在進行";
"验证已被新位置取代" = "驗證已被新位置取代";
"证书未信任" = "證書未信任";
"WiFi代理未配置" = "WiFi代理未配置";
"坐标写入失败" = "坐標寫入失敗";
"改写验证失败" = "改寫驗證失敗";
"运行模式" = "運行模式";
"模式" = "模式";
"状态" = "狀態";
"本机代理" = "本機代理";
"第三方模块" = "第三方模塊";
"检测连接" = "檢測連接";
"虚拟定位" = "虛擬定位";
"定位模拟" = "定位模擬";
"运动状态模拟" = "運動狀態模擬";
"实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。" = "實驗性功能,默認關閉。開啓後會同時模擬定位響應中的運動狀態。";
"随机扰动" = "隨機擾動";
"开启后,下次同步坐标时会给目标点添加随机偏移(默认半径 50 米),避免位置固定在同一点。" = "開啓後,下次同步坐標時會給目標點添加隨機偏移(默認半徑 50 米),避免位置固定在同一點。";
"说明" = "說明";
"工作原理" = "工作原理";
"应用" = "應用";
"进入引导页" = "進入引導頁";
"正在检查…" = "正在檢查…";
"检查更新" = "檢查更新";
"版本" = "版本";
"证书" = "證書";
"重置证书" = "重置證書";
"仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。" = "僅刪除 App 鑰匙串中的設備 CA。iOS 中已經安裝的舊證書需要在系統設置裡手動移除。";
"支持" = "支持";
"使用帮助" = "使用幫助";
"功能建议" = "功能建議";
"分享第三方配置" = "分享第三方配置";
"关于" = "關於";
"如果觉得好用,欢迎去 GitHub 给项目点个 Star" = "如果覺得好用,歡迎去 GitHub 給項目點個 Star";
"致谢" = "致謝";
"核心定位改写逻辑移植自 Yu9191/wloc" = "核心定位改寫邏輯移植自 Yu9191/wloc";
"设置" = "設置";
"完成" = "完成";
"代理操作失败" = "代理操作失敗";
"重置证书?" = "重置證書?";
"重置并生成新证书" = "重置並生成新證書";
"当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。" = "當前虛擬定位和本地代理將停止。App 會刪除鑰匙串中的設備 CA、立即生成新證書,並打開安裝與信任引導。你還需要前往 iOS「設置 → 通用 → VPN 與設備管理」手動刪除舊證書,然後重新下載安裝並完全信任新證書。";
"第三方代理配置" = "第三方代理配置";
"客户端" = "客戶端";
"使用国内镜像下载模块" = "使用國內鏡像下載模塊";
"仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。" = "僅影響之後複製和重新導入的模塊地址;已安裝模塊需要重新導入後切換來源。";
"验证状态" = "驗證狀態";
"已复制模块订阅地址" = "已複製模塊訂閱地址";
"复制模块订阅地址" = "複製模塊訂閱地址";
"复制 %@" = "複製 %@";
"已复制 %@" = "已複製 %@";
"已复制解密域名" = "已複製解密域名";
"复制解密域名" = "複製解密域名";
"打开 %@" = "打開 %@";
"重新打开配置引导" = "重新打開配置引導";
"Egern 直接使用 Surge 的 .sgmodule 模块。" = "Egern 直接使用 Surge 的 .sgmodule 模塊。";
"Stash 直接订阅 .stoverride,不要通过 Script Hub 转换。" = "Stash 直接訂閱 .stoverride,不要通過 Script Hub 轉換。";
"复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。" = "複製模塊訂閱地址後,在對應代理客戶端中添加模塊/重寫訂閱,並為複製的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)啓用 MITM。第三方客戶端保存坐標後,即使關閉本 App,坐標仍由代理客戶端持久化並繼續生效。";
"已是最新版本" = "已是最新版本";
"当前版本 %@,远程最新版本 %@。" = "當前版本 %@,遠程最新版本 %@。";
"检查更新失败" = "檢查更新失敗";
"无法获取远程版本信息,请检查网络后重试。" = "無法獲取遠程版本信息,請檢查網絡後重試。";
"未检测" = "未檢測";
"已连接,有坐标" = "已連接,有坐標";
"已连接,无坐标" = "已連接,無坐標";
"连接失败" = "連接失敗";
"已开启" = "已開啓";
"已关闭" = "已關閉";
"第三方已保存" = "第三方已保存";
"未保存" = "未保存";
"未知" = "未知";
"App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。" = "App 只負責地圖選點、收藏和發送 WGS-84 坐標。第三方代理客戶端通過模塊攔截 Apple WLOC 請求並持久化當前坐標;本模式不啓動本機代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。";
"App 在设备本地运行一个代理服务器(127.0.0.1:8888)。\n\n通过 WiFi 手动代理配置,让系统发往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的定位请求经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。" = "App 在設備本地運行一個代理服務器(127.0.0.1:8888)。\n\n通過 WiFi 手動代理配置,讓系統發往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的定位請求經過這個本地代理。代理使用已安裝的 CA 證書對 HTTPS 流量做中間人解密,把 Apple 返回的定位坐標改寫為你設置的虛擬坐標,再加密返回給系統,從而實現虛擬定位。";
"模式已切换" = "模式已切換";
"第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。" = "第三方代理模式檢測通過。請關閉 Wi-Fi 中的 127.0.0.1:8888 手動代理,避免雙重攔截。";
"APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。" = "APP 模式環境檢測通過。請停用第三方 WLOC 模塊或代理連接,避免雙重攔截。";
"证书重置失败" = "證書重置失敗";
"无法打开客户端" = "無法打開客戶端";
"无法打开 %@,请确认客户端已安装后手动打开。" = "無法打開 %@,請確認客戶端已安裝後手動打開。";
"正在初始化地图与本地代理…" = "正在初始化地圖與本地代理…";
"正在初始化地图…" = "正在初始化地圖…";
"更新说明暂时无法加载,请前往最新 Release 页面查看。" = "更新說明暫時無法加載,請前往最新 Release 頁面查看。";
"需要更新" = "需要更新";
"当前版本 %@ 已停止支持,请更新到 %@ 后继续使用。\n\n%@" = "當前版本 %@ 已停止支持,請更新到 %@ 後繼續使用。\n\n%@";
"立即更新" = "立即更新";
"发现新版本" = "發現新版本";
"当前版本 %@,最新版本 %@。\n\n%@" = "當前版本 %@,最新版本 %@。\n\n%@";
"前往更新" = "前往更新";
"稍后" = "稍後";
"遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板。跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段。" = "遇到問題時,在這裡生成 Bug 報告。系統會運行一次診斷測試,並將完整報告複製到剪切板。跳轉到 GitHub 後,請粘貼到“App 生成的診斷報告”字段。";
"可复现环境" = "可復現環境";
"当前设备上问题稳定复现,非偶发性。" = "當前設備上問題穩定復現,非偶發性。";
"问题描述" = "問題描述";
"正在生成报告…" = "正在生成報告…";
"生成 Bug 报告" = "生成 Bug 報告";
"报告 Bug" = "報告 Bug";
"已生成" = "已生成";
"打开 GitHub 表单" = "打開 GitHub 表單";
"稍后再说" = "稍後再說";
"Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。" = "Bug 報告已複製到剪切板。請在 GitHub 表單的“App 生成的診斷報告”字段中粘貼並提交。";
"过滤日志" = "過濾日誌";
"暂无运行日志" = "暫無運行日誌";
"无匹配日志" = "無匹配日誌";
"运行日志" = "運行日誌";
"日志自动清理,仅保留近 3 天" = "日誌自動清理,僅保留近 3 天";
"关闭" = "關閉";
"复制全部日志" = "複製全部日誌";
"清空全部日志" = "清空全部日誌";
"清空所有运行日志?" = "清空所有運行日誌?";
"清空日志" = "清空日誌";
"取消" = "取消";
"正在检测…" = "正在檢測…";
"环境检测" = "環境檢測";
"检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。" = "檢查第三方模塊能否攔截並響應 query 請求;不會寫入測試坐標。";
"依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。" = "依次檢查:本地代理 → CA 證書信任 → Wi-Fi 代理鏈路。";
"测试日志" = "測試日誌";
"已复制" = "已複製";
"代理运行中" = "代理運行中";
"代理未运行" = "代理未運行";
"可修改" = "可修改";
"不可修改" = "不可修改";
"环境检测通过" = "環境檢測通過";
"环境检测失败: %@" = "環境檢測失敗: %@";
",查看下方日志" = ",查看下方日誌";
"第三方模块连接通过,已有坐标" = "第三方模塊連接通過,已有坐標";
"第三方模块连接通过,暂无坐标" = "第三方模塊連接通過,暫無坐標";
"第三方模块连接失败" = "第三方模塊連接失敗";
"======== 第三方代理连接检测 ========" = "======== 第三方代理連接檢測 ========";
"模式: 测试模式" = "模式: 測試模式";
"请求: wloc-settings/save?action=query" = "請求: wloc-settings/save?action=query";
"拦截响应: 有效 JSON" = "攔截響應: 有效 JSON";
"已保存坐标: %@" = "已保存坐標: %@";
"结果: %@" = "結果: %@";
"生效说明" = "生效說明";
"失效说明" = "失效說明";
"关闭 WiFi 代理" = "關閉 WiFi 代理";
"知道了" = "知道了";
"让虚拟定位生效" = "讓虛擬定位生效";
"确认第三方代理已开启" = "確認第三方代理已開啓";
"保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。" = "保持已導入的 WLOC 模塊、HTTPS 解密和第三方代理/VPN 連接開啓。";
"开启飞行模式" = "開啓飛行模式";
"从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。" = "從控制中心打開飛行模式(點飛機圖標),Wi‑Fi 會自動斷開。這是為了清除 iOS 的定位緩存。等待 2 秒。";
"关闭 Wi‑Fi" = "關閉 Wi‑Fi";
"从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。" = "從控制中心再點一下 Wi‑Fi 圖標,確認 Wi‑Fi 已關閉。等待 2 秒。";
"关闭系统定位服务" = "關閉系統定位服務";
"打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。" = "打開系統「設置 → 隱私與安全性 → 定位服務」,關閉頂部的總開關。等待 2 秒。";
"打开 Wi‑Fi,启动虚拟定位" = "打開 Wi‑Fi,啓動虛擬定位";
"从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" = "從控制中心打開 Wi‑Fi(飛行模式保持開啓),確認第三方代理已連接。坐標已經同步到第三方代理。等待 2 秒。";
"从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。" = "從控制中心打開 Wi‑Fi(飛行模式保持開啓),進入 App 點底部「開始虛擬定位」。等待 2 秒。";
"关闭飞行模式" = "關閉飛行模式";
"从控制中心关闭飞行模式。等待 2 秒。" = "從控制中心關閉飛行模式。等待 2 秒。";
"重新开启定位服务" = "重新開啓定位服務";
"再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。" = "再次進入「設置 → 隱私與安全性 → 定位服務」,打開總開關。完成後打開地圖驗證定位是否已變化。";
"还是无法生效?" = "還是無法生效?";
"操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。" = "操作到第 3 步時關機重啓,開機後從第 4 步繼續。這樣能徹底清除系統緩存的定位數據。";
"去设置" = "去設置";
"取消虚拟定位" = "取消虛擬定位";
"从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。" = "從控制中心打開飛行模式,Wi‑Fi 會自動斷開。等待 2 秒。";
"从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。" = "從控制中心確認 Wi‑Fi 已關閉。等待 2 秒。";
"打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。" = "打開「設置 → 隱私與安全性 → 定位服務」,關閉總開關。等待 2 秒。";
"确认坐标已清除" = "確認坐標已清除";
"App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。" = "App 已通知第三方代理清除虛擬坐標。保持網絡可用並等待 2 秒,讓系統重新獲取真實定位。";
"打开 Wi‑Fi,移除代理" = "打開 Wi‑Fi,移除代理";
"从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。" = "從控制中心打開 Wi‑Fi。然後進入「設置 → 無線局域網 → 點 WiFi 右側 (i) → HTTP 代理」,選擇「關閉」後存儲。等待 2 秒。";
"再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。" = "再次進入「設置 → 隱私與安全性 → 定位服務」打開總開關。打開地圖驗證定位是否恢復。";
"还是无法取消?" = "還是無法取消?";
"操作到第 3 步时关机重启,开机后从第 4 步继续。" = "操作到第 3 步時關機重啓,開機後從第 4 步繼續。";
"移除代理配置" = "移除代理配置";
"停止虚拟定位后,需要手动移除 WiFi 代理配置,否则可能无法上网。\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前 WiFi 右侧 (i) 图标\n3. 找到「HTTP 代理」\n4. 选择「关闭」\n5. 点右上角「存储」" = "停止虛擬定位後,需要手動移除 WiFi 代理配置,否則可能無法上網。\n\n1. 打開「設置 → 無線局域網」\n2. 點擊當前 WiFi 右側 (i) 圖標\n3. 找到「HTTP 代理」\n4. 選擇「關閉」\n5. 點右上角「存儲」";
"社区分享成功配置?" = "社區分享成功配置?";
"去提交" = "去提交";
"复制模板" = "複製模板";
"不再提示" = "不再提示";
"第三方客户端" = "第三方客戶端";
"你正在使用 %@。点击“去提交”会先复制投稿模板,并在 App 内打开社区页面。采纳后将收录到 README,可选择是否匿名署名。" = "你正在使用 %@。點擊“去提交”會先複製投稿模板,並在 App 內打開社區頁面。採納後將收錄到 README,可選擇是否匿名署名。";
"已复制投稿模板" = "已複製投稿模板";
"如果 GitHub 登录或浏览器跳转后模板没有自动填充,可以直接粘贴。" = "如果 GitHub 登錄或瀏覽器跳轉後模板沒有自動填充,可以直接粘貼。";
"无法直接跳转" = "無法直接跳轉";
"定位失败" = "定位失敗";
"打开设置" = "打開設置";
"无法获取当前定位,请检查定位服务是否已开启" = "無法獲取當前定位,請檢查定位服務是否已開啓";
"编辑收藏名称" = "編輯收藏名稱";
"名称" = "名稱";
"保存" = "保存";
"修改收藏地点名称" = "修改收藏地點名稱";
"搜索地点或坐标" = "搜索地點或坐標";
"日志" = "日誌";
"更多" = "更多";
"当前选点" = "當前選點";
"GCJ-02(国内)" = "GCJ-02(國內)";
"WGS-84(国际)" = "WGS-84(國際)";
"无法生效?" = "無法生效?";
"无法取消?" = "無法取消?";
"已收藏,点击取消收藏" = "已收藏,點擊取消收藏";
"收藏当前选点" = "收藏當前選點";
"搜索或点击地图选点后,保存为收藏。" = "搜索或點擊地圖選點後,保存為收藏。";
"切换到此处" = "切換到此處";
"同步到第三方代理" = "同步到第三方代理";
"检测并同步中…" = "檢測並同步中…";
"停止第三方虚拟定位" = "停止第三方虛擬定位";
"开始虚拟定位" = "開始虛擬定位";
"验证环境中…" = "驗證環境中…";
"停止虚拟定位" = "停止虛擬定位";
"当前未连接可用的 Wi-Fi,请连接 Wi-Fi 后配置 127.0.0.1:8888 手动代理。" = "當前未連接可用的 Wi-Fi,請連接 Wi-Fi 後配置 127.0.0.1:8888 手動代理。";
"第三方代理查询失败" = "第三方代理查詢失敗";
"未命名" = "未命名";
"没有找到相关地点" = "沒有找到相關地點";
"虚拟定位已开启" = "虛擬定位已開啓";
"不再提醒" = "不再提醒";
"虚拟定位已关闭" = "虛擬定位已關閉";
"选择模式" = "選擇模式";
"配置 Wi-Fi 代理" = "配置 Wi-Fi 代理";
"初始化 CA 证书" = "初始化 CA 證書";
"选择客户端" = "選擇客戶端";
"导入并检测" = "導入並檢測";
"上一步" = "上一步";
"开始使用" = "開始使用";
"操作失败" = "操作失敗";
"选择运行模式" = "選擇運行模式";
"后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。" = "後續可在“設置 → 運行模式”中切換。兩種模式不要同時攔截 WLOC 請求。";
"仅 Wi-Fi" = "僅 Wi-Fi";
"无外部依赖" = "無外部依賴";
"App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。" = "App 在設備本地啓動代理,通過當前 Wi-Fi 的手動 HTTP 代理改寫定位響應。免費自簽應用無法使用系統 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窩網絡,需要配置 Wi-Fi 代理並安裝 App 生成的 CA。";
"测试模式" = "測試模式";
"App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。" = "App 負責選點,並通過 WLOC 配置接口查詢和同步坐標;第三方代理客戶端負責網絡代理、模塊攔截、MITM 和持久化。證書、VPN 與代理連接均由第三方客戶端處理。";
"正在准备 APP模式本地服务…" = "正在準備 APP模式本地服務…";
"先配置 Wi-Fi 系统代理" = "先配置 Wi-Fi 系統代理";
"在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。配置后点击下方「完成」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。" = "在當前 Wi-Fi 的詳情頁,將「HTTP 代理」設為「手動」:服務器填 127.0.0.1,端口填 8888。配置後點擊下方「完成」。檢測會自動判斷是 Wi-Fi 代理還是證書信任有問題。";
"Wi-Fi 代理设置" = "Wi-Fi 代理設置";
"1 选择手动,2 填写服务器 127.0.0.1,3 填写端口 8888。" = "1 選擇手動,2 填寫服務器 127.0.0.1,3 填寫端口 8888。";
"复制地址" = "複製地址";
"打开 Wi-Fi 设置" = "打開 Wi-Fi 設置";
"第 1 步:下载证书" = "第 1 步:下載證書";
"下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。App 会弹出 Safari 下载页;出现配置描述文件下载提示时,选择「允许」。" = "下載本機隨機生成的 CA 根證書。私鑰僅保存在此設備的鑰匙串中,不會隨證書文件導出。App 會彈出 Safari 下載頁;出現配置描述文件下載提示時,選擇「允許」。";
"打开下载页" = "打開下載頁";
"无法准备证书下载页面,请查看诊断日志" = "無法準備證書下載頁面,請查看診斷日誌";
"第 2 步:安装证书" = "第 2 步:安裝證書";
"下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 Location Spoofer CA 并完成安装。" = "下載完成後打開系統「設置」。如果頂部顯示「已下載描述文件」,點進去安裝;否則進入「通用 → VPN 與設備管理」,找到 Location Spoofer CA 並完成安裝。";
"去安装" = "去安裝";
"安装证书" = "安裝證書";
"1 在「VPN 与设备管理」中打开 Location Spoofer CA 描述文件并完成安装。" = "1 在「VPN 與設備管理」中打開 Location Spoofer CA 描述文件並完成安裝。";
"第 3 步:信任证书" = "第 3 步:信任證書";
"安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 Location Spoofer CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。" = "安裝後進入「設置 → 通用 → 關於本機 → 證書信任設置」,找到 Location Spoofer CA 並開啓完全信任。iOS 保留鑰匙串數據時,重裝 App 會繼續復用同一證書。";
"去信任" = "去信任";
"信任证书" = "信任證書";
"1 在「证书信任设置」中为 Location Spoofer CA 开启完全信任。" = "1 在「證書信任設置」中為 Location Spoofer CA 開啓完全信任。";
"已完成" = "已完成";
"已完成 ✓" = "已完成 ✓";
"iOS 27 beta 6 起,系统已禁止对 gs-loc.apple.com 进行 MITM 拦截。该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。" = "iOS 27 beta 6 起,系統已禁止對 gs-loc.apple.com 進行 MITM 攔截。該版本及之後的 beta 版本暫時無法使用本項目,等待後續適配方案。";
"选择第三方代理客户端" = "選擇第三方代理客戶端";
"除 Shadowrocket 外,当前客户端配置尚未完成真机验证,页面只提供模块导入入口和通用配置提醒。" = "除 Shadowrocket 外,當前客戶端配置尚未完成真機驗證,頁面只提供模塊導入入口和通用配置提醒。";
"第三方客户端适配说明" = "第三方客戶端適配說明";
"App 不连接远程坐标服务器,而是向 Apple 域名发起一个约定请求。第三方客户端需要在本机拦截该请求、保存 WGS-84 坐标并返回 JSON;定位模块再读取同一份数据,修改 Apple WLOC 响应。" = "App 不連接遠程坐標服務器,而是向 Apple 域名發起一個約定請求。第三方客戶端需要在本機攔截該請求、保存 WGS-84 坐標並返回 JSON;定位模塊再讀取同一份數據,修改 Apple WLOC 響應。";
"配置接口" = "配置接口";
"查询:GET ?action=query\n保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>\n清除:GET ?action=clear" = "查詢:GET ?action=query\n保存:GET ?lon=<經度>&lat=<緯度>&acc=<精度>\n清除:GET ?action=clear";
"返回格式" = "返回格式";
"适配要求" = "適配要求";
"客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。" = "客戶端需要支持請求腳本、持久化存儲、HTTP 200 JSON 響應、Apple WLOC 響應腳本,以及 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的 HTTPS 解密。保存接口和 WLOC 響應腳本必須讀取同一份持久化數據。";
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。" = "檢測到第三方代理連接異常,請檢查模塊、MITM 和代理連接後重新檢測。";
"第 1 步:导入 %@ 模块" = "第 1 步:導入 %@ 模塊";
"复制 %@ 的模块订阅地址。" = "複製 %@ 的模塊訂閱地址。";
"打开 Shadowrocket,进入“配置 → 模块”。" = "打開 Shadowrocket,進入“配置 → 模塊”。";
"打开 %@。" = "打開 %@。";
"进入 Shadowrocket 配置" = "進入 Shadowrocket 配置";
"1 点击「模块」进入模块列表,2 可打开当前本地配置详情。" = "1 點擊「模塊」進入模塊列表,2 可打開當前本地配置詳情。";
"点击右上角“+”,粘贴模块订阅地址并导入,然后确认模块已启用。" = "點擊右上角“+”,粘貼模塊訂閱地址並導入,然後確認模塊已啓用。";
"导入 Shadowrocket 模块" = "導入 Shadowrocket 模塊";
"1 点击右上角加号导入模块,2 确认模块已启用。" = "1 點擊右上角加號導入模塊,2 確認模塊已啓用。";
"在 %@ 中导入刚才复制的模块订阅地址。" = "在 %@ 中導入剛才複製的模塊訂閱地址。";
"第 2 步:完成 %@ 配置" = "第 2 步:完成 %@ 配置";
"请在 %@ 中完成相应配置。" = "請在 %@ 中完成相應配置。";
"配置时请复制下方全部解密域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)。" = "配置時請複製下方全部解密域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)。";
"第 2 步:配置 HTTPS 解密" = "第 2 步:配置 HTTPS 解密";
"进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。" = "進入“配置 → 本地文件”,找到帶黃點的配置,點擊右側 i 圖標。";
"进入“HTTPS 解密”,开启解密开关。" = "進入“HTTPS 解密”,開啓解密開關。";
"在域名列表中添加下方复制的全部解密域名。" = "在域名列表中添加下方複製的全部解密域名。";
"配置 HTTPS 解密" = "配置 HTTPS 解密";
"1 开启 HTTPS 解密,2 添加复制的全部解密域名,3 打开证书设置。" = "1 開啓 HTTPS 解密,2 添加複製的全部解密域名,3 打開證書設置。";
"按 Shadowrocket 提示生成并完成证书授权。" = "按 Shadowrocket 提示生成並完成證書授權。";
"授权 Shadowrocket 证书" = "授權 Shadowrocket 證書";
"1 打开 Shadowrocket 证书项并按提示安装、授权。" = "1 打開 Shadowrocket 證書項並按提示安裝、授權。";
"返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。" = "返回 HTTPS 解密頁面,點擊右上角勾號保存,然後開啓代理。";
"打开 Shadowrocket 继续配置" = "打開 Shadowrocket 繼續配置";
"App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。" = "App 只能喚起 Shadowrocket,無法通過公開接口直接跳轉到“模塊”或“HTTPS 解密”頁面。";
"轻点查看大图" = "輕點查看大圖";
"证书尚未安装或信任" = "證書尚未安裝或信任";
"Wi-Fi 代理未正确设置" = "Wi-Fi 代理未正確設置";
"本地代理未能启动" = "本地代理未能啓動";
"检测仍在进行" = "檢測仍在進行";
"检测结果已过期" = "檢測結果已過期";
"定位改写检测失败" = "定位改寫檢測失敗";
"接口连接失败" = "接口連接失敗";
"本地代理初始化失败:%@" = "本地代理初始化失敗:%@";
"✓ 定位环境正常" = "✓ 定位環境正常";
"CA 证书未安装或未信任" = "CA 證書未安裝或未信任";
"Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888" = "Wi-Fi 代理未正確設置,請檢查 127.0.0.1:8888";
"请手动打开「设置」,找到本 App 后检查定位权限。" = "請手動打開「設置」,找到本 App 後檢查定位權限。";
"请手动打开「设置 → 通用」。" = "請手動打開「設置 → 通用」。";
"请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。" = "請手動打開「設置 → 無線局域網」,進入當前 Wi-Fi 的詳情頁。";
"请手动打开「设置 → 隐私与安全性 → 定位服务」。" = "請手動打開「設置 → 隱私與安全性 → 定位服務」。";
"第三方代理返回了无法识别的数据" = "第三方代理返回了無法識別的數據";
"请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接" = "請求未被第三方代理模塊攔截,請檢查模塊、MITM 和代理連接";
"第三方代理保存的坐标与当前选点不一致" = "第三方代理保存的坐標與當前選點不一致";
"第三方代理请求失败:%@" = "第三方代理請求失敗:%@";
"检查模块、MITM、证书和代理/VPN连接" = "檢查模塊、MITM、證書和代理/VPN連接";
"第三方代理拒绝保存坐标" = "第三方代理拒絕保存坐標";
"第三方代理清除坐标失败" = "第三方代理清除坐標失敗";
"已有第三方代理请求正在执行" = "已有第三方代理請求正在執行";
"配置已提供,尚未验证" = "配置已提供,尚未驗證";
"已连接,有保存坐标" = "已連接,有保存坐標";
"已连接,无保存坐标" = "已連接,無保存坐標";
"连接失败(%@)" = "連接失敗(%@)";
"当前客户端:%@" = "當前客戶端:%@";
"配置接口:/wloc-settings/save" = "配置接口:/wloc-settings/save";
"请求动作:WLOC query" = "請求動作:WLOC query";
"检查范围:模块拦截、MITM、证书、代理/VPN 连接" = "檢查範圍:模塊攔截、MITM、證書、代理/VPN 連接";
"连接状态:%@" = "連接狀態:%@";
"检测结果:失败" = "檢測結果:失敗";
"耗时:%lld ms" = "耗時:%lld ms";
"错误类型:%@" = "錯誤類型:%@";
"错误详情:%@" = "錯誤詳情:%@";
"处理建议:%@。" = "處理建議:%@。";
"======== 第三方代理运行检测 ========" = "======== 第三方代理運行檢測 ========";
"触发来源:地图或设置中的第三方代理操作" = "觸發來源:地圖或設置中的第三方代理操作";
"请求动作:WLOC 配置接口" = "請求動作:WLOC 配置接口";
"处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接。" = "處理建議:確認模塊已啓用,並檢查 MITM、證書和代理/VPN 連接。";
"环境信息" = "環境信息";
"App 版本" = "App 版本";
"系统版本" = "系統版本";
"诊断日志" = "診斷日誌";
"不适用" = "不適用";
"是" = "是";
"否" = "否";
"(无诊断数据)" = "(無診斷數據)";
"第三方代理测试模式:模块连接成功;已保存坐标=%@" = "第三方代理測試模式:模塊連接成功;已保存坐標=%@";
"第三方代理测试模式:模块连接失败;%@" = "第三方代理測試模式:模塊連接失敗;%@";
"======== 代理验证测试 ========" = "======== 代理驗證測試 ========";
"App 版本: %@" = "App 版本: %@";
"系统版本: iOS %@" = "系統版本: iOS %@";
"[步骤 A] 检查代理是否运行…" = "[步驟 A] 檢查代理是否運行…";
" 端口: 127.0.0.1:8888" = " 端口: 127.0.0.1:8888";
" ⚠ 代理未运行,尝试启动…" = " ⚠ 代理未運行,嘗試啓動…";
" ✗ 启动失败: %@" = " ✗ 啓動失敗: %@";
" ✓ 代理启动成功" = " ✓ 代理啓動成功";
" ✓ 代理已在运行中" = " ✓ 代理已在運行中";
"[步骤 B] 检测证书与 WiFi 代理…" = "[步驟 B] 檢測證書與 WiFi 代理…";
" 方式: 请求 baidu.com/paopao-verify-<token>" = " 方式: 請求 baidu.com/paopao-verify-<token>";
" 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过" = " 結果判定: TLS 錯誤=證書問題 / 響應不匹配=代理未配置 / 匹配=通過";
" ✗ 无法生成验证 token" = " ✗ 無法生成驗證 token";
" ✓ 证书已信任,WiFi 代理已配置" = " ✓ 證書已信任,WiFi 代理已配置";
" ✗ 响应不匹配: HTTP %lld, %lld bytes,WiFi 代理未配置" = " ✗ 響應不匹配: HTTP %lld, %lld bytes,WiFi 代理未配置";
" ✗ 请求失败 [%@ code=%lld]: %@" = " ✗ 請求失敗 [%@ code=%lld]: %@";
" TLS/证书校验失败,CA 证书未信任" = " TLS/證書校驗失敗,CA 證書未信任";
"======== 环境检测通过 ✓ ========" = "======== 環境檢測通過 ✓ ========";
" --- 代理日志 ---" = " --- 代理日誌 ---";
"成功:{\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\n失败:{\"success\":false,\"error\":\"错误说明\"}" = "成功:{\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\n失敗:{\"success\":false,\"error\":\"錯誤說明\"}";
"定位改写脚本源自 Yu9191/wloc,现由本项目维护" = "定位改寫腳本源自 Yu9191/wloc,現由本項目維護";
"地图" = "地圖";
"坐标转换" = "坐標轉換";
"定位" = "定位";
"实时定位" = "實時定位";
"提醒" = "提醒";
"搜索" = "搜索";
"收藏" = "收藏";
"缩放" = "縮放";
"========== App 启动 ==========" = "========== App 啓動 ==========";
"尚未选择运行模式,跳过本地 CA 和代理初始化" = "尚未選擇運行模式,跳過本地 CA 和代理初始化";
"第三方代理测试模式:跳过本地 CA、代理和环境检测" = "第三方代理測試模式:跳過本地 CA、代理和環境檢測";
"旧坐标数据迁移失败,将在下次启动重试" = "舊坐標數據遷移失敗,將在下次啓動重試";
"地图坐标标准初始化完成,开始后续启动流程" = "地圖坐標標準初始化完成,開始後續啓動流程";
"没有持久化图钉,地图创建前请求实时定位" = "沒有持久化圖釘,地圖創建前請求實時定位";
"已使用实时定位准备唯一初始地图状态" = "已使用實時定位準備唯一初始地圖狀態";
"地图创建前取得的初始实时位置(WGS-84)" = "地圖創建前取得的初始實時位置(WGS-84)";
"地图创建前无法取得实时定位,唯一初始位置使用深圳" = "地圖創建前無法取得實時定位,唯一初始位置使用深圳";
"已找到持久化图钉,直接准备唯一初始地图状态" = "已找到持久化圖釘,直接準備唯一初始地圖狀態";
"启动门禁全部完成,现在创建 MapHomeView" = "啓動門禁全部完成,現在創建 MapHomeView";
"初始化" = "初始化";
"启动代理 127.0.0.1:8888" = "啓動代理 127.0.0.1:8888";
"启动代理: 恢复上次 WGS-84 定位" = "啓動代理: 恢復上次 WGS-84 定位";
"启动成功" = "啓動成功";
"启动失败" = "啓動失敗";
"写入坐标" = "寫入坐標";
"跳过过期坐标写入" = "跳過過期坐標寫入";
"跳过旧验证坐标恢复" = "跳過舊驗證坐標恢復";
"运动状态模拟设置已更新" = "運動狀態模擬設置已更新";
"准备证书下载失败" = "準備證書下載失敗";
"设置虚拟定位坐标" = "設置虛擬定位坐標";
"apply失败" = "apply失敗";
"保存收藏失败" = "保存收藏失敗";
"本地服务初始化失败" = "本地服務初始化失敗";
"代理运行模式已切换" = "代理運行模式已切換";
"已迁移旧版模式初始化状态" = "已遷移舊版模式初始化狀態";
"地图坐标标准检测已有请求进行中" = "地圖坐標標準檢測已有請求進行中";
"地图坐标标准检测开始" = "地圖坐標標準檢測開始";
"地图坐标标准检测获得明确结果" = "地圖坐標標準檢測獲得明確結果";
"地图坐标标准检测不可用,开始实时定位兜底" = "地圖坐標標準檢測不可用,開始實時定位兜底";
"地图坐标标准检测使用兜底结果" = "地圖坐標標準檢測使用兜底結果";
"地图坐标标准检测被取消,保留默认国内标准" = "地圖坐標標準檢測被取消,保留默認國內標準";
"地图坐标标准已确定,允许创建地图" = "地圖坐標標準已確定,允許創建地圖";
"地图坐标标准运行期检测合并到进行中请求" = "地圖坐標標準運行期檢測合併到進行中請求";
"地图坐标标准运行期检测开始" = "地圖坐標標準運行期檢測開始";
"地图坐标标准运行期检测结果已过期,取消写入" = "地圖坐標標準運行期檢測結果已過期,取消寫入";
"地图坐标标准运行期检测完成,标准未变化" = "地圖坐標標準運行期檢測完成,標準未變化";
"地图坐标标准运行期检测发现切换" = "地圖坐標標準運行期檢測發現切換";
"地图坐标标准运行期检测失败,保留当前标准" = "地圖坐標標準運行期檢測失敗,保留當前標準";
"地图坐标标准运行期检测已取消" = "地圖坐標標準運行期檢測已取消";
"实时定位不覆盖固定锚点的明确检测结果" = "實時定位不覆蓋固定錨點的明確檢測結果";
"实时定位确认兜底地图坐标标准无需修正" = "實時定位確認兜底地圖坐標標準無需修正";
"实时定位修正启动兜底地图坐标标准" = "實時定位修正啓動兜底地圖坐標標準";
"第三方代理已保存 WGS-84 坐标" = "第三方代理已保存 WGS-84 坐標";
"第三方代理坐标已清除" = "第三方代理坐標已清除";
"第三方代理请求失败" = "第三方代理請求失敗";
"第三方代理坐标同步成功" = "第三方代理坐標同步成功";
"同步坐标到第三方客户端失败" = "同步坐標到第三方客戶端失敗";
"验证结果" = "驗證結果";
"验证失败" = "驗證失敗";
"开启前检测失败,进入对应环境引导" = "開啓前檢測失敗,進入對應環境引導";
"清除第三方客户端坐标失败" = "清除第三方客戶端坐標失敗";
"累计虚拟定位操作次数" = "累計虛擬定位操作次數";
"已复制坐标" = "已複製坐標";
"地图坐标标准切换时未找到当前选点缓存" = "地圖坐標標準切換時未找到當前選點緩存";
"地图坐标标准切换后已使用缓存坐标对回显当前选点" = "地圖坐標標準切換後已使用緩存坐標對回顯當前選點";
"地图坐标类型已变化" = "地圖坐標類型已變化";
"取消保存收藏:检测期间当前选点已变化" = "取消保存收藏:檢測期間當前選點已變化";
"保存当前选点为收藏" = "保存當前選點為收藏";
"第三方代理查询返回失败" = "第三方代理查詢返回失敗";
"启动后第三方代理状态查询失败" = "啓動後第三方代理狀態查詢失敗";
"检测到 Wi-Fi 网络变化" = "檢測到 Wi-Fi 網絡變化";
"网络仍在变化,重新计算环境检测等待时间" = "網絡仍在變化,重新計算環境檢測等待時間";
"等待 Wi-Fi 连接稳定后检测" = "等待 Wi-Fi 連接穩定後檢測";
"延时检测已被更新的网络事件取消" = "延時檢測已被更新的網絡事件取消";
"稳定等待结束后仍未连接 Wi-Fi,提示检查代理" = "穩定等待結束後仍未連接 Wi-Fi,提示檢查代理";
"开始后台环境检测" = "開始後台環境檢測";
"已有环境检测运行,1 秒后重试" = "已有環境檢測運行,1 秒後重試";
"后台环境检测完成" = "後台環境檢測完成";
"环境检测连续被占用,本次不重复弹窗" = "環境檢測連續被佔用,本次不重復彈窗";
"用户点击实时定位" = "用戶點擊實時定位";
"MapKit 蓝点尚不可用,启动 CLLocationManager 兜底" = "MapKit 藍點尚不可用,啓動 CLLocationManager 兜底";
"MapKit 蓝点抢先完成请求,取消 CLLocationManager 兜底" = "MapKit 藍點搶先完成請求,取消 CLLocationManager 兜底";
"虚拟定位开启后的 MapKit 蓝点标准判定" = "虛擬定位開啓後的 MapKit 藍點標準判定";
"登记实时定位请求上下文" = "登記實時定位請求上下文";
"复用进行中的 CLLocationManager 请求并更新意图上下文" = "復用進行中的 CLLocationManager 請求並更新意圖上下文";
"CLLocationManager 兜底未返回坐标" = "CLLocationManager 兜底未返回坐標";
"丢弃 CLLocationManager 结果:任务已取消或蓝点已抢先完成" = "丟棄 CLLocationManager 結果:任務已取消或藍點已搶先完成";
"实时定位坐标完成标准判断并提交到地图" = "實時定位坐標完成標準判斷並提交到地圖";
"MKLocalSearch 返回地点结果" = "MKLocalSearch 返回地點結果";
"反向地理编码网络失败,准备重试" = "反向地理編碼網絡失敗,準備重試";
"反向地理编码失败" = "反向地理編碼失敗";
"获得搜索结果" = "獲得搜索結果";
"点击收藏点并回显到地图" = "點擊收藏點並回顯到地圖";
"忽略重复 CLLocationManager 请求" = "忽略重復 CLLocationManager 請求";
"CLLocationManager 请求入口" = "CLLocationManager 請求入口";
"授权状态不允许定位" = "授權狀態不允許定位";
"创建 CLLocationManager 请求" = "創建 CLLocationManager 請求";
"请求前台定位授权" = "請求前台定位授權";
"定位授权状态变化" = "定位授權狀態變化";
"CLLocationManager 返回样本批次" = "CLLocationManager 返回樣本批次";
"本批次没有可完成当前请求的样本" = "本批次沒有可完成當前請求的樣本";
"接受 CLLocationManager 样本并完成请求" = "接受 CLLocationManager 樣本並完成請求";
"定位回调失败" = "定位回調失敗";
"跳过 CLLocationManager 缓存样本" = "跳過 CLLocationManager 緩存樣本";
"等待定位授权超时" = "等待定位授權超時";
"单次定位超时,切换持续定位" = "單次定位超時,切換持續定位";
"持续定位超时" = "持續定位超時";
"开始 CLLocationManager 单次定位" = "開始 CLLocationManager 單次定位";
"开始 CLLocationManager 持续定位兜底" = "開始 CLLocationManager 持續定位兜底";
"CLLocationManager 请求完成" = "CLLocationManager 請求完成";
"CLLocationManager 请求结束但没有坐标" = "CLLocationManager 請求結束但沒有坐標";
"使用 CLLocationManager 新鲜缓存" = "使用 CLLocationManager 新鮮緩存";
"实时定位按钮直接使用 MapKit 蓝点缓存" = "實時定位按鈕直接使用 MapKit 藍點緩存";
"主页收到待处理请求所需的 MapKit 蓝点回调" = "主頁收到待處理請求所需的 MapKit 藍點回調";
"主页收到 CLLocationManager 兜底坐标" = "主頁收到 CLLocationManager 兜底坐標";
"原始实时定位坐标" = "原始實時定位坐標";
"检查 CLLocationManager 样本" = "檢查 CLLocationManager 樣本";
"拒绝 CLLocationManager 样本:坐标或精度无效" = "拒絕 CLLocationManager 樣本:坐標或精度無效";
"MapKit 蓝点更新但 location 为空" = "MapKit 藍點更新但 location 為空";
"拒绝 MapKit 蓝点样本:坐标无效" = "拒絕 MapKit 藍點樣本:坐標無效";
"拒绝 MapKit 蓝点样本:水平精度无效" = "拒絕 MapKit 藍點樣本:水平精度無效";
"音频中断恢复" = "音頻中斷恢復";
"音频会话失败" = "音頻會話失敗";
"无法创建静音音频缓冲区" = "無法創建靜音音頻緩衝區";
"引擎启动失败" = "引擎啓動失敗";
"后台保活已启动(静音音频)" = "後台保活已啓動(靜音音頻)";
"后台保活已停止" = "後台保活已停止";
"保存当前图钉失败" = "保存當前圖釘失敗";
"存储缩放" = "存儲縮放";
"旧坐标数据迁移完成" = "舊坐標數據遷移完成";
"远程版本配置加载成功" = "遠程版本配置加載成功";
"版本配置源不可用,尝试下一地址" = "版本配置源不可用,嘗試下一地址";
"版本配置加载失败,继续使用内置配置" = "版本配置加載失敗,繼續使用內置配置";
"版本说明源不可用,尝试下一地址" = "版本說明源不可用,嘗試下一地址";
"版本说明加载失败,将使用最新 Release 页面" = "版本說明加載失敗,將使用最新 Release 頁面";
"复用设备钥匙串中的 CA" = "復用設備鑰匙串中的 CA";
"旧 CA 已迁移到设备钥匙串" = "舊 CA 已遷移到設備鑰匙串";
"已生成并保存设备专属 CA" = "已生成並保存設備專屬 CA";
"已删除设备 CA,等待重新生成" = "已刪除設備 CA,等待重新生成";
"钥匙串中的 CA 无效,准备回退" = "鑰匙串中的 CA 無效,準備回退";
"删除旧 CA 文件失败,将在下次启动重试" = "刪除舊 CA 文件失敗,將在下次啓動重試";
"无法解析 CA 证书 PEM" = "無法解析 CA 證書 PEM";
"无法创建 SecTrust" = "無法創建 SecTrust";
"SecTrust 评估返回错误" = "SecTrust 評估返回錯誤";
"CA 证书已被系统信任" = "CA 證書已被系統信任";
"CA 证书未被系统信任" = "CA 證書未被系統信任";
"调用 Go Core 生成 CA" = "調用 Go Core 生成 CA";
"Go Core CA 生成成功" = "Go Core CA 生成成功";
"本地证书服务已在运行" = "本地證書服務已在運行";
"调用 Go Core 启动本地证书服务" = "調用 Go Core 啓動本地證書服務";
"本地证书服务启动成功" = "本地證書服務啓動成功";
"停止本地证书服务" = "停止本地證書服務";
"开始第三方代理连接检测" = "開始第三方代理連接檢測";
"第三方代理连接检测通过" = "第三方代理連接檢測通過";
"第三方代理连接检测失败" = "第三方代理連接檢測失敗";
"关闭代理前已同步关闭虚拟定位" = "關閉代理前已同步關閉虛擬定位";
"切换 APP 模式前无法清除第三方坐标" = "切換 APP 模式前無法清除第三方坐標";
"设置页第三方连接检测通过" = "設置頁第三方連接檢測通過";
"设置页第三方连接检测失败" = "設置頁第三方連接檢測失敗";
"地图实际显示坐标" = "地圖實際顯示坐標";
"App回到前台" = "App回到前台";
"App已确认地图标准" = "App已確認地圖標準";
"CLLocationManager请求中" = "CLLocationManager請求中";
"Go proxy 启动失败" = "Go proxy 啓動失敗";
"MapKit地图蓝点" = "MapKit地圖藍點";
"MapKit蓝点新样本" = "MapKit藍點新樣本";
"MapKit蓝点缓存" = "MapKit藍點緩存";
"SSID可读取" = "SSID可讀取";
"WLOC写入标准" = "WLOC寫入標準";
"WLOC目标标准" = "WLOC目標標準";
"Wi-Fi接口" = "Wi-Fi接口";
"intent选点revision" = "intent選點revision";
"manager请求中" = "manager請求中";
"上下文存在" = "上下文存在";
"事件原因" = "事件原因";
"任务已取消" = "任務已取消";
"任务已存在" = "任務已存在";
"使用兜底" = "使用兜底";
"保存收藏" = "保存收藏";
"保存数据包含" = "保存數據包含";
"保持未启用" = "保持未啓用";
"保留原第三方坐标" = "保留原第三方坐標";
"保留已启用状态" = "保留已啓用狀態";
"保留标准" = "保留標準";
"修正兜底标准" = "修正兜底標準";
"内存缓存存在" = "內存緩存存在";
"初始坐标来源" = "初始坐標來源";
"初始来源" = "初始來源";
"初始阶段" = "初始階段";
"判定规则" = "判定規則";
"原兜底来源" = "原兜底來源";
"原因" = "原因";
"取值字段" = "取值字段";
"可显示不再提醒" = "可顯示不再提醒";
"启动代理…" = "啓動代理…";
"命中林士街" = "命中林士街";
"固定锚点" = "固定錨點";
"固定锚点查询超过5秒" = "固定錨點查詢超過5秒";
"固定锚点查询返回空结果" = "固定錨點查詢返回空結果";
"国内标准(GCJ-02)" = "國內標準(GCJ-02)";
"国内转换区域" = "國內轉換區域";
"国外非转换区域" = "國外非轉換區域";
"国际标准(WGS-84)" = "國際標準(WGS-84)";
"国际标准(WGS-84)+国内标准(GCJ-02)" = "國際標準(WGS-84)+國內標準(GCJ-02)";
"图钉已按新类型重设" = "圖釘已按新類型重設";
"地图取值字段" = "地圖取值字段";
"地图标准" = "地圖標準";
"坐标有效" = "坐標有效";
"坐标标准" = "坐標標準";
"垂直精度米" = "垂直精度米";
"基础校验通过" = "基礎校驗通過";
"处理建议" = "處理建議";
"失败时提示" = "失敗時提示";
"实时定位存在" = "實時定位存在";
"实时定位服务区域" = "實時定位服務區域";
"客户端模式" = "客戶端模式";
"尝试" = "嘗試";
"已取消位置更新" = "已取消位置更新";
"已恢复真实定位" = "已恢復真實定位";
"已清理" = "已清理";
"已重置" = "已重置";
"开启" = "開啓";
"异步地理编码" = "異步地理編碼";
"当前地图标准" = "當前地圖標準";
"当前客户端" = "當前客戶端";
"当前标准" = "當前標準";
"当前选点revision" = "當前選點revision";
"恢复状态" = "恢復狀態";
"批次索引" = "批次索引";
"持久化字段" = "持久化字段";
"授权状态" = "授權狀態";
"授权状态rawValue" = "授權狀態rawValue";
"探测失败原因" = "探測失敗原因";
"搜索结果" = "搜索結果";
"操作" = "操作";
"新类型" = "新類型";
"无已保存" = "無已保存";
"无法判定" = "無法判定";
"无法启动本地证书服务" = "無法啓動本地證書服務";
"无法生成本地证书" = "無法生成本地證書";
"日志策略" = "日誌策略";
"旧类型" = "舊類型";
"显示坐标字段" = "顯示坐標字段";
"最低版本" = "最低版本";
"最新版本" = "最新版本";
"最终标准" = "最終標準";
"最终阶段" = "最終階段";
"有坐标" = "有坐標";
"有效样本数" = "有效樣本數";
"有缓存" = "有緩存";
"未知错误" = "未知錯誤";
"本地 CA 证书或私钥无效" = "本地 CA 證書或私鑰無效";
"无法写入设备钥匙串(%d)" = "無法寫入設備鑰匙串(%d)";
"来源" = "來源";
"来源类型" = "來源類型";
"林士街" = "林士街";
"查看诊断日志" = "查看診斷日誌";
"样本年龄秒" = "樣本年齡秒";
"样本数" = "樣本數";
"样本时间" = "樣本時間";
"检查范围" = "檢查範圍";
"检测前标准" = "檢測前標準";
"模块拦截、MITM、代理/VPN连接" = "模塊攔截、MITM、代理/VPN連接";
"次数" = "次數";
"每次开启首次或判定变化" = "每次開啓首次或判定變化";
"水平精度米" = "水平精度米";
"活动requestID" = "活動requestID";
"活动阶段" = "活動階段";
"海拔米" = "海拔米";
"满足当前请求时间窗" = "滿足當前請求時間窗";
"点击实时定位" = "點擊實時定位";
"目标所在区域" = "目標所在區域";
"确认标准" = "確認標準";
"社区征集客户端数" = "社區徵集客戶端數";
"等待秒数" = "等待秒數";
"系统缓存存在" = "系統緩存存在";
"结果" = "結果";
"结果数" = "結果數";
"结果来源" = "結果來源";
"缓存" = "緩存";
"缓存上限秒" = "緩存上限秒";
"缓存时效通过" = "緩存時效通過";
"缩放米" = "縮放米";
"网络可用" = "網絡可用";
"耗时毫秒" = "耗時毫秒";
"蓝点回调更接近" = "藍點回調更接近";
"蓝点缓存" = "藍點緩存";
"蓝点缓存存在" = "藍點緩存存在";
"蓝点距GCJ目标米" = "藍點距GCJ目標米";
"蓝点距WGS目标米" = "藍點距WGS目標米";
"触发原因" = "觸發原因";
"诊断位置" = "診斷位置";
"请求动作" = "請求動作";
"超时毫秒" = "超時毫秒";
"输入坐标标准" = "輸入坐標標準";
"连接状态" = "連接狀態";
"选点revision" = "選點revision";
"选点期间发生变化" = "選點期間發生變化";
"错误" = "錯誤";
"错误类型" = "錯誤類型";
"锚点" = "錨點";
"阶段" = "階段";
"首条名称" = "首條名稱";
"首条名称=林士街→GCJ-02,否则→WGS-84" = "首條名稱=林士街→GCJ-02,否則→WGS-84";
"默认国内标准" = "默認國內標準";
"证书下载地址无效" = "證書下載地址無效";
"启动代理失败: %@" = "啓動代理失敗: %@";
+23 -12
View File
@@ -4,21 +4,32 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CORE="$ROOT/Core" CORE="$ROOT/Core"
BUILD="$CORE/build" BUILD="$CORE/build"
IOS_SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
MIN_IOS_VERSION="15.0" MIN_IOS_VERSION="15.0"
export CGO_ENABLED=1 build_archive() {
export CGO_CFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION" local sdk="$1"
export CGO_LDFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION" local min_flag="$2"
export GOOS="ios" local output="$3"
export GOARCH="arm64" 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" cd "$CORE"
go mod download 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"
+12 -2
View File
@@ -27,6 +27,16 @@ fi
mkdir -p build/UnsignedIPA/Payload dist mkdir -p build/UnsignedIPA/Payload dist
ditto "$APP" "build/UnsignedIPA/Payload/PaopaoLocationSpoofer.app" ditto "$APP" "build/UnsignedIPA/Payload/PaopaoLocationSpoofer.app"
BUILD_TIMESTAMP="${BUILD_TIMESTAMP:-$(date '+%Y%m%d-%H%M%S')}"
case "$BUILD_TIMESTAMP" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) ;;
*) echo "BUILD_TIMESTAMP must use yyyyMMdd-HHmmss format" >&2; exit 2 ;;
esac
IPA="$ROOT/dist/PaopaoLocationSpoofer-unsigned.ipa"
TIMESTAMPED_IPA="$ROOT/dist/PaopaoLocationSpoofer-${BUILD_TIMESTAMP}-unsigned.ipa"
rm -f "$IPA"
cd build/UnsignedIPA cd build/UnsignedIPA
zip -qry "$ROOT/dist/PaopaoLocationSpoofer-unsigned.ipa" Payload zip -qry "$IPA" Payload
echo "Output: dist/PaopaoLocationSpoofer-unsigned.ipa" ditto "$IPA" "$TIMESTAMPED_IPA"
echo "Output: $IPA"
echo "Timestamped output: $TIMESTAMPED_IPA"
@@ -0,0 +1,34 @@
import Foundation
// Chinese source keys are extracted from the maintained English table so all
// shipped languages resolve the same keys. Keep both Chinese tables explicit:
// an absent table falls back to the development language (English), not the key.
let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
let resources = root.appendingPathComponent("Resources")
let english = try String(
contentsOf: resources.appendingPathComponent("en.lproj/Localizable.strings"),
encoding: .utf8
)
let pattern = try NSRegularExpression(
pattern: #"^"((?:\\.|[^"\\])*)"\s*=\s*"(?:\\.|[^"\\])*";"#,
options: .anchorsMatchLines
)
let range = NSRange(english.startIndex..<english.endIndex, in: english)
let keys = pattern.matches(in: english, range: range).compactMap { match -> String? in
guard let keyRange = Range(match.range(at: 1), in: english) else { return nil }
return String(english[keyRange])
}
precondition(!keys.isEmpty && Set(keys).count == keys.count, "English keys must be nonempty and unique")
for (locale, transform) in [("zh-Hans", false), ("zh-Hant", true)] {
var lines = ["/* Generated from en.lproj/Localizable.strings by Scripts/generate-chinese-localizations.swift. */"]
lines += keys.map { key in
let value = transform
? (key.applyingTransform(StringTransform(rawValue: "Hans-Hant"), reverse: false) ?? key)
: key
return "\"\(key)\" = \"\(value)\";"
}
let path = resources.appendingPathComponent("\(locale).lproj/Localizable.strings")
try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true)
try (lines.joined(separator: "\n") + "\n").write(to: path, atomically: true, encoding: .utf8)
}
+52
View File
@@ -0,0 +1,52 @@
#!/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"
echo "Before tagging, add docs/releases/${VERSION}.en.md and docs/releases/${VERSION}.zh-Hant.md."
+2
View File
@@ -14,5 +14,7 @@
</array> </array>
<key>get-task-allow</key> <key>get-task-allow</key>
<true/> <true/>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
</dict> </dict>
</plist> </plist>
+183
View File
@@ -43,3 +43,186 @@ enum WlocSettingsStore {
save(WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false)) 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 RandomRadiusStore: ObservableObject {
static let shared = RandomRadiusStore()
private enum Key {
static let isEnabled = "randomRadius.isEnabled"
static let radius = "randomRadius.radius"
}
@Published private(set) var isEnabled: Bool
@Published private(set) var radius: Double
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
isEnabled = defaults.bool(forKey: Key.isEnabled)
radius = defaults.object(forKey: Key.radius) as? Double ?? 50
}
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
defaults.set(enabled, forKey: Key.isEnabled)
}
func setRadius(_ radius: Double) {
self.radius = radius
defaults.set(radius, forKey: Key.radius)
}
}
@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)
}
}
+33
View File
@@ -0,0 +1,33 @@
import Foundation
/// Chooses the app language from the first preferred language only. iOS normally
/// scans the whole preference list, which can select Chinese when Italian is first
/// and Chinese is second; this app intentionally falls back to English instead.
enum AppLocalization {
static func identifier(for preferredLanguages: [String]) -> String {
guard let first = preferredLanguages.first,
Locale(identifier: first).languageCode == "zh" else {
return "en"
}
return Bundle.preferredLocalizations(
from: ["zh-Hans", "zh-Hant"], forPreferences: [first]
).first ?? "zh-Hans"
}
static let identifier = identifier(for: Locale.preferredLanguages)
static let locale = Locale(identifier: identifier)
// An explicit .lproj bundle also covers String(localized:) calls made outside
// SwiftUI views, where the view's locale environment is not available.
private static let localizedBundle: Bundle = {
guard let path = Bundle.main.path(forResource: identifier, ofType: "lproj"),
let bundle = Bundle(path: path) else {
return .main
}
return bundle
}()
static func string(_ value: String.LocalizationValue) -> String {
String(localized: value, bundle: localizedBundle)
}
}
+266
View File
@@ -0,0 +1,266 @@
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.8",
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, language: String = AppLocalization.identifier
) async -> String? {
let session = makeSession()
defer { session.finishTasksAndInvalidate() }
for url in releaseNotesURLs(version: version, language: language) {
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, language: language) 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, language: String = AppLocalization.identifier
) -> [URL] {
// The unsuffixed archive remains Simplified Chinese for older clients.
let suffix: String
switch language {
case "zh-Hans": suffix = ""
case "zh-Hant": suffix = ".zh-Hant"
default: suffix = ".en"
}
let path = "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version)\(suffix).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)
}
static func releaseNotesSummary(_ markdown: String, language: String) -> String? {
var items: [String] = []
var inMainUpdates = false
for rawLine in markdown.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
let heading = language == "zh-Hans" || language == "zh-Hant"
? "## 主要更新" : "## Highlights"
if line == heading {
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, guard isActive, let info = notification.userInfo,
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
type == AVAudioSession.InterruptionType.ended.rawValue else { return } type == AVAudioSession.InterruptionType.ended.rawValue else { return }
start() restartAfterInterruption()
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复") RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
} }
@@ -29,19 +29,28 @@ final class BackgroundKeepAlive {
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
} catch { } catch {
RuntimeLogger.error("APP", "KeepAlive", "音频会话失败", error: error) RuntimeLogger.error("APP", "KeepAlive", "音频会话失败", error: error)
isActive = false
return
} }
let eng = AVAudioEngine() let eng = AVAudioEngine()
let player = AVAudioPlayerNode() let player = AVAudioPlayerNode()
eng.attach(player) eng.attach(player)
let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)! guard let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1),
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3)! 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 buf.frameLength = 44100 * 3
eng.connect(player, to: eng.mainMixerNode, format: fmt) eng.connect(player, to: eng.mainMixerNode, format: fmt)
eng.prepare() eng.prepare()
do { try eng.start() } catch { do { try eng.start() } catch {
RuntimeLogger.error("APP", "KeepAlive", "引擎启动失败", error: error) 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.scheduleBuffer(buf, at: nil, options: .loops)
player.play() player.play()
@@ -50,6 +59,16 @@ final class BackgroundKeepAlive {
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)") RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
} }
private func restartAfterInterruption() {
guard isActive else { return }
playerNode?.stop()
engine?.stop()
playerNode = nil
engine = nil
isActive = false
start()
}
func stop() { func stop() {
guard isActive else { return } guard isActive else { return }
isActive = false isActive = false
+163 -21
View File
@@ -1,46 +1,188 @@
import Foundation 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 AppLocalization.string("本地 CA 证书或私钥无效")
case let .keychain(status): return AppLocalization.string("无法写入设备钥匙串(\(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 { final class CertificateAuthorityStore {
private let directory: URL private let directory: URL
private let generator: () throws -> CertificateAuthority private let generator: () throws -> CertificateAuthority
private let validator: (CertificateAuthority) -> Bool
private let keychain: CertificateAuthorityKeychain
private let certificateURL: URL private let certificateURL: URL
private let keyURL: 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.directory = directory
self.keychain = keychain
self.generator = generator self.generator = generator
self.validator = validator
self.certificateURL = directory.appendingPathComponent("ca-cert.pem") self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
self.keyURL = directory.appendingPathComponent("ca-key.pem") self.keyURL = directory.appendingPathComponent("ca-key.pem")
} }
func ensure() throws -> CertificateAuthority { func ensure() throws -> CertificateAuthority {
if let current = try load() { if let authority = try loadValidKeychainAuthority() {
RuntimeLogger.debug("SHARED", "Certificate.store", "读取已有 CA 文件", details: ["directory": directory.path]) removeLegacyFilesBestEffort()
return current 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() let authority = try generator()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) guard validator(authority) else { throw CertificateAuthorityStoreError.invalidAuthority }
guard let certificateData = authority.certPEM.data(using: .utf8), try keychain.save(authority)
let keyData = authority.keyPEM.data(using: .utf8) else { removeLegacyFilesBestEffort()
throw CocoaError(.fileWriteInapplicableStringEncoding) RuntimeLogger.info("SHARED", "Certificate.store", "已生成并保存设备专属 CA")
}
try certificateData.write(to: certificateURL, options: .atomic)
try keyData.write(to: keyURL, options: .atomic)
applyCompleteProtection(to: certificateURL)
applyCompleteProtection(to: keyURL)
RuntimeLogger.info("SHARED", "Certificate.store", "CA 文件写入完成", details: ["directory": directory.path])
return authority return authority
} }
func load() throws -> CertificateAuthority? { func load() throws -> CertificateAuthority? {
guard FileManager.default.fileExists(atPath: certificateURL.path), FileManager.default.fileExists(atPath: keyURL.path) else { return nil } try loadValidKeychainAuthority()
return CertificateAuthority(certPEM: try String(contentsOf: certificateURL, encoding: .utf8), keyPEM: try String(contentsOf: keyURL, encoding: .utf8))
} }
private func applyCompleteProtection(to url: URL) { func reset() throws {
#if os(iOS) try removeLegacyFiles()
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path) try keychain.remove()
#endif 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 /// 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. /// by the system. Uses SecTrust evaluation against system anchors only — no network needed.
static func isCACertificateTrusted(certPEM: String) -> Bool { static func isCACertificateTrusted(certPEM: String) -> Bool {
guard let certData = certPEM.data(using: .utf8), guard let pemData = certPEM.data(using: .utf8),
let cert = SecCertificateCreateWithData(nil, certData as CFData) else { let block = pemData.pemCertificateBlock,
let cert = SecCertificateCreateWithData(nil, block as CFData) else {
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM") RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
return false return false
} }
@@ -38,3 +39,13 @@ final class CertificateTrustVerifier {
return result 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 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 坐标转换。 /// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。
/// ///
/// 在中国地区,MKMapView 使用高德 (AutoNavi) 瓦片数据(GCJ-02 坐标系), /// MapKit does not expose a public API for its active coordinate reference system.
/// 因此从 MKMapView 的 `centerCoordinate`、`convert(point:toCoordinateFrom:)` /// The app resolves it with a bounded heuristic, then stores both WGS-84 and
/// 等方法返回的坐标也是 GCJ-02。但 CoreLocation / CLLocationManager 返回的 /// GCJ-02 representations at each write boundary so replay does not convert again.
/// 以及 Apple wloc 定位服务使用的都是 WGS-84。
///
/// 虚拟定位的坐标流中:
/// - 地图 UI 层(显示、选点):GCJ-02(与瓦片一致)
/// - 代理写出层(wloc 响应改写):WGS-84
///
/// 因此需要在坐标从地图 UI 进入代理之前做 GCJ-02 → WGS-84 转换。
enum CoordinateConverter { enum CoordinateConverter {
// 椭球参数 (Krasovsky 1940) // 椭球参数 (Krasovsky 1940)
private static let a = 6378245.0 private static let a = 6378245.0
private static let ee = 0.00669342162296594323 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 米) /// GCJ-02 → WGS-84(迭代法,精度优于 0.5 米)
static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) { 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 wgsLat = lat
var wgsLon = lon var wgsLon = lon
// 两次迭代足以收敛到亚米级精度
for _ in 0..<2 { for _ in 0..<2 {
let d = delta(lat: wgsLat, lon: wgsLon) let d = delta(lat: wgsLat, lon: wgsLon)
wgsLat = lat - d.lat wgsLat = lat - d.lat
@@ -30,6 +390,25 @@ enum CoordinateConverter {
return (wgsLat, wgsLon) 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 的增量) /// 计算偏移量 (WGS-84 → GCJ-02 的增量)
private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) { private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
let dLat = transformLat(x: lon - 105.0, y: lat - 35.0) let dLat = transformLat(x: lon - 105.0, y: lat - 35.0)
@@ -60,3 +439,56 @@ enum CoordinateConverter {
return ret 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)
}
}
+10 -17
View File
@@ -12,13 +12,21 @@ enum CoreBridgeError: LocalizedError {
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .generationFailed: return "无法生成本地证书" case .generationFailed: return AppLocalization.string("无法生成本地证书")
case .serverStartFailed: return "无法启动本地证书服务" case .serverStartFailed: return AppLocalization.string("无法启动本地证书服务")
} }
} }
} }
enum CoreBridge { 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 { static func generateCertificateAuthority() throws -> CertificateAuthority {
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA") RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
let result = wloccore_generateca() let result = wloccore_generateca()
@@ -41,21 +49,6 @@ enum CoreBridge {
return String(cString: ptr) return String(cString: ptr)
} }
/// 构造接近真实设备格式的 wloc 请求体(多个 WiFi AP + 蜂窝基站)。
static func testWlocRequestData() -> Data {
guard let ptr = wloccore_testrequesthex() else { return Data([0x0a, 0x02, 0x08, 0x01]) }
defer { free(ptr) }
let hex = String(cString: ptr)
var data = Data()
var idx = hex.startIndex
while idx < hex.endIndex {
let end = hex.index(idx, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
if let b = UInt8(hex[idx..<end], radix: 16) { data.append(b) }
idx = end
}
return data
}
static func flushLogs(category: String) { static func flushLogs(category: String) {
guard let pointer = wloccore_drainlogs() else { return } guard let pointer = wloccore_drainlogs() else { return }
defer { free(pointer) } defer { free(pointer) }
+130 -15
View File
@@ -1,21 +1,79 @@
import CoreLocation
import Foundation import Foundation
struct FavoriteLocation: Codable, Identifiable, Equatable { struct FavoriteLocation: Codable, Identifiable, Equatable {
let id: UUID let id: UUID
var name: String var name: String
var latitude: Double var coordinatePair: CoordinatePair
var longitude: Double
var accuracy: Int var accuracy: Int
var createdAt: Date 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.id = id
self.name = name self.name = name
self.latitude = latitude self.coordinatePair = coordinatePair
self.longitude = longitude
self.accuracy = accuracy self.accuracy = accuracy
self.createdAt = createdAt 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 { struct MapConfiguration: Equatable {
@@ -52,27 +110,72 @@ final class FavoriteLocationStore: ObservableObject {
} }
@discardableResult @discardableResult
func save(name: String, latitude: Double, longitude: Double, accuracy: Int) -> FavoriteLocation { func save(
let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy) 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 { 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) favorites.insert(favorite, at: 0)
select(favorite.id) select(favorite.id)
persist() persistIgnoringFailure()
return favorite 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?) { func select(_ id: UUID?) {
selectedFavoriteID = id selectedFavoriteID = id
defaults.set(id?.uuidString, forKey: Keys.selectedID) 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) { func rename(_ id: UUID, to name: String) {
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return } guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
favorites[idx].name = name favorites[idx].name = name
persist() persistIgnoringFailure()
} }
func delete(_ favorite: FavoriteLocation) { func delete(_ favorite: FavoriteLocation) {
@@ -80,11 +183,23 @@ final class FavoriteLocationStore: ObservableObject {
if selectedFavoriteID == favorite.id { if selectedFavoriteID == favorite.id {
select(favorites.first?.id) select(favorites.first?.id)
} }
persist() persistIgnoringFailure()
} }
private func persist() { func migrateLegacyCoordinates() throws {
guard let data = try? JSONEncoder().encode(favorites) else { return } guard favorites.contains(where: \.isLegacyCoordinateRecord) else { return }
defaults.set(data, forKey: Keys.favorites) 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 Network
import Foundation import Foundation
import SystemConfiguration.CaptiveNetwork
enum WiFiChangeReason: String {
case reconnected = "Wi-Fi 恢复连接"
case interfaceChanged = "网络接口切换到 Wi-Fi"
case ssidChanged = "SSID 发生变化"
}
@MainActor @MainActor
final class NetworkMonitor: ObservableObject { final class NetworkMonitor: ObservableObject {
@@ -7,20 +14,99 @@ final class NetworkMonitor: ObservableObject {
@Published private(set) var isSatisfied = true @Published private(set) var isSatisfied = true
@Published private(set) var isWiFiEnabled = 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 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() { private init() {
let initialSSID = Self.fetchSSID()
currentSSID = initialSSID
lastKnownSSID = initialSSID
monitor.pathUpdateHandler = { [weak self] path in monitor.pathUpdateHandler = { [weak self] path in
let satisfied = path.status == .satisfied let satisfied = path.status == .satisfied
let wifi = path.usesInterfaceType(.wifi) let wifi = path.usesInterfaceType(.wifi)
Task { @MainActor in Task { @MainActor in
self?.isSatisfied = satisfied guard let self else { return }
self?.isWiFiEnabled = wifi 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) 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 AppLocalization.string("APP模式")
case .thirdParty: return AppLocalization.string("第三方代理模式")
}
}
}
@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)
}
}
+154 -7
View File
@@ -1,3 +1,4 @@
import CoreLocation
import Foundation import Foundation
struct RuntimeLogEntry: Codable, Identifiable, Equatable { struct RuntimeLogEntry: Codable, Identifiable, Equatable {
@@ -37,10 +38,35 @@ struct RuntimeLogEntry: Codable, Identifiable, Equatable {
var renderedText: String { var renderedText: String {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let suffix = details.isEmpty let localizedDetails = details.map {
(localizedDiagnosticText($0.key), localizedDiagnosticValue($0.value))
}
let suffix = localizedDetails.isEmpty
? "" ? ""
: " " + details.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: " ") : " " + localizedDetails.sorted(by: { $0.0 < $1.0 }).map { "\($0.0)=\($0.1)" }.joined(separator: " ")
return "\(formatter.string(from: timestamp)) [\(source)] [\(level.rawValue.uppercased())] [\(category)] \(message)\(suffix)" return "\(formatter.string(from: timestamp)) [\(source)] [\(level.rawValue.uppercased())] [\(localizedCategory)] \(localizedMessage)\(suffix)"
}
var localizedCategory: String { localizedDiagnosticText(category) }
var localizedMessage: String { localizedDiagnosticText(message) }
var localizedDetailsText: String {
details.map { (localizedDiagnosticText($0.key), localizedDiagnosticValue($0.value)) }
.sorted(by: { $0.0 < $1.0 })
.map { "\($0.0): \($0.1)" }
.joined(separator: "\n")
}
private func localizedDiagnosticText(_ text: String) -> String {
AppLocalization.string(String.LocalizationValue(text))
}
private func localizedDiagnosticValue(_ value: String) -> String {
switch value {
case "true": return AppLocalization.string("是")
case "false": return AppLocalization.string("否")
default: return localizedDiagnosticText(value)
}
} }
} }
@@ -49,11 +75,17 @@ enum RuntimeLogStore {
private static let decoder = JSONDecoder() private static let decoder = JSONDecoder()
private static let encoder = JSONEncoder() private static let encoder = JSONEncoder()
private static let maximumBytes: UInt64 = 1_500_000 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) { static func append(_ entry: RuntimeLogEntry) {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
do { do {
let now = Date()
try pruneExpiredLogsIfNeeded(now: now)
guard entry.timestamp >= retentionCutoff(now: now) else { return }
let url = try logURL(for: entry.source) let url = try logURL(for: entry.source)
try rotateIfNeeded(url) try rotateIfNeeded(url)
var data = try encoder.encode(entry) var data = try encoder.encode(entry)
@@ -75,7 +107,8 @@ enum RuntimeLogStore {
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] { static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
lock.lock() lock.lock()
defer { lock.unlock() } 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( guard let urls = try? FileManager.default.contentsOfDirectory(
at: directory, at: directory,
includingPropertiesForKeys: nil includingPropertiesForKeys: nil
@@ -90,12 +123,12 @@ enum RuntimeLogStore {
static func clearAll() { static func clearAll() {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true) try? FileManager.default.removeItem(at: logDirectory)
try? FileManager.default.removeItem(at: directory) lastPrunedAt = nil
} }
private static func logURL(for source: String) throws -> URL { 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) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let process = (Bundle.main.bundleIdentifier ?? "unknown-process") let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
.replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: "/", with: "-")
@@ -103,6 +136,56 @@ enum RuntimeLogStore {
return directory.appendingPathComponent("\(process)-\(safeSource).jsonl") 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 { private static func rotateIfNeeded(_ url: URL) throws {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attributes[.size] as? NSNumber, let size = attributes[.size] as? NSNumber,
@@ -162,3 +245,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)
}
}
+290
View File
@@ -0,0 +1,290 @@
import Foundation
struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
let success: Bool
let longitude: Double?
let latitude: Double?
let accuracy: Int?
let error: String?
let motionSimulationEnabled: Bool?
}
enum ThirdPartyProxyConnectionState: Equatable {
case unknown
case connected(active: Bool)
case failed(String)
}
enum ThirdPartyProxyError: LocalizedError, Equatable {
case invalidResponse
case moduleNotIntercepted
case rejected(String)
case coordinateMismatch
case network(String)
var errorDescription: String? {
switch self {
case .invalidResponse:
return AppLocalization.string("第三方代理返回了无法识别的数据")
case .moduleNotIntercepted:
return AppLocalization.string("请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接")
case .rejected(let message):
return message
case .coordinateMismatch:
return AppLocalization.string("第三方代理保存的坐标与当前选点不一致")
case .network(let message):
return AppLocalization.string("第三方代理请求失败:\(message)")
}
}
var recoverySuggestion: String {
return AppLocalization.string("检查模块、MITM、证书和代理/VPN连接")
}
static func recoverySuggestion(for error: Error) -> String {
(error as? Self)?.recoverySuggestion
?? AppLocalization.string("检查模块、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",
"gsp-ssl.ls.apple.com",
"bluedot.is.autonavi.com",
"bluedot.is.autonavi.com.gds.alibabadns.com"
]
static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
@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,
randomRadius: RandomRadiusStore.shared.isEnabled ? RandomRadiusStore.shared.radius : 0
))
guard response.success else {
throw ThirdPartyProxyError.rejected(response.error ?? AppLocalization.string("第三方代理拒绝保存坐标"))
}
guard let latitude = response.latitude,
let longitude = response.longitude,
abs(latitude - wgs84.latitude) <= 0.000_001,
abs(longitude - wgs84.longitude) <= 0.000_001 else {
throw ThirdPartyProxyError.coordinateMismatch
}
activeSettings = response
connectionState = .connected(active: true)
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理已保存 WGS-84 坐标", details: [
"坐标标准": "WGS-84",
"取值字段": "coordinatePair.wgs84",
"accuracy": String(favorite.accuracy)
])
return response
}
func clear() async throws {
let response = try await perform(action: .clear)
guard response.success else {
throw ThirdPartyProxyError.rejected(response.error ?? AppLocalization.string("第三方代理清除坐标失败"))
}
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 ?? AppLocalization.string("第三方代理查询失败"))
}
private enum Action {
case query
case save(latitude: Double, longitude: Double, accuracy: Int, randomRadius: Double)
case clear
}
private func perform(action: Action) async throws -> ThirdPartyProxySettingsResponse {
guard !isRequesting else {
throw ThirdPartyProxyError.rejected(AppLocalization.string("已有第三方代理请求正在执行"))
}
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 randomRadius):
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: "randomRadius",
value: String(format: "%g", locale: Locale(identifier: "en_US_POSIX"), randomRadius)
)
]
}
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 {
/// Bump when a hosted module or script changes to invalidate proxy-client caches.
static let moduleSubscriptionVersion = "1.0.8"
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 : AppLocalization.string("配置已提供,尚未验证")
}
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 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/"
let base = "\(prefix)\(directory)/\(moduleFileName)"
return URL(string: "\(base)?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)
}
}
+12 -8
View File
@@ -1,6 +1,6 @@
import Foundation import Foundation
/// 环境验证结果,UI 层根据此值弹出对应的引导面板。 /// 环境验证结果,由 SetupCoordinator 统一路由到对应引导页面。
enum VerificationResult: Equatable, Identifiable { enum VerificationResult: Equatable, Identifiable {
case success case success
case proxyNotRunning case proxyNotRunning
@@ -26,14 +26,18 @@ enum VerificationResult: Equatable, Identifiable {
var isSuccess: Bool { self == .success } var isSuccess: Bool { self == .success }
/// 对应的引导页类型 /// Localized, user-facing title. `id` stays stable for routing/equality.
var tipKind: TipKind? { var localizedTitle: String {
switch self { switch self {
case .success: return nil case .success: return AppLocalization.string("成功")
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil case .proxyNotRunning: return AppLocalization.string("代理未运行")
case .certNotTrusted: return nil // 走完整引导页,不弹 tip case .verificationInProgress: return AppLocalization.string("已有验证正在进行")
case .wifiProxyNotConfigured: return .proxySetup case .verificationSuperseded: return AppLocalization.string("验证已被新位置取代")
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed case .certNotTrusted: return AppLocalization.string("证书未信任")
case .wifiProxyNotConfigured: return AppLocalization.string("WiFi代理未配置")
case .coordinateWriteFailed: return AppLocalization.string("坐标写入失败")
case .patchFailed: return AppLocalization.string("改写验证失败")
} }
} }
} }
@@ -0,0 +1,12 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class AppLocalizationTests: XCTestCase {
func testOnlyFirstPreferredLanguageSelectsTheAppLanguage() {
XCTAssertEqual(AppLocalization.identifier(for: ["it-IT", "zh-Hans-CN"]), "en")
XCTAssertEqual(AppLocalization.identifier(for: ["fr-FR", "zh-Hant-TW"]), "en")
XCTAssertEqual(AppLocalization.identifier(for: ["zh-Hans-CN", "it-IT"]), "zh-Hans")
XCTAssertEqual(AppLocalization.identifier(for: ["zh-Hant-TW", "en-US"]), "zh-Hant")
XCTAssertEqual(AppLocalization.identifier(for: []), "en")
}
}
@@ -0,0 +1,120 @@
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.8")
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.8", language: "en")
XCTAssertEqual(releaseNotesURLs.first?.host, "gh-proxy.org")
XCTAssertEqual(releaseNotesURLs.last?.host, "raw.githubusercontent.com")
XCTAssertTrue(releaseNotesURLs.last?.path.hasSuffix("/docs/releases/v1.0.8.en.md") == true)
XCTAssertTrue(AppRemoteConfigurationService.releaseNotesURLs(
version: "1.0.8", language: "zh-Hans"
).last?.path.hasSuffix("/docs/releases/v1.0.8.md") == true)
XCTAssertTrue(AppRemoteConfigurationService.releaseNotesURLs(
version: "1.0.8", language: "zh-Hant"
).last?.path.hasSuffix("/docs/releases/v1.0.8.zh-Hant.md") == true)
XCTAssertTrue(AppRemoteConfigurationService.releaseNotesURLs(
version: "1.0.8", language: "it"
).last?.path.hasSuffix("/docs/releases/v1.0.8.en.md") == true)
}
func testReleaseNotesSummaryUsesTheSelectedLanguageHeading() {
let english = "# v1.0.8\n\n## Highlights\n\n- English update\n\n## Installation"
let chinese = "# v1.0.8\n\n## 主要更新\n\n- 中文更新\n\n## 安装"
XCTAssertEqual(AppRemoteConfigurationService.releaseNotesSummary(english, language: "en"), "• English update")
XCTAssertEqual(AppRemoteConfigurationService.releaseNotesSummary(chinese, language: "zh-Hans"), "• 中文更新")
XCTAssertEqual(AppRemoteConfigurationService.releaseNotesSummary(chinese, language: "zh-Hant"), "• 中文更新")
XCTAssertNil(AppRemoteConfigurationService.releaseNotesSummary(chinese, language: "en"))
XCTAssertEqual(AppRemoteConfigurationService.releaseNotesSummary(english, language: "it"), "• English update")
}
func testArchivedReleaseNotesParseInEachLanguage() throws {
let root = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
for (language, name) in [
("en", "v1.0.8.en.md"),
("zh-Hans", "v1.0.8.md"),
("zh-Hant", "v1.0.8.zh-Hant.md")
] {
let markdown = try String(
contentsOf: root.appendingPathComponent("docs/releases/\(name)"),
encoding: .utf8
)
XCTAssertNotNil(AppRemoteConfigurationService.releaseNotesSummary(markdown, language: language))
}
}
}
@@ -2,16 +2,164 @@ import XCTest
@testable import PaopaoLocationSpoofer @testable import PaopaoLocationSpoofer
final class CertificateAuthorityStoreTests: XCTestCase { final class CertificateAuthorityStoreTests: XCTestCase {
func testEnsureCreatesOnceAndThenReusesExistingPair() throws { private let validAuthority = CertificateAuthority(certPEM: "valid-cert", keyPEM: "valid-key")
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) } func testEnsureCreatesOnceThenReusesValidKeychainPair() throws {
let keychain = InMemoryCertificateAuthorityKeychain()
var generations = 0 var generations = 0
let store = CertificateAuthorityStore(directory: directory) { let store = makeStore(keychain: keychain) {
generations += 1 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) 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 @testable import PaopaoLocationSpoofer
final class CertificateTrustVerifierTests: XCTestCase { final class CertificateTrustVerifierTests: XCTestCase {
func testVerifierMapsFailedProbeToUnavailable() async { func testVerifierRejectsMalformedPEM() {
let verifier = CertificateTrustVerifier(probe: { _, _ in false }) XCTAssertFalse(CertificateTrustVerifier.isCACertificateTrusted(certPEM: "not a certificate"))
XCTAssertEqual(await verifier.verify(url: URL(string: "https://127.0.0.1:1/health")!, leafHash: "x"), .unavailable)
} }
} }
@@ -1,4 +1,5 @@
import XCTest import XCTest
import CoreLocation
@testable import PaopaoLocationSpoofer @testable import PaopaoLocationSpoofer
final class FavoriteLocationStoreTests: XCTestCase { final class FavoriteLocationStoreTests: XCTestCase {
@@ -7,14 +8,195 @@ final class FavoriteLocationStoreTests: XCTestCase {
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
let store = FavoriteLocationStore(defaults: defaults) 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(store.selectedFavoriteID, favorite.id)
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾") 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() { func testMapConfigurationNeverRequestsRealUserLocation() {
XCTAssertFalse(MapConfiguration.default.showsUserLocation) XCTAssertFalse(MapConfiguration.default.showsUserLocation)
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest) 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 @MainActor
final class LocationActionCoordinatorTests: XCTestCase { final class LocationActionCoordinatorTests: XCTestCase {
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async { func testApplyStartsProxyAndWritesTheFavoriteWGS84Pair() async {
let events = EventLog() let proxy = FakeLocationActionProxy()
let trust = FakeTrust(canModify: true, events: events) let settings = FakeLocationActionSettingsStore()
let proxy = FakeProxy(activeForClear: false, events: events) let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
let settings = FakeSettings(events: events) let favorite = FavoriteLocation(
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20) name: "深圳湾",
let coordinator = LocationActionCoordinator() latitude: 22.494,
longitude: 113.951,
accuracy: 20,
mapCoordinateSystem: .gcj02
)
let applied = await coordinator.apply(favorite) let applied = await coordinator.apply(favorite)
// LocationActionCoordinator doesn't take injected deps — just verify state
XCTAssertTrue(applied) 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 { func testClearWritesDisabledCoordinatesAndClearsSettings() {
let events = EventLog() let proxy = FakeLocationActionProxy(isRunning: true)
let coordinator = LocationActionCoordinator() let settings = FakeLocationActionSettingsStore()
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
coordinator.clear() 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) XCTAssertFalse(coordinator.virtualLocationEnabled)
} }
func testBusyApplyRejectsASecondRequest() async { func testApplyVerifiedRejectsInactiveProxyWithoutWritingCoordinates() {
let coordinator = LocationActionCoordinator() 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 favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
let first = Task { await coordinator.apply(favorite) } XCTAssertFalse(coordinator.applyVerified(favorite))
let secondApplied = await coordinator.apply(favorite) XCTAssertNil(proxy.lastCoordinates)
// Should reject while busy XCTAssertNil(settings.saved)
XCTAssertFalse(secondApplied)
let firstApplied = await first.value
// First one might succeed or fail depending on proxy state; just check no crash
_ = firstApplied
} }
} }
@MainActor @MainActor
private final class FakeTrust { private final class FakeLocationActionProxy: LocationActionProxying {
var canModify: Bool struct Coordinates: Equatable {
let events: EventLog let latitude: Double
let longitude: Double
init(canModify: Bool, events: EventLog) { let enabled: Bool
self.canModify = canModify let accuracy: Int
self.events = events
} }
func refreshTrust() async { var isRunning: Bool
events.append("trust.refresh") 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 @MainActor
private final class FakeProxy { private final class FakeLocationActionSettingsStore: LocationActionSettingsStoring {
let activeForClear: Bool private(set) var saved: WlocSettings?
let events: EventLog
let connectGate: AsyncGate?
init(activeForClear: Bool, events: EventLog, connectGate: AsyncGate? = nil) {
self.activeForClear = activeForClear
self.events = events
self.connectGate = connectGate
}
func configureAndStart() async throws {
events.append("proxy.connect")
await connectGate?.blockUntilOpened()
}
func stopAndWait() async throws {
events.append("proxy.stop")
}
func send(_ message: String) async throws -> String {
events.append("proxy.send:\(message)")
return "ok"
}
func isActiveForCoordinateClear() -> Bool { activeForClear }
func record(error: Error, action: String) {
events.append("proxy.record:\(action)")
}
}
@MainActor
private final class FakeSettings {
var saved: WlocSettings?
let events: EventLog
init(events: EventLog) { self.events = events }
func load() -> WlocSettings? { saved } func load() -> WlocSettings? { saved }
func save(_ settings: WlocSettings) { saved = settings }
func save(_ settings: WlocSettings) { func clear() { saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false) }
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()
}
} }
@@ -14,8 +14,7 @@ final class MapLocationStateTests: XCTestCase {
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47)) state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
let accepted = state.acceptRealtimeLocation( let accepted = state.acceptRealtimeLocation(
.init(latitude: 39.90, longitude: 116.40), .init(latitude: 39.90, longitude: 116.40),
intent: request, intent: request
focus: true
) )
XCTAssertFalse(accepted) XCTAssertFalse(accepted)
@@ -53,7 +52,7 @@ final class MapLocationStateTests: XCTestCase {
XCTAssertEqual(state.selection.source, .userPan) XCTAssertEqual(state.selection.source, .userPan)
let intent = state.beginRealtimeIntent() 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) XCTAssertEqual(state.selection.source, .realtime)
} }
@@ -174,20 +173,48 @@ final class MapLocationStateTests: XCTestCase {
XCTAssertEqual(state.selection.source, .search) 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() { func testRealtimeIntentCanImmediatelyAcceptNativeLocation() {
let state = MapLocationState(initialCoordinate: initial) let state = MapLocationState(initialCoordinate: initial)
let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25) let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25)
state.updateRealtimeLocation(nativeLocation) state.updateRealtimeLocation(nativeLocation)
let intent = state.beginRealtimeIntent() 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.source, .realtime)
XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001) XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001)
guard case let .focus(coordinate, distance) = state.cameraCommand?.kind else { XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it")
return XCTFail("Expected realtime focus command") }
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(coordinate.latitude, 22.54, accuracy: 0.000001)
XCTAssertEqual(distance, 200) XCTAssertEqual(distanceMeters, state.viewportMeters)
} }
func testZoomMathScalesBothAxesInTheSameDirection() { func testZoomMathScalesBothAxesInTheSameDirection() {
@@ -207,4 +234,71 @@ final class MapLocationStateTests: XCTestCase {
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km") XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km")
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 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,10 +72,14 @@ final class RealtimeLocationManagerTests: XCTestCase {
func testOneShotTimeoutTransitionsToContinuousFallback() async { func testOneShotTimeoutTransitionsToContinuousFallback() async {
let driver = FakeRealtimeLocationDriver() 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 fallbackStarted = expectation(description: "Continuous location fallback started")
driver.onStartUpdating = {
fallbackStarted.fulfill()
}
let request = Task { await manager.requestLocation() } let request = Task { await manager.requestLocation() }
try? await Task.sleep(nanoseconds: 20_000_000) await fulfillment(of: [fallbackStarted], timeout: 1)
XCTAssertEqual(driver.startUpdatingCallCount, 1) XCTAssertEqual(driver.startUpdatingCallCount, 1)
driver.emit(CLLocation(latitude: 39.90, longitude: 116.40)) driver.emit(CLLocation(latitude: 39.90, longitude: 116.40))
@@ -148,6 +152,7 @@ private final class FakeRealtimeLocationDriver: RealtimeLocationDriving {
var authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse var authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse
weak var delegate: CLLocationManagerDelegate? weak var delegate: CLLocationManagerDelegate?
var onRequestLocation: (() -> Void)? var onRequestLocation: (() -> Void)?
var onStartUpdating: (() -> Void)?
private(set) var requestAuthorizationCallCount = 0 private(set) var requestAuthorizationCallCount = 0
private(set) var requestLocationCallCount = 0 private(set) var requestLocationCallCount = 0
private(set) var startUpdatingCallCount = 0 private(set) var startUpdatingCallCount = 0
@@ -164,6 +169,7 @@ private final class FakeRealtimeLocationDriver: RealtimeLocationDriving {
func startUpdatingLocation() { func startUpdatingLocation() {
startUpdatingCallCount += 1 startUpdatingCallCount += 1
onStartUpdating?()
} }
func stopUpdatingLocation() { func stopUpdatingLocation() {
@@ -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,201 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class ThirdPartyProxyManagerTests: XCTestCase {
func testQueryDistinguishesConnectedWithoutCoordinate() async throws {
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
let manager = ThirdPartyProxyManager(requester: requester)
let response = try await manager.query()
XCTAssertFalse(response.success)
XCTAssertEqual(manager.connectionState, .connected(active: false))
XCTAssertEqual(requester.lastURL?.query, "action=query")
}
func testSaveUsesFavoriteWGS84AndAcceptsMatchingResponse() async throws {
let favorite = FavoriteLocation(
name: "深圳湾",
latitude: 22.494,
longitude: 113.951,
accuracy: 20,
mapCoordinateSystem: .gcj02
)
let wgs84 = favorite.coordinatePair.wgs84
let body = String(format: #"{"success":true,"longitude":%.8f,"latitude":%.8f,"accuracy":20}"#,
locale: Locale(identifier: "en_US_POSIX"), wgs84.longitude, wgs84.latitude)
let requester = FakeThirdPartyRequester(body: body)
let manager = ThirdPartyProxyManager(requester: requester)
_ = try await manager.save(favorite)
let components = URLComponents(url: try XCTUnwrap(requester.lastURL), resolvingAgainstBaseURL: false)
let values = Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") })
let latitude = try XCTUnwrap(Double(values["lat"] ?? ""))
let longitude = try XCTUnwrap(Double(values["lon"] ?? ""))
XCTAssertEqual(latitude, wgs84.latitude, accuracy: 0.000_000_01)
XCTAssertEqual(longitude, wgs84.longitude, accuracy: 0.000_000_01)
XCTAssertEqual(values["acc"], "20")
XCTAssertEqual(manager.connectionState, .connected(active: true))
}
func testConnectionUsesLegacySaveQueryEndpoint() async throws {
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
let manager = ThirdPartyProxyManager(requester: requester)
let response = try await manager.query()
XCTAssertFalse(response.success)
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
XCTAssertEqual(requester.requestedURLs.first?.query, "action=query")
}
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)
let manager = ThirdPartyProxyManager(requester: requester)
let response = try await manager.save(favorite)
XCTAssertTrue(response.success)
XCTAssertEqual(manager.connectionState, .connected(active: true))
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
let queryComponents = URLComponents(
url: try XCTUnwrap(requester.requestedURLs.first),
resolvingAgainstBaseURL: false
)
let values = Dictionary(
uniqueKeysWithValues: (queryComponents?.queryItems ?? []).map { ($0.name, $0.value ?? "") }
)
XCTAssertEqual(values["lon"], String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), wgs84.longitude))
XCTAssertEqual(values["lat"], String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), wgs84.latitude))
XCTAssertEqual(values["acc"], "20")
XCTAssertEqual(values["randomRadius"], "0")
}
func testBrokenSaveQueryFailsWithoutCheckingVersion() async {
let requester = FakeThirdPartyRequester(body: "not-json")
let manager = ThirdPartyProxyManager(requester: requester)
do {
_ = try await manager.query()
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 testClientLinksUseProjectOwnedModulesAndVerificationLabels() {
XCTAssertEqual(
ThirdPartyProxyManager.interceptionHostnames,
[
"gs-loc.apple.com",
"gs-loc-cn.apple.com",
"gsp-ssl.ls.apple.com",
"bluedot.is.autonavi.com",
"bluedot.is.autonavi.com.gds.alibabadns.com"
]
)
XCTAssertNil(ThirdPartyProxyClient.shadowrocket.verificationText)
XCTAssertEqual(
ThirdPartyProxyClient.surge.verificationText,
AppLocalization.string("配置已提供,尚未验证")
)
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/Resources/ThirdPartyProxyModules/"
))
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(set) var lastURL: URL?
private(set) var requestedURLs: [URL] = []
init(body: String) {
data = Data(body.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 (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
}
}
+4
View File
@@ -8,6 +8,10 @@ test -x "$BUILD_SCRIPT" || fail "build.sh must be executable"
grep -qF "build-unsigned-ipa.sh" "$BUILD_SCRIPT" || fail "build.sh must call build-unsigned-ipa.sh" grep -qF "build-unsigned-ipa.sh" "$BUILD_SCRIPT" || fail "build.sh must call build-unsigned-ipa.sh"
test -f "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must exist" test -f "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must exist"
grep -qF 'BUILD_TIMESTAMP="${BUILD_TIMESTAMP:-$(date' "$ROOT/Scripts/build-unsigned-ipa.sh" \
|| fail "build output must include its generation timestamp"
grep -qF 'PaopaoLocationSpoofer-${BUILD_TIMESTAMP}-unsigned.ipa' "$ROOT/Scripts/build-unsigned-ipa.sh" \
|| fail "timestamped IPA filename is missing"
# Should NOT contain Tunnel references # Should NOT contain Tunnel references
! grep -qF "Tunnel" "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must not reference Tunnel" ! grep -qF "Tunnel" "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must not reference Tunnel"
+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/App/ProxyManager.swift" || fail "ProxyManager must exist"
test -f "$ROOT/Shared/RuntimeLog.swift" || fail "RuntimeLog must exist" test -f "$ROOT/Shared/RuntimeLog.swift" || fail "RuntimeLog must exist"
test -f "$ROOT/App/RealtimeLocationManager.swift" || fail "RealtimeLocationManager 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 # VPNManager and Tunnel must NOT exist
test ! -f "$ROOT/App/VPNManager.swift" || fail "VPNManager must be removed" test ! -f "$ROOT/App/VPNManager.swift" || fail "VPNManager must be removed"

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