Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e137ff10e9 |
@@ -1,59 +0,0 @@
|
||||
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
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
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: 分享已验证的第三方客户端模块、配置步骤和脱敏截图
|
||||
@@ -34,19 +34,21 @@ jobs:
|
||||
- name: Rename IPA for release
|
||||
run: mv dist/PaopaoLocationSpoofer-unsigned.ipa dist/Location-Spoofer-unsigned.ipa
|
||||
|
||||
- name: Validate archived release notes
|
||||
- name: Read release notes
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
NOTES_FILE="docs/releases/${VERSION}.md"
|
||||
if [ ! -s "$NOTES_FILE" ]; then
|
||||
echo "::error::缺少版本归档 $NOTES_FILE。请在打 tag 前运行 ./Scripts/generate-release-notes.sh $VERSION 并提交。"
|
||||
exit 1
|
||||
if [ -f "$NOTES_FILE" ]; then
|
||||
cat "$NOTES_FILE" > /tmp/release_notes.md
|
||||
else
|
||||
echo "⚠️ $NOTES_FILE 未找到,请创建该文件。" > /tmp/release_notes.md
|
||||
fi
|
||||
if ! grep -Fq "# ${VERSION}" "$NOTES_FILE"; then
|
||||
echo "::error::$NOTES_FILE 的标题必须包含 # ${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
cp "$NOTES_FILE" /tmp/release_notes.md
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
cat /tmp/release_notes.md
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -23,9 +23,5 @@ Wloc.xcodeproj/
|
||||
.github/*
|
||||
!.github/workflows/
|
||||
!.github/workflows/**
|
||||
!.github/DISCUSSION_TEMPLATE/
|
||||
!.github/DISCUSSION_TEMPLATE/**
|
||||
!.github/ISSUE_TEMPLATE/
|
||||
!.github/ISSUE_TEMPLATE/**
|
||||
Core/core
|
||||
docs/superpowers/
|
||||
|
||||
@@ -7,17 +7,13 @@ struct BugReportView: View {
|
||||
@State private var isReproducible = true
|
||||
@State private var isRunning = false
|
||||
@State private var showCopiedAlert = false
|
||||
@State private var githubDestination: SafariDestination?
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// 说明
|
||||
Text("遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板。跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段。")
|
||||
Text("遇到问题时,在这里生成 Issue 报告。系统会运行一次诊断测试,将测试结果和你的描述一起复制到剪切板,然后跳转到 GitHub 提交。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -56,7 +52,7 @@ struct BugReportView: View {
|
||||
if isRunning {
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
Text(isRunning ? "正在生成报告…" : "生成 Bug 报告")
|
||||
Text(isRunning ? "正在生成报告…" : "生成 Issue 报告")
|
||||
.font(.body.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -69,40 +65,29 @@ struct BugReportView: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.navigationTitle("报告 Bug")
|
||||
.navigationTitle("报告 Issue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("已生成", isPresented: $showCopiedAlert) {
|
||||
Button("打开 GitHub 表单") {
|
||||
githubDestination = SafariDestination(url: GitHubSubmission.bugReportURL)
|
||||
Button("跳转到 GitHub") {
|
||||
dismiss()
|
||||
if let url = URL(string: "https://github.com/xweiba/location-spoofer/issues/new") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button("稍后再说", role: .cancel) {
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。")
|
||||
}
|
||||
.sheet(item: $githubDestination) { destination in
|
||||
SafariView(url: destination.url)
|
||||
.ignoresSafeArea()
|
||||
Text("Issue 报告已复制到剪切板。请在 GitHub Issues 页面粘贴并提交。")
|
||||
}
|
||||
}
|
||||
|
||||
private func generateReport() {
|
||||
isRunning = true
|
||||
Task {
|
||||
let testLog: String
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let active = response.success && response.latitude != nil && response.longitude != nil
|
||||
testLog = "第三方代理测试模式:模块连接成功;已保存坐标=\(active ? "是" : "否")"
|
||||
} catch {
|
||||
testLog = "第三方代理测试模式:模块连接失败;\(error.localizedDescription)"
|
||||
}
|
||||
} else {
|
||||
_ = await setup.runVerificationTest()
|
||||
testLog = setup.testLog
|
||||
}
|
||||
// 跑测试
|
||||
_ = await setup.runVerificationTest(testLat: 22.543099, testLon: 113.934576)
|
||||
let testLog = setup.testLog
|
||||
|
||||
// 获取版本信息
|
||||
let appVersion: String = {
|
||||
@@ -117,8 +102,6 @@ struct BugReportView: View {
|
||||
### 环境信息
|
||||
App 版本: \(appVersion)
|
||||
系统版本: iOS \(systemVersion)
|
||||
运行模式: \(runtimeMode.mode.displayName)
|
||||
第三方客户端: \(runtimeMode.mode == .thirdParty ? thirdPartyClient.selectedClient.name : "不适用")
|
||||
可复现环境: \(isReproducible ? "是" : "否")
|
||||
|
||||
### 问题描述
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum CertificateInstaller {
|
||||
static func open(url: URL, completion: @escaping (Bool) -> Void) {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: completion)
|
||||
}
|
||||
}
|
||||
@@ -1,214 +1,71 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@StateObject private var setup = SetupCoordinator()
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var remoteConfiguration = AppRemoteConfigurationStore.shared
|
||||
@State private var phase: AppPhase = .splash
|
||||
@State private var updatePrompt: AppUpdatePrompt?
|
||||
@State private var requiredUpdatePrompt: AppUpdatePrompt?
|
||||
|
||||
enum AppPhase { case splash, setup, map }
|
||||
@ObservedObject private var net = NetworkMonitor.shared
|
||||
@State private var showSetup = false
|
||||
@State private var showEnableTip = false
|
||||
@AppStorage("setupCompleted") private var setupCompleted = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .splash:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 48)).foregroundStyle(.blue)
|
||||
ProgressView()
|
||||
Text(runtimeMode.hasSelectedMode && runtimeMode.mode == .localWiFi
|
||||
? "正在初始化地图与本地代理…"
|
||||
: "正在初始化地图…")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
case .setup:
|
||||
FirstSetupView(setup: setup, onComplete: finishInitialSetup)
|
||||
case .map:
|
||||
NavigationView {
|
||||
MapHomeView(setup: setup)
|
||||
}
|
||||
.fullScreenCover(isPresented: $setup.needsSetup) {
|
||||
FirstSetupView(setup: setup, onComplete: finishPresentedSetup)
|
||||
}
|
||||
NavigationView {
|
||||
MapHomeView(setup: setup)
|
||||
}
|
||||
.task {
|
||||
// 首次打开无标记:必须进引导页
|
||||
if !setupCompleted {
|
||||
showSetup = true
|
||||
return
|
||||
}
|
||||
await setup.refreshTrust()
|
||||
}
|
||||
.task { await bootstrap() }
|
||||
.task { await checkForUpdates() }
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
guard newPhase == .active, let requiredUpdatePrompt else { return }
|
||||
updatePrompt = requiredUpdatePrompt
|
||||
}
|
||||
.alert(item: $updatePrompt) { prompt in
|
||||
updateAlert(for: prompt)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func bootstrap() async {
|
||||
guard runtimeMode.hasSelectedMode else {
|
||||
ProxyManager.shared.stop()
|
||||
RuntimeLogger.info("APP", "Startup", "尚未选择运行模式,跳过本地 CA 和代理初始化")
|
||||
setup.requestModeSelection()
|
||||
phase = .setup
|
||||
return
|
||||
}
|
||||
|
||||
let launchMode = runtimeMode.mode
|
||||
guard runtimeMode.isInitialized(launchMode) else {
|
||||
if launchMode == .localWiFi {
|
||||
await setup.prepareLocalServices()
|
||||
setup.requestSetup()
|
||||
.onChange(of: net.isAirplaneMode) { airplane in
|
||||
guard setupCompleted else { return }
|
||||
if airplane {
|
||||
showEnableTip = true
|
||||
} else {
|
||||
ProxyManager.shared.stop()
|
||||
BackgroundKeepAlive.shared.stop()
|
||||
setup.requestThirdPartyOnboarding()
|
||||
showEnableTip = false
|
||||
Task { await setup.refreshTrust() }
|
||||
}
|
||||
phase = .setup
|
||||
return
|
||||
}
|
||||
|
||||
if launchMode == .localWiFi {
|
||||
await setup.prepareLocalServices()
|
||||
} else {
|
||||
ProxyManager.shared.stop()
|
||||
BackgroundKeepAlive.shared.stop()
|
||||
RuntimeLogger.info("APP", "Startup", "第三方代理测试模式:跳过本地 CA、代理和环境检测")
|
||||
.fullScreenCover(isPresented: $showSetup) {
|
||||
FirstSetupView(setup: setup, onComplete: {
|
||||
setupCompleted = true
|
||||
setup.completeSetup()
|
||||
showSetup = false
|
||||
})
|
||||
}
|
||||
do {
|
||||
try CoordinateStorageMigration.migrateIfNeeded(favorites: FavoriteLocationStore())
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "Startup", "旧坐标数据迁移失败,将在下次启动重试", error: error)
|
||||
// 设置页「进入引导页」入口联动
|
||||
.onChange(of: setup.needsSetup) { needs in
|
||||
if needs { showSetup = true }
|
||||
}
|
||||
|
||||
// MapHomeView is intentionally constructed only after this required
|
||||
// coordinate-system gate resolves, so cached pins are never replayed
|
||||
// into an unknown Apple Maps coordinate system.
|
||||
let mapCoordinateSystem = await CoordinateConverter.resolveInitialMapCoordinateSystem()
|
||||
guard !Task.isCancelled else { return }
|
||||
RuntimeLogger.info("APP", "Startup", "地图坐标标准初始化完成,开始后续启动流程", details: [
|
||||
"地图标准": mapCoordinateSystem.rawValue,
|
||||
"使用兜底": String(CoordinateConverter.initialMapCoordinateSystemUsedFallback)
|
||||
])
|
||||
|
||||
// Resolve the first map center before constructing MapHomeView. This
|
||||
// prevents a Shenzhen/cache frame followed by a second realtime frame.
|
||||
if LastCoordinateStore.load() == nil {
|
||||
RuntimeLogger.info("APP", "Startup", "没有持久化图钉,地图创建前请求实时定位")
|
||||
if let realtime = await RealtimeLocationManager.shared.requestLocation() {
|
||||
let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(realtime)
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: realtime.latitude,
|
||||
lon: realtime.longitude,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: 1_000)
|
||||
RuntimeLogger.info("APP", "Startup", "已使用实时定位准备唯一初始地图状态", details: [
|
||||
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
|
||||
"修正兜底标准": String(mapCoordinateSystemChange != nil),
|
||||
"缩放米": "1000"
|
||||
])
|
||||
RealtimeLocationTrace.coordinate(
|
||||
"地图创建前取得的初始实时位置(WGS-84)",
|
||||
coordinate: realtime
|
||||
)
|
||||
} else {
|
||||
RuntimeLogger.warning("APP", "Startup", "地图创建前无法取得实时定位,唯一初始位置使用深圳", details: [
|
||||
"地图标准": mapCoordinateSystem.rawValue,
|
||||
"缩放米": "1000"
|
||||
])
|
||||
}
|
||||
} else {
|
||||
RuntimeLogger.info("APP", "Startup", "已找到持久化图钉,直接准备唯一初始地图状态")
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
setup.completeSetup()
|
||||
RuntimeLogger.info("APP", "Startup", "启动门禁全部完成,现在创建 MapHomeView")
|
||||
phase = .map
|
||||
}
|
||||
|
||||
private func finishInitialSetup() {
|
||||
let completedMode = runtimeMode.mode
|
||||
runtimeMode.markInitialized(completedMode)
|
||||
setup.completeSetup()
|
||||
phase = .splash
|
||||
Task { await bootstrap() }
|
||||
}
|
||||
|
||||
private func finishPresentedSetup() {
|
||||
runtimeMode.markInitialized(runtimeMode.mode)
|
||||
setup.completeSetup()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func checkForUpdates() async {
|
||||
let currentVersion = Bundle.main.object(
|
||||
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||
) as? String ?? AppRemoteConfiguration.fallback.latestVersion
|
||||
let configuration: AppRemoteConfiguration
|
||||
if let remote = await AppRemoteConfigurationService.fetch() {
|
||||
remoteConfiguration.apply(remote)
|
||||
configuration = remote
|
||||
} else {
|
||||
configuration = remoteConfiguration.configuration
|
||||
}
|
||||
guard let pendingPrompt = configuration.updatePrompt(currentVersion: currentVersion) else { return }
|
||||
let releaseNotes = await AppRemoteConfigurationService.fetchReleaseNotes(
|
||||
version: pendingPrompt.latestVersion
|
||||
)
|
||||
guard !Task.isCancelled,
|
||||
let prompt = configuration.updatePrompt(
|
||||
currentVersion: currentVersion,
|
||||
releaseNotes: releaseNotes
|
||||
) else {
|
||||
return
|
||||
}
|
||||
if prompt.requirement == .required {
|
||||
requiredUpdatePrompt = prompt
|
||||
}
|
||||
updatePrompt = prompt
|
||||
}
|
||||
|
||||
private func updateAlert(for prompt: AppUpdatePrompt) -> Alert {
|
||||
switch prompt.requirement {
|
||||
case .required:
|
||||
let details = prompt.releaseNotes
|
||||
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
|
||||
return Alert(
|
||||
title: Text("需要更新"),
|
||||
message: Text(
|
||||
"当前版本 \(prompt.currentVersion) 已停止支持,请更新到 \(prompt.latestVersion) 后继续使用。\n\n\(details)"
|
||||
),
|
||||
dismissButton: .default(Text("立即更新")) {
|
||||
openUpdatePage(requiredPrompt: prompt)
|
||||
.sheet(isPresented: $showEnableTip) {
|
||||
NavigationView {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("飞行模式已开启")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Wi‑Fi 和蜂窝数据已关闭,虚拟定位无法生效。请关闭飞行模式后重试。")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("知道了") {
|
||||
showEnableTip = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
)
|
||||
case .recommended:
|
||||
let details = prompt.releaseNotes
|
||||
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
|
||||
return Alert(
|
||||
title: Text("发现新版本"),
|
||||
message: Text(
|
||||
"当前版本 \(prompt.currentVersion),最新版本 \(prompt.latestVersion)。\n\n\(details)"
|
||||
),
|
||||
primaryButton: .default(Text("前往更新")) {
|
||||
openUpdatePage(requiredPrompt: nil)
|
||||
},
|
||||
secondaryButton: .cancel(Text("稍后"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func openUpdatePage(requiredPrompt: AppUpdatePrompt?) {
|
||||
UIApplication.shared.open(AppRemoteConfigurationService.releasesURL)
|
||||
guard requiredPrompt != nil else { return }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||||
updatePrompt = self.requiredUpdatePrompt
|
||||
.padding(30)
|
||||
.navigationTitle("提示")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("完成") { showEnableTip = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ struct RuntimeLogsView: View {
|
||||
let testFavorite: FavoriteLocation
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var proxy = ProxyManager.shared
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@State private var entries: [RuntimeLogEntry] = []
|
||||
@State private var isTesting = false
|
||||
@State private var testResult = ""
|
||||
@@ -17,48 +15,25 @@ struct RuntimeLogsView: View {
|
||||
@State private var copiedEntryID: UUID?
|
||||
@State private var copyLogsConfirmed = false
|
||||
@State private var testLogCopied = false
|
||||
@State private var logFilter = ""
|
||||
|
||||
private var filteredEntries: [RuntimeLogEntry] {
|
||||
let q = logFilter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !q.isEmpty else { return entries }
|
||||
return entries.filter { $0.message.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
testPanel
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("过滤日志", text: $logFilter)
|
||||
.textFieldStyle(.plain).font(.caption)
|
||||
if !logFilter.isEmpty {
|
||||
Button { logFilter = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.caption)
|
||||
}
|
||||
}
|
||||
}.padding(.horizontal, 12).padding(.vertical, 6)
|
||||
Divider()
|
||||
if filteredEntries.isEmpty {
|
||||
if entries.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
|
||||
Text(entries.isEmpty ? "暂无运行日志" : "无匹配日志").foregroundStyle(.secondary)
|
||||
Text("暂无运行日志").foregroundStyle(.secondary)
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(filteredEntries.reversed()) { entry in logRow(entry) }
|
||||
ForEach(entries.reversed()) { entry in logRow(entry) }
|
||||
}.padding(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Text("日志自动清理,仅保留近 3 天")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } }
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -99,14 +74,10 @@ struct RuntimeLogsView: View {
|
||||
Button {
|
||||
isTesting = true; testResult = ""; testLogCopied = false
|
||||
Task {
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
await runThirdPartyConnectionTest()
|
||||
} else {
|
||||
let result = await setup.runVerificationTest()
|
||||
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
|
||||
if !result.isSuccess { testResult += ",查看下方日志" }
|
||||
testMessage = setup.testLog
|
||||
}
|
||||
let result = await setup.runVerificationTest(testLat: testFavorite.latitude, testLon: testFavorite.longitude)
|
||||
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
|
||||
if !result.isSuccess { testResult += ",查看下方日志" }
|
||||
testMessage = setup.testLog
|
||||
isTesting = false; refresh()
|
||||
}
|
||||
} label: {
|
||||
@@ -116,7 +87,7 @@ struct RuntimeLogsView: View {
|
||||
} else {
|
||||
Image(systemName: "play.fill").font(.system(size: 13, weight: .bold))
|
||||
}
|
||||
Text(isTesting ? "正在检测…" : "环境检测").font(.subheadline.weight(.semibold))
|
||||
Text(isTesting ? "正在测试…" : "虚拟定位测试").font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).opacity(0.5)
|
||||
}
|
||||
@@ -131,9 +102,7 @@ struct RuntimeLogsView: View {
|
||||
.fill(isTesting ? Color.gray : Color.blue)
|
||||
)
|
||||
.disabled(isTesting || actions.state.isBusy)
|
||||
Text(runtimeMode.mode == .thirdParty
|
||||
? "检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。"
|
||||
: "依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。")
|
||||
Text("依次执行:代理 → 证书 → WiFi 代理 → 坐标写入 → 数据改写验证。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !testMessage.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -178,12 +147,10 @@ struct RuntimeLogsView: View {
|
||||
}.frame(maxHeight: 180)
|
||||
}
|
||||
}
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
HStack(spacing: 14) {
|
||||
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
|
||||
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
|
||||
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
if !testResult.isEmpty {
|
||||
Text(testResult).font(.footnote.weight(.medium))
|
||||
.foregroundStyle(testResult.contains("通过") ? .green : .red)
|
||||
@@ -234,28 +201,4 @@ struct RuntimeLogsView: View {
|
||||
}
|
||||
|
||||
private func refresh() { entries = RuntimeLogStore.loadAll() }
|
||||
|
||||
@MainActor
|
||||
private func runThirdPartyConnectionTest() async {
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let active = response.success && response.latitude != nil && response.longitude != nil
|
||||
testResult = active ? "第三方模块连接通过,已有坐标" : "第三方模块连接通过,暂无坐标"
|
||||
testMessage = """
|
||||
======== 第三方代理连接检测 ========
|
||||
模式: 测试模式
|
||||
请求: wloc-settings/save?action=query
|
||||
拦截响应: 有效 JSON
|
||||
已保存坐标: \(active ? "是" : "否")
|
||||
"""
|
||||
} catch {
|
||||
testResult = "第三方模块连接失败"
|
||||
testMessage = """
|
||||
======== 第三方代理连接检测 ========
|
||||
模式: 测试模式
|
||||
请求: wloc-settings/save?action=query
|
||||
结果: \(error.localizedDescription)
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol LocationActionProxying: AnyObject {
|
||||
var isRunning: Bool { get }
|
||||
func start() async throws
|
||||
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64
|
||||
}
|
||||
|
||||
extension ProxyManager: LocationActionProxying {}
|
||||
|
||||
@MainActor
|
||||
protocol LocationActionSettingsStoring: AnyObject {
|
||||
func load() -> WlocSettings?
|
||||
func save(_ settings: WlocSettings)
|
||||
func clear()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DeviceWlocSettingsStorage: LocationActionSettingsStoring {
|
||||
func load() -> WlocSettings? { WlocSettingsStore.load() }
|
||||
func save(_ settings: WlocSettings) { WlocSettingsStore.save(settings) }
|
||||
func clear() { WlocSettingsStore.clear() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LocationActionCoordinator: ObservableObject {
|
||||
@Published private(set) var state: LocationActionState = .idle
|
||||
@Published private(set) var virtualLocationEnabled = false
|
||||
@Published private(set) var message = ""
|
||||
|
||||
private let proxy: any LocationActionProxying
|
||||
private let settings: any LocationActionSettingsStoring
|
||||
private let proxy = ProxyManager.shared
|
||||
|
||||
init() {
|
||||
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
|
||||
self.virtualLocationEnabled = WlocSettingsStore.load()?.enabled == true
|
||||
}
|
||||
|
||||
func apply(_ favorite: FavoriteLocation) async -> Bool {
|
||||
@@ -73,8 +41,8 @@ final class LocationActionCoordinator: ObservableObject {
|
||||
|
||||
func clear() {
|
||||
guard !state.isBusy else { return }
|
||||
_ = proxy.setCoords(lat: 0, lon: 0, enabled: false, accuracy: 25)
|
||||
settings.clear()
|
||||
proxy.setCoords(lat: 0, lon: 0, enabled: false)
|
||||
WlocSettingsStore.clear()
|
||||
state = .idle
|
||||
virtualLocationEnabled = false
|
||||
message = "已恢复真实定位"
|
||||
@@ -88,28 +56,21 @@ final class LocationActionCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
private func commit(_ favorite: FavoriteLocation) -> Bool {
|
||||
// WLOC 合约固定使用持久化的 WGS-84 值,不依赖当前地图地图坐标标准。
|
||||
let wgs = favorite.coordinatePair.wgs84
|
||||
let value = WlocSettings(
|
||||
longitude: wgs.longitude,
|
||||
latitude: wgs.latitude,
|
||||
// MKMapView 在中国地区使用高德瓦片(GCJ-02),返回的坐标是 GCJ-02。
|
||||
// 但 Apple wloc 定位服务使用 WGS-84,因此写入代理前需要转换为 WGS-84。
|
||||
let wgs = CoordinateConverter.gcj02ToWgs84(lat: favorite.latitude, lon: favorite.longitude)
|
||||
WlocSettingsStore.save(WlocSettings(
|
||||
longitude: wgs.lon,
|
||||
latitude: wgs.lat,
|
||||
accuracy: favorite.accuracy,
|
||||
enabled: true
|
||||
)
|
||||
settings.save(value)
|
||||
_ = proxy.setCoords(
|
||||
lat: wgs.latitude,
|
||||
lon: wgs.longitude,
|
||||
))
|
||||
proxy.setCoords(
|
||||
lat: wgs.lat,
|
||||
lon: wgs.lon,
|
||||
enabled: true,
|
||||
accuracy: favorite.accuracy
|
||||
)
|
||||
RuntimeLogger.info("APP", "坐标转换", "设置虚拟定位坐标", details: [
|
||||
"WLOC写入标准": CoordinateConverter.MapCoordinateSystem.wgs84.diagnosticName,
|
||||
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
|
||||
"目标所在区域": CoordinateConverter.usesGCJ02ServiceArea(lat: wgs.latitude, lon: wgs.longitude) ? "国内转换区域" : "国外非转换区域",
|
||||
"取值字段": "coordinatePair.wgs84",
|
||||
"accuracy": String(favorite.accuracy)
|
||||
])
|
||||
state = .idle
|
||||
virtualLocationEnabled = true
|
||||
message = "虚拟定位已开启"
|
||||
|
||||
@@ -131,17 +131,12 @@ final class MapLocationState: ObservableObject {
|
||||
private var nextRealtimeIntentID: UInt64 = 0
|
||||
private var latestRealtimeIntentID: UInt64 = 0
|
||||
|
||||
init(
|
||||
initialCoordinate: CLLocationCoordinate2D,
|
||||
initialViewportMeters: CLLocationDistance = 1_000,
|
||||
initialSource: MapSelectionSource = .initial,
|
||||
initialName: String? = nil
|
||||
) {
|
||||
init(initialCoordinate: CLLocationCoordinate2D, initialViewportMeters: CLLocationDistance = 1_000) {
|
||||
viewportMeters = initialViewportMeters
|
||||
selection = MapSelection(
|
||||
coordinate: initialCoordinate,
|
||||
source: initialSource,
|
||||
explicitName: initialName?.nonEmpty,
|
||||
source: .initial,
|
||||
explicitName: nil,
|
||||
revision: nextSelectionRevision
|
||||
)
|
||||
}
|
||||
@@ -222,13 +217,6 @@ final class MapLocationState: ObservableObject {
|
||||
if coordinate == nil { realtimeLocation = nil }
|
||||
}
|
||||
|
||||
/// Discards a blue-point sample represented in a superseded MapKit
|
||||
/// coordinate system. The next native callback repopulates the cache.
|
||||
func clearRealtimeLocationForMapCoordinateSystemChange() {
|
||||
realtimeLocation = nil
|
||||
realtimeCoordinate = nil
|
||||
}
|
||||
|
||||
func updateExplicitName(_ name: String, forFavoriteID favoriteID: UUID) {
|
||||
guard selection.source == .favorite(favoriteID) else { return }
|
||||
selection = MapSelection(
|
||||
@@ -243,20 +231,6 @@ final class MapLocationState: ObservableObject {
|
||||
viewportMeters = max(50, distanceMeters)
|
||||
}
|
||||
|
||||
/// Updates only the map representation of the current physical selection.
|
||||
/// This preserves revision/source so an in-flight user action is not invalidated.
|
||||
func reprojectSelectionForMapCoordinateSystemChange(_ coordinate: CLLocationCoordinate2D) {
|
||||
guard CLLocationCoordinate2DIsValid(coordinate),
|
||||
!selection.coordinate.isApproximatelyEqual(to: coordinate) else { return }
|
||||
selection = MapSelection(
|
||||
coordinate: coordinate,
|
||||
source: selection.source,
|
||||
explicitName: selection.explicitName,
|
||||
revision: selection.revision
|
||||
)
|
||||
issueCameraCommand(.focus(coordinate: coordinate, distanceMeters: viewportMeters))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
|
||||
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
|
||||
@@ -314,132 +288,45 @@ extension CLLocationCoordinate2D {
|
||||
|
||||
// MARK: - 持久化存储
|
||||
|
||||
struct LastCoordinate: Codable, Equatable {
|
||||
let coordinatePair: CoordinatePair
|
||||
let zoomMeters: CLLocationDistance
|
||||
|
||||
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
|
||||
coordinatePair.coordinate(for: mapCoordinateSystem)
|
||||
}
|
||||
}
|
||||
|
||||
enum LastCoordinateStore {
|
||||
private struct StoredPosition: Codable {
|
||||
let coordinatePair: CoordinatePair
|
||||
let zoomMeters: CLLocationDistance
|
||||
}
|
||||
|
||||
private static let positionKey = "lastMapPositionV1"
|
||||
private static let legacyLatKey = "lastMapLat"
|
||||
private static let legacyLonKey = "lastMapLon"
|
||||
|
||||
static func save(
|
||||
mapCoordinate: CLLocationCoordinate2D,
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
|
||||
zoomMeters: CLLocationDistance,
|
||||
defaults: UserDefaults = AppGroup.defaults
|
||||
) {
|
||||
save(
|
||||
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
|
||||
zoomMeters: zoomMeters,
|
||||
defaults: defaults
|
||||
)
|
||||
}
|
||||
|
||||
static func save(
|
||||
coordinatePair: CoordinatePair,
|
||||
zoomMeters: CLLocationDistance,
|
||||
defaults: UserDefaults = AppGroup.defaults
|
||||
) {
|
||||
do {
|
||||
let value = StoredPosition(coordinatePair: coordinatePair, zoomMeters: max(50, zoomMeters))
|
||||
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
|
||||
ViewportStore.save(value.zoomMeters, defaults: defaults)
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "地图", "保存当前图钉失败", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
static func load(defaults: UserDefaults = AppGroup.defaults) -> LastCoordinate? {
|
||||
if let data = defaults.data(forKey: positionKey),
|
||||
let value = try? JSONDecoder().decode(StoredPosition.self, from: data) {
|
||||
return LastCoordinate(coordinatePair: value.coordinatePair, zoomMeters: value.zoomMeters)
|
||||
}
|
||||
|
||||
let legacyLatitude = defaults.double(forKey: legacyLatKey)
|
||||
let legacyLongitude = defaults.double(forKey: legacyLonKey)
|
||||
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
|
||||
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else {
|
||||
return nil
|
||||
}
|
||||
return LastCoordinate(
|
||||
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
|
||||
zoomMeters: ViewportStore.load(defaults: defaults) ?? 1_000
|
||||
)
|
||||
}
|
||||
|
||||
static func updateZoom(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
|
||||
guard let current = load(defaults: defaults) else { return }
|
||||
save(coordinatePair: current.coordinatePair, zoomMeters: meters, defaults: defaults)
|
||||
}
|
||||
|
||||
static func migrateLegacyCoordinate(
|
||||
defaults: UserDefaults = AppGroup.defaults,
|
||||
legacyDefaults: UserDefaults = .standard
|
||||
) throws {
|
||||
guard defaults.data(forKey: positionKey) == nil else { return }
|
||||
let legacyLatitude = legacyDefaults.double(forKey: legacyLatKey)
|
||||
let legacyLongitude = legacyDefaults.double(forKey: legacyLonKey)
|
||||
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
|
||||
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else { return }
|
||||
let value = StoredPosition(
|
||||
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
|
||||
zoomMeters: ViewportStore.load(defaults: legacyDefaults) ?? 1_000
|
||||
)
|
||||
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum ViewportStore {
|
||||
private static let key = "mapViewportMeters"
|
||||
|
||||
static func save(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
|
||||
let value = max(50, meters)
|
||||
defaults.set(value, forKey: key)
|
||||
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(value)])
|
||||
static func save(_ meters: CLLocationDistance) {
|
||||
UserDefaults.standard.set(meters, forKey: key)
|
||||
}
|
||||
|
||||
static func load(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance? {
|
||||
let value = defaults.double(forKey: key)
|
||||
return value > 0 ? value : nil
|
||||
/// 取持久化缩放值;未存过返回 nil
|
||||
static func load() -> CLLocationDistance? {
|
||||
let v = UserDefaults.standard.double(forKey: key)
|
||||
return v > 0 ? v : nil
|
||||
}
|
||||
|
||||
static func loadOrDefault(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance {
|
||||
if let value = load(defaults: defaults) { return value }
|
||||
/// 取持久化缩放值,取不到返回默认 1km 并立即存储
|
||||
static func loadOrDefault() -> CLLocationDistance {
|
||||
if let v = load() { return v }
|
||||
let fallback: CLLocationDistance = 1_000
|
||||
save(fallback, defaults: defaults)
|
||||
save(fallback)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
enum CoordinateStorageMigration {
|
||||
private static let versionKey = "coordinateStorageMigrationVersion"
|
||||
static let currentVersion = 1
|
||||
struct LastCoordinate {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) }
|
||||
var isValid: Bool { CLLocationCoordinate2DIsValid(coordinate) && (latitude != 0 || longitude != 0) }
|
||||
}
|
||||
|
||||
static func migrateIfNeeded(
|
||||
favorites: FavoriteLocationStore,
|
||||
defaults: UserDefaults = AppGroup.defaults,
|
||||
legacyDefaults: UserDefaults = .standard
|
||||
) throws {
|
||||
guard defaults.integer(forKey: versionKey) < currentVersion else { return }
|
||||
// Pre-migration map pin and viewport values lived in UserDefaults.standard;
|
||||
// favorites already lived in App Group defaults.
|
||||
try LastCoordinateStore.migrateLegacyCoordinate(
|
||||
defaults: defaults,
|
||||
legacyDefaults: legacyDefaults
|
||||
enum LastCoordinateStore {
|
||||
private static let latKey = "lastMapLat"
|
||||
private static let lonKey = "lastMapLon"
|
||||
static func save(lat: Double, lon: Double) {
|
||||
UserDefaults.standard.set(lat, forKey: latKey)
|
||||
UserDefaults.standard.set(lon, forKey: lonKey)
|
||||
}
|
||||
/// 取持久化坐标,未存过或无效返回 nil
|
||||
static func load() -> LastCoordinate? {
|
||||
let c = LastCoordinate(
|
||||
latitude: UserDefaults.standard.double(forKey: latKey),
|
||||
longitude: UserDefaults.standard.double(forKey: lonKey)
|
||||
)
|
||||
try favorites.migrateLegacyCoordinates()
|
||||
defaults.set(currentVersion, forKey: versionKey)
|
||||
RuntimeLogger.info("APP", "坐标转换", "旧坐标数据迁移完成")
|
||||
return c.isValid ? c : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,15 +32,12 @@ enum MapZoomMath {
|
||||
|
||||
struct MapViewRepresentable: UIViewRepresentable {
|
||||
let selection: MapSelection
|
||||
let initialViewportMeters: CLLocationDistance
|
||||
let cameraCommand: MapCameraCommand?
|
||||
let onRealtimeLocationChanged: (CLLocation) -> Void
|
||||
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
|
||||
let onViewportChanged: (CLLocationDistance) -> Void
|
||||
let onMapTap: (CLLocationCoordinate2D) -> Void
|
||||
let onUserZoomChanged: ((CLLocationDistance) -> Void)?
|
||||
var onZoomIn: (() -> Void)?
|
||||
var onZoomOut: (() -> Void)?
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
@@ -48,12 +45,7 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
let map = MKMapView()
|
||||
map.delegate = context.coordinator
|
||||
map.showsUserLocation = true
|
||||
let initialDistance = max(50, initialViewportMeters)
|
||||
RuntimeLogger.info("APP", "地图", "makeUIView", details: [
|
||||
"zoom": String(initialDistance),
|
||||
"初始坐标来源": String(describing: selection.source),
|
||||
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
let initialDistance = ViewportStore.loadOrDefault()
|
||||
map.setRegion(
|
||||
MKCoordinateRegion(
|
||||
center: selection.coordinate,
|
||||
@@ -63,83 +55,10 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
animated: false
|
||||
)
|
||||
|
||||
// Center pin — positioned relative to geographic center, not Auto Layout
|
||||
let pinSize: CGFloat = 38
|
||||
let sizeCfg = UIImage.SymbolConfiguration(pointSize: pinSize, weight: .semibold)
|
||||
let paletteCfg = UIImage.SymbolConfiguration(paletteColors: [.white, .red])
|
||||
let pinImage = UIImage(systemName: "mappin",
|
||||
withConfiguration: sizeCfg.applying(paletteCfg))
|
||||
let pin = UIImageView(image: pinImage)
|
||||
pin.frame = CGRect(x: 0, y: 0, width: pinSize, height: pinSize)
|
||||
pin.isUserInteractionEnabled = false
|
||||
pin.layer.shadowColor = UIColor.black.cgColor
|
||||
pin.layer.shadowOpacity = 0.28
|
||||
pin.layer.shadowRadius = 5
|
||||
pin.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
map.addSubview(pin)
|
||||
context.coordinator.centerPin = pin
|
||||
|
||||
// Zoom controls — UIKit subviews inside MKMapView, move with the map
|
||||
let zoomStack = UIStackView()
|
||||
zoomStack.axis = .vertical
|
||||
zoomStack.spacing = 0
|
||||
zoomStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
zoomStack.alignment = .center
|
||||
zoomStack.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.8)
|
||||
zoomStack.layer.cornerRadius = 13
|
||||
zoomStack.layer.shadowColor = UIColor.black.cgColor
|
||||
zoomStack.layer.shadowOpacity = 0.18
|
||||
zoomStack.layer.shadowRadius = 7
|
||||
zoomStack.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
|
||||
let zoomInBtn = UIButton(type: .system)
|
||||
zoomInBtn.setImage(UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
|
||||
zoomInBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomInTapped), for: .touchUpInside)
|
||||
zoomInBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
zoomInBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
|
||||
let zoomLabel = UILabel()
|
||||
let roundedDesc = UIFont.systemFont(ofSize: 9, weight: .semibold).fontDescriptor.withDesign(.rounded)
|
||||
zoomLabel.font = roundedDesc.flatMap { UIFont(descriptor: $0, size: 9) } ?? .systemFont(ofSize: 9, weight: .semibold)
|
||||
zoomLabel.textColor = .secondaryLabel
|
||||
zoomLabel.textAlignment = .center
|
||||
zoomLabel.adjustsFontSizeToFitWidth = true
|
||||
zoomLabel.minimumScaleFactor = 0.7
|
||||
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: initialDistance)
|
||||
zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
context.coordinator.zoomLabel = zoomLabel
|
||||
|
||||
let zoomOutBtn = UIButton(type: .system)
|
||||
zoomOutBtn.setImage(UIImage(systemName: "minus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
|
||||
zoomOutBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomOutTapped), for: .touchUpInside)
|
||||
zoomOutBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
zoomOutBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
|
||||
let sep1 = UIView(); sep1.translatesAutoresizingMaskIntoConstraints = false
|
||||
sep1.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
|
||||
sep1.backgroundColor = .separator
|
||||
sep1.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
let sep2 = UIView(); sep2.translatesAutoresizingMaskIntoConstraints = false
|
||||
sep2.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
|
||||
sep2.backgroundColor = .separator
|
||||
sep2.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
|
||||
zoomStack.addArrangedSubview(zoomInBtn)
|
||||
zoomStack.addArrangedSubview(sep1)
|
||||
zoomStack.addArrangedSubview(zoomLabel)
|
||||
zoomStack.addArrangedSubview(sep2)
|
||||
zoomStack.addArrangedSubview(zoomOutBtn)
|
||||
map.addSubview(zoomStack)
|
||||
NSLayoutConstraint.activate([
|
||||
zoomStack.leadingAnchor.constraint(equalTo: map.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
||||
zoomStack.topAnchor.constraint(equalTo: map.safeAreaLayoutGuide.topAnchor, constant: 130),
|
||||
])
|
||||
|
||||
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||
tap.cancelsTouchesInView = false
|
||||
map.addGestureRecognizer(tap)
|
||||
context.coordinator.map = map
|
||||
context.coordinator.setupKeyboardObservers()
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -157,48 +76,11 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
private var activeCommandIsZoom = false
|
||||
private var regionChangeWasUserDriven = false
|
||||
private var isPinchZoom = false
|
||||
weak var zoomLabel: UILabel?
|
||||
weak var centerPin: UIImageView?
|
||||
private let pinSize: CGFloat = 38
|
||||
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
|
||||
private var userDotDiameter: CGFloat = 20
|
||||
private var keyboardObserverTokens: [NSObjectProtocol] = []
|
||||
private var lastForwardedRealtimeTimestamp: Date?
|
||||
|
||||
deinit {
|
||||
keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver)
|
||||
}
|
||||
|
||||
@objc func zoomInTapped() { parent.onZoomIn?() }
|
||||
@objc func zoomOutTapped() { parent.onZoomOut?() }
|
||||
|
||||
init(parent: MapViewRepresentable) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
private func updatePinPosition(on mapView: MKMapView) {
|
||||
guard let pin = centerPin else { return }
|
||||
let centerPt = mapView.convert(mapView.centerCoordinate, toPointTo: mapView)
|
||||
// pin 尖在底边,上移自身一半 + 蓝点半径对齐
|
||||
pin.center = CGPoint(
|
||||
x: centerPt.x,
|
||||
y: centerPt.y - pinSize / 2 + 5
|
||||
)
|
||||
}
|
||||
|
||||
func setupKeyboardObservers() {
|
||||
guard keyboardObserverTokens.isEmpty else { return }
|
||||
let nc = NotificationCenter.default
|
||||
keyboardObserverTokens.append(nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self, let map = self.map else { return }
|
||||
self.updatePinPosition(on: map)
|
||||
})
|
||||
keyboardObserverTokens.append(nc.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self, let map = self.map else { return }
|
||||
self.updatePinPosition(on: map)
|
||||
})
|
||||
}
|
||||
|
||||
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
|
||||
guard let command, command.id != lastConsumedCommandID else { return }
|
||||
lastConsumedCommandID = command.id
|
||||
@@ -210,7 +92,8 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
let region: MKCoordinateRegion
|
||||
switch command.kind {
|
||||
case let .focus(coordinate, _):
|
||||
// 保持当前 span 不变,避免正方形 region 在竖屏 inflate
|
||||
// 保持当前 span 不变,只移动中心点,避免 latitudinalMeters
|
||||
// 与 visibleVerticalDistance 之间因屏幕宽高比引入 2x 漂移
|
||||
region = MKCoordinateRegion(center: coordinate, span: map.region.span)
|
||||
case let .zoom(factor):
|
||||
region = MKCoordinateRegion(
|
||||
@@ -228,36 +111,11 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
parent.onMapTap(map.convert(point, toCoordinateFrom: map))
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
|
||||
for view in views where view.annotation is MKUserLocation {
|
||||
// 取蓝点实际大小,隐藏精度圈
|
||||
userDotDiameter = view.bounds.width
|
||||
for sub in view.subviews where sub.bounds.width > userDotDiameter + 4 {
|
||||
sub.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||
guard let location = visibleUserLocationSample(userLocation) else {
|
||||
RuntimeLogger.warning("APP", "实时定位", "MapKit 蓝点更新但 location 为空", details: [
|
||||
"来源": "MKMapView.didUpdate"
|
||||
])
|
||||
return
|
||||
}
|
||||
guard CLLocationCoordinate2DIsValid(location.coordinate) else {
|
||||
RealtimeLocationTrace.log("拒绝 MapKit 蓝点样本:坐标无效", location: location, details: [
|
||||
"来源": "MKMapView.didUpdate"
|
||||
], level: .warning)
|
||||
return
|
||||
}
|
||||
guard location.horizontalAccuracy >= 0 else {
|
||||
RealtimeLocationTrace.log("拒绝 MapKit 蓝点样本:水平精度无效", location: location, details: [
|
||||
"来源": "MKMapView.didUpdate"
|
||||
], level: .warning)
|
||||
return
|
||||
}
|
||||
forwardRealtimeLocation(location)
|
||||
guard let location = userLocation.location,
|
||||
CLLocationCoordinate2DIsValid(location.coordinate),
|
||||
location.horizontalAccuracy >= 0 else { return }
|
||||
parent.onRealtimeLocationChanged(location)
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
|
||||
@@ -285,15 +143,6 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
|
||||
let distance = visibleVerticalDistance(in: mapView)
|
||||
parent.onViewportChanged(distance)
|
||||
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
|
||||
updatePinPosition(on: mapView)
|
||||
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
|
||||
if let ul = visibleUserLocationSample(mapView.userLocation),
|
||||
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
|
||||
if lastForwardedRealtimeTimestamp.map({ ul.timestamp > $0 }) ?? true {
|
||||
forwardRealtimeLocation(ul)
|
||||
}
|
||||
}
|
||||
|
||||
let userZoomed: Bool
|
||||
if activeCameraCommandID != nil {
|
||||
@@ -316,28 +165,6 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
regionChangeWasUserDriven = false
|
||||
}
|
||||
|
||||
private func forwardRealtimeLocation(_ location: CLLocation) {
|
||||
lastForwardedRealtimeTimestamp = location.timestamp
|
||||
parent.onRealtimeLocationChanged(location)
|
||||
}
|
||||
|
||||
/// `MKUserLocation.coordinate` is the coordinate of the blue-point
|
||||
/// annotation in the current map representation. Keep the native
|
||||
/// accuracy/timestamp metadata, but do not replace it with the raw
|
||||
/// Core Location coordinate carried by `location.coordinate`.
|
||||
private func visibleUserLocationSample(_ userLocation: MKUserLocation) -> CLLocation? {
|
||||
guard let native = userLocation.location else { return nil }
|
||||
return CLLocation(
|
||||
coordinate: userLocation.coordinate,
|
||||
altitude: native.altitude,
|
||||
horizontalAccuracy: native.horizontalAccuracy,
|
||||
verticalAccuracy: native.verticalAccuracy,
|
||||
course: native.course,
|
||||
speed: native.speed,
|
||||
timestamp: native.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private func visibleVerticalDistance(in map: MKMapView) -> CLLocationDistance {
|
||||
let centerX = map.bounds.midX
|
||||
let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map)
|
||||
|
||||
@@ -25,9 +25,6 @@ final class ProxyManager: ObservableObject {
|
||||
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
|
||||
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
|
||||
let accuracy = CInt(settings?.accuracy ?? 25)
|
||||
if enabled != 0 {
|
||||
RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位")
|
||||
}
|
||||
let result: UInt = authority.certPEM.withCString { cp in
|
||||
authority.keyPEM.withCString { kp in
|
||||
UInt(wloccore_startproxy(UnsafeMutablePointer(mutating: cp), UnsafeMutablePointer(mutating: kp), CDouble(lat), CDouble(lon), enabled, accuracy))
|
||||
@@ -62,7 +59,8 @@ final class ProxyManager: ObservableObject {
|
||||
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
|
||||
"revision": String(coordinateRevision),
|
||||
"enabled": String(enabled),
|
||||
"accuracy": String(accuracy)
|
||||
"lat": String(lat),
|
||||
"lon": String(lon)
|
||||
])
|
||||
return coordinateRevision
|
||||
}
|
||||
@@ -119,19 +117,15 @@ final class ProxyManager: ObservableObject {
|
||||
return (Double(r.r0), Double(r.r1), r.r2 != 0)
|
||||
}
|
||||
|
||||
func prepareCertificateDownloadURL() async -> URL? {
|
||||
func openCertificateDownload() async {
|
||||
do {
|
||||
if !isRunning { try await start() }
|
||||
guard let url = URL(string: "http://127.0.0.1:8888/cert") else {
|
||||
error = "证书下载地址无效"
|
||||
return nil
|
||||
}
|
||||
error = nil
|
||||
return url
|
||||
// 本地代理直接提供 CA 证书下载
|
||||
guard let url = URL(string: "http://127.0.0.1:8888/cert") else { return }
|
||||
await MainActor.run { UIApplication.shared.open(url) }
|
||||
} catch {
|
||||
self.error = "启动代理失败: \(error.localizedDescription)"
|
||||
RuntimeLogger.error("APP", "Certificate", "准备证书下载失败", error: error)
|
||||
return nil
|
||||
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
private let driver: RealtimeLocationDriving
|
||||
private let oneShotTimeoutNanoseconds: UInt64
|
||||
private let fallbackTimeoutNanoseconds: UInt64
|
||||
private let cacheMaxAge: TimeInterval = 20
|
||||
private let cacheMaxAge: TimeInterval
|
||||
private var nextRequestID: UInt64 = 0
|
||||
private var activeRequest: ActiveRequest?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
@@ -69,11 +69,13 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
init(
|
||||
driver: RealtimeLocationDriving,
|
||||
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.oneShotTimeoutNanoseconds = oneShotTimeoutNanoseconds
|
||||
self.fallbackTimeoutNanoseconds = fallbackTimeoutNanoseconds
|
||||
self.cacheMaxAge = cacheMaxAge
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
super.init()
|
||||
driver.delegate = self
|
||||
@@ -92,31 +94,21 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
|
||||
func requestLocation() async -> CLLocationCoordinate2D? {
|
||||
guard activeRequest == nil else {
|
||||
RuntimeLogger.warning("APP", "实时定位", "忽略重复 CLLocationManager 请求", details: [
|
||||
"活动requestID": activeRequest.map { String($0.id) } ?? "nil",
|
||||
"活动阶段": activeRequest.map { phaseName($0.phase) } ?? "nil"
|
||||
])
|
||||
RuntimeLogger.warning("APP", "定位", "忽略重复实时定位请求")
|
||||
return nil
|
||||
}
|
||||
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求入口", details: [
|
||||
"授权状态": authorizationName(authorizationStatus),
|
||||
"内存缓存存在": String(location != nil),
|
||||
"系统缓存存在": String(driver.location != nil)
|
||||
])
|
||||
guard authorizationStatus != .denied, authorizationStatus != .restricted else {
|
||||
RuntimeLogger.warning("APP", "实时定位", "授权状态不允许定位", details: [
|
||||
"授权状态": authorizationName(authorizationStatus)
|
||||
])
|
||||
return nil
|
||||
}
|
||||
|
||||
if let cached = freshestCachedLocation() {
|
||||
location = cached
|
||||
RealtimeLocationTrace.log("使用 CLLocationManager 新鲜缓存", location: cached, details: [
|
||||
"来源": "manager-memory-or-system",
|
||||
"缓存上限秒": String(Int(cacheMaxAge))
|
||||
RuntimeLogger.info("APP", "定位", "使用系统缓存实时定位", details: [
|
||||
"age": String(format: "%.2f", max(0, -cached.timestamp.timeIntervalSinceNow)),
|
||||
"lat": String(cached.coordinate.latitude),
|
||||
"lon": String(cached.coordinate.longitude)
|
||||
])
|
||||
return cached.coordinate
|
||||
}
|
||||
@@ -124,11 +116,7 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
nextRequestID &+= 1
|
||||
let requestID = nextRequestID
|
||||
isRequesting = true
|
||||
RuntimeLogger.info("APP", "实时定位", "创建 CLLocationManager 请求", details: [
|
||||
"requestID": String(requestID),
|
||||
"授权状态": authorizationName(authorizationStatus),
|
||||
"初始阶段": "awaitingAuthorization"
|
||||
])
|
||||
RuntimeLogger.info("APP", "定位", "请求实时定位…", details: ["requestID": String(requestID)])
|
||||
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
@@ -140,9 +128,6 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
)
|
||||
|
||||
if authorizationStatus == .notDetermined {
|
||||
RuntimeLogger.info("APP", "实时定位", "请求前台定位授权", details: [
|
||||
"requestID": String(requestID)
|
||||
])
|
||||
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
||||
driver.requestWhenInUseAuthorization()
|
||||
} else {
|
||||
@@ -170,11 +155,6 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
MainActor.assumeIsolated {
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
RuntimeLogger.info("APP", "实时定位", "定位授权状态变化", details: [
|
||||
"授权状态": authorizationName(authorizationStatus),
|
||||
"requestID": activeRequest.map { String($0.id) } ?? "nil",
|
||||
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
|
||||
])
|
||||
switch authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
if let request = activeRequest, request.phase == .awaitingAuthorization {
|
||||
@@ -192,27 +172,6 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
MainActor.assumeIsolated {
|
||||
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 返回样本批次", details: [
|
||||
"样本数": String(locations.count),
|
||||
"requestID": activeRequest.map { String($0.id) } ?? "nil",
|
||||
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
|
||||
])
|
||||
for (index, candidate) in locations.enumerated() {
|
||||
let valid = Self.isValid(candidate)
|
||||
let freshEnough = activeRequest?.startedAt.map {
|
||||
candidate.timestamp >= $0.addingTimeInterval(-1)
|
||||
} ?? false
|
||||
RealtimeLocationTrace.log(
|
||||
valid ? "检查 CLLocationManager 样本" : "拒绝 CLLocationManager 样本:坐标或精度无效",
|
||||
location: candidate,
|
||||
details: [
|
||||
"批次索引": String(index),
|
||||
"基础校验通过": String(valid),
|
||||
"满足当前请求时间窗": String(freshEnough)
|
||||
],
|
||||
level: valid ? .info : .warning
|
||||
)
|
||||
}
|
||||
let validLocations = locations.filter(Self.isValid)
|
||||
if let latestValid = validLocations.last {
|
||||
location = latestValid
|
||||
@@ -224,19 +183,8 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
let latest = validLocations.last(where: {
|
||||
$0.timestamp >= startedAt.addingTimeInterval(-1)
|
||||
}) else {
|
||||
if let request = activeRequest, request.phase != .awaitingAuthorization {
|
||||
RuntimeLogger.warning("APP", "实时定位", "本批次没有可完成当前请求的样本", details: [
|
||||
"requestID": String(request.id),
|
||||
"阶段": phaseName(request.phase),
|
||||
"有效样本数": String(validLocations.count)
|
||||
])
|
||||
}
|
||||
return
|
||||
}
|
||||
RealtimeLocationTrace.log("接受 CLLocationManager 样本并完成请求", location: latest, details: [
|
||||
"requestID": String(request.id),
|
||||
"阶段": phaseName(request.phase)
|
||||
])
|
||||
finishRequest(id: request.id, coordinate: latest.coordinate)
|
||||
}
|
||||
}
|
||||
@@ -265,19 +213,8 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
}
|
||||
|
||||
private func freshestCachedLocation(now: Date = Date()) -> CLLocation? {
|
||||
let candidates = [location, driver.location].compactMap { $0 }
|
||||
for candidate in candidates {
|
||||
let valid = Self.isValid(candidate)
|
||||
let age = abs(candidate.timestamp.timeIntervalSince(now))
|
||||
if !valid || age > cacheMaxAge {
|
||||
RealtimeLocationTrace.log("跳过 CLLocationManager 缓存样本", location: candidate, details: [
|
||||
"基础校验通过": String(valid),
|
||||
"缓存时效通过": String(age <= cacheMaxAge),
|
||||
"缓存上限秒": String(Int(cacheMaxAge))
|
||||
], level: .warning)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
[location, driver.location]
|
||||
.compactMap { $0 }
|
||||
.filter(Self.isValid)
|
||||
.filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge }
|
||||
.max(by: { $0.timestamp < $1.timestamp })
|
||||
@@ -323,10 +260,6 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
request.phase = .oneShot
|
||||
request.startedAt = Date()
|
||||
activeRequest = request
|
||||
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 单次定位", details: [
|
||||
"requestID": String(requestID),
|
||||
"超时毫秒": String(oneShotTimeoutNanoseconds / 1_000_000)
|
||||
])
|
||||
scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds)
|
||||
driver.requestLocation()
|
||||
}
|
||||
@@ -337,10 +270,6 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
request.phase == .oneShot else { return }
|
||||
request.phase = .continuousFallback
|
||||
activeRequest = request
|
||||
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 持续定位兜底", details: [
|
||||
"requestID": String(requestID),
|
||||
"超时毫秒": String(fallbackTimeoutNanoseconds / 1_000_000)
|
||||
])
|
||||
driver.startUpdatingLocation()
|
||||
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
||||
}
|
||||
@@ -358,38 +287,15 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
activeRequest = nil
|
||||
isRequesting = false
|
||||
|
||||
if coordinate != nil {
|
||||
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求完成", details: [
|
||||
if let coordinate {
|
||||
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
|
||||
"requestID": String(requestID),
|
||||
"最终阶段": phaseName(request.phase),
|
||||
"有坐标": "true"
|
||||
"lat": String(coordinate.latitude),
|
||||
"lon": String(coordinate.longitude)
|
||||
])
|
||||
} else {
|
||||
RuntimeLogger.warning("APP", "实时定位", "CLLocationManager 请求结束但没有坐标", details: [
|
||||
"requestID": String(requestID),
|
||||
"最终阶段": phaseName(request.phase),
|
||||
"有坐标": "false"
|
||||
])
|
||||
RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)])
|
||||
}
|
||||
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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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) {}
|
||||
}
|
||||
@@ -4,150 +4,67 @@ struct SettingsView: View {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
@ObservedObject var actions: LocationActionCoordinator
|
||||
@ObservedObject private var proxy = ProxyManager.shared
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var activeTip: TipKind?
|
||||
@State private var proxyOperationError = ""
|
||||
@State private var proxyOperationAlertTitle = "代理操作失败"
|
||||
@State private var modeOperationRunning = false
|
||||
@State private var copiedClient: ThirdPartyProxyClient?
|
||||
@State private var showCertificateResetConfirmation = false
|
||||
@State private var githubDestination: SafariDestination?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("运行模式") {
|
||||
Picker("模式", selection: runtimeModeBinding) {
|
||||
ForEach(ProxyRuntimeMode.allCases) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.disabled(modeOperationRunning || actions.state.isBusy || thirdPartyProxy.isRequesting)
|
||||
|
||||
}
|
||||
|
||||
Section("状态") {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
HStack {
|
||||
Label("本机代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
|
||||
Spacer()
|
||||
Toggle("", isOn: proxyBinding).labelsHidden()
|
||||
.tint(.blue)
|
||||
.disabled(actions.state.isBusy)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Label("第三方模块", systemImage: thirdPartyStatusIcon)
|
||||
Spacer()
|
||||
Text(thirdPartyStatusText).foregroundStyle(.secondary)
|
||||
}
|
||||
Button {
|
||||
detectThirdPartyConnection()
|
||||
} label: {
|
||||
if thirdPartyProxy.isRequesting {
|
||||
HStack { ProgressView(); Text("正在检测…") }
|
||||
} else {
|
||||
Label("检测连接", systemImage: "network")
|
||||
}
|
||||
}
|
||||
.disabled(thirdPartyProxy.isRequesting)
|
||||
HStack {
|
||||
Label("代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
|
||||
Spacer()
|
||||
Toggle("", isOn: proxyBinding).labelsHidden()
|
||||
.tint(.blue)
|
||||
}
|
||||
HStack {
|
||||
Label("虚拟定位", systemImage: virtualLocationIsActive ? "location.fill" : "location.slash")
|
||||
Label("虚拟定位", systemImage: actions.virtualLocationEnabled ? "location.fill" : "location.slash")
|
||||
Spacer()
|
||||
Text(virtualLocationStatusText).foregroundStyle(.secondary)
|
||||
Text(actions.virtualLocationEnabled ? "已开启" : "已关闭").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
thirdPartyConfigurationSection
|
||||
} else {
|
||||
Section("说明") {
|
||||
Button {
|
||||
activeTip = .activation
|
||||
} label: {
|
||||
Label("生效说明", systemImage: "checklist")
|
||||
}
|
||||
Button {
|
||||
activeTip = .deactivation
|
||||
} label: {
|
||||
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
|
||||
}
|
||||
Button {
|
||||
activeTip = .removeProxy
|
||||
} label: {
|
||||
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
|
||||
}
|
||||
Section("说明") {
|
||||
Button {
|
||||
activeTip = .activation
|
||||
} label: {
|
||||
Label("生效说明", systemImage: "checklist")
|
||||
}
|
||||
Button {
|
||||
activeTip = .deactivation
|
||||
} label: {
|
||||
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
|
||||
}
|
||||
Button {
|
||||
activeTip = .removeProxy
|
||||
} label: {
|
||||
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Section("工作原理") {
|
||||
Text(workflowDescription)
|
||||
Text("""
|
||||
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
||||
|
||||
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("应用") {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
Button {
|
||||
setup.requestSetup()
|
||||
} label: {
|
||||
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
Button {
|
||||
setup.needsSetup = true
|
||||
} label: {
|
||||
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
valueRow("版本", value: versionText)
|
||||
}
|
||||
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
Section("证书") {
|
||||
Button(role: .destructive) {
|
||||
showCertificateResetConfirmation = true
|
||||
} label: {
|
||||
Label("重置证书", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
.disabled(modeOperationRunning || actions.state.isBusy)
|
||||
|
||||
Text("仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("支持") {
|
||||
NavigationLink {
|
||||
BugReportView(setup: setup)
|
||||
} label: {
|
||||
Label("报告 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")
|
||||
}
|
||||
Label("报告 Issue", systemImage: "ladybug")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,30 +100,6 @@ struct SettingsView: View {
|
||||
.sheet(item: $activeTip) { kind in
|
||||
TipSheetView(kind: kind)
|
||||
}
|
||||
.sheet(item: $githubDestination) { destination in
|
||||
SafariView(url: destination.url)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.alert(proxyOperationAlertTitle, isPresented: Binding(
|
||||
get: { !proxyOperationError.isEmpty },
|
||||
set: { if !$0 { proxyOperationError = "" } }
|
||||
)) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text(proxyOperationError)
|
||||
}
|
||||
.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 {
|
||||
@@ -223,253 +116,11 @@ struct SettingsView: View {
|
||||
Binding(get: { proxy.isRunning }, set: { on in
|
||||
Task {
|
||||
if on {
|
||||
do {
|
||||
try await proxy.start()
|
||||
} catch {
|
||||
proxy.error = error.localizedDescription
|
||||
proxyOperationAlertTitle = "代理操作失败"
|
||||
proxyOperationError = error.localizedDescription
|
||||
}
|
||||
do { try await proxy.start() } catch { proxy.error = error.localizedDescription }
|
||||
} else {
|
||||
if actions.virtualLocationEnabled {
|
||||
actions.clear()
|
||||
RuntimeLogger.info("APP", "Settings", "关闭代理前已同步关闭虚拟定位")
|
||||
}
|
||||
proxy.stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var runtimeModeBinding: Binding<ProxyRuntimeMode> {
|
||||
Binding(
|
||||
get: { runtimeMode.mode },
|
||||
set: { newMode in switchRuntimeMode(to: newMode) }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thirdPartyConfigurationSection: some View {
|
||||
Section("第三方代理配置") {
|
||||
Picker("客户端", selection: Binding(
|
||||
get: { thirdPartyClient.selectedClient },
|
||||
set: { thirdPartyClient.select($0) }
|
||||
)) {
|
||||
ForEach(ThirdPartyProxyClient.allCases) { client in
|
||||
Text(client.name).tag(client)
|
||||
}
|
||||
}
|
||||
|
||||
if let verificationText = thirdPartyClient.selectedClient.verificationText {
|
||||
HStack {
|
||||
Text("验证状态")
|
||||
Spacer()
|
||||
Text(verificationText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = thirdPartyClient.selectedClient.subscriptionURL.absoluteString
|
||||
copiedClient = thirdPartyClient.selectedClient
|
||||
} label: {
|
||||
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
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("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyStatusIcon: String {
|
||||
switch thirdPartyProxy.connectionState {
|
||||
case .unknown: return "questionmark.circle"
|
||||
case .connected: return "checkmark.circle.fill"
|
||||
case .failed: return "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyStatusText: String {
|
||||
switch thirdPartyProxy.connectionState {
|
||||
case .unknown: return "未检测"
|
||||
case .connected(let active): return active ? "已连接,有坐标" : "已连接,无坐标"
|
||||
case .failed: return "连接失败"
|
||||
}
|
||||
}
|
||||
|
||||
private var virtualLocationStatusText: String {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
return actions.virtualLocationEnabled ? "已开启" : "已关闭"
|
||||
}
|
||||
if case .connected(let active) = thirdPartyProxy.connectionState {
|
||||
return active ? "第三方已保存" : "未保存"
|
||||
}
|
||||
return "未知"
|
||||
}
|
||||
|
||||
private var workflowDescription: String {
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
return "App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。"
|
||||
}
|
||||
return """
|
||||
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
||||
|
||||
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
"""
|
||||
}
|
||||
|
||||
private func switchRuntimeMode(to newMode: ProxyRuntimeMode) {
|
||||
guard newMode != runtimeMode.mode, !modeOperationRunning else { return }
|
||||
modeOperationRunning = true
|
||||
Task { @MainActor in
|
||||
defer { modeOperationRunning = false }
|
||||
switch newMode {
|
||||
case .thirdParty:
|
||||
if actions.virtualLocationEnabled { actions.clear() }
|
||||
proxy.stop()
|
||||
setup.completeSetup()
|
||||
runtimeMode.setMode(.thirdParty)
|
||||
if runtimeMode.isInitialized(.thirdParty) {
|
||||
do {
|
||||
_ = try await thirdPartyProxy.query()
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。"
|
||||
} catch {
|
||||
openThirdPartySetup(for: error)
|
||||
}
|
||||
} else {
|
||||
setup.requestThirdPartyOnboarding()
|
||||
dismiss()
|
||||
}
|
||||
case .localWiFi:
|
||||
do {
|
||||
try await thirdPartyProxy.clear()
|
||||
} catch {
|
||||
RuntimeLogger.warning("APP", "Mode", "切换 APP 模式前无法清除第三方坐标", details: [
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
}
|
||||
runtimeMode.setMode(.localWiFi)
|
||||
await setup.prepareLocalServices()
|
||||
if runtimeMode.isInitialized(.localWiFi) {
|
||||
let result = await setup.runVerificationTest()
|
||||
setup.applyVerificationResult(result)
|
||||
if result.isSuccess {
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
setup.requestSetup()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detectThirdPartyConnection() {
|
||||
let client = thirdPartyClient.selectedClient
|
||||
let startedAt = Date()
|
||||
Task { @MainActor in
|
||||
do {
|
||||
_ = try await thirdPartyProxy.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)),
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
]
|
||||
)
|
||||
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 = "证书重置失败"
|
||||
proxyOperationError = setup.message
|
||||
return
|
||||
}
|
||||
setup.requestCertificateSetup()
|
||||
dismiss()
|
||||
} catch {
|
||||
proxyOperationAlertTitle = "证书重置失败"
|
||||
proxyOperationError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
|
||||
guard let url = client.launchURL else { return }
|
||||
UIApplication.shared.open(url, options: [:]) { opened in
|
||||
guard !opened else { return }
|
||||
Task { @MainActor in
|
||||
proxyOperationAlertTitle = "无法打开客户端"
|
||||
proxyOperationError = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var virtualLocationIsActive: Bool {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
return actions.virtualLocationEnabled
|
||||
}
|
||||
if case .connected(let active) = thirdPartyProxy.connectionState {
|
||||
return active
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ final class SetupCoordinator: ObservableObject {
|
||||
@Published var message = ""
|
||||
@Published var isBrowsingWithoutTrust = false
|
||||
@Published var testLog = ""
|
||||
@Published private(set) var lastVerificationResult: VerificationResult?
|
||||
// 启动检测失败才弹引导页;检测通过则保持 false
|
||||
@Published var needsSetup = false
|
||||
@Published private(set) var setupStep: SetupStep = .proxy
|
||||
|
||||
let certificateStore = CertificateAuthorityStore()
|
||||
let proxy = ProxyManager.shared
|
||||
@@ -26,81 +24,79 @@ final class SetupCoordinator: ObservableObject {
|
||||
|
||||
var canModify: Bool { proxy.isRunning && trustState == .trusted }
|
||||
|
||||
/// Local services are prepared before the setup UI so the environment test
|
||||
/// can distinguish Wi-Fi proxy configuration from CA trust failures.
|
||||
@discardableResult
|
||||
func prepareLocalServices() async -> Bool {
|
||||
func refreshTrust() async {
|
||||
trustState = .checking
|
||||
message = "正在检测…"
|
||||
testLog = ""
|
||||
do {
|
||||
_ = try certificateStore.ensure()
|
||||
if !proxy.isRunning { try await proxy.start() }
|
||||
message = ""
|
||||
return true
|
||||
// 强制走代理 POST 到 Apple 定位接口:TLS 成功 = CA 信任
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 5
|
||||
config.timeoutIntervalForResource = 8
|
||||
config.connectionProxyDictionary = [
|
||||
kCFNetworkProxiesHTTPEnable as String: true,
|
||||
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
|
||||
kCFNetworkProxiesHTTPPort as String: 8888,
|
||||
]
|
||||
// 用当前保存的虚拟定位坐标做测试;没有则用默认坐标
|
||||
let saved = WlocSettingsStore.load()
|
||||
let testLat = saved?.latitude ?? 22.543099
|
||||
let testLon = saved?.longitude ?? 113.934576
|
||||
let testAccuracy = saved?.accuracy ?? 25
|
||||
let req = makeWlocRequest()
|
||||
let (_, resp) = try await URLSession(configuration: config).data(for: req)
|
||||
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
// 400 = Apple 拒绝了测试请求体,但 TLS 握手成功 = 证书已信任
|
||||
if status == 0 {
|
||||
trustState = .unavailable
|
||||
message = "代理链路异常,未收到响应"
|
||||
return
|
||||
}
|
||||
// 走同一套改写验证:Go Core 内模拟 Apple 响应并确认坐标被改写
|
||||
let patchResult = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: testAccuracy)
|
||||
if patchResult.hasPrefix("ok:") {
|
||||
trustState = .trusted
|
||||
message = "✓ 定位环境正常(返回 \(status))"
|
||||
} else {
|
||||
trustState = .unavailable
|
||||
message = "定位数据改写验证失败:\(patchResult)"
|
||||
}
|
||||
} catch {
|
||||
message = "本地代理初始化失败:\(error.localizedDescription)"
|
||||
RuntimeLogger.error("APP", "Startup", "本地服务初始化失败", error: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func applyVerificationResult(_ result: VerificationResult) {
|
||||
lastVerificationResult = result
|
||||
switch result {
|
||||
case .success:
|
||||
trustState = .trusted
|
||||
needsSetup = false
|
||||
message = "✓ 定位环境正常"
|
||||
case .certNotTrusted:
|
||||
trustState = .unavailable
|
||||
setupStep = .cert
|
||||
needsSetup = true
|
||||
message = "CA 证书未安装或未信任"
|
||||
case .verificationInProgress, .verificationSuperseded:
|
||||
break
|
||||
default:
|
||||
trustState = .unavailable
|
||||
setupStep = .proxy
|
||||
needsSetup = true
|
||||
message = "Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888"
|
||||
let ns = error as NSError
|
||||
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
|
||||
message = "CA 证书未信任,请去「设置→通用→关于→证书信任设置」开启或重新安装"
|
||||
} else if ns.domain == NSURLErrorDomain && ns.code == -1001 {
|
||||
message = "检测超时,请检查代理是否正常"
|
||||
} else if ns.domain == NSURLErrorDomain && ns.code == -1200 {
|
||||
message = "代理未启动或无法连接"
|
||||
} else {
|
||||
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
|
||||
}
|
||||
}
|
||||
needsSetup = !canModify
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive() {}
|
||||
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
|
||||
func completeSetup() { needsSetup = false }
|
||||
func 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
|
||||
}
|
||||
func requestSetup() { needsSetup = true }
|
||||
|
||||
// MARK: - Step-by-step verification test
|
||||
|
||||
func runVerificationTest() async -> VerificationResult {
|
||||
private func makeWlocRequest() -> URLRequest {
|
||||
var req = URLRequest(url: URL(string: "https://gs-loc.apple.com/clls/wloc")!)
|
||||
req.httpMethod = "POST"
|
||||
req.httpBody = CoreBridge.testWlocRequestData()
|
||||
req.setValue("application/x-protobuf", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("wloc/1.0", forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("application/x-protobuf", forHTTPHeaderField: "Accept")
|
||||
return req
|
||||
}
|
||||
|
||||
func runVerificationTest(testLat: Double = 22.543099, testLon: Double = 113.934576) async -> VerificationResult {
|
||||
guard !isVerificationRunning else { return .verificationInProgress }
|
||||
isVerificationRunning = true
|
||||
defer { isVerificationRunning = false }
|
||||
@@ -111,6 +107,7 @@ final class SetupCoordinator: ObservableObject {
|
||||
log("======== 代理验证测试 ========")
|
||||
log("App 版本: \(appVersion)")
|
||||
log("系统版本: iOS \(UIDevice.current.systemVersion)")
|
||||
log("测试目标: lat=\(testLat), lon=\(testLon)")
|
||||
log("")
|
||||
|
||||
// Step A: Proxy running
|
||||
@@ -129,6 +126,18 @@ final class SetupCoordinator: ObservableObject {
|
||||
}
|
||||
collectProxyLogs(since: stepAStart, to: log)
|
||||
|
||||
// Verification must never overwrite a newer location action.
|
||||
let previousCoordinates = proxy.coordinateSnapshot(
|
||||
accuracy: WlocSettingsStore.load()?.accuracy ?? 25
|
||||
)
|
||||
var verificationCoordinateRevision: UInt64?
|
||||
defer {
|
||||
if let revision = verificationCoordinateRevision {
|
||||
let restored = proxy.restoreCoords(previousCoordinates, ifUnchangedSince: revision)
|
||||
log(restored ? " ↩ 已恢复验证前的代理坐标" : " ↩ 检测到更新位置,跳过旧坐标恢复")
|
||||
}
|
||||
}
|
||||
|
||||
// Step B: Combined CA + WiFi proxy check (single request)
|
||||
log("")
|
||||
log("[步骤 B] 检测证书与 WiFi 代理…")
|
||||
@@ -152,48 +161,63 @@ final class SetupCoordinator: ObservableObject {
|
||||
if body == verifyToken {
|
||||
log(" ✓ 证书已信任,WiFi 代理已配置")
|
||||
} else {
|
||||
log(" ✗ 响应不匹配: HTTP \(statusCode), \(data.count) bytes,WiFi 代理未配置")
|
||||
log(" ✗ 响应不匹配 (HTTP \(statusCode)),WiFi 代理未配置")
|
||||
log(" 收到: \(body.prefix(100))")
|
||||
return .wifiProxyNotConfigured
|
||||
}
|
||||
} catch {
|
||||
let ns = error as NSError
|
||||
let msg = error.localizedDescription
|
||||
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)")
|
||||
if isCertificateTrustError(nsError: ns, message: msg) {
|
||||
log(" TLS/证书校验失败,CA 证书未信任")
|
||||
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
|
||||
log(" TLS 握手被拒,CA 证书未信任")
|
||||
return .certNotTrusted
|
||||
}
|
||||
if msg.contains("TLS") {
|
||||
log(" 包含 TLS → 证书问题")
|
||||
return .certNotTrusted
|
||||
}
|
||||
return .wifiProxyNotConfigured
|
||||
}
|
||||
collectProxyLogs(since: stepBStart, to: log)
|
||||
|
||||
// Step C: Write and verify coordinates
|
||||
log("[步骤 C] 写入测试坐标并验证…")
|
||||
guard let testCoordinateRevision = proxy.setCoordsIfUnchanged(
|
||||
lat: testLat,
|
||||
lon: testLon,
|
||||
enabled: true,
|
||||
accuracy: 25,
|
||||
expectedRevision: previousCoordinates.revision
|
||||
) else {
|
||||
log(" ↪ 检测到更新位置,取消过期验证坐标写入")
|
||||
return .verificationSuperseded
|
||||
}
|
||||
verificationCoordinateRevision = testCoordinateRevision
|
||||
let coords = proxy.getCoords()
|
||||
if coords.enabled && abs(coords.lat - testLat) < 0.001 && abs(coords.lon - testLon) < 0.001 {
|
||||
log(" ✓ 坐标写入成功: lat=\(coords.lat) lon=\(coords.lon)")
|
||||
} else {
|
||||
log(" ✗ 坐标验证失败: enabled=\(coords.enabled) lat=\(coords.lat) lon=\(coords.lon)")
|
||||
return .coordinateWriteFailed("写入后回读不一致")
|
||||
}
|
||||
|
||||
// Step D: verify data rewriting via Go test patch
|
||||
log("[步骤 D] 验证定位数据改写…")
|
||||
let result = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: 25)
|
||||
log(" \(result)")
|
||||
if result.hasPrefix("ok:") {
|
||||
log(" ✓ 定位数据改写验证通过")
|
||||
} else {
|
||||
log(" ✗ 定位数据改写失败")
|
||||
return .patchFailed(result)
|
||||
}
|
||||
|
||||
log("")
|
||||
log("======== 环境检测通过 ✓ ========")
|
||||
return .success
|
||||
}
|
||||
|
||||
/// Classifies TLS trust failures without relying on localized error text alone.
|
||||
private func isCertificateTrustError(nsError: NSError, message: String) -> Bool {
|
||||
if nsError.domain == NSURLErrorDomain {
|
||||
let trustErrorCodes: Set<Int> = [
|
||||
-1200, // secure connection failed
|
||||
-1201, // server certificate has bad date
|
||||
-1202, // server certificate untrusted
|
||||
-1203, // server certificate has unknown root
|
||||
-1204, // server certificate not yet valid
|
||||
-1205, // client certificate rejected
|
||||
-1206, // client certificate required
|
||||
]
|
||||
return trustErrorCodes.contains(nsError.code)
|
||||
}
|
||||
|
||||
let normalized = message.lowercased()
|
||||
return normalized.contains("tls")
|
||||
|| normalized.contains("ssl")
|
||||
|| normalized.contains("certificate")
|
||||
|| normalized.contains("证书")
|
||||
}
|
||||
|
||||
/// 拉取 Go 代理的详细日志(CONNECT/请求/上游响应/改写结果)到 testLog
|
||||
private func collectProxyLogs(since date: Date, to log: (String) -> Void) {
|
||||
CoreBridge.flushLogs(category: "Proxy")
|
||||
|
||||
@@ -4,12 +4,14 @@ enum TipKind: String, Identifiable {
|
||||
case activation = "生效说明"
|
||||
case deactivation = "失效说明"
|
||||
case removeProxy = "关闭 WiFi 代理"
|
||||
case certificate = "证书问题"
|
||||
case proxySetup = "配置代理"
|
||||
case rewriteFailed = "改写失败"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct TipSheetView: View {
|
||||
let kind: TipKind
|
||||
var runtimeMode: ProxyRuntimeMode = .localWiFi
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -17,9 +19,12 @@ struct TipSheetView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
switch kind {
|
||||
case .activation: ActivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
|
||||
case .deactivation: DeactivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
|
||||
case .activation: ActivationTipContent(dismiss: { dismiss() })
|
||||
case .deactivation: DeactivationTipContent(dismiss: { dismiss() })
|
||||
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
|
||||
case .certificate: CertificateTipContent(dismiss: { dismiss() })
|
||||
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
|
||||
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
|
||||
}
|
||||
}.padding(16)
|
||||
}
|
||||
@@ -48,34 +53,37 @@ private func openSettings(_ destination: SystemSettingsDestination) {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 共享组件
|
||||
|
||||
private struct TipCloseButton: View {
|
||||
let action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 生效说明
|
||||
|
||||
struct ActivationTipContent: View {
|
||||
var runtimeMode: ProxyRuntimeMode = .localWiFi
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("让虚拟定位生效", systemImage: "checklist")) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if runtimeMode == .thirdParty {
|
||||
step(0, "确认第三方代理已开启", "保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。")
|
||||
}
|
||||
step(1, "开启飞行模式", "从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。")
|
||||
step(2, "关闭 Wi‑Fi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。")
|
||||
systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。")
|
||||
step(4, "打开 Wi‑Fi,启动虚拟定位", runtimeMode == .thirdParty ? "从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" : "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
|
||||
step(4, "打开 Wi‑Fi,启动虚拟定位", "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
|
||||
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
|
||||
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。")
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
GroupBox(label: Label("还是无法生效?", systemImage: "exclamationmark.triangle")) {
|
||||
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
|
||||
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -111,7 +119,6 @@ struct ActivationTipContent: View {
|
||||
// MARK: - 失效说明
|
||||
|
||||
struct DeactivationTipContent: View {
|
||||
var runtimeMode: ProxyRuntimeMode = .localWiFi
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -120,23 +127,14 @@ struct DeactivationTipContent: View {
|
||||
step(1, "开启飞行模式", "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。")
|
||||
step(2, "关闭 Wi‑Fi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
|
||||
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
|
||||
if runtimeMode == .thirdParty {
|
||||
step(4, "确认坐标已清除", "App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。")
|
||||
} else {
|
||||
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
|
||||
}
|
||||
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
|
||||
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
|
||||
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。")
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
GroupBox(label: Label("还是无法取消?", systemImage: "exclamationmark.triangle")) {
|
||||
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
|
||||
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。").font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -186,3 +184,68 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,18 +41,6 @@ func wloccore_generateca() (r0, r1 *C.char) {
|
||||
return C.CString(string(cert)), C.CString(string(key))
|
||||
}
|
||||
|
||||
//export wloccore_validateca
|
||||
func wloccore_validateca(certData, keyData *C.char) C.int {
|
||||
if certData == nil || keyData == nil {
|
||||
return 0
|
||||
}
|
||||
if _, err := parseCA([]byte(C.GoString(certData)), []byte(C.GoString(keyData))); err != nil {
|
||||
logEvent("validateca failed: " + err.Error())
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
//export wloccore_startproxy
|
||||
func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t {
|
||||
if certData == nil || keyData == nil {
|
||||
@@ -76,12 +64,13 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
|
||||
//export wloccore_stopproxy
|
||||
func wloccore_stopproxy(h C.uintptr_t) C.int {
|
||||
logEvent("stopproxy requested")
|
||||
srv, handle, ok := proxyForHandle(h)
|
||||
handle := cgo.Handle(h)
|
||||
srv, ok := handle.Value().(*http.Server)
|
||||
handle.Delete()
|
||||
if !ok {
|
||||
logEvent("stopproxy failed: invalid handle")
|
||||
return 1
|
||||
}
|
||||
handle.Delete()
|
||||
if err := stopProxy(srv); err != nil {
|
||||
logEvent("stopproxy failed: " + err.Error())
|
||||
return 2
|
||||
@@ -90,20 +79,6 @@ func wloccore_stopproxy(h C.uintptr_t) C.int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func proxyForHandle(h C.uintptr_t) (server *http.Server, handle cgo.Handle, ok bool) {
|
||||
if h == 0 {
|
||||
return nil, 0, false
|
||||
}
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
server, handle, ok = nil, 0, false
|
||||
}
|
||||
}()
|
||||
handle = cgo.Handle(h)
|
||||
server, ok = handle.Value().(*http.Server)
|
||||
return server, handle, ok
|
||||
}
|
||||
|
||||
//export wloccore_setcoords
|
||||
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
|
||||
stateMu.Lock()
|
||||
@@ -112,7 +87,7 @@ func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
|
||||
currentEnabled = enabled != 0
|
||||
currentAccuracy = int(accuracy)
|
||||
stateMu.Unlock()
|
||||
logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " accuracy=" + strconv.Itoa(int(accuracy)))
|
||||
logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " lat=" + strconv.FormatFloat(float64(lat), 'f', 6, 64) + " lon=" + strconv.FormatFloat(float64(lon), 'f', 6, 64) + " accuracy=" + strconv.Itoa(int(accuracy)))
|
||||
}
|
||||
|
||||
//export wloccore_getcoords
|
||||
@@ -152,17 +127,12 @@ func wloccore_startcertserver(certData, keyData *C.char) C.uintptr_t {
|
||||
return C.uintptr_t(cgo.NewHandle(server))
|
||||
}
|
||||
|
||||
func certificateServerForHandle(h C.uintptr_t) (server *certificateServer, handle cgo.Handle, ok bool) {
|
||||
func certificateServerForHandle(h C.uintptr_t) (*certificateServer, cgo.Handle, 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().(*certificateServer)
|
||||
handle := cgo.Handle(h)
|
||||
server, ok := handle.Value().(*certificateServer)
|
||||
return server, handle, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
func randomSerialNumber() (*big.Int, error) {
|
||||
// Keep the serial positive and within the RFC 5280 recommended 20-octet bound.
|
||||
limit := new(big.Int).Lsh(big.NewInt(1), 159)
|
||||
return rand.Int(rand.Reader, limit)
|
||||
func deterministicReader() io.Reader {
|
||||
seed := sha256.Sum256([]byte("paopao-location-spoofer-ca-v1"))
|
||||
src := rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8])))
|
||||
return rand.New(src)
|
||||
}
|
||||
|
||||
func generateCA() (certPEM, keyPEM []byte, err error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
serialNumber, err := randomSerialNumber()
|
||||
rng := deterministicReader()
|
||||
privateKey, err := rsa.GenerateKey(rng, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Location Spoofer"},
|
||||
CommonName: "Location Spoofer CA",
|
||||
Organization: []string{"WLOC"},
|
||||
CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"),
|
||||
},
|
||||
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
NotAfter: time.Date(2045, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
@@ -42,7 +41,7 @@ func generateCA() (certPEM, keyPEM []byte, err error) {
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
certDER, err := x509.CreateCertificate(rng, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -15,45 +15,6 @@ func TestGenerateCA(t *testing.T) {
|
||||
if len(certPEM) == 0 || len(keyPEM) == 0 {
|
||||
t.Fatal("empty CA output")
|
||||
}
|
||||
cert := parseCertificatePEM(t, certPEM)
|
||||
if !cert.IsCA {
|
||||
t.Fatal("certificate is not a CA")
|
||||
}
|
||||
if cert.Subject.CommonName != "Location Spoofer CA" {
|
||||
t.Fatalf("unexpected CA common name: %q", cert.Subject.CommonName)
|
||||
}
|
||||
if len(cert.Subject.Organization) != 1 || cert.Subject.Organization[0] != "Location Spoofer" {
|
||||
t.Fatalf("unexpected CA organization: %v", cert.Subject.Organization)
|
||||
}
|
||||
if time.Until(cert.NotAfter).Hours() < 360*24 {
|
||||
t.Fatal("CA validity is too short")
|
||||
}
|
||||
if _, err := parseCA(certPEM, keyPEM); err != nil {
|
||||
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")
|
||||
@@ -62,5 +23,13 @@ func parseCertificatePEM(t *testing.T, certPEM []byte) *x509.Certificate {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cert
|
||||
if !cert.IsCA {
|
||||
t.Fatal("certificate is not a CA")
|
||||
}
|
||||
if time.Until(cert.NotAfter).Hours() < 360*24 {
|
||||
t.Fatal("CA validity is too short")
|
||||
}
|
||||
if _, err := parseCA(certPEM, keyPEM); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -94,10 +95,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
||||
}
|
||||
if r.URL.Path == "/coords" {
|
||||
stateMu.Lock()
|
||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||
enabled, lat, lon := currentEnabled, currentLat, currentLon
|
||||
stateMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, accuracy)))
|
||||
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, currentAccuracy)))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
|
||||
@@ -145,16 +146,20 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
||||
logEvent("CONNECT " + host + " -> MITM (verify)")
|
||||
return mitmAction, host
|
||||
}
|
||||
// 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.
|
||||
logEvent("CONNECT " + host + " -> passthrough")
|
||||
return goproxy.OkConnect, host
|
||||
})
|
||||
}
|
||||
|
||||
proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||
// Do not buffer or log arbitrary global-proxy traffic. Keep requests streaming
|
||||
// and do not persist unrelated request content in diagnostics.
|
||||
body := []byte(nil)
|
||||
if req.Body != nil {
|
||||
body, _ = io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
logEvent(fmt.Sprintf("proxy request target=%s method=%s path=%s size=%d body_prefix=%s",
|
||||
req.Host, req.Method, req.URL.Path, len(body), hexPrefix(body, 64)))
|
||||
return serveLocalRequests(req, ctx)
|
||||
})
|
||||
proxy.OnResponse().DoFunc(patchWlocResponse)
|
||||
@@ -171,7 +176,7 @@ func serveLocalRequests(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request
|
||||
}
|
||||
if (h == "baidu.com" || h == "www.baidu.com") && strings.HasPrefix(req.URL.Path, "/paopao-verify-") {
|
||||
token := strings.TrimPrefix(req.URL.Path, "/paopao-verify-")
|
||||
logEvent("verify request received")
|
||||
logEvent("verify request path=" + req.URL.Path + " token=" + token)
|
||||
if checkVerifyToken(token) {
|
||||
resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token)
|
||||
resp.Header.Set("Cache-Control", "no-store")
|
||||
@@ -224,36 +229,41 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||
stateMu.Unlock()
|
||||
|
||||
const maxPatchBodyBytes int64 = 1 << 20
|
||||
if resp.ContentLength > maxPatchBodyBytes {
|
||||
logEvent(fmt.Sprintf("wloc response passed through: body exceeds patch limit (%d bytes)", resp.ContentLength))
|
||||
return resp
|
||||
}
|
||||
|
||||
originalBody := resp.Body
|
||||
body, err := io.ReadAll(io.LimitReader(originalBody, maxPatchBodyBytes+1))
|
||||
body, err := io.ReadAll(originalBody)
|
||||
originalBody.Close()
|
||||
if err != nil {
|
||||
logEvent("wloc response read failed: " + err.Error())
|
||||
resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), originalBody))
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
if int64(len(body)) > maxPatchBodyBytes {
|
||||
logEvent("wloc response passed through: body exceeds patch limit")
|
||||
resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), originalBody))
|
||||
return resp
|
||||
}
|
||||
originalBody.Close()
|
||||
logEvent(fmt.Sprintf("wloc upstream response status=%d size=%d headers=[%s] body_prefix=%s",
|
||||
resp.StatusCode, len(body), summarizeHeaders(resp.Header), hexPrefix(body, 64)))
|
||||
|
||||
if !enabled || resp.StatusCode != http.StatusOK || len(body) == 0 {
|
||||
if !enabled {
|
||||
logEvent("wloc upstream response passed through (spoofing disabled)")
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logEvent(fmt.Sprintf("wloc upstream response passed through (status %d != 200)", resp.StatusCode))
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
if len(body) == 0 {
|
||||
logEvent("wloc upstream response empty, passed through")
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
|
||||
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy})
|
||||
if err != nil || bytes.Equal(patched, body) {
|
||||
if err != nil {
|
||||
logEvent("wloc patch skipped: " + err.Error())
|
||||
}
|
||||
if err != nil {
|
||||
logEvent("wloc patch skipped: " + err.Error())
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
if bytes.Equal(patched, body) {
|
||||
logEvent("wloc patch produced identical body, passed through")
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
@@ -263,10 +273,30 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
||||
logEvent(fmt.Sprintf("wloc patched locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d", stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched)))
|
||||
logEvent(fmt.Sprintf("wloc patched target=%.6f,%.6f accuracy=%d locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d body_prefix=%s",
|
||||
lat, lon, accuracy, stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched), hexPrefix(patched, 64)))
|
||||
return resp
|
||||
}
|
||||
|
||||
func hexPrefix(b []byte, n int) string {
|
||||
if len(b) > n {
|
||||
b = b[:n]
|
||||
}
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
func summarizeHeaders(h http.Header) string {
|
||||
if len(h) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(h))
|
||||
for k, v := range h {
|
||||
parts = append(parts, k+"="+strings.Join(v, ","))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func generateProxyMobileConfig() string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
||||
@@ -4,13 +4,7 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -114,109 +108,3 @@ func TestTransparentBodyUnchanged(t *testing.T) {
|
||||
t.Fatal("expected non-patchable body to error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchWlocResponsePassesThroughOversizedBody(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("x"), (1<<20)+1)
|
||||
req := httptest.NewRequest(http.MethodPost, "https://gs-loc.apple.com/clls/wloc", nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Request: req,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||
ContentLength: int64(len(payload)),
|
||||
}
|
||||
|
||||
patched := patchWlocResponse(resp, nil)
|
||||
got, err := io.ReadAll(patched.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatal("oversized WLOC response was changed or truncated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeLocalRequestsKeepsUnrelatedRequestBodyStreaming(t *testing.T) {
|
||||
const secret = "body-must-not-be-buffered-or-logged"
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.com/upload", bytes.NewBufferString(secret))
|
||||
returned, response := serveLocalRequests(req, nil)
|
||||
if response != nil {
|
||||
t.Fatalf("unexpected local response: %d", response.StatusCode)
|
||||
}
|
||||
got, err := io.ReadAll(returned.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != secret {
|
||||
t.Fatalf("request body changed: got %q", got)
|
||||
}
|
||||
if logs := drainLogs(); bytes.Contains([]byte(logs), []byte(secret)) {
|
||||
t.Fatal("request body leaked into diagnostic logs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordsEndpointReturnsAtomicSnapshot(t *testing.T) {
|
||||
stateMu.Lock()
|
||||
previousLat, previousLon := currentLat, currentLon
|
||||
previousEnabled, previousAccuracy := currentEnabled, currentAccuracy
|
||||
currentLat, currentLon, currentEnabled, currentAccuracy = 0, 0, false, 0
|
||||
stateMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stateMu.Lock()
|
||||
currentLat, currentLon = previousLat, previousLon
|
||||
currentEnabled, currentAccuracy = previousEnabled, previousAccuracy
|
||||
stateMu.Unlock()
|
||||
})
|
||||
|
||||
handler := newProxy(nil).NonproxyHandler
|
||||
const updates = 20_000
|
||||
const readers = 8
|
||||
const readsPerReader = 2_500
|
||||
|
||||
var writers sync.WaitGroup
|
||||
writers.Add(1)
|
||||
go func() {
|
||||
defer writers.Done()
|
||||
for i := 1; i <= updates; i++ {
|
||||
stateMu.Lock()
|
||||
currentLat = float64(i)
|
||||
currentLon = -float64(i)
|
||||
currentEnabled = i%2 == 0
|
||||
currentAccuracy = i
|
||||
stateMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
errs := make(chan error, readers)
|
||||
var readersGroup sync.WaitGroup
|
||||
for range readers {
|
||||
readersGroup.Add(1)
|
||||
go func() {
|
||||
defer readersGroup.Done()
|
||||
for i := 0; i < readsPerReader; i++ {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://proxy.local/coords", nil))
|
||||
var snapshot struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Accuracy int `json:"accuracy"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &snapshot); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if snapshot.Lat != 0 && (snapshot.Lon != -snapshot.Lat || snapshot.Accuracy != int(snapshot.Lat)) {
|
||||
errs <- fmt.Errorf("torn coordinate snapshot: %+v", snapshot)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
readersGroup.Wait()
|
||||
writers.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,451 +2,162 @@
|
||||
|
||||
# 📍 Location Spoofer
|
||||
|
||||
### iOS Location Service Research & Testing Framework
|
||||
### iOS Location Spoofer · DingTalk · WeChat · Apple Watch Region Unlock · Fake GPS
|
||||
|
||||
An open-source project for **iOS location-service research, software development testing, and QA validation**.
|
||||
|
||||
The project uses either an on-device proxy or a third-party proxy client to simulate selected Apple location-service
|
||||
responses in a controlled test environment.
|
||||
**No VPN, no jailbreak — run a local HTTP proxy on your iPhone to rewrite Apple location responses.**<br>
|
||||
Works with DingTalk check-in, WeChat location sharing, and any app that uses system location. Map selection, real-time location, environment verification, certificate setup, and runtime logs in a single app.
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#why-no-vpn)
|
||||
|
||||
[Features](#feature-overview) ·
|
||||
[How It Works](#how-it-works) ·
|
||||
[Quick Start](#quick-start) ·
|
||||
[Build](#building-the-project) ·
|
||||
[中文](README.md)
|
||||
[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md)
|
||||
|
||||
<img src="images/主界面.jpg" alt="Location Spoofer iOS Fake GPS main interface" width="380">
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
## Project Scope
|
||||
## Credits
|
||||
|
||||
Location Spoofer is a tool for studying iOS location-service behavior and testing location-dependent software.
|
||||
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.
|
||||
|
||||
It provides:
|
||||
## Why Location Spoofer?
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
The project does not modify the target app's source code and does not provide telemetry, remote control, or data
|
||||
collection services.
|
||||
| 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. |
|
||||
|
||||
## Feature Overview
|
||||
## Screenshots
|
||||
|
||||
- **Native map interaction**
|
||||
- Uses MapKit for the map and system blue dot;
|
||||
- Supports search, map taps, center-point dragging, and zooming;
|
||||
- Supports favorites and restoration of the latest selection;
|
||||
- Shows both domestic and international coordinate representations with separate copy actions.
|
||||
| Main Interface | Apple Maps | Amap | Apple Watch |
|
||||
|---|---|---|---|
|
||||
|  |  |  |  |
|
||||
|
||||
- **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.
|
||||
## Key Features
|
||||
|
||||
- **Two runtime modes**
|
||||
- App Mode: runs the Go proxy on-device and covers only the current Wi-Fi network;
|
||||
- Third-party Proxy Mode: uses a supported proxy client and may cover Wi-Fi, 4G, or 5G depending on that client.
|
||||
|
||||
- **Environment checks**
|
||||
- App Mode checks the local proxy, CA trust, and request path;
|
||||
- Third-party Proxy Mode checks the WLOC configuration API and module response;
|
||||
- Failures route to the relevant setup or diagnostics screen.
|
||||
|
||||
- **Development diagnostics**
|
||||
- Runtime logs;
|
||||
- Log copy and cleanup;
|
||||
- Map coordinate-system change records;
|
||||
- Sanitized issue-report generation.
|
||||
|
||||
## How It Works
|
||||
|
||||
### App Mode
|
||||
|
||||
App Mode runs the local Go proxy inside the app. A manual HTTP proxy on the current Wi-Fi routes the selected requests
|
||||
through that on-device proxy.
|
||||
|
||||
```text
|
||||
iOS location request
|
||||
│
|
||||
│ Manual HTTP proxy on the current Wi-Fi
|
||||
▼
|
||||
On-device wloccore Go proxy
|
||||
│
|
||||
│ Handle selected Apple location-service requests
|
||||
▼
|
||||
Apple location-service response
|
||||
│
|
||||
│ Test coordinate response
|
||||
▼
|
||||
The system and apps read the location result
|
||||
```
|
||||
|
||||
App Mode:
|
||||
|
||||
- Does not create a Network Extension;
|
||||
- Does not display or occupy the system VPN slot;
|
||||
- Covers only the current Wi-Fi network;
|
||||
- Requires a manual HTTP proxy on that Wi-Fi network;
|
||||
- Requires installation and trust of the CA generated by the app;
|
||||
- Handles only the Apple location-service and environment-verification traffic defined by the project. It is not a
|
||||
general-purpose packet capture tool.
|
||||
|
||||
### Third-party Proxy Mode
|
||||
|
||||
Third-party Proxy Mode does not start the app's Go proxy and does not use the CA generated by the app.
|
||||
|
||||
```text
|
||||
Map selection
|
||||
│
|
||||
│ WGS-84 coordinates
|
||||
▼
|
||||
WLOC configuration API
|
||||
│
|
||||
▼
|
||||
Third-party proxy client stores the configuration
|
||||
│
|
||||
▼
|
||||
Third-party client processes location-service requests
|
||||
```
|
||||
|
||||
In this mode:
|
||||
|
||||
- The app owns map selection, favorites, coordinate synchronization, and coordinate clearing;
|
||||
- The third-party client owns proxy/VPN, MITM, certificates, and rule execution;
|
||||
- The third-party client owns coordinate persistence;
|
||||
- Wi-Fi, 4G, and 5G support depends on the client;
|
||||
- The configuration may remain active after Location Spoofer closes.
|
||||
|
||||
#### Configuration API and Client Integration
|
||||
|
||||
The app does not upload coordinates to a project server. It sends the following GET request, which the third-party
|
||||
client must intercept locally on the device:
|
||||
|
||||
```text
|
||||
https://gs-loc.apple.com/wloc-settings/save
|
||||
```
|
||||
|
||||
| Action | Query parameters | Purpose |
|
||||
|---|---|---|
|
||||
| Query | `action=query` | Verify module connectivity and read the stored coordinate |
|
||||
| Save | `lon=<WGS-84 longitude>&lat=<WGS-84 latitude>&acc=<accuracy>` | Store the selected coordinate |
|
||||
| Clear | `action=clear` | Remove the stored test coordinate |
|
||||
|
||||
The interception script must return HTTP 200 with JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"longitude": 113.0,
|
||||
"latitude": 22.0,
|
||||
"accuracy": 25
|
||||
}
|
||||
```
|
||||
|
||||
Failures use:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error description"
|
||||
}
|
||||
```
|
||||
|
||||
When no coordinate is stored, a query may return `{"success":false,"error":"无已保存的坐标"}`. The app interprets
|
||||
that response as “module connected, virtual location inactive.” A successful save response must echo the requested
|
||||
WGS-84 longitude and latitude.
|
||||
|
||||
To integrate another third-party client:
|
||||
|
||||
1. Add an HTTP request script for `gs-loc.apple.com/wloc-settings/save` that parses the parameters and returns the
|
||||
specified JSON;
|
||||
2. Store coordinates, accuracy, and optional state in the client's persistent storage;
|
||||
3. Enable HTTPS decryption for `gs-loc.apple.com` and `gs-loc-cn.apple.com`;
|
||||
4. Intercept `gs-loc(-cn).apple.com/clls/wloc` responses, read the same persistent data, and modify the WLOC response;
|
||||
5. Publish an importable module and verify query, save, clear, and location restoration on a real device.
|
||||
|
||||
The app validates only the configuration endpoint's HTTP status, JSON shape, and coordinate round trip. It does not
|
||||
manage the third-party client's certificate, MITM, VPN, or proxy state.
|
||||
|
||||
Do not enable App Mode interception and Third-party Proxy Mode interception at the same time.
|
||||
|
||||
## Runtime Modes
|
||||
|
||||
### App Mode
|
||||
|
||||
Suitable for:
|
||||
|
||||
- Wi-Fi-only device testing;
|
||||
- Local testing without a third-party proxy client;
|
||||
- Workflows that need in-app proxy, certificate, and environment guidance.
|
||||
|
||||
Requirements:
|
||||
|
||||
- An iOS device;
|
||||
- A Wi-Fi network that permits manual HTTP proxy configuration;
|
||||
- Installation and trust of the local CA;
|
||||
- Completion of the in-app proxy and environment checks.
|
||||
|
||||
### Third-party Proxy Mode
|
||||
|
||||
Suitable for:
|
||||
|
||||
- Tests that need Wi-Fi, 4G, or 5G coverage;
|
||||
- Existing supported proxy-client workflows;
|
||||
- Cases where the third-party client should keep the proxy configuration active.
|
||||
|
||||
Current client status:
|
||||
|
||||
| Client | Status | Community configuration |
|
||||
|---|---|---|
|
||||
| Shadowrocket | Currently used for on-device testing | Built-in App guide |
|
||||
| Surge | Configuration provided, not fully verified | Contributions wanted |
|
||||
| Quantumult X | Configuration provided, not fully verified | Contributions wanted |
|
||||
| Loon | Configuration provided, not fully verified | Contributions wanted |
|
||||
| Stash | Configuration provided, not fully verified | Contributions wanted |
|
||||
| Egern | Uses the Surge module, not fully verified | Contributions wanted |
|
||||
|
||||
Community configurations are reviewed by client. Accepted submissions are linked in this table with attribution unless
|
||||
the contributor requests anonymous inclusion.
|
||||
|
||||
Module snapshots and provenance:
|
||||
|
||||
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
The selected client owns its certificates, MITM configuration, and proxy switches. Review third-party modules and
|
||||
scripts before importing them.
|
||||
- **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
|
||||
|
||||
You can use:
|
||||
- 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).
|
||||
|
||||
- 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).
|
||||
Detailed steps in the [self-signing guide](docs/SELF-SIGNING.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.
|
||||
### 2. Install & Trust the CA
|
||||
|
||||
#### 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
|
||||
Follow the first-setup wizard to download the profile, then:
|
||||
|
||||
```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
|
||||
Settings → General → VPN & Device Management → install WLOC CA
|
||||
Settings → General → About → Certificate Trust Settings → enable full trust
|
||||
```
|
||||
|
||||
## Building the Project
|
||||
### 3. Configure the Current Wi‑Fi Proxy
|
||||
|
||||
Source builds require:
|
||||
On the current Wi‑Fi's proxy settings, choose "Manual":
|
||||
|
||||
- macOS;
|
||||
- Xcode;
|
||||
- Xcode Command Line Tools;
|
||||
- XcodeGen;
|
||||
- Go 1.23 or newer.
|
||||
```text
|
||||
Server: 127.0.0.1
|
||||
Port: 8888
|
||||
Authentication: off
|
||||
```
|
||||
|
||||
Building the iOS app directly on Windows is not supported.
|
||||
### 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
|
||||
git clone https://github.com/xweiba/location-spoofer.git
|
||||
cd location-spoofer
|
||||
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Build and run Simulator tests:
|
||||
|
||||
```bash
|
||||
./build.sh --test
|
||||
```
|
||||
|
||||
The build script generates an unsigned IPA:
|
||||
The unsigned IPA is at:
|
||||
|
||||
```text
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
Deploy it to a test device using your own signing and installation process.
|
||||
```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
|
||||
```
|
||||
|
||||
## Privacy and Security Boundaries
|
||||
|
||||
- The project contains no telemetry or remote-control service;
|
||||
- The project does not automatically upload location data;
|
||||
- Runtime logs remain in the device App Group container and retain only the latest three days;
|
||||
- Issue reports are copied by the user before being submitted to GitHub;
|
||||
- App Mode accesses the local proxy and the environment-verification URL;
|
||||
- Third-party Proxy Mode may access the upstream module URL and the WLOC configuration endpoint;
|
||||
- The CA private key generated by the app is stored in the device Keychain;
|
||||
- Third-party MITM, certificates, and proxy behavior are owned by the selected client.
|
||||
|
||||
Do not post real locations, authentication information, CA private keys, or complete sensitive logs in public issues.
|
||||
|
||||
## Limitations
|
||||
|
||||
- iOS updates may change location-service behavior;
|
||||
- MapKit coordinate output can vary with the system, region, and location environment;
|
||||
- System location caches may delay visible changes;
|
||||
- Each third-party proxy client requires separate compatibility testing;
|
||||
- Not every app uses the same location API;
|
||||
- Not every app or service accepts test coordinates;
|
||||
- Behavior is not guaranteed across every network, device model, or iOS version.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome for:
|
||||
|
||||
- [Bug reports](https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml);
|
||||
- [Feature requests](https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE);
|
||||
- [Usage help and compatibility results](https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9);
|
||||
- [Third-party client configurations and sanitized screenshots](https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB);
|
||||
- Performance improvements;
|
||||
- Documentation improvements;
|
||||
- Additional tests.
|
||||
|
||||
For bugs, generate a report from **Settings → Support → Report Bug** in the App whenever possible. The report includes:
|
||||
|
||||
- iOS version;
|
||||
- App version;
|
||||
- Runtime mode;
|
||||
- Selected third-party client;
|
||||
- Whether the issue is reproducible;
|
||||
- Problem description;
|
||||
- Sanitized runtime logs;
|
||||
|
||||
Paste it into the **App-generated diagnostic report** field in the GitHub Issue Form.
|
||||
|
||||
## Documentation
|
||||
## Documentation & Feedback
|
||||
|
||||
- [Build guide](docs/BUILD.md)
|
||||
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Self-signing guide](docs/SELF-SIGNING.md)
|
||||
- [Changelog](docs/CHANGELOG.md)
|
||||
- [中文文档](README.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
## Feature Preview
|
||||
When reporting issues, please include reproduction steps, iOS version, device model, and sanitized runtime logs.
|
||||
|
||||
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.
|
||||
## Links
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Main interface</th>
|
||||
<th>Apple Maps test</th>
|
||||
<th>Amap test</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="Location Spoofer map selection interface" width="220"></td>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="Apple Maps location test scenario" width="220"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="Amap location test scenario" width="220"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>WeChat test</th>
|
||||
<th>DingTalk test</th>
|
||||
<th>Apple Watch scenario test</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/微信.jpg" alt="WeChat location test scenario" width="220"></td>
|
||||
<td><img src="images/钉钉.jpg" alt="DingTalk location test scenario" width="220"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch region-feature test scenario" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Acknowledgements and Links
|
||||
|
||||
The core location-response handling approach, Go implementation, and third-party modules are based on:
|
||||
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
Community link:
|
||||
|
||||
- [LINUX DO](https://linux.do/)
|
||||
|
||||
Thanks to the open-source contributors working on iOS location-service research, network proxies, and mobile testing
|
||||
tools.
|
||||
**LinuxDo** — [https://linux.do](https://linux.do/)
|
||||
|
||||
@@ -2,438 +2,173 @@
|
||||
|
||||
# 📍 Location Spoofer
|
||||
|
||||
### iOS Location Service Research & Testing Framework
|
||||
### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS
|
||||
|
||||
一个用于 **iOS 定位服务研究、软件开发测试和 QA 验证** 的开源项目。
|
||||
|
||||
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
|
||||
不同地理位置和定位场景下的行为。
|
||||
**无需 VPN、无需越狱,在 iPhone 本机通过 Wi‑Fi HTTP 代理改写 Apple 定位响应。**<br>
|
||||
可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#为什么不需要-vpn)
|
||||
|
||||
[功能概览](#功能概览) ·
|
||||
[工作原理](#工作原理) ·
|
||||
[快速开始](#快速开始) ·
|
||||
[构建项目](#构建项目) ·
|
||||
[English](README.en.md)
|
||||
[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md)
|
||||
|
||||
<img src="images/主界面.jpg" alt="Location Spoofer iOS 虚拟定位 Fake GPS 主界面" width="380">
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本项目用于学习研究、自有设备测试、软件开发和 QA 验证。
|
||||
>
|
||||
> 请仅在你拥有或获得授权的设备、网络和软件环境中使用,并遵守当地法律法规、网络管理规定以及相关服务条
|
||||
> 款。
|
||||
>
|
||||
> 本项目不保证兼容所有 iOS 版本或第三方应用,也不承诺绕过第三方应用的安全策略、业务限制或服务规则。
|
||||
> 本项目用于学习、安全研究与自有设备测试。它会安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。
|
||||
|
||||
## 项目定位
|
||||
## 致谢
|
||||
|
||||
Location Spoofer 是一个面向 iOS 定位服务行为研究和开发测试的工具。
|
||||
核心定位响应改写思路与 Go 实现来源于 [Yu9191/wloc](https://github.com/Yu9191/wloc)。本项目在此基础上增加 SwiftUI 界面、MapKit 选点、证书与代理引导、环境验证、收藏和诊断能力。
|
||||
|
||||
它提供:
|
||||
## 为什么选择 Location Spoofer?
|
||||
|
||||
- 原生地图选点和位置场景切换;
|
||||
- Apple 定位服务响应的测试环境模拟;
|
||||
- 本地代理和第三方代理两种运行模式;
|
||||
- 坐标标准识别与 WGS-84 / GCJ-02 双坐标管理;
|
||||
- 环境检测、运行日志和问题诊断;
|
||||
- 收藏位置和上次地图状态恢复。
|
||||
很多 iOS 虚拟定位工具依赖电脑常驻、开发者调试、VPN 或越狱。本项目采用不同路线:在 iPhone 本机运行 Go 代理,仅对 Apple 定位服务目标请求进行处理。
|
||||
|
||||
项目不修改目标 App 的源代码,也不提供远程控制或数据采集服务。
|
||||
| 特性 | 说明 |
|
||||
|---|---|
|
||||
| 🚫 **无 VPN** | 不创建 VPN 隧道,仅使用定位权限,无需后台刷新、通知等额外权限 |
|
||||
| 📱 **无需越狱** | 支持自行签名安装,最低部署目标为 iOS 15 |
|
||||
| 🗺️ **原生地图体验** | 使用 Apple 地图同款蓝点,搜索、点击、拖动选点体验与 Apple 地图一致 |
|
||||
| 📍 **系统级虚拟定位** | 支持钉钉、微信、Apple 地图、高德等 App 的虚拟实时定位 |
|
||||
| 🔍 **可见缩放范围** | 左侧缩放控件显示当前可视范围,地点名称随级别自动适配 |
|
||||
| 🧪 **环境检测** | 检查代理、CA 信任、Wi‑Fi 接管、坐标写入与响应改写 |
|
||||
| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 |
|
||||
|
||||
## 功能概览
|
||||
## 效果预览
|
||||
|
||||
- **原生地图交互**
|
||||
- 使用 MapKit 显示地图和系统蓝点;
|
||||
- 支持搜索、点击选点、拖动地图中心和缩放;
|
||||
- 支持收藏位置和恢复上次选点;
|
||||
- 当前选点同时显示国内坐标和国际坐标,可分别复制。
|
||||
<table>
|
||||
<tr>
|
||||
<th>应用主界面</th>
|
||||
<th>Apple 地图</th>
|
||||
<th>高德地图</th>
|
||||
<th>Apple Watch</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="iOS 虚拟定位应用主界面" width="210"></td>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="iPhone Location Spoofer Apple Maps 效果" width="210"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="Fake GPS 高德地图定位效果" width="210"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能验证" width="210"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **定位服务响应模拟**
|
||||
- 通过代理层处理指定的 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 模块,尚未完整验证 | 待征集 |
|
||||
|
||||
社区配置按客户端分区审核;采纳后会在上表链接教程和投稿者,投稿者也可以选择匿名收录。
|
||||
|
||||
相关模块快照和来源记录:
|
||||
|
||||
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
第三方客户端、证书、MITM 和代理开关由客户端自身负责。导入任何第三方模块前,请先审查其配置和脚本内容。
|
||||
- **iOS 虚拟定位 / Fake GPS**:将当前地图选点应用到本机定位响应改写代理,适配钉钉打卡、微信位置共享等场景。
|
||||
- **原生实时位置**:地图显示 MapKit 自带蓝点,不再由 App 额外绘制实时位置标记。
|
||||
- **并发安全选点**:拖动、点击、搜索、收藏和异步定位按用户最新意图处理,旧结果不会覆盖新选点。
|
||||
- **地点名称分级**:近距离显示 POI、门牌或道路;拉远后显示社区、区县、城市或省份。
|
||||
- **地图范围显示**:缩放控件中显示 `180 m`、`2.5 km`、`126 km` 等当前可视范围。
|
||||
- **收藏与快速切换**:保存常用坐标,并明确显示当前准备应用的位置。
|
||||
- **配置引导**:提供证书安装、完全信任、Wi‑Fi HTTP 代理、生效与恢复说明。
|
||||
- **问题诊断**:内置验证流程和结构化运行日志。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装应用
|
||||
### 1. 安装 App
|
||||
|
||||
你可以使用:
|
||||
- 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 获取构建产物并自行签名;或
|
||||
- 在 macOS + Xcode 环境按[构建说明](docs/BUILD.md)编译。
|
||||
|
||||
- 自己的 Apple Developer 签名环境;
|
||||
- 适合个人测试的自签工具;
|
||||
- 项目 Releases 中的未签名 IPA;
|
||||
- 在 macOS 上按[构建说明](docs/BUILD.md)自行构建。
|
||||
详细步骤见[自签安装说明](docs/SELF-SIGNING.md)。
|
||||
|
||||
免费自签环境可能无法使用 Network Extension,因此 APP 模式采用设备内本地代理和 Wi-Fi 手动代理,不依赖
|
||||
VPN 组件。
|
||||
### 2. 安装并信任 CA
|
||||
|
||||
#### 自签安装说明
|
||||
|
||||
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/ 构建、模块和版本文档
|
||||
设置 → 通用 → VPN 与设备管理 → 安装 WLOC CA
|
||||
设置 → 通用 → 关于本机 → 证书信任设置 → 完全信任
|
||||
```
|
||||
|
||||
## 构建项目
|
||||
### 3. 配置当前 Wi‑Fi 代理
|
||||
|
||||
源码构建需要 macOS 环境:
|
||||
在当前 Wi‑Fi 的"配置代理"中选择"手动":
|
||||
|
||||
- macOS;
|
||||
- Xcode;
|
||||
- Xcode Command Line Tools;
|
||||
- XcodeGen;
|
||||
- Go 1.23 或更高版本。
|
||||
```text
|
||||
服务器:127.0.0.1
|
||||
端口:8888
|
||||
鉴定:关闭
|
||||
```
|
||||
|
||||
当前项目不支持在 Windows 上直接构建 iOS 应用。
|
||||
### 4. 选点并启用
|
||||
|
||||
1. 搜索、点击或拖动地图选择位置;点击实时位置按钮可回到 MapKit 蓝点。
|
||||
2. 点击"开始虚拟定位",等待环境检测通过。
|
||||
3. 按 App 内"生效说明"刷新飞行模式、Wi‑Fi 和定位服务状态。
|
||||
4. 打开 Apple 地图或目标 App 验证结果。
|
||||
|
||||
### 5. 恢复真实位置
|
||||
|
||||
停止虚拟定位,关闭当前 Wi‑Fi 的手动代理,并按 App 内"失效说明"刷新系统定位缓存。若系统仍保留旧缓存,请重启设备后再检查。
|
||||
|
||||
## 为什么不需要 VPN?
|
||||
|
||||
```text
|
||||
iPhone 定位请求
|
||||
│ 当前 Wi‑Fi HTTP 代理:127.0.0.1:8888
|
||||
▼
|
||||
本机 wloccore(Go)
|
||||
│ 仅处理目标 Apple 定位服务请求
|
||||
├──────────────► Apple 定位服务
|
||||
◄──────────────┘
|
||||
│ 改写目标响应中的坐标
|
||||
▼
|
||||
系统与应用读取定位结果
|
||||
```
|
||||
|
||||
项目不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**但它仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 切换 Wi‑Fi 后需要重新检查代理设置;停止使用后应及时关闭手动代理。
|
||||
|
||||
## 兼容性
|
||||
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| iOS | 15.0+ |
|
||||
| 构建 | macOS、Xcode、XcodeGen |
|
||||
| Swift | 5.9 |
|
||||
| Go | 1.23+ |
|
||||
| 网络 | 可手动配置 HTTP 代理的 Wi‑Fi |
|
||||
| 安装 | 自行签名或使用 Releases 构建产物 |
|
||||
|
||||
效果会受到 iOS 版本、网络、系统定位缓存和目标 App 自身策略影响,不承诺兼容所有系统或第三方 App。
|
||||
|
||||
## 构建与项目结构
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xweiba/location-spoofer.git
|
||||
cd location-spoofer
|
||||
|
||||
./build.sh
|
||||
```
|
||||
|
||||
运行构建并执行 Simulator 测试:
|
||||
|
||||
```bash
|
||||
./build.sh --test
|
||||
```
|
||||
|
||||
构建脚本会生成未签名 IPA:
|
||||
构建产物默认位于:
|
||||
|
||||
```text
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
之后需要使用你自己的签名和安装流程部署到测试设备。
|
||||
```text
|
||||
App/ SwiftUI、MapKit、定位和配置流程
|
||||
Core/ Go 本机代理与定位响应改写
|
||||
Shared/ 收藏、设置、日志和共享模型
|
||||
Resources/ Info.plist、Entitlements 与图标
|
||||
Scripts/ 构建、签名和检查脚本
|
||||
Tests/ XCTest 与 Bash 契约测试
|
||||
docs/ 构建、自签和版本更新文档
|
||||
```
|
||||
|
||||
## 隐私与安全边界
|
||||
|
||||
- 项目不包含遥测或远程控制服务;
|
||||
- 项目不会自动上传用户位置数据;
|
||||
- 运行日志保存在设备 App Group 容器中,并自动保留近三天;
|
||||
- 问题报告需要用户主动复制后提交到 GitHub;
|
||||
- APP 模式会访问本机代理和环境验证地址;
|
||||
- 第三方代理模式可能访问上游模块地址和 WLOC 配置接口;
|
||||
- App 生成的 CA 私钥保存在设备 Keychain 中;
|
||||
- 第三方客户端模块、MITM 和证书链路由用户选择的客户端负责。
|
||||
|
||||
请不要把真实位置、认证信息、证书私钥或完整敏感日志提交到公开 Issue。
|
||||
|
||||
## 限制
|
||||
|
||||
- iOS 系统版本变化可能影响定位服务行为;
|
||||
- MapKit 的坐标返回标准可能随系统、地区和定位环境变化;
|
||||
- 系统定位存在缓存,切换位置后不一定立即生效;
|
||||
- 第三方代理客户端的兼容性和规则行为需要分别验证;
|
||||
- 不保证所有应用都使用同一种定位 API;
|
||||
- 不保证所有应用或服务都接受测试坐标;
|
||||
- 不保证在所有网络环境、设备型号和 iOS 版本上表现一致。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交:
|
||||
|
||||
- [Bug Report](https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml);
|
||||
- [功能建议](https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE);
|
||||
- [使用帮助与兼容性测试](https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9);
|
||||
- [第三方客户端配置和脱敏原始截图](https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB);
|
||||
- 性能改进;
|
||||
- 文档改进;
|
||||
- 测试补充。
|
||||
|
||||
提交 Bug 时建议优先通过 App 的“设置 → 支持 → 报告 Bug”生成报告。报告包含:
|
||||
|
||||
- iOS 版本;
|
||||
- App 版本;
|
||||
- 使用的运行模式;
|
||||
- 当前第三方客户端;
|
||||
- 是否可以稳定复现;
|
||||
- 问题描述;
|
||||
- 脱敏后的运行日志;
|
||||
|
||||
GitHub Issue Form 中的“App 生成的诊断报告”字段与 App 复制内容一一对应。
|
||||
|
||||
## 文档
|
||||
## 文档与反馈
|
||||
|
||||
- [构建说明](docs/BUILD.md)
|
||||
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
|
||||
- [社区客户端教程与截图提交](docs/COMMUNITY_TUTORIALS.md)
|
||||
- [更新日志](docs/CHANGELOG.md)
|
||||
- [英文文档](README.en.md)
|
||||
- [自签安装](docs/SELF-SIGNING.md)
|
||||
- [v1.0.0 更新日志](docs/CHANGELOG.md)
|
||||
- [English README](README.en.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
## 功能预览
|
||||
反馈问题时,请附上复现步骤、iOS 版本、设备型号及已脱敏的运行日志。
|
||||
|
||||
以下截图用于展示主界面和部分真机测试场景。实际结果会受到 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](https://github.com/Yu9191/wloc)
|
||||
|
||||
友链:
|
||||
|
||||
- [LINUX DO](https://linux.do/)
|
||||
|
||||
感谢开源社区中参与 iOS 定位服务研究、网络代理和移动端测试工具建设的贡献者。
|
||||
**LinuxDo** — [https://linux.do](https://linux.do/)
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-mode-certificate-install.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 39 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-mode-certificate-trust.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-mode-wifi-proxy.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 30 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadowrocket-config-details.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 39 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadowrocket-https-ca.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 39 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadowrocket-https-decryption.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 56 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadowrocket-module-import.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 60 KiB |
@@ -23,12 +23,6 @@
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>App-Prefs</string>
|
||||
<string>shadowrocket</string>
|
||||
<string>surge</string>
|
||||
<string>quantumult-x</string>
|
||||
<string>loon</string>
|
||||
<string>stash</string>
|
||||
<string>egern</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
@@ -39,7 +33,7 @@
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
|
||||
@@ -6,7 +6,5 @@
|
||||
<array>
|
||||
<string>group.com.paopaolabs.location-spoofer</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,11 +0,0 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
|
||||
[rewrite_local]
|
||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc url script-response-body https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js
|
||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save url script-echo-response https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
||||
|
||||
[mitm]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -1,19 +0,0 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f
|
||||
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!openUrl=https://wloc-pages.pages.dev/
|
||||
|
||||
[Argument]
|
||||
longitude = input, "113.94114", tag=经度(在线选点优先)
|
||||
latitude = input, "22.544577", tag=纬度(在线选点优先)
|
||||
accuracy = input, "25", tag=精度(米)
|
||||
logLevel = select, "info", "off", "error", "warn", "debug", "all", tag=日志级别
|
||||
|
||||
[Script]
|
||||
http-response ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC, argument=[{longitude},{latitude},{accuracy},{logLevel}]
|
||||
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js, timeout=10, tag=WLOC Settings
|
||||
|
||||
[MITM]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -1,13 +0,0 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 (Shadowrocket 小火箭) | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
||||
#!category=Tools
|
||||
|
||||
[Script]
|
||||
Apple WLOC = type=http-response,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc,requires-body=1,binary-body-mode=1,max-size=0,timeout=30,script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js,argument=longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
|
||||
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save,requires-body=0,max-size=0,timeout=10,script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -1,14 +0,0 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!category=Tools
|
||||
#!arguments=经度:113.94114, 纬度:22.544577, 精度:25, 日志级别:info
|
||||
#!arguments-desc=经度/纬度: 默认坐标(在线选点储存后优先)\n精度: GPS精度(米)\n日志级别: off/error/warn/info/debug/all\n\n使用方法: 打开选点页面 -> 选位置 -> 储存到设备
|
||||
|
||||
[Script]
|
||||
Apple WLOC = type=http-response, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc", requires-body=1, binary-body-mode=1, max-size=0, timeout=30, script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js, argument=longitude={{{经度}}}&latitude={{{纬度}}}&accuracy={{{精度}}}&logLevel={{{日志级别}}}
|
||||
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save", requires-body=0, max-size=0, timeout=10, script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Apple WLOC 定位修改
|
||||
desc: "修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/"
|
||||
author: Yu9191 Rewrite
|
||||
homepage: https://github.com/Yu9191/wloc
|
||||
icon: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
||||
category: Tools
|
||||
|
||||
http:
|
||||
mitm:
|
||||
- "gs-loc.apple.com"
|
||||
- "gs-loc-cn.apple.com"
|
||||
script:
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc
|
||||
name: WLOC.Location
|
||||
type: response
|
||||
require-body: true
|
||||
binary-mode: true
|
||||
max-size: 0
|
||||
timeout: 30
|
||||
argument: longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save
|
||||
name: WLOC.Settings
|
||||
type: request
|
||||
require-body: false
|
||||
timeout: 10
|
||||
|
||||
script-providers:
|
||||
WLOC.Location:
|
||||
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js
|
||||
interval: 86400
|
||||
WLOC.Settings:
|
||||
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
||||
interval: 86400
|
||||
@@ -4,32 +4,21 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CORE="$ROOT/Core"
|
||||
BUILD="$CORE/build"
|
||||
|
||||
IOS_SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
|
||||
MIN_IOS_VERSION="15.0"
|
||||
|
||||
build_archive() {
|
||||
local sdk="$1"
|
||||
local min_flag="$2"
|
||||
local output="$3"
|
||||
local sdk_path
|
||||
sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path)"
|
||||
export CGO_ENABLED=1
|
||||
export CGO_CFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
|
||||
export CGO_LDFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
|
||||
export GOOS="ios"
|
||||
export GOARCH="arm64"
|
||||
|
||||
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"
|
||||
mkdir -p "$BUILD"
|
||||
cd "$CORE"
|
||||
go mod download
|
||||
|
||||
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"
|
||||
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 device and simulator Core archives"
|
||||
|
||||
echo "Built $BUILD/libwloccore.a"
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/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"
|
||||
@@ -14,7 +14,5 @@
|
||||
</array>
|
||||
<key>get-task-allow</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -43,112 +43,3 @@ enum WlocSettingsStore {
|
||||
save(WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
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.3",
|
||||
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 configurationURL = 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 sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: configurationURL)
|
||||
request.timeoutInterval = 1.5
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置请求返回非 200,继续使用内置配置")
|
||||
return nil
|
||||
}
|
||||
let configuration = try AppRemoteConfiguration.decode(data)
|
||||
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
|
||||
"最新版本": configuration.latestVersion,
|
||||
"最低版本": configuration.minimumSupportedVersion,
|
||||
"社区征集客户端数": String(configuration.communityPromptClients.count)
|
||||
])
|
||||
return configuration
|
||||
} catch {
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置", details: [
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchReleaseNotes(version: String) async -> String? {
|
||||
guard let url = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
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 {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明不存在,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
guard let markdown = String(data: data, encoding: .utf8) else {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明编码无效,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
return releaseNotesSummary(markdown)
|
||||
} catch {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
|
||||
"版本": version,
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func releaseNotesSummary(_ markdown: String) -> String? {
|
||||
var items: [String] = []
|
||||
var inMainUpdates = false
|
||||
for rawLine in markdown.components(separatedBy: .newlines) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line == "## 主要更新" {
|
||||
inMainUpdates = true
|
||||
continue
|
||||
}
|
||||
if inMainUpdates, line.hasPrefix("## ") {
|
||||
break
|
||||
}
|
||||
guard inMainUpdates, line.hasPrefix("- ") else { continue }
|
||||
items.append("• " + line.dropFirst(2))
|
||||
}
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
private enum AppRemoteConfigurationError: Error {
|
||||
case invalidVersion
|
||||
case invalidClient
|
||||
}
|
||||
|
||||
private struct NumericVersion: Comparable {
|
||||
let components: [Int]
|
||||
|
||||
init?(_ value: String) {
|
||||
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard !parts.isEmpty else { return nil }
|
||||
var parsed: [Int] = []
|
||||
for part in parts {
|
||||
guard !part.isEmpty, let number = Int(part), number >= 0 else { return nil }
|
||||
parsed.append(number)
|
||||
}
|
||||
components = parsed
|
||||
}
|
||||
|
||||
static func < (lhs: NumericVersion, rhs: NumericVersion) -> Bool {
|
||||
let count = max(lhs.components.count, rhs.components.count)
|
||||
for index in 0..<count {
|
||||
let left = index < lhs.components.count ? lhs.components[index] : 0
|
||||
let right = index < rhs.components.count ? rhs.components[index] : 0
|
||||
if left != right { return left < right }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ final class BackgroundKeepAlive {
|
||||
guard isActive, let info = notification.userInfo,
|
||||
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
type == AVAudioSession.InterruptionType.ended.rawValue else { return }
|
||||
restartAfterInterruption()
|
||||
start()
|
||||
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
|
||||
}
|
||||
|
||||
@@ -29,28 +29,19 @@ final class BackgroundKeepAlive {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "KeepAlive", "音频会话失败", error: error)
|
||||
isActive = false
|
||||
return
|
||||
}
|
||||
|
||||
let eng = AVAudioEngine()
|
||||
let player = AVAudioPlayerNode()
|
||||
eng.attach(player)
|
||||
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1),
|
||||
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3) else {
|
||||
RuntimeLogger.error("APP", "KeepAlive", "无法创建静音音频缓冲区")
|
||||
isActive = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
return
|
||||
}
|
||||
let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)!
|
||||
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3)!
|
||||
buf.frameLength = 44100 * 3
|
||||
eng.connect(player, to: eng.mainMixerNode, format: fmt)
|
||||
eng.prepare()
|
||||
do { try eng.start() } catch {
|
||||
RuntimeLogger.error("APP", "KeepAlive", "引擎启动失败", error: error)
|
||||
isActive = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
return
|
||||
isActive = false; return
|
||||
}
|
||||
player.scheduleBuffer(buf, at: nil, options: .loops)
|
||||
player.play()
|
||||
@@ -59,16 +50,6 @@ final class BackgroundKeepAlive {
|
||||
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
|
||||
}
|
||||
|
||||
private func restartAfterInterruption() {
|
||||
guard isActive else { return }
|
||||
playerNode?.stop()
|
||||
engine?.stop()
|
||||
playerNode = nil
|
||||
engine = nil
|
||||
isActive = false
|
||||
start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isActive else { return }
|
||||
isActive = false
|
||||
|
||||
@@ -1,188 +1,46 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol CertificateAuthorityKeychain {
|
||||
func load() throws -> CertificateAuthority?
|
||||
func save(_ authority: CertificateAuthority) throws
|
||||
func remove() throws
|
||||
}
|
||||
|
||||
enum CertificateAuthorityStoreError: LocalizedError {
|
||||
case invalidAuthority
|
||||
case keychain(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAuthority: return "本地 CA 证书或私钥无效"
|
||||
case let .keychain(status): return "无法写入设备钥匙串(\(status))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class DeviceCertificateAuthorityKeychain: CertificateAuthorityKeychain {
|
||||
private enum Item {
|
||||
static let service = "com.paopaolabs.location-spoofer.certificate-authority"
|
||||
static let certificateAccount = "root-ca-certificate"
|
||||
static let keyAccount = "root-ca-private-key"
|
||||
}
|
||||
|
||||
func load() throws -> CertificateAuthority? {
|
||||
guard let certPEM = try load(account: Item.certificateAccount),
|
||||
let keyPEM = try load(account: Item.keyAccount) else {
|
||||
return nil
|
||||
}
|
||||
return CertificateAuthority(certPEM: certPEM, keyPEM: keyPEM)
|
||||
}
|
||||
|
||||
func save(_ authority: CertificateAuthority) throws {
|
||||
try remove()
|
||||
do {
|
||||
try save(authority.certPEM, account: Item.certificateAccount)
|
||||
try save(authority.keyPEM, account: Item.keyAccount)
|
||||
} catch {
|
||||
try? remove()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func remove() throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw CertificateAuthorityStoreError.keychain(status)
|
||||
}
|
||||
}
|
||||
|
||||
private func load(account: String) throws -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
throw CertificateAuthorityStoreError.keychain(status)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func save(_ value: String, account: String) throws {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||||
}
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw CertificateAuthorityStoreError.keychain(status) }
|
||||
}
|
||||
}
|
||||
|
||||
final class CertificateAuthorityStore {
|
||||
private let directory: URL
|
||||
private let generator: () throws -> CertificateAuthority
|
||||
private let validator: (CertificateAuthority) -> Bool
|
||||
private let keychain: CertificateAuthorityKeychain
|
||||
private let certificateURL: URL
|
||||
private let keyURL: URL
|
||||
|
||||
init(
|
||||
directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true),
|
||||
keychain: CertificateAuthorityKeychain = DeviceCertificateAuthorityKeychain(),
|
||||
generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority,
|
||||
validator: @escaping (CertificateAuthority) -> Bool = CoreBridge.isValidCertificateAuthority
|
||||
) {
|
||||
init(directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true), generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority) {
|
||||
self.directory = directory
|
||||
self.keychain = keychain
|
||||
self.generator = generator
|
||||
self.validator = validator
|
||||
self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
|
||||
self.keyURL = directory.appendingPathComponent("ca-key.pem")
|
||||
}
|
||||
|
||||
func ensure() throws -> CertificateAuthority {
|
||||
if let authority = try loadValidKeychainAuthority() {
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.debug("SHARED", "Certificate.store", "复用设备钥匙串中的 CA")
|
||||
return authority
|
||||
if let current = try load() {
|
||||
RuntimeLogger.debug("SHARED", "Certificate.store", "读取已有 CA 文件", details: ["directory": directory.path])
|
||||
return current
|
||||
}
|
||||
|
||||
if let legacy = try loadLegacyAuthority(), validator(legacy) {
|
||||
try keychain.save(legacy)
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "旧 CA 已迁移到设备钥匙串")
|
||||
return legacy
|
||||
}
|
||||
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "未找到 CA 文件,开始生成", details: ["directory": directory.path])
|
||||
let authority = try generator()
|
||||
guard validator(authority) else { throw CertificateAuthorityStoreError.invalidAuthority }
|
||||
try keychain.save(authority)
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "已生成并保存设备专属 CA")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
guard let certificateData = authority.certPEM.data(using: .utf8),
|
||||
let keyData = authority.keyPEM.data(using: .utf8) else {
|
||||
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||||
}
|
||||
try certificateData.write(to: certificateURL, options: .atomic)
|
||||
try keyData.write(to: keyURL, options: .atomic)
|
||||
applyCompleteProtection(to: certificateURL)
|
||||
applyCompleteProtection(to: keyURL)
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "CA 文件写入完成", details: ["directory": directory.path])
|
||||
return authority
|
||||
}
|
||||
|
||||
func load() throws -> CertificateAuthority? {
|
||||
try loadValidKeychainAuthority()
|
||||
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))
|
||||
}
|
||||
|
||||
func reset() throws {
|
||||
try removeLegacyFiles()
|
||||
try keychain.remove()
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "已删除设备 CA,等待重新生成")
|
||||
}
|
||||
|
||||
private func loadValidKeychainAuthority() throws -> CertificateAuthority? {
|
||||
guard let authority = try keychain.load() else { return nil }
|
||||
guard validator(authority) else {
|
||||
RuntimeLogger.warning("SHARED", "Certificate.store", "钥匙串中的 CA 无效,准备回退")
|
||||
try? keychain.remove()
|
||||
return nil
|
||||
}
|
||||
return authority
|
||||
}
|
||||
|
||||
private func loadLegacyAuthority() throws -> CertificateAuthority? {
|
||||
guard FileManager.default.fileExists(atPath: certificateURL.path),
|
||||
FileManager.default.fileExists(atPath: keyURL.path) else {
|
||||
return nil
|
||||
}
|
||||
return CertificateAuthority(
|
||||
certPEM: try String(contentsOf: certificateURL, encoding: .utf8),
|
||||
keyPEM: try String(contentsOf: keyURL, encoding: .utf8)
|
||||
)
|
||||
}
|
||||
|
||||
private func removeLegacyFilesBestEffort() {
|
||||
do {
|
||||
try removeLegacyFiles()
|
||||
} catch {
|
||||
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeLegacyFiles() throws {
|
||||
let fileManager = FileManager.default
|
||||
for url in [keyURL, certificateURL] where fileManager.fileExists(atPath: url.path) {
|
||||
try fileManager.removeItem(at: url)
|
||||
}
|
||||
if fileManager.fileExists(atPath: directory.path) {
|
||||
try fileManager.removeItem(at: directory)
|
||||
}
|
||||
private func applyCompleteProtection(to url: URL) {
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ final class CertificateTrustVerifier {
|
||||
/// Check whether a CA certificate (given as PEM data) is installed and fully trusted
|
||||
/// by the system. Uses SecTrust evaluation against system anchors only — no network needed.
|
||||
static func isCACertificateTrusted(certPEM: String) -> Bool {
|
||||
guard let pemData = certPEM.data(using: .utf8),
|
||||
let block = pemData.pemCertificateBlock,
|
||||
let cert = SecCertificateCreateWithData(nil, block as CFData) else {
|
||||
guard let certData = certPEM.data(using: .utf8),
|
||||
let cert = SecCertificateCreateWithData(nil, certData as CFData) else {
|
||||
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
|
||||
return false
|
||||
}
|
||||
@@ -39,13 +38,3 @@ final class CertificateTrustVerifier {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
var pemCertificateBlock: Data? {
|
||||
guard let text = String(data: self, encoding: .utf8),
|
||||
let begin = text.range(of: "-----BEGIN CERTIFICATE-----"),
|
||||
let end = text.range(of: "-----END CERTIFICATE-----") else { return nil }
|
||||
let body = text[begin.upperBound..<end.lowerBound].components(separatedBy: .whitespacesAndNewlines).joined()
|
||||
return Data(base64Encoded: body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,387 +1,27 @@
|
||||
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 坐标转换。
|
||||
///
|
||||
/// MapKit does not expose a public API for its active coordinate reference system.
|
||||
/// The app resolves it with a bounded heuristic, then stores both WGS-84 and
|
||||
/// GCJ-02 representations at each write boundary so replay does not convert again.
|
||||
/// 在中国地区,MKMapView 使用高德 (AutoNavi) 瓦片数据(GCJ-02 坐标系),
|
||||
/// 因此从 MKMapView 的 `centerCoordinate`、`convert(point:toCoordinateFrom:)`
|
||||
/// 等方法返回的坐标也是 GCJ-02。但 CoreLocation / CLLocationManager 返回的
|
||||
/// 以及 Apple wloc 定位服务使用的都是 WGS-84。
|
||||
///
|
||||
/// 虚拟定位的坐标流中:
|
||||
/// - 地图 UI 层(显示、选点):GCJ-02(与瓦片一致)
|
||||
/// - 代理写出层(wloc 响应改写):WGS-84
|
||||
///
|
||||
/// 因此需要在坐标从地图 UI 进入代理之前做 GCJ-02 → WGS-84 转换。
|
||||
enum CoordinateConverter {
|
||||
// 椭球参数 (Krasovsky 1940)
|
||||
private static let a = 6378245.0
|
||||
private static let ee = 0.00669342162296594323
|
||||
|
||||
/// 坐标类型
|
||||
enum MapCoordinateSystem: String {
|
||||
case gcj02 = "GCJ-02"
|
||||
case wgs84 = "WGS-84"
|
||||
|
||||
var diagnosticName: String {
|
||||
switch self {
|
||||
case .gcj02: return "国内标准(GCJ-02)"
|
||||
case .wgs84: return "国际标准(WGS-84)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CoordinateRepresentationDiagnosis: Equatable {
|
||||
let inferredSystem: MapCoordinateSystem?
|
||||
let distanceToWGS84: CLLocationDistance
|
||||
let distanceToGCJ02: CLLocationDistance
|
||||
|
||||
var inferredName: String {
|
||||
inferredSystem?.diagnosticName ?? "无法判定"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 地图坐标标准
|
||||
|
||||
struct MapCoordinateSystemChange: Equatable {
|
||||
let previous: MapCoordinateSystem
|
||||
let current: MapCoordinateSystem
|
||||
}
|
||||
|
||||
enum RuntimeMapCoordinateSystemRefreshResult: Equatable {
|
||||
case unchanged(MapCoordinateSystem)
|
||||
case changed(MapCoordinateSystemChange)
|
||||
case unavailable(reason: String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
/// 当前 Apple 地图坐标标准。检测不可用时使用国内 GCJ-02 作为兜底。
|
||||
@MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02
|
||||
@MainActor private static var mapCoordinateSystemCheckPending = false
|
||||
@MainActor private(set) static var initialMapCoordinateSystemUsedFallback = true
|
||||
|
||||
/// Startup gate: only request realtime location if the public MapKit probe
|
||||
/// cannot resolve a coordinate type. This guarantees a finite answer before
|
||||
/// persisted map positions are replayed.
|
||||
@MainActor
|
||||
static func resolveInitialMapCoordinateSystem() async -> MapCoordinateSystem {
|
||||
guard !mapCoordinateSystemCheckPending else {
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测已有请求进行中")
|
||||
return currentMapCoordinateSystem
|
||||
}
|
||||
mapCoordinateSystemCheckPending = true
|
||||
defer { mapCoordinateSystemCheckPending = false }
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测开始", details: [
|
||||
"锚点": "22.283819,114.158439",
|
||||
"判定规则": "首条名称=林士街→GCJ-02,否则→WGS-84",
|
||||
"缓存": "false"
|
||||
])
|
||||
|
||||
let nextType: MapCoordinateSystem
|
||||
switch await fixedAnchorCoordinateSystemProbe() {
|
||||
case let .response(name, count):
|
||||
nextType = mapCoordinateSystem(forFixedAnchorFirstResultName: name)
|
||||
initialMapCoordinateSystemUsedFallback = false
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"命中林士街": String(name == "林士街"),
|
||||
"最终标准": nextType.rawValue,
|
||||
"结果来源": "固定锚点"
|
||||
])
|
||||
case .unavailable(let reason), .timedOut(let reason):
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测不可用,开始实时定位兜底", details: [
|
||||
"原因": reason
|
||||
])
|
||||
let realtime = await RealtimeLocationManager.shared.requestLocation()
|
||||
if let realtime,
|
||||
CLLocationCoordinate2DIsValid(realtime),
|
||||
!usesGCJ02ServiceArea(lat: realtime.latitude, lon: realtime.longitude) {
|
||||
nextType = .wgs84
|
||||
} else {
|
||||
nextType = .gcj02
|
||||
}
|
||||
initialMapCoordinateSystemUsedFallback = true
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测使用兜底结果", details: [
|
||||
"探测失败原因": reason,
|
||||
"实时定位存在": String(realtime != nil),
|
||||
"最终标准": nextType.rawValue,
|
||||
"结果来源": realtime == nil ? "默认国内标准" : "实时定位服务区域"
|
||||
])
|
||||
case .cancelled:
|
||||
initialMapCoordinateSystemUsedFallback = true
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测被取消,保留默认国内标准", details: [
|
||||
"最终标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return currentMapCoordinateSystem
|
||||
}
|
||||
|
||||
currentMapCoordinateSystem = nextType
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准已确定,允许创建地图", details: [
|
||||
"最终标准": nextType.rawValue,
|
||||
"使用兜底": String(initialMapCoordinateSystemUsedFallback),
|
||||
"缓存": "false"
|
||||
])
|
||||
return nextType
|
||||
}
|
||||
|
||||
/// Re-runs the fixed-anchor MapKit behavior probe while the map is alive.
|
||||
/// Runtime failures preserve the last confirmed type: a potentially spoofed
|
||||
/// Core Location sample is not authoritative for MapKit's representation.
|
||||
@MainActor
|
||||
static func refreshRuntimeMapCoordinateSystem(reason: String) async -> RuntimeMapCoordinateSystemRefreshResult {
|
||||
guard !mapCoordinateSystemCheckPending else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测合并到进行中请求", details: [
|
||||
"触发原因": reason,
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return .unchanged(currentMapCoordinateSystem)
|
||||
}
|
||||
mapCoordinateSystemCheckPending = true
|
||||
defer { mapCoordinateSystemCheckPending = false }
|
||||
|
||||
let previous = currentMapCoordinateSystem
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测开始", details: [
|
||||
"触发原因": reason,
|
||||
"检测前标准": previous.rawValue,
|
||||
"锚点": "22.283819,114.158439",
|
||||
"缓存": "false"
|
||||
])
|
||||
|
||||
switch await fixedAnchorCoordinateSystemProbe() {
|
||||
case let .response(name, count):
|
||||
guard !Task.isCancelled else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测结果已过期,取消写入", details: [
|
||||
"触发原因": reason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .cancelled
|
||||
}
|
||||
let detected = mapCoordinateSystem(forFixedAnchorFirstResultName: name)
|
||||
initialMapCoordinateSystemUsedFallback = false
|
||||
guard detected != previous else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测完成,标准未变化", details: [
|
||||
"触发原因": reason,
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"确认标准": detected.rawValue
|
||||
])
|
||||
return .unchanged(detected)
|
||||
}
|
||||
let change = MapCoordinateSystemChange(previous: previous, current: detected)
|
||||
currentMapCoordinateSystem = detected
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测发现切换", details: [
|
||||
"触发原因": reason,
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"from": previous.rawValue,
|
||||
"to": detected.rawValue
|
||||
])
|
||||
return .changed(change)
|
||||
case .unavailable(let failureReason), .timedOut(let failureReason):
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测失败,保留当前标准", details: [
|
||||
"触发原因": reason,
|
||||
"原因": failureReason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .unavailable(reason: failureReason)
|
||||
case .cancelled:
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测已取消", details: [
|
||||
"触发原因": reason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .cancelled
|
||||
}
|
||||
}
|
||||
|
||||
static func mapCoordinateSystem(forFixedAnchorFirstResultName name: String) -> MapCoordinateSystem {
|
||||
name == "林士街" ? .gcj02 : .wgs84
|
||||
}
|
||||
|
||||
/// A user-requested realtime sample is WGS-84 and can correct a provisional
|
||||
/// startup map coordinate system without altering persisted coordinate pairs.
|
||||
@MainActor
|
||||
static func correctMapCoordinateSystemUsingRealtime(_ coordinate: CLLocationCoordinate2D) -> MapCoordinateSystemChange? {
|
||||
guard CLLocationCoordinate2DIsValid(coordinate) else { return nil }
|
||||
guard initialMapCoordinateSystemUsedFallback else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "实时定位不覆盖固定锚点的明确检测结果", details: [
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return nil
|
||||
}
|
||||
let next: MapCoordinateSystem = usesGCJ02ServiceArea(lat: coordinate.latitude, lon: coordinate.longitude) ? .gcj02 : .wgs84
|
||||
guard next != currentMapCoordinateSystem else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "实时定位确认兜底地图坐标标准无需修正", details: [
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return nil
|
||||
}
|
||||
let change = MapCoordinateSystemChange(previous: currentMapCoordinateSystem, current: next)
|
||||
currentMapCoordinateSystem = next
|
||||
RuntimeLogger.warning("APP", "坐标转换", "实时定位修正启动兜底地图坐标标准", details: [
|
||||
"from": change.previous.rawValue,
|
||||
"to": change.current.rawValue
|
||||
])
|
||||
return change
|
||||
}
|
||||
|
||||
private static func fixedAnchorCoordinateSystemProbe() async -> MapCoordinateSystemProbeResult {
|
||||
let request = MKLocalSearch.Request()
|
||||
request.naturalLanguageQuery = "22.283819, 114.158439"
|
||||
let search = MKLocalSearch(request: request)
|
||||
let resolver = MapCoordinateSystemProbeResolver()
|
||||
|
||||
return await withTaskCancellationHandler(operation: {
|
||||
await withCheckedContinuation { continuation in
|
||||
let timeout = DispatchWorkItem {
|
||||
search.cancel()
|
||||
resolver.resolve(.timedOut(reason: "固定锚点查询超过5秒"))
|
||||
}
|
||||
resolver.install(continuation, timeout: timeout)
|
||||
guard !resolver.isResolved else { return }
|
||||
search.start { response, error in
|
||||
if let error {
|
||||
let nsError = error as NSError
|
||||
resolver.resolve(.unavailable(
|
||||
reason: "\(nsError.domain)(\(nsError.code)): \(nsError.localizedDescription)"
|
||||
))
|
||||
return
|
||||
}
|
||||
let items = response?.mapItems ?? []
|
||||
guard let first = items.first,
|
||||
let name = first.name?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty else {
|
||||
resolver.resolve(.unavailable(reason: "固定锚点查询返回空结果"))
|
||||
return
|
||||
}
|
||||
resolver.resolve(.response(name: name, count: items.count))
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout)
|
||||
}
|
||||
}, onCancel: {
|
||||
search.cancel()
|
||||
resolver.resolve(.cancelled)
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the complete persisted pair once at the map input boundary.
|
||||
static func coordinatePair(lat: Double, lon: Double, mapCoordinateSystem: MapCoordinateSystem) -> CoordinatePair {
|
||||
let raw = CoordinatePair.Value(latitude: lat, longitude: lon)
|
||||
switch mapCoordinateSystem {
|
||||
case .wgs84:
|
||||
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
|
||||
return CoordinatePair(
|
||||
wgs84: raw,
|
||||
gcj02: .init(latitude: gcj.lat, longitude: gcj.lon)
|
||||
)
|
||||
case .gcj02:
|
||||
let wgs = gcj02ToWgs84(lat: lat, lon: lon)
|
||||
return CoordinatePair(
|
||||
wgs84: .init(latitude: wgs.lat, longitude: wgs.lon),
|
||||
gcj02: raw
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy raw domestic map values were historically displayed as GCJ-02;
|
||||
/// overseas values were WGS-84 and stay identity coordinates.
|
||||
static func legacyCoordinatePair(lat: Double, lon: Double) -> CoordinatePair {
|
||||
let type: MapCoordinateSystem = usesGCJ02ServiceArea(lat: lat, lon: lon) ? .gcj02 : .wgs84
|
||||
return coordinatePair(lat: lat, lon: lon, mapCoordinateSystem: type)
|
||||
}
|
||||
|
||||
// MARK: - 工具
|
||||
|
||||
/// Haversine 距离(米)
|
||||
static func distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
||||
let r = 6371000.0
|
||||
let dLat = (lat2 - lat1) * .pi / 180.0
|
||||
let dLon = (lon2 - lon1) * .pi / 180.0
|
||||
let a = sin(dLat / 2) * sin(dLat / 2)
|
||||
+ cos(lat1 * .pi / 180.0) * cos(lat2 * .pi / 180.0)
|
||||
* sin(dLon / 2) * sin(dLon / 2)
|
||||
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
}
|
||||
|
||||
/// Identifies which stored representation a runtime sample most closely
|
||||
/// resembles. Overseas identity pairs and unrelated samples stay ambiguous.
|
||||
static func diagnoseRepresentation(
|
||||
sample: CLLocationCoordinate2D,
|
||||
pair: CoordinatePair,
|
||||
maximumDistance: CLLocationDistance = 1_000,
|
||||
minimumSeparation: CLLocationDistance = 30
|
||||
) -> CoordinateRepresentationDiagnosis {
|
||||
let distanceToWGS84 = distance(
|
||||
lat1: sample.latitude,
|
||||
lon1: sample.longitude,
|
||||
lat2: pair.wgs84.latitude,
|
||||
lon2: pair.wgs84.longitude
|
||||
)
|
||||
let distanceToGCJ02 = distance(
|
||||
lat1: sample.latitude,
|
||||
lon1: sample.longitude,
|
||||
lat2: pair.gcj02.latitude,
|
||||
lon2: pair.gcj02.longitude
|
||||
)
|
||||
let nearestDistance = min(distanceToWGS84, distanceToGCJ02)
|
||||
let separation = abs(distanceToWGS84 - distanceToGCJ02)
|
||||
let inferredSystem: MapCoordinateSystem?
|
||||
if nearestDistance > maximumDistance || separation < minimumSeparation {
|
||||
inferredSystem = nil
|
||||
} else {
|
||||
inferredSystem = distanceToWGS84 < distanceToGCJ02 ? .wgs84 : .gcj02
|
||||
}
|
||||
return CoordinateRepresentationDiagnosis(
|
||||
inferredSystem: inferredSystem,
|
||||
distanceToWGS84: distanceToWGS84,
|
||||
distanceToGCJ02: distanceToGCJ02
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 核心转换
|
||||
|
||||
/// GCJ-02 → WGS-84(迭代法,精度优于 0.5 米)
|
||||
static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
|
||||
var wgsLat = lat
|
||||
var wgsLon = lon
|
||||
// 两次迭代足以收敛到亚米级精度
|
||||
for _ in 0..<2 {
|
||||
let d = delta(lat: wgsLat, lon: wgsLon)
|
||||
wgsLat = lat - d.lat
|
||||
@@ -390,25 +30,6 @@ enum CoordinateConverter {
|
||||
return (wgsLat, wgsLon)
|
||||
}
|
||||
|
||||
/// WGS-84 → GCJ-02
|
||||
static func wgs84ToGcj02(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
|
||||
let d = delta(lat: lat, lon: lon)
|
||||
return (lat + d.lat, lon + d.lon)
|
||||
}
|
||||
|
||||
/// Keep the GCJ-02 service region explicit. Outside this region, conversion
|
||||
/// is identity so the domestic fallback can never shift an overseas value.
|
||||
static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool {
|
||||
let mainland = lat >= 0.8293 && lat <= 55.8271 && lon >= 72.004 && lon <= 137.8347
|
||||
let hongKong = lat >= 22.13 && lat <= 22.57 && lon >= 113.82 && lon <= 114.45
|
||||
let macao = lat >= 22.05 && lat <= 22.25 && lon >= 113.52 && lon <= 113.65
|
||||
let taiwan = lat >= 21.75 && lat <= 25.35 && lon >= 119.30 && lon <= 122.10
|
||||
return mainland || hongKong || macao || taiwan
|
||||
}
|
||||
|
||||
// MARK: - 内部
|
||||
|
||||
/// 计算偏移量 (WGS-84 → GCJ-02 的增量)
|
||||
private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
let dLat = transformLat(x: lon - 105.0, y: lat - 35.0)
|
||||
@@ -439,56 +60,3 @@ enum CoordinateConverter {
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
private enum MapCoordinateSystemProbeResult {
|
||||
case response(name: String, count: Int)
|
||||
case unavailable(reason: String)
|
||||
case timedOut(reason: String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private final class MapCoordinateSystemProbeResolver: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var result: MapCoordinateSystemProbeResult?
|
||||
private var continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>?
|
||||
private var timeout: DispatchWorkItem?
|
||||
|
||||
var isResolved: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return result != nil
|
||||
}
|
||||
|
||||
func install(
|
||||
_ continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>,
|
||||
timeout: DispatchWorkItem
|
||||
) {
|
||||
lock.lock()
|
||||
if let result {
|
||||
lock.unlock()
|
||||
timeout.cancel()
|
||||
continuation.resume(returning: result)
|
||||
return
|
||||
}
|
||||
self.continuation = continuation
|
||||
self.timeout = timeout
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func resolve(_ nextResult: MapCoordinateSystemProbeResult) {
|
||||
lock.lock()
|
||||
guard result == nil else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
result = nextResult
|
||||
let continuation = continuation
|
||||
let timeout = timeout
|
||||
self.continuation = nil
|
||||
self.timeout = nil
|
||||
lock.unlock()
|
||||
|
||||
timeout?.cancel()
|
||||
continuation?.resume(returning: nextResult)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,6 @@ enum CoreBridgeError: LocalizedError {
|
||||
}
|
||||
|
||||
enum CoreBridge {
|
||||
static func isValidCertificateAuthority(_ authority: CertificateAuthority) -> Bool {
|
||||
authority.certPEM.withCString { certificate in
|
||||
authority.keyPEM.withCString { key in
|
||||
wloccore_validateca(UnsafeMutablePointer(mutating: certificate), UnsafeMutablePointer(mutating: key)) != 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func generateCertificateAuthority() throws -> CertificateAuthority {
|
||||
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
|
||||
let result = wloccore_generateca()
|
||||
@@ -49,6 +41,21 @@ enum CoreBridge {
|
||||
return String(cString: ptr)
|
||||
}
|
||||
|
||||
/// 构造接近真实设备格式的 wloc 请求体(多个 WiFi AP + 蜂窝基站)。
|
||||
static func testWlocRequestData() -> Data {
|
||||
guard let ptr = wloccore_testrequesthex() else { return Data([0x0a, 0x02, 0x08, 0x01]) }
|
||||
defer { free(ptr) }
|
||||
let hex = String(cString: ptr)
|
||||
var data = Data()
|
||||
var idx = hex.startIndex
|
||||
while idx < hex.endIndex {
|
||||
let end = hex.index(idx, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
|
||||
if let b = UInt8(hex[idx..<end], radix: 16) { data.append(b) }
|
||||
idx = end
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func flushLogs(category: String) {
|
||||
guard let pointer = wloccore_drainlogs() else { return }
|
||||
defer { free(pointer) }
|
||||
|
||||
@@ -1,79 +1,21 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct FavoriteLocation: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var coordinatePair: CoordinatePair
|
||||
var latitude: Double
|
||||
var longitude: Double
|
||||
var accuracy: Int
|
||||
var createdAt: Date
|
||||
private var wasDecodedFromLegacyCoordinates = false
|
||||
|
||||
/// 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()
|
||||
) {
|
||||
init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double, accuracy: Int, createdAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.coordinatePair = coordinatePair
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.accuracy = accuracy
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracy: Int,
|
||||
createdAt: Date = Date(),
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02
|
||||
) {
|
||||
self.init(
|
||||
id: id,
|
||||
name: name,
|
||||
coordinatePair: CoordinateConverter.coordinatePair(lat: latitude, lon: longitude, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, coordinatePair, accuracy, createdAt, latitude, longitude
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
accuracy = try container.decode(Int.self, forKey: .accuracy)
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
if let pair = try container.decodeIfPresent(CoordinatePair.self, forKey: .coordinatePair) {
|
||||
coordinatePair = pair
|
||||
} else {
|
||||
let latitude = try container.decode(Double.self, forKey: .latitude)
|
||||
let longitude = try container.decode(Double.self, forKey: .longitude)
|
||||
coordinatePair = CoordinateConverter.legacyCoordinatePair(lat: latitude, lon: longitude)
|
||||
wasDecodedFromLegacyCoordinates = true
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(coordinatePair, forKey: .coordinatePair)
|
||||
try container.encode(accuracy, forKey: .accuracy)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
struct MapConfiguration: Equatable {
|
||||
@@ -110,72 +52,27 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(
|
||||
name: String,
|
||||
mapCoordinate: CLLocationCoordinate2D,
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
|
||||
accuracy: Int
|
||||
) -> FavoriteLocation {
|
||||
save(
|
||||
FavoriteLocation(
|
||||
name: name,
|
||||
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Saves a coordinate pair whose source representation was already typed
|
||||
/// before an asynchronous map-coordinate-system refresh.
|
||||
@discardableResult
|
||||
func save(name: String, coordinatePair: CoordinatePair, accuracy: Int) -> FavoriteLocation {
|
||||
save(FavoriteLocation(name: name, coordinatePair: coordinatePair, accuracy: accuracy))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func save(_ favorite: FavoriteLocation) -> FavoriteLocation {
|
||||
func save(name: String, latitude: Double, longitude: Double, accuracy: Int) -> FavoriteLocation {
|
||||
let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy)
|
||||
// 去重:相同坐标删除旧数据,新数据插入顶部
|
||||
favorites.removeAll {
|
||||
abs($0.coordinatePair.wgs84.latitude - favorite.coordinatePair.wgs84.latitude) < 0.000001
|
||||
&& abs($0.coordinatePair.wgs84.longitude - favorite.coordinatePair.wgs84.longitude) < 0.000001
|
||||
abs($0.latitude - favorite.latitude) < 0.000001 && abs($0.longitude - favorite.longitude) < 0.000001
|
||||
}
|
||||
favorites.insert(favorite, at: 0)
|
||||
select(favorite.id)
|
||||
persistIgnoringFailure()
|
||||
persist()
|
||||
return favorite
|
||||
}
|
||||
|
||||
/// Compatibility entry point for callers that already own raw map values.
|
||||
@discardableResult
|
||||
func save(name: String, latitude: Double, longitude: Double, accuracy: Int, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02) -> FavoriteLocation {
|
||||
save(
|
||||
name: name,
|
||||
mapCoordinate: .init(latitude: latitude, longitude: longitude),
|
||||
mapCoordinateSystem: mapCoordinateSystem,
|
||||
accuracy: accuracy
|
||||
)
|
||||
}
|
||||
|
||||
func select(_ id: UUID?) {
|
||||
selectedFavoriteID = id
|
||||
defaults.set(id?.uuidString, forKey: Keys.selectedID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectMatching(coordinatePair: CoordinatePair) -> FavoriteLocation? {
|
||||
let matchingFavorite = favorites.first {
|
||||
$0.coordinatePair.matchesWGS84(
|
||||
latitude: coordinatePair.wgs84.latitude,
|
||||
longitude: coordinatePair.wgs84.longitude
|
||||
)
|
||||
}
|
||||
select(matchingFavorite?.id)
|
||||
return matchingFavorite
|
||||
}
|
||||
|
||||
func rename(_ id: UUID, to name: String) {
|
||||
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
|
||||
favorites[idx].name = name
|
||||
persistIgnoringFailure()
|
||||
persist()
|
||||
}
|
||||
|
||||
func delete(_ favorite: FavoriteLocation) {
|
||||
@@ -183,23 +80,11 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
if selectedFavoriteID == favorite.id {
|
||||
select(favorites.first?.id)
|
||||
}
|
||||
persistIgnoringFailure()
|
||||
persist()
|
||||
}
|
||||
|
||||
func migrateLegacyCoordinates() throws {
|
||||
guard favorites.contains(where: \.isLegacyCoordinateRecord) else { return }
|
||||
try persist()
|
||||
}
|
||||
|
||||
private func persistIgnoringFailure() {
|
||||
do {
|
||||
try persist()
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "收藏", "保存收藏失败", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() throws {
|
||||
defaults.set(try JSONEncoder().encode(favorites), forKey: Keys.favorites)
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(favorites) else { return }
|
||||
defaults.set(data, forKey: Keys.favorites)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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 展示投稿账号
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import Network
|
||||
import Foundation
|
||||
import SystemConfiguration.CaptiveNetwork
|
||||
|
||||
enum WiFiChangeReason: String {
|
||||
case reconnected = "Wi-Fi 恢复连接"
|
||||
case interfaceChanged = "网络接口切换到 Wi-Fi"
|
||||
case ssidChanged = "SSID 发生变化"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NetworkMonitor: ObservableObject {
|
||||
@@ -14,99 +7,20 @@ final class NetworkMonitor: ObservableObject {
|
||||
|
||||
@Published private(set) var isSatisfied = true
|
||||
@Published private(set) var isWiFiEnabled = true
|
||||
@Published private(set) var currentSSID: String?
|
||||
|
||||
/// Wi-Fi 重连、接口切换或 SSID 变化时触发。调用方必须在离开页面时移除订阅。
|
||||
private var wifiChangeHandlers: [UUID: @MainActor (WiFiChangeReason) -> Void] = [:]
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private var ssidTimer: Timer?
|
||||
private var wasSatisfied = true
|
||||
private var wasWiFiEnabled = true
|
||||
private var hasReceivedInitialPath = false
|
||||
private var lastKnownSSID: String?
|
||||
|
||||
private init() {
|
||||
let initialSSID = Self.fetchSSID()
|
||||
currentSSID = initialSSID
|
||||
lastKnownSSID = initialSSID
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let satisfied = path.status == .satisfied
|
||||
let wifi = path.usesInterfaceType(.wifi)
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let reason: WiFiChangeReason?
|
||||
if !self.hasReceivedInitialPath {
|
||||
// NWPathMonitor 的首次回调只是状态基线,不是网络切换。
|
||||
self.hasReceivedInitialPath = true
|
||||
reason = nil
|
||||
} else if satisfied && wifi && !self.wasSatisfied {
|
||||
reason = .reconnected
|
||||
} else if satisfied && wifi && !self.wasWiFiEnabled {
|
||||
// 蜂窝网络和 Wi-Fi 都可能是 satisfied,不能只比较 status。
|
||||
reason = .interfaceChanged
|
||||
} else {
|
||||
reason = nil
|
||||
}
|
||||
self.wasSatisfied = satisfied
|
||||
self.wasWiFiEnabled = wifi
|
||||
self.isSatisfied = satisfied
|
||||
self.isWiFiEnabled = wifi
|
||||
if let reason {
|
||||
self.notifyWiFiChanged(reason: reason)
|
||||
}
|
||||
self?.isSatisfied = satisfied
|
||||
self?.isWiFiEnabled = wifi
|
||||
}
|
||||
}
|
||||
monitor.start(queue: .main)
|
||||
startSSIDPolling()
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
var isAirplaneMode: Bool { !isSatisfied }
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum ProxyRuntimeMode: String, CaseIterable, Codable, Identifiable {
|
||||
case localWiFi
|
||||
case thirdParty
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .localWiFi: return "APP模式"
|
||||
case .thirdParty: return "第三方代理模式"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProxyRuntimeModeStore: ObservableObject {
|
||||
static let shared = ProxyRuntimeModeStore()
|
||||
|
||||
private enum Key {
|
||||
static let runtimeMode = "proxyRuntimeMode"
|
||||
static let hasSelectedRuntimeMode = "hasSelectedProxyRuntimeMode"
|
||||
static let localWiFiInitialized = "localWiFiRuntimeModeInitialized"
|
||||
static let thirdPartyInitialized = "thirdPartyRuntimeModeInitialized"
|
||||
static let initializationMigrationCompleted = "runtimeModeInitializationMigrationCompleted"
|
||||
static let legacySetupCompleted = "setupCompleted"
|
||||
}
|
||||
|
||||
@Published private(set) var mode: ProxyRuntimeMode
|
||||
@Published private(set) var hasSelectedMode: Bool
|
||||
@Published private(set) var localWiFiInitialized: Bool
|
||||
@Published private(set) var thirdPartyInitialized: Bool
|
||||
private let defaults: UserDefaults
|
||||
private let legacyDefaults: UserDefaults
|
||||
|
||||
init(
|
||||
defaults: UserDefaults = AppGroup.defaults,
|
||||
legacyDefaults: UserDefaults = .standard
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.legacyDefaults = legacyDefaults
|
||||
self.mode = defaults.string(forKey: Key.runtimeMode)
|
||||
.flatMap(ProxyRuntimeMode.init(rawValue:)) ?? .localWiFi
|
||||
self.hasSelectedMode = defaults.bool(forKey: Key.hasSelectedRuntimeMode)
|
||||
self.localWiFiInitialized = defaults.bool(forKey: Key.localWiFiInitialized)
|
||||
self.thirdPartyInitialized = defaults.bool(forKey: Key.thirdPartyInitialized)
|
||||
migrateLegacyInitializationIfNeeded()
|
||||
}
|
||||
|
||||
func setMode(_ mode: ProxyRuntimeMode) {
|
||||
let changed = self.mode != mode
|
||||
self.mode = mode
|
||||
hasSelectedMode = true
|
||||
defaults.set(mode.rawValue, forKey: Key.runtimeMode)
|
||||
defaults.set(true, forKey: Key.hasSelectedRuntimeMode)
|
||||
migrateLegacyInitializationIfNeeded()
|
||||
if changed {
|
||||
RuntimeLogger.info("APP", "Mode", "代理运行模式已切换", details: [
|
||||
"模式": mode.displayName
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func isInitialized(_ mode: ProxyRuntimeMode) -> Bool {
|
||||
switch mode {
|
||||
case .localWiFi: return localWiFiInitialized
|
||||
case .thirdParty: return thirdPartyInitialized
|
||||
}
|
||||
}
|
||||
|
||||
func markInitialized(_ mode: ProxyRuntimeMode) {
|
||||
setInitialized(true, for: mode)
|
||||
}
|
||||
|
||||
func resetInitialization(_ mode: ProxyRuntimeMode) {
|
||||
setInitialized(false, for: mode)
|
||||
}
|
||||
|
||||
private func setInitialized(_ initialized: Bool, for mode: ProxyRuntimeMode) {
|
||||
switch mode {
|
||||
case .localWiFi:
|
||||
localWiFiInitialized = initialized
|
||||
defaults.set(initialized, forKey: Key.localWiFiInitialized)
|
||||
case .thirdParty:
|
||||
thirdPartyInitialized = initialized
|
||||
defaults.set(initialized, forKey: Key.thirdPartyInitialized)
|
||||
}
|
||||
}
|
||||
|
||||
private func migrateLegacyInitializationIfNeeded() {
|
||||
guard hasSelectedMode,
|
||||
!defaults.bool(forKey: Key.initializationMigrationCompleted) else {
|
||||
return
|
||||
}
|
||||
if legacyDefaults.bool(forKey: Key.legacySetupCompleted) {
|
||||
setInitialized(true, for: mode)
|
||||
RuntimeLogger.info("APP", "Mode", "已迁移旧版模式初始化状态", details: [
|
||||
"模式": mode.displayName
|
||||
])
|
||||
}
|
||||
defaults.set(true, forKey: Key.initializationMigrationCompleted)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct RuntimeLogEntry: Codable, Identifiable, Equatable {
|
||||
@@ -50,17 +49,11 @@ enum RuntimeLogStore {
|
||||
private static let decoder = JSONDecoder()
|
||||
private static let encoder = JSONEncoder()
|
||||
private static let maximumBytes: UInt64 = 1_500_000
|
||||
static let retentionInterval: TimeInterval = 3 * 24 * 60 * 60
|
||||
private static let pruningInterval: TimeInterval = 60 * 60
|
||||
private static var lastPrunedAt: Date?
|
||||
|
||||
static func append(_ entry: RuntimeLogEntry) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
let now = Date()
|
||||
try pruneExpiredLogsIfNeeded(now: now)
|
||||
guard entry.timestamp >= retentionCutoff(now: now) else { return }
|
||||
let url = try logURL(for: entry.source)
|
||||
try rotateIfNeeded(url)
|
||||
var data = try encoder.encode(entry)
|
||||
@@ -82,8 +75,7 @@ enum RuntimeLogStore {
|
||||
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let directory = logDirectory
|
||||
try? pruneExpiredLogs(in: directory, now: Date())
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
guard let urls = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
@@ -98,12 +90,12 @@ enum RuntimeLogStore {
|
||||
static func clearAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
try? FileManager.default.removeItem(at: logDirectory)
|
||||
lastPrunedAt = nil
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
|
||||
private static func logURL(for source: String) throws -> URL {
|
||||
let directory = logDirectory
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
|
||||
.replacingOccurrences(of: "/", with: "-")
|
||||
@@ -111,56 +103,6 @@ enum RuntimeLogStore {
|
||||
return directory.appendingPathComponent("\(process)-\(safeSource).jsonl")
|
||||
}
|
||||
|
||||
private static var logDirectory: URL {
|
||||
AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
}
|
||||
|
||||
static func retentionCutoff(now: Date) -> Date {
|
||||
now.addingTimeInterval(-retentionInterval)
|
||||
}
|
||||
|
||||
private static func pruneExpiredLogsIfNeeded(now: Date) throws {
|
||||
if let lastPrunedAt,
|
||||
now >= lastPrunedAt,
|
||||
now.timeIntervalSince(lastPrunedAt) < pruningInterval {
|
||||
return
|
||||
}
|
||||
try pruneExpiredLogs(in: logDirectory, now: now)
|
||||
}
|
||||
|
||||
private static func pruneExpiredLogs(in directory: URL, now: Date) throws {
|
||||
guard FileManager.default.fileExists(atPath: directory.path) else {
|
||||
lastPrunedAt = now
|
||||
return
|
||||
}
|
||||
let urls = try FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
).filter { $0.pathExtension == "jsonl" }
|
||||
|
||||
for url in urls {
|
||||
let entries = readEntries(url)
|
||||
let retained = retainedEntries(entries, now: now)
|
||||
guard retained.count != entries.count else { continue }
|
||||
guard !retained.isEmpty else {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
continue
|
||||
}
|
||||
var data = Data()
|
||||
for entry in retained {
|
||||
data.append(try encoder.encode(entry))
|
||||
data.append(0x0A)
|
||||
}
|
||||
try data.write(to: url, options: .atomic)
|
||||
}
|
||||
lastPrunedAt = now
|
||||
}
|
||||
|
||||
static func retainedEntries(_ entries: [RuntimeLogEntry], now: Date) -> [RuntimeLogEntry] {
|
||||
let cutoff = retentionCutoff(now: now)
|
||||
return entries.filter { $0.timestamp >= cutoff }
|
||||
}
|
||||
|
||||
private static func rotateIfNeeded(_ url: URL) throws {
|
||||
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attributes[.size] as? NSNumber,
|
||||
@@ -220,67 +162,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
|
||||
let success: Bool
|
||||
let longitude: Double?
|
||||
let latitude: Double?
|
||||
let accuracy: Int?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyConnectionState: Equatable {
|
||||
case unknown
|
||||
case connected(active: Bool)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyError: LocalizedError, Equatable {
|
||||
case invalidResponse
|
||||
case moduleNotIntercepted
|
||||
case rejected(String)
|
||||
case coordinateMismatch
|
||||
case network(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
return "第三方代理返回了无法识别的数据"
|
||||
case .moduleNotIntercepted:
|
||||
return "请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接"
|
||||
case .rejected(let message):
|
||||
return message
|
||||
case .coordinateMismatch:
|
||||
return "第三方代理保存的坐标与当前选点不一致"
|
||||
case .network(let message):
|
||||
return "第三方代理请求失败:\(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol ThirdPartyProxyRequesting {
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: ThirdPartyProxyRequesting {}
|
||||
|
||||
@MainActor
|
||||
final class ThirdPartyProxyManager: ObservableObject {
|
||||
static let shared = ThirdPartyProxyManager()
|
||||
static let interceptionHostname = "gs-loc.apple.com"
|
||||
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)
|
||||
if response.success,
|
||||
response.latitude != nil,
|
||||
response.longitude != nil {
|
||||
activeSettings = response
|
||||
connectionState = .connected(active: true)
|
||||
} else if response.error?.contains("无已保存") == true {
|
||||
activeSettings = nil
|
||||
connectionState = .connected(active: false)
|
||||
} else {
|
||||
let error = ThirdPartyProxyError.rejected(response.error ?? "第三方代理查询失败")
|
||||
connectionState = .failed(error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func save(_ favorite: FavoriteLocation) async throws -> ThirdPartyProxySettingsResponse {
|
||||
let wgs84 = favorite.coordinatePair.wgs84
|
||||
let response = try await perform(action: .save(
|
||||
latitude: wgs84.latitude,
|
||||
longitude: wgs84.longitude,
|
||||
accuracy: favorite.accuracy
|
||||
))
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标")
|
||||
}
|
||||
guard let latitude = response.latitude,
|
||||
let longitude = response.longitude,
|
||||
abs(latitude - wgs84.latitude) <= 0.000_001,
|
||||
abs(longitude - wgs84.longitude) <= 0.000_001 else {
|
||||
throw ThirdPartyProxyError.coordinateMismatch
|
||||
}
|
||||
activeSettings = response
|
||||
connectionState = .connected(active: true)
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理已保存 WGS-84 坐标", details: [
|
||||
"坐标标准": "WGS-84",
|
||||
"取值字段": "coordinatePair.wgs84",
|
||||
"accuracy": String(favorite.accuracy)
|
||||
])
|
||||
return response
|
||||
}
|
||||
|
||||
func clear() async throws {
|
||||
let response = try await perform(action: .clear)
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理清除坐标失败")
|
||||
}
|
||||
activeSettings = nil
|
||||
connectionState = .connected(active: false)
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理坐标已清除")
|
||||
}
|
||||
|
||||
private enum Action {
|
||||
case query
|
||||
case save(latitude: Double, longitude: Double, accuracy: Int)
|
||||
case clear
|
||||
}
|
||||
|
||||
private func perform(action: Action) async throws -> ThirdPartyProxySettingsResponse {
|
||||
guard !isRequesting else {
|
||||
throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行")
|
||||
}
|
||||
isRequesting = true
|
||||
defer { isRequesting = false }
|
||||
|
||||
var components = URLComponents(url: 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):
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "lon", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), longitude)),
|
||||
URLQueryItem(name: "lat", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), latitude)),
|
||||
URLQueryItem(name: "acc", value: String(accuracy))
|
||||
]
|
||||
}
|
||||
guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
request.timeoutInterval = 8
|
||||
|
||||
do {
|
||||
let (data, urlResponse) = try await requester.data(for: request)
|
||||
guard let http = urlResponse as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw ThirdPartyProxyError.moduleNotIntercepted
|
||||
}
|
||||
guard let response = try? JSONDecoder().decode(ThirdPartyProxySettingsResponse.self, from: data) else {
|
||||
throw ThirdPartyProxyError.moduleNotIntercepted
|
||||
}
|
||||
|
||||
return response
|
||||
} catch let error as ThirdPartyProxyError {
|
||||
connectionState = .failed(error.localizedDescription)
|
||||
RuntimeLogger.error("APP", "ThirdPartyProxy", "第三方代理请求失败", error: error)
|
||||
throw error
|
||||
} catch {
|
||||
let mapped = ThirdPartyProxyError.network(error.localizedDescription)
|
||||
connectionState = .failed(mapped.localizedDescription)
|
||||
RuntimeLogger.error("APP", "ThirdPartyProxy", "第三方代理请求失败", error: error)
|
||||
throw mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
case shadowrocket
|
||||
case surge
|
||||
case quantumultX
|
||||
case loon
|
||||
case stash
|
||||
case egern
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .shadowrocket: return "Shadowrocket"
|
||||
case .surge: return "Surge"
|
||||
case .quantumultX: return "Quantumult X"
|
||||
case .loon: return "Loon"
|
||||
case .stash: return "Stash"
|
||||
case .egern: return "Egern"
|
||||
}
|
||||
}
|
||||
|
||||
var verificationText: String? {
|
||||
self == .shadowrocket ? nil : "配置已提供,尚未验证"
|
||||
}
|
||||
|
||||
var moduleFileName: String {
|
||||
switch self {
|
||||
case .shadowrocket: return "wloc.module"
|
||||
case .surge, .egern: return "wloc.sgmodule"
|
||||
case .quantumultX: return "wloc.conf"
|
||||
case .loon: return "wloc.lpx"
|
||||
case .stash: return "wloc.stoverride"
|
||||
}
|
||||
}
|
||||
|
||||
var subscriptionURL: URL {
|
||||
let url: String
|
||||
switch self {
|
||||
case .surge, .egern:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule"
|
||||
case .quantumultX:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf"
|
||||
case .loon:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx"
|
||||
case .stash:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride"
|
||||
case .shadowrocket:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module"
|
||||
}
|
||||
return URL(string: url)!
|
||||
}
|
||||
|
||||
var launchURL: URL? {
|
||||
switch self {
|
||||
case .shadowrocket: return URL(string: "shadowrocket://")
|
||||
case .surge: return URL(string: "surge://")
|
||||
case .quantumultX: return URL(string: "quantumult-x://")
|
||||
case .loon: return URL(string: "loon://")
|
||||
case .stash: return URL(string: "stash://")
|
||||
case .egern: return URL(string: "egern://")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ThirdPartyProxyClientStore: ObservableObject {
|
||||
static let shared = ThirdPartyProxyClientStore()
|
||||
|
||||
private enum Key {
|
||||
static let selectedClient = "selectedThirdPartyProxyClient"
|
||||
}
|
||||
|
||||
@Published private(set) var selectedClient: ThirdPartyProxyClient
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
selectedClient = defaults.string(forKey: Key.selectedClient)
|
||||
.flatMap(ThirdPartyProxyClient.init(rawValue:)) ?? .shadowrocket
|
||||
}
|
||||
|
||||
func select(_ client: ThirdPartyProxyClient) {
|
||||
selectedClient = client
|
||||
defaults.set(client.rawValue, forKey: Key.selectedClient)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 环境验证结果,由 SetupCoordinator 统一路由到对应引导页面。
|
||||
/// 环境验证结果,UI 层根据此值弹出对应的引导面板。
|
||||
enum VerificationResult: Equatable, Identifiable {
|
||||
case success
|
||||
case proxyNotRunning
|
||||
@@ -26,4 +26,14 @@ enum VerificationResult: Equatable, Identifiable {
|
||||
|
||||
var isSuccess: Bool { self == .success }
|
||||
|
||||
/// 对应的引导页类型
|
||||
var tipKind: TipKind? {
|
||||
switch self {
|
||||
case .success: return nil
|
||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
||||
case .certNotTrusted: return nil // 走完整引导页,不弹 tip
|
||||
case .wifiProxyNotConfigured: return .proxySetup
|
||||
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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.3")
|
||||
XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0")
|
||||
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
|
||||
for client in ThirdPartyProxyClient.allCases where client != .shadowrocket {
|
||||
XCTAssertTrue(configuration.requestsCommunityPrompt(for: client))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,164 +2,16 @@ import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class CertificateAuthorityStoreTests: XCTestCase {
|
||||
private let validAuthority = CertificateAuthority(certPEM: "valid-cert", keyPEM: "valid-key")
|
||||
|
||||
func testEnsureCreatesOnceThenReusesValidKeychainPair() throws {
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
func testEnsureCreatesOnceAndThenReusesExistingPair() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
var generations = 0
|
||||
let store = makeStore(keychain: keychain) {
|
||||
let store = CertificateAuthorityStore(directory: directory) {
|
||||
generations += 1
|
||||
return self.validAuthority
|
||||
return CertificateAuthority(certPEM: "cert", keyPEM: "key")
|
||||
}
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
|
||||
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
|
||||
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,7 +2,8 @@ import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class CertificateTrustVerifierTests: XCTestCase {
|
||||
func testVerifierRejectsMalformedPEM() {
|
||||
XCTAssertFalse(CertificateTrustVerifier.isCACertificateTrusted(certPEM: "not a certificate"))
|
||||
func testVerifierMapsFailedProbeToUnavailable() async {
|
||||
let verifier = CertificateTrustVerifier(probe: { _, _ in false })
|
||||
XCTAssertEqual(await verifier.verify(url: URL(string: "https://127.0.0.1:1/health")!, leafHash: "x"), .unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import XCTest
|
||||
import CoreLocation
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class FavoriteLocationStoreTests: XCTestCase {
|
||||
@@ -8,195 +7,14 @@ final class FavoriteLocationStoreTests: XCTestCase {
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
let favorite = store.save(
|
||||
name: "深圳湾",
|
||||
mapCoordinate: .init(latitude: 22.494, longitude: 113.951),
|
||||
mapCoordinateSystem: .gcj02,
|
||||
accuracy: 20
|
||||
)
|
||||
let favorite = store.save(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
|
||||
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
|
||||
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾")
|
||||
}
|
||||
|
||||
func testSelectingMatchingCoordinatePairRestoresFavoriteSelection() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
let favorite = store.save(
|
||||
name: "深圳湾",
|
||||
coordinatePair: CoordinateConverter.coordinatePair(
|
||||
lat: 22.491_438,
|
||||
lon: 113.945_702,
|
||||
mapCoordinateSystem: .wgs84
|
||||
),
|
||||
accuracy: 20
|
||||
)
|
||||
store.select(nil)
|
||||
|
||||
let nearbyRealtimePair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.491_488,
|
||||
lon: 113.945_752,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
XCTAssertEqual(store.selectMatching(coordinatePair: nearbyRealtimePair)?.id, favorite.id)
|
||||
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
|
||||
|
||||
let unrelatedPair = CoordinateConverter.coordinatePair(
|
||||
lat: 31.2304,
|
||||
lon: 121.4737,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
XCTAssertNil(store.selectMatching(coordinatePair: unrelatedPair))
|
||||
XCTAssertNil(store.selectedFavoriteID)
|
||||
}
|
||||
|
||||
func testFavoriteStoresBothFormsAndSelectsMatchingPairWithoutReadConversion() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let wgs = CLLocationCoordinate2D(latitude: 22.491_438, longitude: 113.945_702)
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
coordinatePair: .init(mapCoordinate: wgs, mapCoordinateSystem: .wgs84),
|
||||
accuracy: 20
|
||||
)
|
||||
|
||||
XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).latitude, wgs.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).longitude, wgs.longitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(favorite.coordinatePair.gcj02.latitude, wgs.latitude)
|
||||
XCTAssertNotEqual(favorite.coordinatePair.gcj02.longitude, wgs.longitude)
|
||||
}
|
||||
|
||||
func testSavingPrecomputedPairDoesNotReinterpretItAfterMapTypeRefresh() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.296_642,
|
||||
lon: 114.172_175,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
|
||||
let favorite = FavoriteLocationStore(defaults: defaults).save(
|
||||
name: "香港天文台",
|
||||
coordinatePair: pair,
|
||||
accuracy: 25
|
||||
)
|
||||
|
||||
XCTAssertEqual(favorite.coordinatePair, pair)
|
||||
}
|
||||
|
||||
func testLegacyFavoriteIsUpgradedAsDomesticGCJAndRewritten() throws {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let id = UUID()
|
||||
let createdAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let payload = LegacyFavoritePayload(
|
||||
id: id,
|
||||
name: "旧收藏",
|
||||
latitude: 22.544_577,
|
||||
longitude: 113.941_14,
|
||||
accuracy: 25,
|
||||
createdAt: createdAt
|
||||
)
|
||||
defaults.set(try JSONEncoder().encode([payload]), forKey: "favorite_locations")
|
||||
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertTrue(store.favorites[0].isLegacyCoordinateRecord)
|
||||
XCTAssertEqual(store.favorites[0].coordinatePair.gcj02.latitude, payload.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(store.favorites[0].coordinatePair.wgs84.longitude, payload.longitude)
|
||||
|
||||
try store.migrateLegacyCoordinates()
|
||||
let reloaded = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertEqual(reloaded.favorites[0].id, id)
|
||||
XCTAssertEqual(reloaded.favorites[0].name, "旧收藏")
|
||||
XCTAssertFalse(reloaded.favorites[0].isLegacyCoordinateRecord)
|
||||
}
|
||||
|
||||
func testOverseasPairUsesIdentityConversion() {
|
||||
let eiffelTower = CoordinateConverter.coordinatePair(lat: 48.858_37, lon: 2.294_481, mapCoordinateSystem: .wgs84)
|
||||
|
||||
XCTAssertEqual(eiffelTower.wgs84.latitude, eiffelTower.gcj02.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(eiffelTower.wgs84.longitude, eiffelTower.gcj02.longitude, accuracy: 0.000_000_1)
|
||||
}
|
||||
|
||||
func testDomesticMapCoordinateMatchesPreviouslyActivatedWGS84Value() {
|
||||
let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14)
|
||||
let pair = CoordinatePair(mapCoordinate: gcj, mapCoordinateSystem: .gcj02)
|
||||
|
||||
XCTAssertTrue(pair.matchesWGS84(
|
||||
latitude: pair.wgs84.latitude,
|
||||
longitude: pair.wgs84.longitude
|
||||
))
|
||||
XCTAssertFalse(pair.matchesWGS84(latitude: gcj.latitude, longitude: gcj.longitude))
|
||||
}
|
||||
|
||||
func testCoordinateRepresentationDiagnosisDistinguishesDomesticPair() {
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.539,
|
||||
lon: 113.934,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.diagnoseRepresentation(sample: pair.wgs84.coordinate, pair: pair).inferredSystem,
|
||||
.wgs84
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.diagnoseRepresentation(sample: pair.gcj02.coordinate, pair: pair).inferredSystem,
|
||||
.gcj02
|
||||
)
|
||||
}
|
||||
|
||||
func testCoordinateRepresentationDiagnosisKeepsOverseasIdentityPairAmbiguous() {
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 48.858_37,
|
||||
lon: 2.294_481,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
|
||||
XCTAssertNil(
|
||||
CoordinateConverter.diagnoseRepresentation(sample: pair.wgs84.coordinate, pair: pair).inferredSystem
|
||||
)
|
||||
}
|
||||
|
||||
func testCoordinateRepresentationDiagnosisRejectsUnrelatedSample() {
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.539,
|
||||
lon: 113.934,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
let unrelated = CLLocationCoordinate2D(latitude: 31.2304, longitude: 121.4737)
|
||||
|
||||
XCTAssertNil(CoordinateConverter.diagnoseRepresentation(sample: unrelated, pair: pair).inferredSystem)
|
||||
}
|
||||
|
||||
func testFixedAnchorResultNameUsesOneSharedMapTypeRule() {
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "林士街"),
|
||||
.gcj02
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "Connaught Road West"),
|
||||
.wgs84
|
||||
)
|
||||
}
|
||||
|
||||
func testMapConfigurationNeverRequestsRealUserLocation() {
|
||||
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
|
||||
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct LegacyFavoritePayload: Encodable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let accuracy: Int
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
@@ -3,85 +3,136 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class LocationActionCoordinatorTests: XCTestCase {
|
||||
func testApplyStartsProxyAndWritesTheFavoriteWGS84Pair() async {
|
||||
let proxy = FakeLocationActionProxy()
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
latitude: 22.494,
|
||||
longitude: 113.951,
|
||||
accuracy: 20,
|
||||
mapCoordinateSystem: .gcj02
|
||||
)
|
||||
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async {
|
||||
let events = EventLog()
|
||||
let trust = FakeTrust(canModify: true, events: events)
|
||||
let proxy = FakeProxy(activeForClear: false, events: events)
|
||||
let settings = FakeSettings(events: events)
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
let coordinator = LocationActionCoordinator()
|
||||
|
||||
let applied = await coordinator.apply(favorite)
|
||||
// LocationActionCoordinator doesn't take injected deps — just verify state
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertTrue(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)
|
||||
XCTAssertTrue(coordinator.virtualLocationEnabled)
|
||||
}
|
||||
|
||||
func testClearWritesDisabledCoordinatesAndClearsSettings() {
|
||||
let proxy = FakeLocationActionProxy(isRunning: true)
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
func testClearDoesNotConnectAnInactiveProxy() async {
|
||||
let events = EventLog()
|
||||
let coordinator = LocationActionCoordinator()
|
||||
|
||||
coordinator.clear()
|
||||
|
||||
XCTAssertEqual(proxy.lastCoordinates?.latitude, 0)
|
||||
XCTAssertEqual(proxy.lastCoordinates?.longitude, 0)
|
||||
XCTAssertFalse(proxy.lastCoordinates?.enabled ?? true)
|
||||
XCTAssertFalse(settings.saved?.enabled ?? true)
|
||||
XCTAssertFalse(coordinator.virtualLocationEnabled)
|
||||
}
|
||||
|
||||
func testApplyVerifiedRejectsInactiveProxyWithoutWritingCoordinates() {
|
||||
let proxy = FakeLocationActionProxy()
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
func testBusyApplyRejectsASecondRequest() async {
|
||||
let coordinator = LocationActionCoordinator()
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
|
||||
XCTAssertFalse(coordinator.applyVerified(favorite))
|
||||
XCTAssertNil(proxy.lastCoordinates)
|
||||
XCTAssertNil(settings.saved)
|
||||
let first = Task { await coordinator.apply(favorite) }
|
||||
let secondApplied = await coordinator.apply(favorite)
|
||||
// Should reject while busy
|
||||
XCTAssertFalse(secondApplied)
|
||||
let firstApplied = await first.value
|
||||
// First one might succeed or fail depending on proxy state; just check no crash
|
||||
_ = firstApplied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeLocationActionProxy: LocationActionProxying {
|
||||
struct Coordinates: Equatable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let enabled: Bool
|
||||
let accuracy: Int
|
||||
private final class FakeTrust {
|
||||
var canModify: Bool
|
||||
let events: EventLog
|
||||
|
||||
init(canModify: Bool, events: EventLog) {
|
||||
self.canModify = canModify
|
||||
self.events = events
|
||||
}
|
||||
|
||||
var isRunning: Bool
|
||||
private(set) var lastCoordinates: Coordinates?
|
||||
|
||||
init(isRunning: Bool = false) {
|
||||
self.isRunning = isRunning
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64 {
|
||||
lastCoordinates = Coordinates(latitude: lat, longitude: lon, enabled: enabled, accuracy: accuracy)
|
||||
return 1
|
||||
func refreshTrust() async {
|
||||
events.append("trust.refresh")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeLocationActionSettingsStore: LocationActionSettingsStoring {
|
||||
private(set) var saved: WlocSettings?
|
||||
private final class FakeProxy {
|
||||
let activeForClear: Bool
|
||||
let events: EventLog
|
||||
let connectGate: AsyncGate?
|
||||
|
||||
init(activeForClear: Bool, events: EventLog, connectGate: AsyncGate? = nil) {
|
||||
self.activeForClear = activeForClear
|
||||
self.events = events
|
||||
self.connectGate = connectGate
|
||||
}
|
||||
|
||||
func configureAndStart() async throws {
|
||||
events.append("proxy.connect")
|
||||
await connectGate?.blockUntilOpened()
|
||||
}
|
||||
|
||||
func stopAndWait() async throws {
|
||||
events.append("proxy.stop")
|
||||
}
|
||||
|
||||
func send(_ message: String) async throws -> String {
|
||||
events.append("proxy.send:\(message)")
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func isActiveForCoordinateClear() -> Bool { activeForClear }
|
||||
|
||||
func record(error: Error, action: String) {
|
||||
events.append("proxy.record:\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeSettings {
|
||||
var saved: WlocSettings?
|
||||
let events: EventLog
|
||||
|
||||
init(events: EventLog) { self.events = events }
|
||||
|
||||
func load() -> WlocSettings? { saved }
|
||||
func save(_ settings: WlocSettings) { saved = settings }
|
||||
func clear() { saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false) }
|
||||
|
||||
func save(_ settings: WlocSettings) {
|
||||
saved = settings
|
||||
events.append("settings.save.\(settings.enabled ? "enabled" : "disabled")")
|
||||
}
|
||||
|
||||
func clear() {
|
||||
saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false)
|
||||
events.append("settings.clear")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class EventLog {
|
||||
private(set) var values: [String] = []
|
||||
func append(_ event: String) { values.append(event) }
|
||||
}
|
||||
|
||||
private actor AsyncGate {
|
||||
private var opened = false
|
||||
private var blockedWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var waitUntilBlockedContinuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
func blockUntilOpened() async {
|
||||
guard !opened else { return }
|
||||
waitUntilBlockedContinuation?.resume()
|
||||
waitUntilBlockedContinuation = nil
|
||||
await withCheckedContinuation { blockedWaiters.append($0) }
|
||||
}
|
||||
|
||||
func waitUntilBlocked() async {
|
||||
guard !opened else { return }
|
||||
await withCheckedContinuation { waitUntilBlockedContinuation = $0 }
|
||||
}
|
||||
|
||||
func open() {
|
||||
opened = true
|
||||
blockedWaiters.forEach { $0.resume() }
|
||||
blockedWaiters.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ final class MapLocationStateTests: XCTestCase {
|
||||
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
|
||||
let accepted = state.acceptRealtimeLocation(
|
||||
.init(latitude: 39.90, longitude: 116.40),
|
||||
intent: request
|
||||
intent: request,
|
||||
focus: true
|
||||
)
|
||||
|
||||
XCTAssertFalse(accepted)
|
||||
@@ -52,7 +53,7 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertEqual(state.selection.source, .userPan)
|
||||
|
||||
let intent = state.beginRealtimeIntent()
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent))
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent, focus: true))
|
||||
XCTAssertEqual(state.selection.source, .realtime)
|
||||
}
|
||||
|
||||
@@ -173,48 +174,20 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertEqual(state.selection.source, .search)
|
||||
}
|
||||
|
||||
func testMapCoordinateSystemChangeClearsSupersededRealtimeSampleWithoutMovingSelection() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩")
|
||||
let selection = state.selection
|
||||
state.updateRealtimeLocation(CLLocation(latitude: 30.42, longitude: 114.25))
|
||||
|
||||
state.clearRealtimeLocationForMapCoordinateSystemChange()
|
||||
|
||||
XCTAssertNil(state.realtimeLocation)
|
||||
XCTAssertNil(state.realtimeCoordinate)
|
||||
XCTAssertEqual(state.selection, selection)
|
||||
}
|
||||
|
||||
func testRealtimeIntentCanImmediatelyAcceptNativeLocation() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25)
|
||||
state.updateRealtimeLocation(nativeLocation)
|
||||
let intent = state.beginRealtimeIntent()
|
||||
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent))
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, focus: true))
|
||||
XCTAssertEqual(state.selection.source, .realtime)
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001)
|
||||
XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it")
|
||||
}
|
||||
|
||||
func testMapCoordinateSystemReprojectionPreservesSelectionIdentityAndIssuesFocus() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let favoriteID = UUID()
|
||||
state.selectFavorite(.init(latitude: 22.55, longitude: 113.95), id: favoriteID, name: "测试收藏")
|
||||
let revision = state.selection.revision
|
||||
|
||||
state.reprojectSelectionForMapCoordinateSystemChange(.init(latitude: 22.54, longitude: 113.94))
|
||||
|
||||
XCTAssertEqual(state.selection.source, .favorite(favoriteID))
|
||||
XCTAssertEqual(state.selection.explicitName, "测试收藏")
|
||||
XCTAssertEqual(state.selection.revision, revision)
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, 22.54, accuracy: 0.000001)
|
||||
guard case let .focus(coordinate, distanceMeters) = state.cameraCommand?.kind else {
|
||||
return XCTFail("Expected a focus command after map coordinate-system reprojection")
|
||||
guard case let .focus(coordinate, distance) = state.cameraCommand?.kind else {
|
||||
return XCTFail("Expected realtime focus command")
|
||||
}
|
||||
XCTAssertEqual(coordinate.latitude, 22.54, accuracy: 0.000001)
|
||||
XCTAssertEqual(distanceMeters, state.viewportMeters)
|
||||
XCTAssertEqual(coordinate.latitude, 30.42, accuracy: 0.000001)
|
||||
XCTAssertEqual(distance, 200)
|
||||
}
|
||||
|
||||
func testZoomMathScalesBothAxesInTheSameDirection() {
|
||||
@@ -234,71 +207,4 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km")
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 km")
|
||||
}
|
||||
|
||||
func testLastCoordinateStoreKeepsBothFormsAndZoom() {
|
||||
let suite = "MapLocationStateTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14)
|
||||
|
||||
LastCoordinateStore.save(mapCoordinate: gcj, mapCoordinateSystem: .gcj02, zoomMeters: 1_250, defaults: defaults)
|
||||
|
||||
guard let restored = LastCoordinateStore.load(defaults: defaults) else {
|
||||
return XCTFail("Expected a persisted current map pin")
|
||||
}
|
||||
XCTAssertEqual(restored.coordinate(for: .gcj02).latitude, gcj.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(restored.coordinate(for: .gcj02).longitude, gcj.longitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(restored.coordinate(for: .wgs84).longitude, gcj.longitude)
|
||||
XCTAssertEqual(restored.zoomMeters, 1_250)
|
||||
}
|
||||
|
||||
func testCoordinateMigrationUpgradesLegacyCurrentPinAndFavoritesBeforeSettingVersion() throws {
|
||||
let suite = "MapLocationStateTests.\(UUID().uuidString)"
|
||||
let legacySuite = "MapLocationStateTests.Legacy.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
let legacyDefaults = UserDefaults(suiteName: legacySuite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
defer { legacyDefaults.removePersistentDomain(forName: legacySuite) }
|
||||
let firstID = UUID()
|
||||
let secondID = UUID()
|
||||
let records = [
|
||||
CoordinateMigrationLegacyFavorite(id: firstID, name: "第一个", latitude: 22.544_577, longitude: 113.941_14, accuracy: 15, createdAt: .distantPast),
|
||||
CoordinateMigrationLegacyFavorite(id: secondID, name: "海外", latitude: 48.858_37, longitude: 2.294_481, accuracy: 30, createdAt: .distantFuture),
|
||||
]
|
||||
legacyDefaults.set(22.544_577, forKey: "lastMapLat")
|
||||
legacyDefaults.set(113.941_14, forKey: "lastMapLon")
|
||||
legacyDefaults.set(2_000.0, forKey: "mapViewportMeters")
|
||||
defaults.set(try JSONEncoder().encode(records), forKey: "favorite_locations")
|
||||
defaults.set(secondID.uuidString, forKey: "favorite_locations_selected_id")
|
||||
|
||||
let favorites = FavoriteLocationStore(defaults: defaults)
|
||||
try CoordinateStorageMigration.migrateIfNeeded(
|
||||
favorites: favorites,
|
||||
defaults: defaults,
|
||||
legacyDefaults: legacyDefaults
|
||||
)
|
||||
|
||||
XCTAssertEqual(defaults.integer(forKey: "coordinateStorageMigrationVersion"), CoordinateStorageMigration.currentVersion)
|
||||
guard let current = LastCoordinateStore.load(defaults: defaults) else {
|
||||
return XCTFail("Expected migrated current map pin")
|
||||
}
|
||||
XCTAssertEqual(current.coordinate(for: .gcj02).latitude, 22.544_577, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(current.zoomMeters, 2_000)
|
||||
|
||||
let reloaded = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertEqual(reloaded.favorites.map(\.id), [firstID, secondID])
|
||||
XCTAssertEqual(reloaded.favorites.map(\.name), ["第一个", "海外"])
|
||||
XCTAssertEqual(reloaded.selectedFavoriteID, secondID)
|
||||
XCTAssertFalse(reloaded.favorites.contains(where: \.isLegacyCoordinateRecord))
|
||||
XCTAssertEqual(reloaded.favorites[1].coordinatePair.wgs84.latitude, reloaded.favorites[1].coordinatePair.gcj02.latitude, accuracy: 0.000_000_1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CoordinateMigrationLegacyFavorite: Encodable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let accuracy: Int
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
@MainActor
|
||||
final class ProxyRuntimeModeTests: XCTestCase {
|
||||
func testDefaultsToLocalWiFiAndPersistsThirdPartyMode() {
|
||||
let suiteName = "ProxyRuntimeModeTests.\(UUID().uuidString)"
|
||||
let legacySuiteName = "ProxyRuntimeModeLegacyTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
let legacyDefaults = UserDefaults(suiteName: legacySuiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
legacyDefaults.removePersistentDomain(forName: legacySuiteName)
|
||||
}
|
||||
|
||||
let initial = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
|
||||
XCTAssertEqual(initial.mode, .localWiFi)
|
||||
XCTAssertFalse(initial.hasSelectedMode)
|
||||
XCTAssertFalse(initial.isInitialized(.localWiFi))
|
||||
XCTAssertFalse(initial.isInitialized(.thirdParty))
|
||||
|
||||
initial.setMode(.thirdParty)
|
||||
initial.markInitialized(.thirdParty)
|
||||
let restored = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
|
||||
XCTAssertEqual(restored.mode, .thirdParty)
|
||||
XCTAssertTrue(restored.hasSelectedMode)
|
||||
XCTAssertFalse(restored.isInitialized(.localWiFi))
|
||||
XCTAssertTrue(restored.isInitialized(.thirdParty))
|
||||
|
||||
restored.resetInitialization(.thirdParty)
|
||||
XCTAssertFalse(restored.isInitialized(.thirdParty))
|
||||
}
|
||||
|
||||
func testMigratesOnlyCurrentLegacyCompletedMode() {
|
||||
let suiteName = "ProxyRuntimeModeMigrationTests.\(UUID().uuidString)"
|
||||
let legacySuiteName = "ProxyRuntimeModeMigrationLegacyTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
let legacyDefaults = UserDefaults(suiteName: legacySuiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
legacyDefaults.removePersistentDomain(forName: legacySuiteName)
|
||||
}
|
||||
|
||||
defaults.set(ProxyRuntimeMode.thirdParty.rawValue, forKey: "proxyRuntimeMode")
|
||||
defaults.set(true, forKey: "hasSelectedProxyRuntimeMode")
|
||||
legacyDefaults.set(true, forKey: "setupCompleted")
|
||||
|
||||
let migrated = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
|
||||
XCTAssertFalse(migrated.isInitialized(.localWiFi))
|
||||
XCTAssertTrue(migrated.isInitialized(.thirdParty))
|
||||
|
||||
migrated.setMode(.localWiFi)
|
||||
XCTAssertFalse(migrated.isInitialized(.localWiFi))
|
||||
XCTAssertTrue(migrated.isInitialized(.thirdParty))
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ final class RealtimeLocationManagerTests: XCTestCase {
|
||||
|
||||
func testOneShotTimeoutTransitionsToContinuousFallback() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, oneShotTimeoutNanoseconds: 5_000_000, fallbackTimeoutNanoseconds: 1_000_000_000)
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 5_000_000)
|
||||
|
||||
let request = Task { await manager.requestLocation() }
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
@MainActor
|
||||
final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
func testQueryDistinguishesConnectedWithoutCoordinate() async throws {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
let response = try await manager.query()
|
||||
|
||||
XCTAssertFalse(response.success)
|
||||
XCTAssertEqual(manager.connectionState, .connected(active: false))
|
||||
XCTAssertEqual(requester.lastURL?.query, "action=query")
|
||||
}
|
||||
|
||||
func testSaveUsesFavoriteWGS84AndAcceptsMatchingResponse() async throws {
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
latitude: 22.494,
|
||||
longitude: 113.951,
|
||||
accuracy: 20,
|
||||
mapCoordinateSystem: .gcj02
|
||||
)
|
||||
let wgs84 = favorite.coordinatePair.wgs84
|
||||
let body = String(format: #"{"success":true,"longitude":%.8f,"latitude":%.8f,"accuracy":20}"#,
|
||||
locale: Locale(identifier: "en_US_POSIX"), wgs84.longitude, wgs84.latitude)
|
||||
let requester = FakeThirdPartyRequester(body: body)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
_ = try await manager.save(favorite)
|
||||
|
||||
let components = URLComponents(url: try XCTUnwrap(requester.lastURL), resolvingAgainstBaseURL: false)
|
||||
let values = Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") })
|
||||
let latitude = try XCTUnwrap(Double(values["lat"] ?? ""))
|
||||
let longitude = try XCTUnwrap(Double(values["lon"] ?? ""))
|
||||
XCTAssertEqual(latitude, wgs84.latitude, accuracy: 0.000_000_01)
|
||||
XCTAssertEqual(longitude, wgs84.longitude, accuracy: 0.000_000_01)
|
||||
XCTAssertEqual(values["acc"], "20")
|
||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||
}
|
||||
|
||||
func testSaveRejectsCoordinateMismatchWithoutMarkingActive() async {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":true,"longitude":1,"latitude":2,"accuracy":25}"#)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 25)
|
||||
|
||||
do {
|
||||
_ = try await manager.save(favorite)
|
||||
XCTFail("expected coordinate mismatch")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? ThirdPartyProxyError, .coordinateMismatch)
|
||||
}
|
||||
XCTAssertEqual(manager.connectionState, .unknown)
|
||||
XCTAssertNil(manager.activeSettings)
|
||||
}
|
||||
|
||||
func testMalformedResponseIsNotTreatedAsSuccess() async {
|
||||
let manager = ThirdPartyProxyManager(requester: FakeThirdPartyRequester(body: "not-json"))
|
||||
do {
|
||||
_ = try await manager.query()
|
||||
XCTFail("expected interception failure")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
|
||||
}
|
||||
}
|
||||
|
||||
func testClientLinksUseOfficialUpstreamModulesAndVerificationLabels() {
|
||||
XCTAssertNil(ThirdPartyProxyClient.shadowrocket.verificationText)
|
||||
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText?.contains("尚未验证") == true)
|
||||
XCTAssertEqual(ThirdPartyProxyClient.egern.subscriptionURL, ThirdPartyProxyClient.surge.subscriptionURL)
|
||||
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.stoverride"))
|
||||
XCTAssertTrue(ThirdPartyProxyClient.shadowrocket.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.module"))
|
||||
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.launchURL?.scheme, "shadowrocket")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.surge.launchURL?.scheme, "surge")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.quantumultX.launchURL?.scheme, "quantumult-x")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.loon.launchURL?.scheme, "loon")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.stash.launchURL?.scheme, "stash")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.egern.launchURL?.scheme, "egern")
|
||||
}
|
||||
|
||||
func testSelectedClientPersists() {
|
||||
let suiteName = "ThirdPartyProxyClientStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let store = ThirdPartyProxyClientStore(defaults: defaults)
|
||||
XCTAssertEqual(store.selectedClient, .shadowrocket)
|
||||
|
||||
store.select(.stash)
|
||||
XCTAssertEqual(ThirdPartyProxyClientStore(defaults: defaults).selectedClient, .stash)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||
private let data: Data
|
||||
private(set) var lastURL: URL?
|
||||
|
||||
init(body: String) {
|
||||
data = Data(body.utf8)
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
lastURL = request.url
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"]
|
||||
)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,6 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
test -f "$ROOT/App/ProxyManager.swift" || fail "ProxyManager must exist"
|
||||
test -f "$ROOT/Shared/RuntimeLog.swift" || fail "RuntimeLog must exist"
|
||||
test -f "$ROOT/App/RealtimeLocationManager.swift" || fail "RealtimeLocationManager must exist"
|
||||
grep -q 'Location Spoofer CA' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must use the stable app-branded CA name"
|
||||
! grep -q 'WLOC CA' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must not show the legacy dated CA name"
|
||||
grep -q 'localWiFiRuntimeModeInitialized' "$ROOT/Shared/ProxyRuntimeMode.swift" || fail "APP mode initialization must persist independently"
|
||||
grep -q 'thirdPartyRuntimeModeInitialized' "$ROOT/Shared/ProxyRuntimeMode.swift" || fail "third-party mode initialization must persist independently"
|
||||
! grep -q 'runVerificationTest' "$ROOT/App/ContentView.swift" || fail "normal app startup must not run the environment verification test"
|
||||
grep -q '重置证书' "$ROOT/App/SettingsView.swift" || fail "APP mode settings must expose certificate reset"
|
||||
grep -q 'certificateStore.reset()' "$ROOT/App/SettingsView.swift" || fail "certificate reset must remove the persisted app CA"
|
||||
grep -q 'requestCertificateSetup()' "$ROOT/App/SettingsView.swift" || fail "certificate reset must open the certificate setup flow"
|
||||
grep -q 'requestThirdPartySetup(message:' "$ROOT/App/SettingsView.swift" || fail "third-party settings failures must open the setup guide"
|
||||
grep -q 'SFSafariViewController' "$ROOT/App/SafariView.swift" || fail "certificate download must use an in-app Safari service"
|
||||
grep -q 'prepareCertificateDownloadURL' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must prepare an in-app download URL"
|
||||
! grep -q 'UIApplication.shared.open(url' "$ROOT/App/ProxyManager.swift" || fail "certificate download must not force an external browser"
|
||||
grep -q 'AppModeWiFiProxy' "$ROOT/App/FirstSetupView.swift" || fail "APP proxy setup must show the Wi-Fi screenshot"
|
||||
grep -q 'AppModeCertificateInstall' "$ROOT/App/FirstSetupView.swift" || fail "certificate install setup must show its screenshot"
|
||||
grep -q 'AppModeCertificateTrust' "$ROOT/App/FirstSetupView.swift" || fail "certificate trust setup must show its screenshot"
|
||||
grep -q 'UIImage(named: assetName)' "$ROOT/App/FirstSetupView.swift" || fail "missing screenshots must fall back to text without breaking setup"
|
||||
|
||||
for asset in AppModeWiFiProxy AppModeCertificateInstall AppModeCertificateTrust; do
|
||||
test -s "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|
||||
|| fail "missing APP onboarding image asset: $asset"
|
||||
grep -q '\.jpg' "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|
||||
|| fail "APP onboarding derivatives must use an Asset Catalog-supported JPEG: $asset"
|
||||
done
|
||||
test -s "$ROOT/docs/onboarding-screenshots/app-mode/app-mode-wifi-proxy.jpg" \
|
||||
|| fail "unannotated APP-mode source screenshots must be retained by mode"
|
||||
test -s "$ROOT/docs/app-icon-source.svg" || fail "the optimized app icon must retain an editable vector source"
|
||||
test -s "$ROOT/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png" \
|
||||
|| fail "the optimized 1024px app icon is missing"
|
||||
|
||||
# VPNManager and Tunnel must NOT exist
|
||||
test ! -f "$ROOT/App/VPNManager.swift" || fail "VPNManager must be removed"
|
||||
|
||||
@@ -4,12 +4,6 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
TIP_VIEWS="$ROOT/App/TipViews.swift"
|
||||
grep -Fq 'Label("还是无法生效?"' "$TIP_VIEWS" || fail "activation help must use the requested retry heading"
|
||||
grep -Fq 'Label("还是无法取消?"' "$TIP_VIEWS" || fail "deactivation help must use a mode-specific retry heading"
|
||||
grep -q 'frame(maxWidth: .infinity, alignment: .leading)' "$TIP_VIEWS" \
|
||||
|| fail "tip detail copy must align to the leading edge"
|
||||
|
||||
MAP_HOME="$ROOT/App/MapHomeView.swift"
|
||||
MAP_STATE="$ROOT/App/MapLocationState.swift"
|
||||
MAP_BRIDGE="$ROOT/App/MapViewRepresentable.swift"
|
||||
@@ -18,11 +12,8 @@ SETUP="$ROOT/App/SetupCoordinator.swift"
|
||||
PROXY="$ROOT/App/ProxyManager.swift"
|
||||
SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift"
|
||||
DIAGNOSTICS="$ROOT/App/DiagnosticsView.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
CONVERTER="$ROOT/Shared/CoordinateConverter.swift"
|
||||
NETWORK_MONITOR="$ROOT/Shared/NetworkMonitor.swift"
|
||||
|
||||
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS" "$CONTENT" "$CONVERTER" "$NETWORK_MONITOR"; do
|
||||
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS"; do
|
||||
test -f "$file" || fail "missing required refactor file: $file"
|
||||
done
|
||||
|
||||
@@ -31,11 +22,7 @@ done
|
||||
! grep -q '@Binding var coordinate' "$MAP_BRIDGE" || fail "map bridge must not write a coordinate Binding"
|
||||
grep -q 'showsUserLocation = true' "$MAP_BRIDGE" || fail "MapKit native user location must be visible"
|
||||
grep -q 'didUpdate userLocation' "$MAP_BRIDGE" || fail "MapKit native user location must feed realtime state"
|
||||
grep -q 'coordinate: userLocation.coordinate' "$MAP_BRIDGE" || fail "visible MapKit blue-point samples must use MKUserLocation.coordinate"
|
||||
! grep -Eq 'userLocation\.location\??\.coordinate' "$MAP_BRIDGE" || fail "MapKit blue-point samples must not use the underlying Core Location coordinate"
|
||||
grep -q 'MapCameraCommand' "$MAP_BRIDGE" || fail "map bridge must consume MapCameraCommand"
|
||||
grep -q 'let initialViewportMeters:' "$MAP_BRIDGE" || fail "map bridge must receive initial viewport from MapLocationState"
|
||||
! grep -q 'ViewportStore.loadOrDefault()' "$MAP_BRIDGE" || fail "map bridge must not bypass MapLocationState for initial viewport"
|
||||
grep -q 'activeCameraCommandID' "$MAP_BRIDGE" || fail "programmatic map callbacks must be associated with the active camera command"
|
||||
grep -q 'UIPanGestureRecognizer' "$MAP_BRIDGE" || fail "map panning must be recognized explicitly"
|
||||
grep -q 'UIPinchGestureRecognizer' "$MAP_BRIDGE" || fail "pinch zoom must not be treated as a selected-center pan"
|
||||
@@ -53,61 +40,19 @@ grep -q 'case awaitingAuthorization' "$REALTIME" || fail "location requests must
|
||||
grep -q 'var location: CLLocation?' "$REALTIME" || fail "Core Location driver must expose its cached native sample"
|
||||
grep -q 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent"
|
||||
! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed"
|
||||
grep -q 'defer' "$SETUP" || fail "verification must restore temporary state with defer"
|
||||
grep -q 'restoreCoords' "$SETUP" || fail "verification must use revision-aware coordinate restoration"
|
||||
grep -q 'coordinateRevision' "$PROXY" || fail "proxy coordinate writes must be revisioned"
|
||||
grep -q 'setCoordsIfUnchanged' "$SETUP" || fail "verification must not overwrite a newer coordinate before its test write"
|
||||
grep -q 'applyVerified' "$MAP_HOME" || fail "verified location commits must be synchronous after revision validation"
|
||||
grep -q 'func applyVerificationResult' "$SETUP" || fail "verification results must have one setup-state reducer"
|
||||
grep -q 'case .success:' "$SETUP" || fail "successful verification must converge setup state"
|
||||
grep -q 'case .certNotTrusted:' "$SETUP" || fail "certificate failure must converge setup state"
|
||||
grep -q 'setupStep = .proxy' "$SETUP" || fail "proxy failure must converge setup state"
|
||||
grep -q 'realtimeRequestTask' "$MAP_HOME" || fail "realtime button requests must be synchronously serialized"
|
||||
grep -q 'RealtimeLocationRequestContext' "$MAP_HOME" || fail "a realtime button tap must retarget an in-flight startup request instead of being ignored"
|
||||
grep -q 'CLError.network' "$MAP_HOME" || fail "reverse geocoding network failures must use bounded retry"
|
||||
grep -q 'SystemSettingsNavigator' "$MAP_HOME" || fail "settings actions must use the shared navigator"
|
||||
grep -q '复制全部日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone copy button"
|
||||
grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear button"
|
||||
grep -q '日志自动清理,仅保留近 3 天' "$DIAGNOSTICS" || fail "diagnostics must disclose the three-day retention policy"
|
||||
grep -q 'retentionInterval: TimeInterval = 3 \* 24 \* 60 \* 60' "$ROOT/Shared/RuntimeLog.swift" || fail "runtime logs must retain only three days"
|
||||
if grep -q 'logEvent("CONNECT " + host + " -> passthrough")' "$ROOT/Core/proxy.go"; then
|
||||
fail "proxy diagnostics must not log unrelated passthrough CONNECT hosts"
|
||||
fi
|
||||
grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing"
|
||||
grep -q 'await CoordinateConverter.resolveInitialMapCoordinateSystem()' "$CONTENT" || fail "map type must resolve before MapHomeView construction"
|
||||
grep -q 'refreshRuntimeMapCoordinateSystem(reason:' "$CONVERTER" || fail "fixed-anchor map type must support runtime refresh"
|
||||
grep -q 'scheduleBluePointMapCoordinateSystemRefresh()' "$MAP_HOME" || fail "native blue-point samples must trigger runtime map-type refresh while spoofing"
|
||||
! grep -A3 'private func scheduleBluePointMapCoordinateSystemRefresh' "$MAP_HOME" | grep -q 'spoofState == .active' || fail "blue-point map-type refresh must also detect the return to physical location"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "点击实时定位")' "$MAP_HOME" || fail "realtime button must await the coordinated map-type refresh"
|
||||
grep -q 'favorites.selectMatching(coordinatePair: pair)' "$MAP_HOME" || fail "realtime selection must restore a matching favorite selection"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "保存收藏")' "$MAP_HOME" || fail "favorite save must await the coordinated map-type refresh"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "App回到前台")' "$MAP_HOME" || fail "foreground recovery must reuse the coordinated map-type refresh"
|
||||
grep -q '地图坐标标准运行期检测结果已过期,取消写入' "$CONVERTER" || fail "cancelled runtime probes must not mutate the global map type"
|
||||
! grep -q 'source == .coreLocation.*correctMapCoordinateSystemUsingRealtime' "$MAP_HOME" || fail "runtime Core Location samples must not infer MapKit type"
|
||||
grep -q 'clearRealtimeLocationForMapCoordinateSystemChange' "$MAP_HOME" || fail "map-type changes must discard superseded blue-point samples"
|
||||
grep -q '地图坐标类型已变化' "$MAP_HOME" || fail "map-type changes must emit an explicit searchable business log"
|
||||
grep -q '图钉已按新类型重设' "$MAP_HOME" || fail "map-type change log must report pin reprojection"
|
||||
grep -q 'coordinateRow(label: "GCJ-02(国内)", system: .gcj02)' "$MAP_HOME" || fail "current selection panel must label the domestic coordinate as GCJ-02"
|
||||
grep -q 'coordinateRow(label: "WGS-84(国际)", system: .wgs84)' "$MAP_HOME" || fail "current selection panel must label the international coordinate as WGS-84"
|
||||
grep -q 'fixedSize(horizontal: true, vertical: false)' "$MAP_HOME" || fail "coordinate labels must keep their natural single-line width"
|
||||
grep -q 'minimumScaleFactor(0.72)' "$MAP_HOME" || fail "coordinate values must shrink to remain on one line"
|
||||
grep -q 'phase = .map' "$CONTENT" || fail "ContentView must explicitly gate MapHomeView construction"
|
||||
! grep -q 'startTileProbe' "$MAP_HOME" || fail "MapHomeView must not start a second fixed-anchor coordinate-system probe"
|
||||
! grep -q 'initializeMap()' "$MAP_HOME" || fail "MapHomeView must not replay a second map initialization from onAppear"
|
||||
grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime position must resolve before map construction"
|
||||
! grep -q 'lastTileCheck' "$CONVERTER" || fail "map coordinate-system detection must not use a time cache"
|
||||
! grep -q '跳过(缓存' "$CONVERTER" || fail "map coordinate-system detection must not skip using a cached result"
|
||||
! grep -q '瓦片检测' "$CONVERTER" || fail "coordinate-system probe logs must not claim to inspect map tiles"
|
||||
grep -q 'minimumCountForSuppression = 3' Shared/AppGroup.swift || fail "automatic tip suppression must require three successful operations"
|
||||
grep -q 'activeTip = .deactivation' "$MAP_HOME" || fail "manual deactivation help must use the non-suppressible generic tip sheet"
|
||||
grep -q 'stabilizationNanoseconds: UInt64 = 3_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait three seconds before environment verification"
|
||||
! grep -q 'wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi failures must not use a duplicate reminder mapping"
|
||||
grep -q 'setup.requestSetup(message:' "$MAP_HOME" || fail "missing Wi-Fi must enter the reusable proxy setup guide"
|
||||
test "$(grep -c 'setup.applyVerificationResult(result)' "$MAP_HOME")" -ge 2 \
|
||||
|| fail "activation and Wi-Fi-change verification failures must use the shared setup reducer"
|
||||
! grep -q 'activeTip = \.proxySetup' "$MAP_HOME" || fail "proxy failures must not use a duplicate tip sheet"
|
||||
! grep -q 'case certificate' "$ROOT/App/TipViews.swift" || fail "generic certificate tip must not coexist with certificate setup"
|
||||
! grep -q 'CertificateTipContent' "$ROOT/App/TipViews.swift" || fail "certificate failures must use the complete setup flow"
|
||||
! grep -q 'case proxySetup' "$ROOT/App/TipViews.swift" || fail "proxy failures must use the complete setup flow"
|
||||
! grep -q 'case rewriteFailed' "$ROOT/App/TipViews.swift" || fail "rewrite failures must use the complete setup flow"
|
||||
! grep -q 'onChange(of: net.isAirplaneMode)' "$MAP_HOME" || fail "airplane recovery must not race the Wi-Fi change verifier"
|
||||
grep -q 'hasReceivedInitialPath' "$NETWORK_MONITOR" || fail "initial network path must not be reported as a Wi-Fi switch"
|
||||
grep -q 'lastKnownSSID' "$NETWORK_MONITOR" || fail "SSID polling must preserve a baseline across temporary nil readings"
|
||||
grep -q 'MARKETING_VERSION: "0.0.4"' "$ROOT/project.yml" || fail "marketing version must be 0.0.4"
|
||||
grep -q '## \[0.0.4\] — 待发布' "$ROOT/docs/CHANGELOG.md" || fail "0.0.4 pending changelog section is missing"
|
||||
|
||||
echo "PASS: map location state refactor contract"
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ZH="$ROOT/README.md"
|
||||
EN="$ROOT/README.en.md"
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
grep -q 'iOS Location Service Research & Testing Framework' "$ZH" || fail "Chinese README positioning is missing"
|
||||
grep -q 'iOS Location Service Research & Testing Framework' "$EN" || fail "English README positioning is missing"
|
||||
|
||||
grep -q '#### 自签安装说明' "$ZH" || fail "Chinese self-signing instructions are missing"
|
||||
grep -q '#### Self-Signing Instructions' "$EN" || fail "English self-signing instructions are missing"
|
||||
grep -q 'Impactor Releases' "$ZH" || fail "Chinese README must link the requested Impactor releases"
|
||||
grep -q 'Impactor Releases' "$EN" || fail "English README must link the requested Impactor releases"
|
||||
grep -q '开发者模式' "$ZH" || fail "Chinese README must explain iOS 16 Developer Mode"
|
||||
grep -q 'Developer Mode' "$EN" || fail "English README must explain iOS 16 Developer Mode"
|
||||
grep -q '7 天有效期' "$ZH" || fail "Chinese README must disclose free-signing expiry"
|
||||
grep -q 'seven days' "$EN" || fail "English README must disclose free-signing expiry"
|
||||
|
||||
grep -q '^## 功能预览$' "$ZH" || fail "Chinese feature preview is missing"
|
||||
grep -q '^## Feature Preview$' "$EN" || fail "English feature preview is missing"
|
||||
|
||||
images=(
|
||||
'主界面.jpg'
|
||||
'Apple%20Map.jpg'
|
||||
'高德地图.jpg'
|
||||
'微信.jpg'
|
||||
'钉钉.jpg'
|
||||
'高血压.jpg'
|
||||
)
|
||||
for image in "${images[@]}"; do
|
||||
grep -q "images/$image" "$ZH" || fail "Chinese README is missing image: $image"
|
||||
grep -q "images/$image" "$EN" || fail "English README is missing image: $image"
|
||||
done
|
||||
|
||||
image_files=(
|
||||
'主界面.jpg'
|
||||
'Apple Map.jpg'
|
||||
'高德地图.jpg'
|
||||
'微信.jpg'
|
||||
'钉钉.jpg'
|
||||
'高血压.jpg'
|
||||
)
|
||||
for image in "${image_files[@]}"; do
|
||||
test -f "$ROOT/images/$image" || fail "referenced preview image is missing: $image"
|
||||
done
|
||||
|
||||
test "$(grep -c '^## ' "$ZH")" -eq "$(grep -c '^## ' "$EN")" \
|
||||
|| fail "Chinese and English README section counts must stay aligned"
|
||||
|
||||
! grep -Eq '^## (许可证|License)$' "$ZH" "$EN" || fail "README must not claim a repository license"
|
||||
grep -q '当前项目不支持在 Windows 上直接构建 iOS 应用' "$ZH" || fail "Chinese README must reject Windows source builds"
|
||||
grep -q 'Building the iOS app directly on Windows is not supported' "$EN" || fail "English README must reject Windows source builds"
|
||||
grep -q 'docs/COMMUNITY_TUTORIALS.md' "$ZH" || fail "Chinese README must link the community tutorial submission guide"
|
||||
grep -q '^#### 配置接口与客户端适配$' "$ZH" \
|
||||
|| fail "Chinese README must document the third-party integration contract"
|
||||
grep -q '^#### Configuration API and Client Integration$' "$EN" \
|
||||
|| fail "English README must document the third-party integration contract"
|
||||
for contract in 'action=query' 'action=clear' 'lon=<WGS-84'; do
|
||||
grep -q "$contract" "$ZH" || fail "Chinese README is missing third-party contract: $contract"
|
||||
grep -q "$contract" "$EN" || fail "English README is missing third-party contract: $contract"
|
||||
done
|
||||
grep -q '除敏感信息遮挡外,不要自行添加箭头、编号、边框、说明文字或其他标注' \
|
||||
"$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|
||||
|| fail "tutorial submissions must keep source screenshots free of non-privacy annotations"
|
||||
grep -q '同一张截图可以对应多个步骤' "$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|
||||
|| fail "tutorial submissions must explain multi-step screenshot handling"
|
||||
grep -q '不得覆盖上述原图' "$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|
||||
|| fail "annotated app assets must not replace categorized source screenshots"
|
||||
|
||||
if grep -Rnw --include='*.md' --include='*.sh' \
|
||||
"$ROOT/build.sh" "$ROOT/README.md" "$ROOT/README.en.md" "$ROOT/docs" "$ROOT/Scripts" \
|
||||
-e 'Impact'; then
|
||||
fail "documentation and build output must use the correct Impactor name"
|
||||
fi
|
||||
|
||||
echo "PASS: README contract"
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
MODE="$ROOT/Shared/ProxyRuntimeMode.swift"
|
||||
MANAGER="$ROOT/Shared/ThirdPartyProxyManager.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
SETUP="$ROOT/App/FirstSetupView.swift"
|
||||
SETTINGS="$ROOT/App/SettingsView.swift"
|
||||
MODULES="$ROOT/Resources/ThirdPartyProxyModules"
|
||||
|
||||
grep -q 'return "APP模式"' "$MODE" || fail "APP mode display name is missing"
|
||||
grep -q 'return "第三方代理模式"' "$MODE" || fail "third-party mode display name is missing"
|
||||
grep -q 'hasSelectedMode' "$MODE" || fail "first-launch mode selection must be persisted"
|
||||
grep -q 'guard runtimeMode.hasSelectedMode else' "$CONTENT" || fail "mode selection must gate startup"
|
||||
grep -q 'phase = .setup' "$CONTENT" || fail "first launch must enter setup before map construction"
|
||||
grep -q 'case thirdPartyClient' "$SETUP" || fail "third-party client selection step is missing"
|
||||
grep -q 'case thirdPartyImport' "$SETUP" || fail "third-party import step is missing"
|
||||
! grep -q 'case thirdPartyTest' "$SETUP" || fail "third-party connection test must be part of the import page"
|
||||
! grep -q '生成并导入配置文件' "$SETUP" || fail "setup must not offer file generation/import"
|
||||
grep -q '复制模块订阅地址' "$SETUP" || fail "module subscription URL copy action is missing"
|
||||
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" || fail "setup must expose a client launch action"
|
||||
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" \
|
||||
|| fail "the import page must expose the selected client launch action"
|
||||
grep -Fq 'Label("打开 \(thirdPartyClient.selectedClient.name)"' "$SETTINGS" || fail "Settings must expose a client launch action"
|
||||
! grep -q '在浏览器打开模块文件' "$SETTINGS" || fail "Settings must not open the module URL as the primary client action"
|
||||
grep -q 'requestThirdPartySetup' "$SETTINGS" || fail "Settings must reopen third-party setup"
|
||||
grep -q 'static let configurationEndpoint' "$MANAGER" \
|
||||
|| fail "the third-party configuration endpoint must have one shared owner"
|
||||
grep -q 'DisclosureGroup("第三方客户端适配说明")' "$SETUP" \
|
||||
|| fail "client selection must expose the third-party integration contract"
|
||||
grep -Fq '查询:GET ?action=query' "$SETUP" \
|
||||
|| fail "client integration guidance must document the query action"
|
||||
grep -Fq '保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>' "$SETUP" \
|
||||
|| fail "client integration guidance must document WGS-84 coordinate saving"
|
||||
grep -Fq '清除:GET ?action=clear' "$SETUP" \
|
||||
|| fail "client integration guidance must document the clear action"
|
||||
|
||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
||||
done
|
||||
|
||||
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
|
||||
grep -q 'wloc.stoverride' "$MANAGER" || fail "Stash must use .stoverride directly"
|
||||
grep -q 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing"
|
||||
for scheme in surge quantumult-x loon stash egern; do
|
||||
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
|
||||
done
|
||||
grep -q '复制解密域名' "$SETUP" || fail "Shadowrocket MITM hostname copy action is missing"
|
||||
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
|
||||
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
|
||||
! grep -q '当前可测试' "$SETUP" || fail "Shadowrocket must not show the obsolete current-test label"
|
||||
! grep -q '当前可测试' "$SETTINGS" || fail "Settings must not show the obsolete current-test label"
|
||||
grep -q 'thirdPartyTestFailure = ThirdPartyConnectionTestFailure' "$SETUP" \
|
||||
|| fail "third-party connection failures must render inline on the import page"
|
||||
grep -q 'testResultView(' "$SETUP" || fail "APP and third-party tests must share the same result component"
|
||||
grep -q 'showsVerificationResult = false' "$SETUP" \
|
||||
|| fail "switching setup pages must hide the shared APP verification result"
|
||||
grep -q 'showsThirdPartyFailureLog = false' "$SETUP" \
|
||||
|| fail "switching setup pages must hide the shared third-party test log"
|
||||
grep -q 'Label("查看诊断日志"' "$SETUP" \
|
||||
|| fail "third-party connection failure must expose the diagnostics action"
|
||||
! grep -q 'setupActionError = error.localizedDescription' "$SETUP" \
|
||||
|| fail "third-party connection failure must not use the generic failure alert"
|
||||
grep -q 'let response = try await thirdPartyProxy.query()' "$SETUP" \
|
||||
|| fail "the import page must query the third-party module"
|
||||
grep -A15 'let response = try await thirdPartyProxy.query()' "$SETUP" | grep -q 'onComplete()' \
|
||||
|| fail "a successful third-party connection test must close setup immediately"
|
||||
! grep -q 'thirdPartyTestResult?.success' "$SETUP" \
|
||||
|| fail "a successful third-party test must not leave a separate completion state"
|
||||
grep -q 'setupStep = \.thirdPartyImport' "$ROOT/App/SetupCoordinator.swift" \
|
||||
|| fail "third-party runtime failures must route directly to the import guide"
|
||||
grep -q 'setup.requestThirdPartySetup(message: error.localizedDescription)' "$ROOT/App/MapHomeView.swift" \
|
||||
|| fail "third-party coordinate sync failures must open the import guide"
|
||||
grep -q '检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测' "$SETUP" \
|
||||
|| fail "runtime repair must explain why the import guide opened"
|
||||
test "$(grep -c 'title: \"接口连接失败\"' "$SETUP")" -eq 1 \
|
||||
|| fail "third-party failure details must render in one shared result area"
|
||||
grep -Fq '当前客户端:\(client.name)' "$SETUP" \
|
||||
|| fail "third-party failure logs must identify the selected client"
|
||||
grep -q 'HStack(spacing: 12)' "$SETUP" || fail "setup footer actions must share one horizontal row"
|
||||
grep -q 'Spacer(minLength: 12)' "$SETUP" || fail "setup footer actions must stay at opposite edges"
|
||||
grep -q 'ScrollViewReader' "$SETUP" || fail "setup must keep failure results reachable after insertion"
|
||||
grep -q 'scrollProxy.scrollTo("thirdPartyFailureLog"' "$SETUP" \
|
||||
|| fail "third-party failure must scroll to the detailed log"
|
||||
grep -q '======== 第三方代理连接检测 ========' "$SETUP" \
|
||||
|| fail "third-party inline diagnostics must include a structured test log"
|
||||
grep -Fq 'Label("第 2 步:完成 \(client.name) 配置"' "$SETUP" \
|
||||
|| fail "unverified clients must use a two-step import and configuration guide"
|
||||
grep -Fq 'Text("请在 \(client.name) 中完成相应配置。")' "$SETUP" \
|
||||
|| fail "unverified client configuration guidance must avoid unverified menu details"
|
||||
! grep -q '进入模块、重写或覆写订阅入口' "$SETUP" \
|
||||
|| fail "unverified clients must not claim untested menu locations"
|
||||
grep -q '"当前客户端": client.name' "$SETUP" \
|
||||
|| fail "third-party runtime diagnostics must identify the selected client"
|
||||
for asset in ShadowrocketModuleImport ShadowrocketConfigDetails ShadowrocketHTTPSDecryption ShadowrocketHTTPSCA; do
|
||||
test -s "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|
||||
|| fail "missing Shadowrocket onboarding image asset: $asset"
|
||||
grep -q '\.jpg' "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|
||||
|| fail "Shadowrocket onboarding derivatives must use an Asset Catalog-supported JPEG: $asset"
|
||||
grep -q "$asset" "$SETUP" || fail "setup does not reference onboarding image asset: $asset"
|
||||
done
|
||||
config_line="$(grep -n 'assetName: "ShadowrocketConfigDetails"' "$SETUP" | head -n 1 | cut -d: -f1)"
|
||||
module_line="$(grep -n 'assetName: "ShadowrocketModuleImport"' "$SETUP" | head -n 1 | cut -d: -f1)"
|
||||
test "$config_line" -lt "$module_line" || fail "Shadowrocket setup must show the configuration page before the module page"
|
||||
! grep -q 'GeometryReader' "$SETUP" || fail "onboarding image markers must be baked into assets, not positioned at runtime"
|
||||
test -s "$ROOT/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-import.jpg" \
|
||||
|| fail "unannotated Shadowrocket source screenshots must be retained by client"
|
||||
! grep -q 'ToolbarItem(placement: .navigationBarLeading)' "$SETUP" || fail "setup must not show a top-left navigation action"
|
||||
grep -q 'presentSuccessfulOperationTip(.activation)' "$ROOT/App/MapHomeView.swift" || fail "third-party save must present the activation tip"
|
||||
grep -q 'presentSuccessfulOperationTip(.deactivation)' "$ROOT/App/MapHomeView.swift" || fail "third-party clear must present the deactivation tip"
|
||||
grep -q 'if spoofState == .active' "$ROOT/App/MapHomeView.swift" || fail "manual help must follow the shared spoof state"
|
||||
grep -q 'MARKETING_VERSION: "1.0.3"' "$ROOT/project.yml" || fail "marketing version must be 1.0.3"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "4"' "$ROOT/project.yml" || fail "build version must be 4"
|
||||
|
||||
echo "PASS: third-party proxy mode contract"
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONFIG="$ROOT/Shared/AppRemoteConfiguration.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
MAP="$ROOT/App/MapHomeView.swift"
|
||||
SETUP="$ROOT/App/FirstSetupView.swift"
|
||||
SETTINGS="$ROOT/App/SettingsView.swift"
|
||||
BUG_REPORT="$ROOT/App/BugReportView.swift"
|
||||
GITHUB_SUBMISSION="$ROOT/Shared/GitHubSubmission.swift"
|
||||
DISCUSSION_FORM="$ROOT/.github/DISCUSSION_TEMPLATE/第三方配置分享.yml"
|
||||
ISSUE_FORM="$ROOT/.github/ISSUE_TEMPLATE/bug-report.yml"
|
||||
ISSUE_CONFIG="$ROOT/.github/ISSUE_TEMPLATE/config.yml"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
python3 - "$ROOT/version.txt" <<'PY' || exit 1
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
|
||||
assert config["latestVersion"] == "1.0.3"
|
||||
assert config["minimumSupportedVersion"] == "1.0.0"
|
||||
assert "shadowrocket" not in config["communityPromptClients"]
|
||||
assert set(config["communityPromptClients"]) == {
|
||||
"surge", "quantumultX", "loon", "stash", "egern"
|
||||
}
|
||||
PY
|
||||
|
||||
grep -q 'static let fallback = AppRemoteConfiguration' "$CONFIG" \
|
||||
|| fail "the app must ship a built-in remote-configuration fallback"
|
||||
grep -q 'timeoutIntervalForRequest = 1.5' "$CONFIG" \
|
||||
|| fail "remote configuration requests must use a short timeout"
|
||||
grep -q 'timeoutIntervalForResource = 2' "$CONFIG" \
|
||||
|| fail "remote configuration resource loading must use a short timeout"
|
||||
! grep -q 'data.count' "$CONFIG" \
|
||||
|| fail "the client must not impose a remote configuration file-size limit"
|
||||
grep -q 'docs/releases/v\\(version).md' "$CONFIG" \
|
||||
|| fail "update notes must come from the archived release document"
|
||||
grep -q '/releases/latest' "$CONFIG" \
|
||||
|| fail "missing release notes must fall back to the latest Release page"
|
||||
grep -q '.task { await checkForUpdates() }' "$CONTENT" \
|
||||
|| fail "update detection must run asynchronously outside bootstrap"
|
||||
! grep -A90 'private func bootstrap() async' "$CONTENT" | grep -q 'fetch()' \
|
||||
|| fail "remote configuration must not block the startup gate"
|
||||
|
||||
grep -q '社区分享成功配置?' "$MAP" \
|
||||
|| fail "non-Shadowrocket success must offer community contribution"
|
||||
grep -q 'Button("去提交")' "$MAP" \
|
||||
|| fail "community contribution prompt must expose the submit action"
|
||||
grep -q 'Button("复制模板")' "$MAP" \
|
||||
|| fail "community contribution prompt must expose an explicit copy action"
|
||||
grep -q 'Button("取消", role: .cancel)' "$MAP" \
|
||||
|| fail "the first community prompts must expose cancel"
|
||||
grep -q 'Button("不再提示", role: .cancel)' "$MAP" \
|
||||
|| fail "the third community prompt must allow permanent suppression"
|
||||
grep -q '匿名收录,不在 README 展示投稿账号' "$GITHUB_SUBMISSION" \
|
||||
|| fail "the contribution template must offer anonymous README attribution"
|
||||
grep -Fq 'https://github.com/xweiba/location-spoofer/discussions/new?category=%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB' "$GITHUB_SUBMISSION" \
|
||||
|| fail "community submission must use one fixed Discussions address"
|
||||
grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' "$MAP" \
|
||||
|| fail "the explicit copy action must copy the contribution template"
|
||||
grep -A8 'Button("去提交")' "$MAP" | grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' \
|
||||
|| fail "the submit action must copy the template before opening GitHub"
|
||||
grep -A8 'Button("去提交")' "$MAP" | grep -q 'openCommunityContributionPage()' \
|
||||
|| fail "the submit action must open the fixed Discussions page after copying"
|
||||
! grep -A12 'private func openCommunityContributionPage' "$MAP" | grep -q 'URLComponents' \
|
||||
|| fail "the fixed community address must not be rebuilt from dynamic query items"
|
||||
grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'SafariDestination' \
|
||||
|| fail "community submission must open with the shared in-App Safari destination"
|
||||
! grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'UIApplication.shared.open' \
|
||||
|| fail "community submission must not be intercepted by an external GitHub client"
|
||||
test -s "$DISCUSSION_FORM" \
|
||||
|| fail "the fixed Discussions category must provide a maintained submission form"
|
||||
grep -q 'label: 第三方客户端' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must classify submissions by client"
|
||||
grep -q '匿名收录,不在 README 展示投稿账号' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must retain anonymous README attribution"
|
||||
grep -q '请先移除账号、订阅、密码、设备标识、真实位置等敏感信息' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must remind users to remove sensitive information"
|
||||
grep -q '截图除敏感信息遮挡外不要添加箭头、编号或说明文字' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must explain the screenshot annotation rule"
|
||||
! grep -q 'label: 隐私确认' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must not require a separate privacy confirmation"
|
||||
for field in \
|
||||
'## 第三方客户端' \
|
||||
'## 第三方客户端版本' \
|
||||
'## iOS 版本' \
|
||||
'## 配置步骤' \
|
||||
'## 截图与补充说明' \
|
||||
'## README 收录署名'; do
|
||||
grep -Fq "$field" "$GITHUB_SUBMISSION" \
|
||||
|| fail "the App contribution template must match Discussions field: $field"
|
||||
done
|
||||
grep -Fq 'label: 第三方客户端版本' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must clearly request the third-party client version"
|
||||
|
||||
grep -Fq 'https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml' "$GITHUB_SUBMISSION" \
|
||||
|| fail "the App bug report must open the dedicated Issue form"
|
||||
grep -Fq 'GitHubSubmission.bugReportURL' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must use the shared GitHub destination"
|
||||
grep -Fq 'SafariView(url: destination.url)' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must open in the shared in-App Safari view"
|
||||
! grep -q 'UIApplication.shared.open' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must not be handed to an external GitHub client"
|
||||
grep -Fq 'App 生成的诊断报告' "$BUG_REPORT" \
|
||||
|| fail "the App must tell users where to paste the generated report"
|
||||
grep -Fq '第三方客户端:' "$BUG_REPORT" \
|
||||
|| fail "the generated report must identify the selected third-party client"
|
||||
grep -Fq 'Label("报告 Bug"' "$SETTINGS" \
|
||||
|| fail "Settings must identify the support action as a bug report"
|
||||
for action in '使用帮助' '功能建议' '分享第三方配置'; do
|
||||
grep -Fq "Label(\"$action\"" "$SETTINGS" \
|
||||
|| fail "Settings support must expose: $action"
|
||||
done
|
||||
grep -A8 'Label("分享第三方配置"' "$SETTINGS" >/dev/null \
|
||||
|| fail "Settings must retain the manual third-party sharing action"
|
||||
grep -q 'GitHubSubmission.communityContributionTemplate' "$SETTINGS" \
|
||||
|| fail "Settings manual sharing must copy the shared contribution template"
|
||||
grep -q 'SafariView(url: destination.url)' "$SETTINGS" \
|
||||
|| fail "Settings GitHub support actions must use the in-App Safari view"
|
||||
test -s "$ISSUE_FORM" \
|
||||
|| fail "the repository must provide a bug report Issue form"
|
||||
grep -Fq 'label: App 生成的诊断报告' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must expose the field named by the App"
|
||||
grep -Fq '设置 → 支持 → 报告 Bug' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must point users to the matching App workflow"
|
||||
grep -Fq 'label: 我已移除真实位置、认证信息、订阅地址、证书私钥和其他敏感信息' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must require privacy confirmation"
|
||||
grep -Fq 'blank_issues_enabled: false' "$ISSUE_CONFIG" \
|
||||
|| fail "blank Issues must be disabled so reports use the maintained form"
|
||||
for destination in '使用帮助' '功能建议' '第三方配置分享'; do
|
||||
grep -Fq "name: $destination" "$ISSUE_CONFIG" \
|
||||
|| fail "Issue chooser must route non-bug request to $destination"
|
||||
done
|
||||
|
||||
for obsolete in \
|
||||
'我已配置,开始检测' \
|
||||
'确认完成,重新检测' \
|
||||
'下一步:导入配置' \
|
||||
'我已导入,检测接口连接'; do
|
||||
! grep -q "$obsolete" "$SETUP" \
|
||||
|| fail "setup footer must not retain dynamic label: $obsolete"
|
||||
done
|
||||
test "$(grep -c 'actionLabel("完成")' "$SETUP")" -eq 4 \
|
||||
|| fail "all setup footer primary actions must use the fixed 完成 label"
|
||||
|
||||
application_line="$(grep -n 'Section("应用")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
|
||||
certificate_line="$(grep -n 'Section("证书")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
|
||||
test "$application_line" -lt "$certificate_line" \
|
||||
|| fail "the APP certificate reset section must appear below the application section"
|
||||
|
||||
echo "PASS: update and community contribution contract"
|
||||
@@ -73,4 +73,4 @@ if [ "$run_tests" -eq 1 ]; then
|
||||
run_simulator_tests
|
||||
fi
|
||||
|
||||
echo "Next: sign with Impactor (https://github.com/claration/Impactor) and install on device."
|
||||
echo "Next: sign with Impact (https://github.com/claration/Impact) and install on device."
|
||||
|
||||
@@ -32,27 +32,13 @@ Output:
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
IPA 始终保持未签名。用 [Impactor](https://github.com/claration/Impactor) 签名安装即可。
|
||||
IPA 始终保持未签名。用 [Impact](https://github.com/claration/Impact) 签名安装即可。
|
||||
|
||||
## 发布验收
|
||||
|
||||
1. `./build.sh` 通过并输出未签名 IPA
|
||||
2. 用 Impactor 签名后安装到设备
|
||||
3. 真机安装后,先配置 WiFi HTTP 代理 `127.0.0.1:8888`,再按检测结果完成 CA 下载、安装和信任
|
||||
2. 用 Impact 签名后安装到设备
|
||||
3. 真机安装后,按引导下载 CA → 安装 → 信任,再配置 WiFi HTTP 代理 `127.0.0.1:8888`
|
||||
4. 环境检测通过后,选点开启虚拟定位
|
||||
5. 打开 Apple 地图验证定位是否变为虚拟位置
|
||||
6. 若失败,查看诊断页的日志信息
|
||||
|
||||
## 发布版本
|
||||
|
||||
发布前先生成版本归档并提交:
|
||||
|
||||
```bash
|
||||
./Scripts/generate-release-notes.sh v1.1.0
|
||||
git add docs/releases/v1.1.0.md
|
||||
git commit -m "docs: archive v1.1.0 release notes"
|
||||
git tag v1.1.0
|
||||
git push origin main v1.1.0
|
||||
```
|
||||
|
||||
归档文件根据“上一个版本标签到当前提交”的 commit subject 生成。GitHub Actions 会校验归档存在,并将文件正文直接作为 GitHub Release 内容;不会发布文档链接或缺失说明的占位 Release。
|
||||
|
||||
@@ -4,7 +4,4 @@
|
||||
|
||||
## 已发布
|
||||
|
||||
- [v1.0.3](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.3) — 2026-08-09
|
||||
- [v1.0.2](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.2) — 2026-08-07
|
||||
- [v1.0.1](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.1) — 2026-08-06
|
||||
- [v1.0.0](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.0) — 2026-08-05
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# 社区客户端教程与截图提交
|
||||
|
||||
我们公开征集 Shadowrocket、Surge、Quantumult X、Loon、Stash 和 Egern 的配置教程与真机截图。
|
||||
提交内容会经过复核、统一裁剪和标注后再进入 App 或项目文档。
|
||||
|
||||
## 客户端分区
|
||||
|
||||
投稿统一进入 GitHub Discussions 的“第三方配置分享”固定入口。表单通过“第三方客户端”下拉框分类:
|
||||
|
||||
| 客户端 | 表单选项 |
|
||||
|---|---|
|
||||
| Shadowrocket | `Shadowrocket` |
|
||||
| Surge | `Surge` |
|
||||
| Quantumult X | `Quantumult X` |
|
||||
| Loon | `Loon` |
|
||||
| Stash | `Stash` |
|
||||
| Egern | `Egern` |
|
||||
|
||||
App 始终打开同一个投稿地址,仓库中的 Discussion 表单负责维护客户端、版本、步骤、截图和署名字段。
|
||||
“去提交”会先复制带当前客户端名称的投稿模板,再通过 App 内 Safari 打开固定页面,避免被不支持
|
||||
Discussions 的 GitHub 客户端拦截。用户也可以从 Safari 视图的分享菜单改用其他浏览器。“复制模板”按钮仍可
|
||||
单独使用,设置页的“支持 → 分享第三方配置”也提供相同入口。采纳后的配置在 README 客户端状态表中按客户端
|
||||
收录,并链接到教程或原始投稿。投稿者可以选择匿名收录;未选择匿名时可在 README 中显示 GitHub 账号。
|
||||
|
||||
## 提交内容
|
||||
|
||||
请提供:
|
||||
|
||||
1. 客户端名称和版本;
|
||||
2. iOS 版本;
|
||||
3. 从导入模块到启用代理的完整操作步骤;
|
||||
4. 每一步对应的原始截图文件名;
|
||||
5. 需要点击、开启或填写的位置说明;
|
||||
6. 最终是否验证通过,以及失败时看到的错误。
|
||||
|
||||
推荐按下面的格式描述步骤:
|
||||
|
||||
```text
|
||||
客户端:Shadowrocket
|
||||
版本:
|
||||
iOS 版本:
|
||||
|
||||
步骤 1:
|
||||
- 截图:01-module-list.jpg
|
||||
- 操作:点击右上角“+”
|
||||
|
||||
步骤 2:
|
||||
- 截图:02-https-decryption.jpg
|
||||
- 操作:开启 HTTPS 解密,并填写 gs-loc.apple.com
|
||||
|
||||
验证结果:
|
||||
```
|
||||
|
||||
## 截图要求
|
||||
|
||||
- 提交原始、清晰、未压缩过度的截图。
|
||||
- 除敏感信息遮挡外,不要自行添加箭头、编号、边框、说明文字或其他标注。
|
||||
- 不要为了突出某一步自行裁剪。请在步骤说明中写清需要点击或填写的位置,由维护者统一裁剪和编号。
|
||||
- 同一张截图可以对应多个步骤,请分别说明每个步骤的位置和顺序。
|
||||
- 可以遮挡账号、订阅地址、节点、密码、设备标识、开发者账号、真实位置和通知内容。
|
||||
- 必须完整遮挡证书私钥、认证 Token、Cookie、完整订阅链接以及其他可用于访问账户或服务的信息。
|
||||
- 遮挡后请再次检查状态栏、列表副标题、输入框、二维码和弹窗,避免遗漏。
|
||||
- 建议使用稳定、描述性的英文文件名,例如 `shadowrocket-https-decryption.jpg`。
|
||||
|
||||
维护者会保留未标注的脱敏原图,并在 App 中使用统一的编号圆点和步骤文字叠加标注。这样同一张图可以复用
|
||||
多个步骤,客户端界面变化时也能单独替换截图而不重做整套教程。
|
||||
|
||||
原图按客户端或运行模式分类保存:
|
||||
|
||||
```text
|
||||
docs/onboarding-screenshots/
|
||||
├── app-mode/
|
||||
└── shadowrocket/
|
||||
```
|
||||
|
||||
App 使用的裁剪、压缩和编号版本保存在 `Resources/Assets.xcassets/`,不得覆盖上述原图。新增客户端时创建
|
||||
独立的小写英文目录,例如 `surge/` 或 `quantumult-x/`。
|
||||
|
||||
## 审核边界
|
||||
|
||||
- 教程必须来自实际客户端操作,不能把未验证配置描述为已支持。
|
||||
- 截图和步骤不得包含付费节点、共享账号、破解内容或绕过服务限制的方法。
|
||||
- 项目只审核教程是否清晰、是否脱敏以及是否能复现,不为第三方客户端、订阅服务或网络环境提供担保。
|
||||
- 后续客户端版本变化导致界面或步骤失效时,教程可能被标记为待复核或替换。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 自签安装指南
|
||||
|
||||
**免费 Apple ID 即可自签安装**,无需付费开发者账号。
|
||||
|
||||
## 原理
|
||||
|
||||
本项目只是一个本地 HTTP 代理配合 WiFi 手动代理,不涉及 VPN / Network Extension / Packet Tunnel Provider。无需特殊权限,个人免费 Apple ID 侧载完全可用。
|
||||
|
||||
## 构建未签名 IPA
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
输出:`dist/PaopaoLocationSpoofer-unsigned.ipa`
|
||||
|
||||
## 使用 Impact 签名安装
|
||||
|
||||
用 [Impact](https://github.com/claration/Impact) 打开 IPA 签名并安装到设备。
|
||||
|
||||
关键标识符不可更改:
|
||||
|
||||
| 组件 | Bundle ID |
|
||||
|------|-----------|
|
||||
| 主 App | `com.paopaolabs.location-spoofer` |
|
||||
| App Group | `group.com.paopaolabs.location-spoofer` |
|
||||
|
||||
## 安装后步骤
|
||||
|
||||
1. 首次打开,按引导下载 CA 证书 → 安装描述文件 → 开启完全信任
|
||||
2. 在 WiFi 设置中配置 HTTP 代理为 `127.0.0.1:8888`
|
||||
3. 环境检测通过后即可使用
|
||||
|
||||
## iOS 26+ 注意事项
|
||||
|
||||
开启虚拟定位后需重启设备清除定位缓存,详见 README 中的使用说明。
|
||||
@@ -1,31 +0,0 @@
|
||||
# Third-party proxy module snapshots
|
||||
|
||||
The files under `Resources/ThirdPartyProxyModules/` are bundled configuration
|
||||
snapshots from [Yu9191/wloc](https://github.com/Yu9191/wloc). They are retained
|
||||
in the App bundle for release provenance and offline inspection. The setup UI
|
||||
copies the official subscription URL instead of exporting these files.
|
||||
|
||||
- Upstream commit: `eec07a8dc8de6dbaee8eac1fb376e4d03020154a`
|
||||
- Snapshot date: 2026-08-06
|
||||
- Source directory: `modules/`
|
||||
|
||||
| Bundled file | Client |
|
||||
|---|---|
|
||||
| `wloc.module` | Shadowrocket |
|
||||
| `wloc.sgmodule` | Surge and Egern |
|
||||
| `wloc.conf` | Quantumult X |
|
||||
| `wloc.lpx` | Loon |
|
||||
| `wloc.stoverride` | Stash |
|
||||
|
||||
SHA-256:
|
||||
|
||||
```text
|
||||
bb5e17b60027704971660b0ea2df3560ceff973c27d43e7f2c2c18b48d368ac6 wloc.conf
|
||||
1fb451616fb17242849f72490f016afcdb8aa81a0b086f6dd5f94e1af3d58ee1 wloc.lpx
|
||||
97cab104056428aa0e90521c3bf2646e9739b0b4c83272b31790f99584bca89e wloc.module
|
||||
5d6b82c31316f4a7be65e3b8d2335f4338e01af98e262948118eefe63abf7034 wloc.sgmodule
|
||||
cb06593752db8b223dfa5cd1cbd089115fe3a541f5c8532491615923e83df2cb wloc.stoverride
|
||||
```
|
||||
|
||||
The official subscription URLs are the setup UI's import path. Egern reuses the
|
||||
Surge module. Stash imports `.stoverride` directly.
|
||||
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<rect width="1024" height="1024" fill="#087FF5"/>
|
||||
|
||||
<path
|
||||
d="M168 736 C292 736 284 590 420 590 C520 590 514 458 606 458"
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="42"
|
||||
stroke-linecap="round"
|
||||
opacity="0.42"
|
||||
/>
|
||||
<circle cx="168" cy="736" r="50" fill="#35D6C1" stroke="#FFFFFF" stroke-width="24"/>
|
||||
|
||||
<path
|
||||
d="M656 156 C488 156 370 276 370 434 C370 638 656 878 656 878 C656 878 942 638 942 434 C942 276 824 156 656 156 Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
<circle cx="656" cy="420" r="126" fill="#FF453A"/>
|
||||
<circle cx="656" cy="420" r="48" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 681 B |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 154 KiB |
@@ -6,7 +6,7 @@
|
||||
|
||||
- **🗺️ 原生地图体验**:Apple MapKit 蓝点实时定位,搜索地点、点击选点、拖动浏览与 Apple 地图一致
|
||||
- **📍 虚拟定位引擎**:基于本地 HTTP 代理 MITM 方案,无需 VPN、无需越狱,对钉钉、微信及任意系统定位 App 生效
|
||||
- **🧪 完整设置引导**:WiFi 代理配置 → 证书安装与信任 → 环境验证,逐步检查本地代理、CA 信任和 WiFi 代理链路
|
||||
- **🧪 完整设置引导**:证书安装 → WiFi 代理配置 → 环境验证,逐步检测代理、CA 信任、坐标写入与响应改写
|
||||
- **✈️ 飞行模式缓存清除**:一键化操作引导,按步骤刷新飞行模式、WiFi 和定位服务状态
|
||||
- **⭐ 收藏与快速切换**:保存常用坐标,一键跳转
|
||||
- **🧾 诊断日志**:实时查看定位请求和改写状态,每条独立可复制
|
||||
@@ -16,14 +16,14 @@
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| iOS | 15.0+ |
|
||||
| 安装 | 自行签名(推荐 [Impactor](https://github.com/claration/Impactor)) |
|
||||
| 安装 | 自行签名(推荐 [Impact](https://github.com/claration/Impact)) |
|
||||
| 网络 | 可手动配置 HTTP 代理的 WiFi |
|
||||
|
||||
### 自签安装
|
||||
### 安装
|
||||
|
||||
1. 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 下载 `PaopaoLocationSpoofer-unsigned.ipa`
|
||||
2. 使用 [Impactor](https://github.com/claration/Impactor) 签名安装
|
||||
3. 签名时保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements
|
||||
2. 使用 [Impact](https://github.com/claration/Impact) 签名安装
|
||||
3. 按 App 内引导完成证书安装和 WiFi 代理配置
|
||||
|
||||
### 致谢
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# v1.0.1
|
||||
|
||||
发布日期:2026-08-06
|
||||
|
||||
## 提交变更总结
|
||||
|
||||
- `59a7fa3` 新增 APP模式与第三方代理模式,支持复制模块订阅地址、唤起 Shadowrocket、Surge、Quantumult X、Loon、Stash、Egern,并完善首次启动和模式切换引导。
|
||||
- `de9744d` 优化启动初始化、证书安装、坐标双格式持久化和旧数据迁移;增加日志清理、操作提醒及诊断入口。
|
||||
- `3882452` 加固本地代理、CA 持久化、MapKit 坐标标准探测、实时定位和 WLOC WGS-84 写入链路。
|
||||
- `bba2378` 修正固定锚点坐标标准探测,使用 Core Location 反向地理编码完成兜底判断。
|
||||
- `3f090da` 将 Wi-Fi 切换后的环境检测稳定等待时间调整为 3 秒。
|
||||
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
<!-- commit-range: v1.0.0..v1.0.1 (curated because the historical v1.0.0 tag is not an ancestor of main) -->
|
||||