Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3499f080d7 | ||
|
|
c7ae14d4b2 | ||
|
|
a3f39a93e7 | ||
|
|
811f9c9343 | ||
|
|
8bd15eb13c | ||
|
|
b0c60673af | ||
|
|
111080ad1a | ||
|
|
8acc33186c | ||
|
|
7721443484 | ||
|
|
7e362e6b6c | ||
|
|
d11550228c | ||
|
|
104df4e4a2 | ||
|
|
f38a4ebff1 | ||
|
|
bae701d4c6 | ||
|
|
97cbe247ba | ||
|
|
b52d72bef2 | ||
|
|
e86909cc2f | ||
|
|
0e8e83fd33 | ||
|
|
881270a21a | ||
|
|
5bd4899bc8 | ||
|
|
1bd298ed3d | ||
|
|
577a1bdb24 | ||
|
|
96012f5afa | ||
|
|
794cf9d004 | ||
|
|
3260af748b | ||
|
|
094027e3fe | ||
|
|
ba17fd765b | ||
|
|
5dd0ef711d | ||
|
|
f9b137b0f7 | ||
|
|
542d74e94f | ||
|
|
2d2ce4f06d | ||
|
|
3862ddc3c8 | ||
|
|
4aa4f63e62 | ||
|
|
c8aba2be78 |
@@ -34,21 +34,19 @@ jobs:
|
||||
- name: Rename IPA for release
|
||||
run: mv dist/PaopaoLocationSpoofer-unsigned.ipa dist/Location-Spoofer-unsigned.ipa
|
||||
|
||||
- name: Read release notes
|
||||
id: changelog
|
||||
- name: Validate archived release notes
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
NOTES_FILE="docs/releases/${VERSION}.md"
|
||||
if [ -f "$NOTES_FILE" ]; then
|
||||
cat "$NOTES_FILE" > /tmp/release_notes.md
|
||||
else
|
||||
echo "⚠️ $NOTES_FILE 未找到,请创建该文件。" > /tmp/release_notes.md
|
||||
if [ ! -s "$NOTES_FILE" ]; then
|
||||
echo "::error::缺少版本归档 $NOTES_FILE。请在打 tag 前运行 ./Scripts/generate-release-notes.sh $VERSION 并提交。"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
cat /tmp/release_notes.md
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
if ! grep -Fq "# ${VERSION}" "$NOTES_FILE"; then
|
||||
echo "::error::$NOTES_FILE 的标题必须包含 # ${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
cp "$NOTES_FILE" /tmp/release_notes.md
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -7,6 +7,8 @@ struct BugReportView: View {
|
||||
@State private var isReproducible = true
|
||||
@State private var isRunning = false
|
||||
@State private var showCopiedAlert = false
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -85,9 +87,19 @@ struct BugReportView: View {
|
||||
private func generateReport() {
|
||||
isRunning = true
|
||||
Task {
|
||||
// 跑测试
|
||||
_ = await setup.runVerificationTest(testLat: 22.543099, testLon: 113.934576)
|
||||
let testLog = setup.testLog
|
||||
let testLog: String
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let active = response.success && response.latitude != nil && response.longitude != nil
|
||||
testLog = "第三方代理测试模式:模块连接成功;已保存坐标=\(active ? "是" : "否")"
|
||||
} catch {
|
||||
testLog = "第三方代理测试模式:模块连接失败;\(error.localizedDescription)"
|
||||
}
|
||||
} else {
|
||||
_ = await setup.runVerificationTest()
|
||||
testLog = setup.testLog
|
||||
}
|
||||
|
||||
// 获取版本信息
|
||||
let appVersion: String = {
|
||||
@@ -102,6 +114,7 @@ struct BugReportView: View {
|
||||
### 环境信息
|
||||
App 版本: \(appVersion)
|
||||
系统版本: iOS \(systemVersion)
|
||||
运行模式: \(runtimeMode.mode.displayName)
|
||||
可复现环境: \(isReproducible ? "是" : "否")
|
||||
|
||||
### 问题描述
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum CertificateInstaller {
|
||||
static func open(url: URL, completion: @escaping (Bool) -> Void) {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: completion)
|
||||
}
|
||||
}
|
||||
@@ -2,70 +2,142 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var setup = SetupCoordinator()
|
||||
@ObservedObject private var net = NetworkMonitor.shared
|
||||
@State private var showSetup = false
|
||||
@State private var showEnableTip = false
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@State private var phase: AppPhase = .splash
|
||||
@State private var verifiedDuringInitialSetup = false
|
||||
@AppStorage("setupCompleted") private var setupCompleted = false
|
||||
|
||||
enum AppPhase { case splash, setup, map }
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
MapHomeView(setup: setup)
|
||||
}
|
||||
.task {
|
||||
// 首次打开无标记:必须进引导页
|
||||
if !setupCompleted {
|
||||
showSetup = true
|
||||
return
|
||||
Group {
|
||||
switch phase {
|
||||
case .splash:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 48)).foregroundStyle(.blue)
|
||||
ProgressView()
|
||||
Text(runtimeMode.hasSelectedMode && runtimeMode.mode == .localWiFi
|
||||
? "正在初始化地图与本地代理…"
|
||||
: "正在初始化地图…")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
case .setup:
|
||||
FirstSetupView(setup: setup, onComplete: finishInitialSetup)
|
||||
case .map:
|
||||
NavigationView {
|
||||
MapHomeView(setup: setup)
|
||||
}
|
||||
.fullScreenCover(isPresented: $setup.needsSetup) {
|
||||
FirstSetupView(setup: setup, onComplete: {
|
||||
setupCompleted = true
|
||||
setup.completeSetup()
|
||||
})
|
||||
}
|
||||
}
|
||||
await setup.refreshTrust()
|
||||
}
|
||||
.onChange(of: net.isAirplaneMode) { airplane in
|
||||
guard setupCompleted else { return }
|
||||
if airplane {
|
||||
showEnableTip = true
|
||||
.task { await bootstrap() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func bootstrap() async {
|
||||
guard runtimeMode.hasSelectedMode else {
|
||||
ProxyManager.shared.stop()
|
||||
RuntimeLogger.info("APP", "Startup", "尚未选择运行模式,跳过本地 CA 和代理初始化")
|
||||
setup.requestModeSelection()
|
||||
phase = .setup
|
||||
return
|
||||
}
|
||||
|
||||
let launchMode = runtimeMode.mode
|
||||
guard setupCompleted else {
|
||||
if launchMode == .localWiFi {
|
||||
await setup.prepareLocalServices()
|
||||
setup.requestSetup()
|
||||
} else {
|
||||
showEnableTip = false
|
||||
Task { await setup.refreshTrust() }
|
||||
ProxyManager.shared.stop()
|
||||
BackgroundKeepAlive.shared.stop()
|
||||
setup.requestThirdPartySetup()
|
||||
}
|
||||
phase = .setup
|
||||
return
|
||||
}
|
||||
.fullScreenCover(isPresented: $showSetup) {
|
||||
FirstSetupView(setup: setup, onComplete: {
|
||||
setupCompleted = true
|
||||
setup.completeSetup()
|
||||
showSetup = false
|
||||
})
|
||||
|
||||
if launchMode == .localWiFi {
|
||||
await setup.prepareLocalServices()
|
||||
} else {
|
||||
ProxyManager.shared.stop()
|
||||
setup.completeSetup()
|
||||
RuntimeLogger.info("APP", "Startup", "第三方代理测试模式:跳过本地 CA、代理和环境检测")
|
||||
}
|
||||
// 设置页「进入引导页」入口联动
|
||||
.onChange(of: setup.needsSetup) { needs in
|
||||
if needs { showSetup = true }
|
||||
do {
|
||||
try CoordinateStorageMigration.migrateIfNeeded(favorites: FavoriteLocationStore())
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "Startup", "旧坐标数据迁移失败,将在下次启动重试", error: error)
|
||||
}
|
||||
.sheet(isPresented: $showEnableTip) {
|
||||
NavigationView {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("飞行模式已开启")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Wi‑Fi 和蜂窝数据已关闭,虚拟定位无法生效。请关闭飞行模式后重试。")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("知道了") {
|
||||
showEnableTip = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(30)
|
||||
.navigationTitle("提示")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("完成") { showEnableTip = false }
|
||||
}
|
||||
}
|
||||
|
||||
// MapHomeView is intentionally constructed only after this required
|
||||
// coordinate-system gate resolves, so cached pins are never replayed
|
||||
// into an unknown Apple Maps coordinate system.
|
||||
let mapCoordinateSystem = await CoordinateConverter.resolveInitialMapCoordinateSystem()
|
||||
guard !Task.isCancelled else { return }
|
||||
RuntimeLogger.info("APP", "Startup", "地图坐标标准初始化完成,开始后续启动流程", details: [
|
||||
"地图标准": mapCoordinateSystem.rawValue,
|
||||
"使用兜底": String(CoordinateConverter.initialMapCoordinateSystemUsedFallback)
|
||||
])
|
||||
|
||||
// Resolve the first map center before constructing MapHomeView. This
|
||||
// prevents a Shenzhen/cache frame followed by a second realtime frame.
|
||||
if LastCoordinateStore.load() == nil {
|
||||
RuntimeLogger.info("APP", "Startup", "没有持久化图钉,地图创建前请求实时定位")
|
||||
if let realtime = await RealtimeLocationManager.shared.requestLocation() {
|
||||
let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(realtime)
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: realtime.latitude,
|
||||
lon: realtime.longitude,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: 1_000)
|
||||
RuntimeLogger.info("APP", "Startup", "已使用实时定位准备唯一初始地图状态", details: [
|
||||
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
|
||||
"修正兜底标准": String(mapCoordinateSystemChange != nil),
|
||||
"缩放米": "1000"
|
||||
])
|
||||
RealtimeLocationTrace.coordinate(
|
||||
"地图创建前取得的初始实时位置(WGS-84)",
|
||||
coordinate: realtime
|
||||
)
|
||||
} else {
|
||||
RuntimeLogger.warning("APP", "Startup", "地图创建前无法取得实时定位,唯一初始位置使用深圳", details: [
|
||||
"地图标准": mapCoordinateSystem.rawValue,
|
||||
"缩放米": "1000"
|
||||
])
|
||||
}
|
||||
} else {
|
||||
RuntimeLogger.info("APP", "Startup", "已找到持久化图钉,直接准备唯一初始地图状态")
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
if launchMode == .thirdParty {
|
||||
setup.completeSetup()
|
||||
} else if verifiedDuringInitialSetup {
|
||||
verifiedDuringInitialSetup = false
|
||||
setup.completeSetup()
|
||||
} else if setupCompleted {
|
||||
let result = await setup.runVerificationTest()
|
||||
setup.applyVerificationResult(result)
|
||||
} else {
|
||||
setup.requestSetup()
|
||||
}
|
||||
RuntimeLogger.info("APP", "Startup", "启动门禁全部完成,现在创建 MapHomeView")
|
||||
phase = .map
|
||||
}
|
||||
|
||||
private func finishInitialSetup() {
|
||||
verifiedDuringInitialSetup = runtimeMode.mode == .localWiFi
|
||||
setupCompleted = true
|
||||
setup.completeSetup()
|
||||
phase = .splash
|
||||
Task { await bootstrap() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ struct RuntimeLogsView: View {
|
||||
let testFavorite: FavoriteLocation
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject private var proxy = ProxyManager.shared
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@State private var entries: [RuntimeLogEntry] = []
|
||||
@State private var isTesting = false
|
||||
@State private var testResult = ""
|
||||
@@ -15,25 +17,48 @@ struct RuntimeLogsView: View {
|
||||
@State private var copiedEntryID: UUID?
|
||||
@State private var copyLogsConfirmed = false
|
||||
@State private var testLogCopied = false
|
||||
@State private var logFilter = ""
|
||||
|
||||
private var filteredEntries: [RuntimeLogEntry] {
|
||||
let q = logFilter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !q.isEmpty else { return entries }
|
||||
return entries.filter { $0.message.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
testPanel
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("过滤日志", text: $logFilter)
|
||||
.textFieldStyle(.plain).font(.caption)
|
||||
if !logFilter.isEmpty {
|
||||
Button { logFilter = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.caption)
|
||||
}
|
||||
}
|
||||
}.padding(.horizontal, 12).padding(.vertical, 6)
|
||||
Divider()
|
||||
if entries.isEmpty {
|
||||
if filteredEntries.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
|
||||
Text("暂无运行日志").foregroundStyle(.secondary)
|
||||
Text(entries.isEmpty ? "暂无运行日志" : "无匹配日志").foregroundStyle(.secondary)
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(entries.reversed()) { entry in logRow(entry) }
|
||||
ForEach(filteredEntries.reversed()) { entry in logRow(entry) }
|
||||
}.padding(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("运行日志").navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Text("日志自动清理,仅保留近 3 天")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) { Button("关闭") { dismiss() } }
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -74,10 +99,14 @@ struct RuntimeLogsView: View {
|
||||
Button {
|
||||
isTesting = true; testResult = ""; testLogCopied = false
|
||||
Task {
|
||||
let result = await setup.runVerificationTest(testLat: testFavorite.latitude, testLon: testFavorite.longitude)
|
||||
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
|
||||
if !result.isSuccess { testResult += ",查看下方日志" }
|
||||
testMessage = setup.testLog
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
await runThirdPartyConnectionTest()
|
||||
} else {
|
||||
let result = await setup.runVerificationTest()
|
||||
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
|
||||
if !result.isSuccess { testResult += ",查看下方日志" }
|
||||
testMessage = setup.testLog
|
||||
}
|
||||
isTesting = false; refresh()
|
||||
}
|
||||
} label: {
|
||||
@@ -87,7 +116,7 @@ struct RuntimeLogsView: View {
|
||||
} else {
|
||||
Image(systemName: "play.fill").font(.system(size: 13, weight: .bold))
|
||||
}
|
||||
Text(isTesting ? "正在测试…" : "虚拟定位测试").font(.subheadline.weight(.semibold))
|
||||
Text(isTesting ? "正在检测…" : "环境检测").font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).opacity(0.5)
|
||||
}
|
||||
@@ -102,7 +131,9 @@ struct RuntimeLogsView: View {
|
||||
.fill(isTesting ? Color.gray : Color.blue)
|
||||
)
|
||||
.disabled(isTesting || actions.state.isBusy)
|
||||
Text("依次执行:代理 → 证书 → WiFi 代理 → 坐标写入 → 数据改写验证。")
|
||||
Text(runtimeMode.mode == .thirdParty
|
||||
? "检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。"
|
||||
: "依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !testMessage.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -147,10 +178,12 @@ struct RuntimeLogsView: View {
|
||||
}.frame(maxHeight: 180)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
|
||||
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
HStack(spacing: 14) {
|
||||
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
|
||||
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if !testResult.isEmpty {
|
||||
Text(testResult).font(.footnote.weight(.medium))
|
||||
.foregroundStyle(testResult.contains("通过") ? .green : .red)
|
||||
@@ -201,4 +234,28 @@ struct RuntimeLogsView: View {
|
||||
}
|
||||
|
||||
private func refresh() { entries = RuntimeLogStore.loadAll() }
|
||||
|
||||
@MainActor
|
||||
private func runThirdPartyConnectionTest() async {
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let active = response.success && response.latitude != nil && response.longitude != nil
|
||||
testResult = active ? "第三方模块连接通过,已有坐标" : "第三方模块连接通过,暂无坐标"
|
||||
testMessage = """
|
||||
======== 第三方代理连接检测 ========
|
||||
模式: 测试模式
|
||||
请求: wloc-settings/save?action=query
|
||||
拦截响应: 有效 JSON
|
||||
已保存坐标: \(active ? "是" : "否")
|
||||
"""
|
||||
} catch {
|
||||
testResult = "第三方模块连接失败"
|
||||
testMessage = """
|
||||
======== 第三方代理连接检测 ========
|
||||
模式: 测试模式
|
||||
请求: wloc-settings/save?action=query
|
||||
结果: \(error.localizedDescription)
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SetupStep: Int, CaseIterable {
|
||||
case cert = 0, proxy = 1, verify = 2
|
||||
case mode
|
||||
case proxy
|
||||
case cert
|
||||
case thirdPartyClient
|
||||
case thirdPartyImport
|
||||
case thirdPartyTest
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .mode: return "选择模式"
|
||||
case .proxy: return "配置 Wi-Fi 代理"
|
||||
case .cert: return "初始化 CA 证书"
|
||||
case .proxy: return "初始化代理"
|
||||
case .verify: return "环境检测"
|
||||
case .thirdPartyClient: return "选择客户端"
|
||||
case .thirdPartyImport: return "导入配置"
|
||||
case .thirdPartyTest: return "连接检测"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,283 +24,615 @@ struct FirstSetupView: View {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
let onComplete: () -> Void
|
||||
|
||||
@State private var step: SetupStep = .cert
|
||||
@State private var step: SetupStep
|
||||
@State private var downloadedDone = false
|
||||
@State private var installedDone = false
|
||||
@State private var trustedDone = false
|
||||
@State private var proxyDone = false
|
||||
@State private var testPassed: VerificationResult? = nil
|
||||
@State private var testMessage = ""
|
||||
@State private var isLoading = false
|
||||
@State private var result: VerificationResult?
|
||||
@State private var isVerifying = false
|
||||
@State private var isPreparingMode = false
|
||||
@State private var manualHint = ""
|
||||
@State private var setupActionError = ""
|
||||
@State private var showDiagnostics = false
|
||||
@StateObject private var diagnosticActions = LocationActionCoordinator()
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
@State private var copiedSubscriptionURL = false
|
||||
@State private var copiedMITMHostname = false
|
||||
|
||||
private var certDone: Bool { downloadedDone && installedDone && trustedDone }
|
||||
init(setup: SetupCoordinator, onComplete: @escaping () -> Void) {
|
||||
self.setup = setup
|
||||
self.onComplete = onComplete
|
||||
_step = State(initialValue: setup.setupStep)
|
||||
}
|
||||
|
||||
private var certificateStepsComplete: Bool { downloadedDone && installedDone && trustedDone }
|
||||
private var diagnosticFavorite: FavoriteLocation {
|
||||
FavoriteLocation(name: "诊断位置", latitude: 22.544577, longitude: 113.94114, accuracy: 25)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 顶部大步骤进度
|
||||
HStack(spacing: 6) {
|
||||
ForEach(SetupStep.allCases, id: \.rawValue) { s in
|
||||
VStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(s.rawValue < step.rawValue ? Color.green
|
||||
: s.rawValue == step.rawValue ? Color.blue
|
||||
: Color.gray.opacity(0.3))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(s.title).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
if s.rawValue < SetupStep.allCases.count - 1 {
|
||||
Rectangle().fill(s.rawValue < step.rawValue ? Color.green : Color.gray.opacity(0.3))
|
||||
.frame(height: 2).frame(maxWidth: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 18)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch step {
|
||||
case .cert: certStepView
|
||||
case .proxy: proxyStepView
|
||||
case .verify: verifyStepView
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
// 底部导航
|
||||
Divider()
|
||||
HStack {
|
||||
if step.rawValue > 0 {
|
||||
Button("上一步") { step = SetupStep(rawValue: step.rawValue - 1)! }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Spacer()
|
||||
if step == .verify && testPassed?.isSuccess == true {
|
||||
Button("完成,进入主页") { onComplete() }.buttonStyle(.borderedProminent)
|
||||
} else if step == .cert && certDone {
|
||||
Button("下一步") { step = .proxy }.buttonStyle(.borderedProminent)
|
||||
} else if step == .proxy && proxyDone {
|
||||
Button("下一步") { step = .verify }.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.vertical, 10)
|
||||
}
|
||||
.alert("无法直接跳转", isPresented: Binding(
|
||||
get: { !manualHint.isEmpty },
|
||||
set: { if !$0 { manualHint = "" } }
|
||||
)) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: { Text(manualHint) }
|
||||
}
|
||||
|
||||
// MARK: - 第 1 步:初始化 CA 证书(三个步骤平铺)
|
||||
|
||||
private var certStepView: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox(label: Label("第 1 步:下载证书", systemImage: "arrow.down.circle")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("虚拟定位需要通过自签 CA 证书来解密和改写定位请求。点击下方按钮,Safari 会打开下载页面。Safari 弹出「此网站正尝试下载一个配置描述文件」时,点「允许」。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await setup.proxy.openCertificateDownload() }
|
||||
} label: {
|
||||
Label("去下载", systemImage: "arrow.down.circle.fill").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
Button {
|
||||
downloadedDone = true
|
||||
} label: {
|
||||
Label(downloadedDone ? "已完成 ✓" : "已完成", systemImage: downloadedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(downloadedDone ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupBox(label: Label("第 2 步:安装证书", systemImage: "square.and.arrow.down")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("下载完成后打开系统「设置」:\n\n1. 如果顶部显示了「已下载描述文件」,点进去安装\n2. 如果没显示:进入「通用 → VPN与设备管理」,找到 WLOC CA 证书点击安装\n\n安装时系统会要求输入锁屏密码,确认后点右上角「安装」即可。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Button { openSettings(.general) } label: {
|
||||
Label("去安装", systemImage: "gearshape").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
Button { installedDone = true } label: {
|
||||
Label(installedDone ? "已完成 ✓" : "已完成", systemImage: installedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(installedDone ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupBox(label: Label("第 3 步:信任证书", systemImage: "shield.checkered")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("证书安装后还需要开启信任,否则系统会拦截代理的 HTTPS 请求:\n\n1. 打开「设置 → 通用 → 关于本机」\n2. 滑到底部找到「证书信任设置」\n3. 找到 WLOC CA,打开旁边的开关\n4. 弹出的警告中点「继续」\n\n⚠️ 每次重装 App 都需要重新下载安装证书。如果检测时报 TLS 错误,说明证书过期或不匹配,请删除旧证书(设置 → 通用 → VPN与设备管理)后重新安装。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Button { openSettings(.general) } label: {
|
||||
Label("去信任", systemImage: "shield.checkered").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
Button { trustedDone = true } label: {
|
||||
Label(trustedDone ? "已完成 ✓" : "已完成", systemImage: trustedDone ? "checkmark.circle.fill" : "circle").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(trustedDone ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 第 2 步:初始化代理
|
||||
|
||||
private var proxyStepView: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox(label: Label("配置 WiFi 系统代理", systemImage: "wifi")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("要让系统定位请求经过本地代理,需要在 WiFi 设置中手动配置:\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前连接的 WiFi 右侧 (i) 图标\n3. 滑到底部找到「HTTP 代理」,选择「手动」\n4. 服务器填入 127.0.0.1,端口填入 8888\n5. 点右上角「存储」\n\n⚠️ 只有连接的这个 WiFi 会走代理,蜂窝数据不受影响。换 WiFi 后需要重新配置。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
UIPasteboard.general.string = "127.0.0.1:8888"
|
||||
} label: {
|
||||
Label("复制地址", systemImage: "doc.on.doc")
|
||||
.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(.blue)
|
||||
Button {
|
||||
openSettings(.wifi)
|
||||
} label: {
|
||||
Label("去设置", systemImage: "wifi")
|
||||
.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
}
|
||||
|
||||
Button {
|
||||
proxyDone = true
|
||||
} label: {
|
||||
Label(proxyDone ? "已完成 ✓" : "已完成", systemImage: proxyDone ? "checkmark.circle.fill" : "circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).tint(proxyDone ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 第 3 步:环境检测
|
||||
|
||||
private var verifyStepView: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox(label: Label("检测整个流程", systemImage: "checklist")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text("点击「开始检测」后依次检查代理运行、证书信任与 WiFi 代理配置、坐标写入、数据改写。全部通过后「完成,进入主页」按钮才会亮起。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let result = testPassed, result.isSuccess {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green).font(.title3)
|
||||
Text("全部检测通过").font(.subheadline.weight(.semibold)).foregroundStyle(.green)
|
||||
}.padding(12).background(Color.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
} else if let result = testPassed {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.red).font(.title3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("测试未通过").font(.subheadline.weight(.semibold)).foregroundStyle(.red)
|
||||
Text(failureSummary(result)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let step = failureRemedyStep(result) {
|
||||
Button {
|
||||
withAnimation(.none) { self.step = step }
|
||||
testPassed = nil
|
||||
testMessage = ""
|
||||
} label: {
|
||||
Label("去处理", systemImage: "arrow.right.circle.fill")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
if !testMessage.isEmpty {
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
progress
|
||||
ScrollView {
|
||||
Text(testMessage)
|
||||
.font(.caption.monospaced()).textSelection(.enabled)
|
||||
.padding(10).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
}.frame(maxHeight: 280)
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch step {
|
||||
case .mode: modeStep
|
||||
case .proxy: proxyStep
|
||||
case .cert: certificateStep
|
||||
case .thirdPartyClient: thirdPartyClientStep
|
||||
case .thirdPartyImport: thirdPartyImportStep
|
||||
case .thirdPartyTest: thirdPartyTestStep
|
||||
}
|
||||
if let result { resultView(result) }
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
Divider()
|
||||
VStack(spacing: 10) {
|
||||
primaryAction
|
||||
if step != .mode {
|
||||
Button("上一步") { returnToPreviousStep() }
|
||||
.disabled(isVerifying || thirdPartyProxy.isRequesting)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.navigationTitle("开始使用")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showDiagnostics) {
|
||||
NavigationView {
|
||||
RuntimeLogsView(
|
||||
setup: setup,
|
||||
actions: diagnosticActions,
|
||||
testFavorite: diagnosticFavorite
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert("无法直接跳转", isPresented: Binding(
|
||||
get: { !manualHint.isEmpty },
|
||||
set: { if !$0 { manualHint = "" } }
|
||||
)) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: { Text(manualHint) }
|
||||
.alert("操作失败", isPresented: Binding(
|
||||
get: { !setupActionError.isEmpty },
|
||||
set: { if !$0 { setupActionError = "" } }
|
||||
)) {
|
||||
Button("查看诊断日志") { showDiagnostics = true }
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text(setupActionError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progress: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(visibleSteps, id: \.rawValue) { value in
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(value.rawValue <= step.rawValue ? Color.blue : Color.gray.opacity(0.3))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(value.title).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if value != visibleSteps.last {
|
||||
Rectangle().fill(Color.gray.opacity(0.3)).frame(width: 28, height: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
private var visibleSteps: [SetupStep] {
|
||||
switch step {
|
||||
case .mode:
|
||||
return [.mode]
|
||||
case .proxy, .cert:
|
||||
return [.mode, .proxy, .cert]
|
||||
case .thirdPartyClient, .thirdPartyImport, .thirdPartyTest:
|
||||
return [.mode, .thirdPartyClient, .thirdPartyImport, .thirdPartyTest]
|
||||
}
|
||||
}
|
||||
|
||||
private var modeStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("选择运行模式")
|
||||
.font(.title2.bold())
|
||||
Text("后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
modeCard(
|
||||
title: "APP模式",
|
||||
icon: "iphone.and.arrow.forward",
|
||||
badges: ["仅 Wi-Fi", "无外部依赖"],
|
||||
description: "App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。",
|
||||
tint: .blue
|
||||
) {
|
||||
selectMode(.localWiFi)
|
||||
}
|
||||
.disabled(isPreparingMode)
|
||||
|
||||
modeCard(
|
||||
title: "第三方代理模式",
|
||||
icon: "network.badge.shield.half.filled",
|
||||
badges: ["Wi-Fi + 4G/5G", "测试模式"],
|
||||
description: "App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。",
|
||||
tint: .orange
|
||||
) {
|
||||
selectMode(.thirdParty)
|
||||
}
|
||||
.disabled(isPreparingMode)
|
||||
|
||||
if isPreparingMode {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("正在准备 APP模式本地服务…")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func modeCard(
|
||||
title: String,
|
||||
icon: String,
|
||||
badges: [String],
|
||||
description: String,
|
||||
tint: Color,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.headline)
|
||||
.foregroundStyle(tint)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(badges, id: \.self) { badge in
|
||||
Text(badge)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(tint.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(tint.opacity(0.25)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var proxyStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
GroupBox(label: Label("先配置 Wi-Fi 系统代理", systemImage: "wifi")) {
|
||||
Text("在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。完成后点击下方「我已配置,开始检测」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Button { UIPasteboard.general.string = "127.0.0.1:8888" } label: {
|
||||
Label("复制地址", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button { openSettings(.wifi) } label: {
|
||||
Label("打开 Wi-Fi 设置", systemImage: "gearshape").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var certificateStep: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
certificateCard(
|
||||
title: "第 1 步:下载证书",
|
||||
icon: "arrow.down.circle",
|
||||
description: "下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。Safari 出现配置描述文件下载提示时,选择「允许」。",
|
||||
actionTitle: "去下载",
|
||||
actionIcon: "arrow.down.circle.fill",
|
||||
complete: downloadedDone,
|
||||
action: {
|
||||
Task {
|
||||
let opened = await setup.proxy.openCertificateDownload()
|
||||
if !opened {
|
||||
setupActionError = setup.proxy.error ?? "无法打开证书下载页面,请查看诊断日志"
|
||||
}
|
||||
}
|
||||
},
|
||||
markComplete: { downloadedDone = true }
|
||||
)
|
||||
certificateCard(
|
||||
title: "第 2 步:安装证书",
|
||||
icon: "square.and.arrow.down",
|
||||
description: "下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 WLOC CA 并完成安装。",
|
||||
actionTitle: "去安装",
|
||||
actionIcon: "gearshape",
|
||||
complete: installedDone,
|
||||
action: { openSettings(.general) },
|
||||
markComplete: { installedDone = true }
|
||||
)
|
||||
certificateCard(
|
||||
title: "第 3 步:信任证书",
|
||||
icon: "shield.checkered",
|
||||
description: "安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 WLOC CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。",
|
||||
actionTitle: "去信任",
|
||||
actionIcon: "shield.checkered",
|
||||
complete: trustedDone,
|
||||
action: { openSettings(.general) },
|
||||
markComplete: { trustedDone = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyClientStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
GroupBox(label: Label("选择第三方代理客户端", systemImage: "app.badge.checkmark")) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(ThirdPartyProxyClient.allCases) { client in
|
||||
Button {
|
||||
thirdPartyClient.select(client)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(client.name).foregroundStyle(.primary)
|
||||
Text(client.verificationText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(client == .shadowrocket ? .green : .orange)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: thirdPartyClient.selectedClient == client ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(thirdPartyClient.selectedClient == client ? .blue : .secondary)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if client != ThirdPartyProxyClient.allCases.last { Divider() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Egern 直接使用 Surge 模块。Stash 直接订阅 .stoverride,不需要 Script Hub 转换。除 Shadowrocket 外,当前仅提供配置,尚未完成真机验证。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyImportStep: some View {
|
||||
let client = thirdPartyClient.selectedClient
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
GroupBox(label: Label("第 1 步:导入 \(client.name) 模块", systemImage: "square.and.arrow.down")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(importInstructions(for: client))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = client.subscriptionURL.absoluteString
|
||||
copiedSubscriptionURL = true
|
||||
} label: {
|
||||
Label(copiedSubscriptionURL ? "已复制订阅地址" : "复制订阅地址", systemImage: "doc.on.doc")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
openThirdPartyClient(client)
|
||||
} label: {
|
||||
Label("打开 \(client.name)", systemImage: "arrow.up.forward.app")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
if client == .shadowrocket {
|
||||
shadowrocketHTTPSDecryptionGuide
|
||||
} else {
|
||||
Text("请复制订阅地址,在客户端的模块、重写或覆写订阅入口中添加。证书、MITM、VPN 和代理连接请按第三方客户端自己的流程配置。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shadowrocketHTTPSDecryptionGuide: some View {
|
||||
GroupBox(label: Label("第 2 步:配置 HTTPS 解密", systemImage: "lock.open")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
|
||||
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
|
||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostname
|
||||
copiedMITMHostname = true
|
||||
} label: {
|
||||
Label(copiedMITMHostname ? "已复制 gs-loc.apple.com" : "复制解密域名", systemImage: "doc.on.doc")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
|
||||
instructionRow(5, "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。")
|
||||
|
||||
Button {
|
||||
openThirdPartyClient(.shadowrocket)
|
||||
} label: {
|
||||
Label("打开 Shadowrocket 继续配置", systemImage: "arrow.up.forward.app")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Text("App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func instructionRow(_ number: Int, _ text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(number)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 20, height: 20)
|
||||
.background(Color.blue, in: Circle())
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func importInstructions(for client: ThirdPartyProxyClient) -> String {
|
||||
if client == .shadowrocket {
|
||||
return "先复制订阅地址。然后打开 Shadowrocket,进入“配置 → 模块”,点击右上角“+”,粘贴订阅地址并完成导入。导入完成后,模块配置由 Shadowrocket 保存,不依赖本 App 持续运行。"
|
||||
}
|
||||
return "先复制订阅地址,然后打开 \(client.name),在模块、重写或覆写订阅入口中粘贴并导入。配置由 \(client.name) 保存,不依赖本 App 持续运行。"
|
||||
}
|
||||
|
||||
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
|
||||
guard let url = client.launchURL else { return }
|
||||
UIApplication.shared.open(url, options: [:]) { opened in
|
||||
guard !opened else { return }
|
||||
Task { @MainActor in
|
||||
manualHint = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyTestStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
GroupBox(label: Label("检测配置接口", systemImage: "network")) {
|
||||
Text("请先在第三方客户端中启用刚导入的配置,并按客户端要求完成 MITM、证书和代理/VPN 连接。App 只调用 WLOC 查询接口确认模块能否正常响应,不检查或管理第三方证书。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func returnToPreviousStep() {
|
||||
result = nil
|
||||
setupActionError = ""
|
||||
switch step {
|
||||
case .mode:
|
||||
break
|
||||
case .proxy, .thirdPartyClient:
|
||||
step = .mode
|
||||
case .cert:
|
||||
step = .proxy
|
||||
case .thirdPartyImport:
|
||||
step = .thirdPartyClient
|
||||
case .thirdPartyTest:
|
||||
step = .thirdPartyImport
|
||||
}
|
||||
}
|
||||
|
||||
private func certificateCard(
|
||||
title: String,
|
||||
icon: String,
|
||||
description: String,
|
||||
actionTitle: String,
|
||||
actionIcon: String,
|
||||
complete: Bool,
|
||||
action: @escaping () -> Void,
|
||||
markComplete: @escaping () -> Void
|
||||
) -> some View {
|
||||
GroupBox(label: Label(title, systemImage: icon)) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Color.clear.frame(height: 0).padding(.top, 2)
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
HStack(spacing: 10) {
|
||||
Button(action: action) {
|
||||
Label(actionTitle, systemImage: actionIcon).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
Button(action: markComplete) {
|
||||
Label(
|
||||
complete ? "已完成 ✓" : "已完成",
|
||||
systemImage: complete ? "checkmark.circle.fill" : "circle"
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(complete ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func resultView(_ result: VerificationResult) -> some View {
|
||||
let success = result.isSuccess
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label(success ? "环境检测通过" : failureSummary(result), systemImage: success ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(success ? .green : .red)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if !success {
|
||||
Text(setup.testLog).font(.caption.monospaced()).textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(8)
|
||||
Button {
|
||||
showDiagnostics = true
|
||||
} label: {
|
||||
Label("查看诊断日志", systemImage: "doc.text.magnifyingglass")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background((success ? Color.green : Color.red).opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryAction: some View {
|
||||
if step == .mode {
|
||||
EmptyView()
|
||||
} else if step == .proxy {
|
||||
Button {
|
||||
isLoading = true; testMessage = ""
|
||||
Task {
|
||||
testPassed = await setup.runVerificationTest()
|
||||
testMessage = setup.testLog
|
||||
isLoading = false
|
||||
}
|
||||
verifyAfterProxyConfirmation()
|
||||
} label: {
|
||||
HStack {
|
||||
if isLoading { ProgressView().tint(.white).controlSize(.small) }
|
||||
Text(buttonText).frame(maxWidth: .infinity)
|
||||
}
|
||||
actionLabel("我已配置,开始检测")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(buttonTint)
|
||||
.disabled(isLoading)
|
||||
|
||||
.disabled(isVerifying)
|
||||
} else if step == .cert {
|
||||
Button {
|
||||
UIPasteboard.general.string = testMessage
|
||||
verifyAfterCertificateConfirmation()
|
||||
} label: {
|
||||
Label("复制检测日志", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.bordered).disabled(testMessage.isEmpty)
|
||||
actionLabel("确认完成,重新检测")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!certificateStepsComplete || isVerifying)
|
||||
} else if step == .thirdPartyClient {
|
||||
Button("下一步:导入配置") { step = .thirdPartyImport }
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
} else if step == .thirdPartyImport {
|
||||
Button("我已导入,下一步") { step = .thirdPartyTest }
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
} else {
|
||||
Button {
|
||||
verifyThirdPartyConnection()
|
||||
} label: {
|
||||
actionLabel("检测接口连接")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isVerifying || thirdPartyProxy.isRequesting)
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonText: String {
|
||||
guard let result = testPassed else { return "开始检测" }
|
||||
return result.isSuccess ? "重新检测" : "⚠️ 重新测试"
|
||||
private func selectMode(_ mode: ProxyRuntimeMode) {
|
||||
guard !isPreparingMode else { return }
|
||||
runtimeMode.setMode(mode)
|
||||
result = nil
|
||||
switch mode {
|
||||
case .localWiFi:
|
||||
isPreparingMode = true
|
||||
Task { @MainActor in
|
||||
await setup.prepareLocalServices()
|
||||
isPreparingMode = false
|
||||
step = .proxy
|
||||
}
|
||||
case .thirdParty:
|
||||
setup.proxy.stop()
|
||||
BackgroundKeepAlive.shared.stop()
|
||||
step = .thirdPartyClient
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonTint: Color {
|
||||
guard let result = testPassed else { return .blue }
|
||||
return result.isSuccess ? .blue : .red
|
||||
private func verifyThirdPartyConnection() {
|
||||
guard !isVerifying else { return }
|
||||
isVerifying = true
|
||||
result = nil
|
||||
Task { @MainActor in
|
||||
defer { isVerifying = false }
|
||||
do {
|
||||
_ = try await thirdPartyProxy.query()
|
||||
onComplete()
|
||||
} catch {
|
||||
setupActionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func actionLabel(_ title: String) -> some View {
|
||||
HStack {
|
||||
if isVerifying { ProgressView().tint(.white).controlSize(.small) }
|
||||
Text(title).frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyAfterProxyConfirmation() {
|
||||
runVerification { result in
|
||||
if result.isSuccess {
|
||||
onComplete()
|
||||
} else if result == .certNotTrusted {
|
||||
step = .cert
|
||||
} else {
|
||||
step = .proxy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyAfterCertificateConfirmation() {
|
||||
runVerification { result in
|
||||
if result.isSuccess {
|
||||
onComplete()
|
||||
} else if result != .certNotTrusted {
|
||||
step = .proxy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runVerification(completion: @escaping (VerificationResult) -> Void) {
|
||||
guard !isVerifying else { return }
|
||||
isVerifying = true
|
||||
result = nil
|
||||
Task {
|
||||
let verification = await setup.runVerificationTest()
|
||||
setup.applyVerificationResult(verification)
|
||||
guard !Task.isCancelled else { return }
|
||||
result = verification
|
||||
isVerifying = false
|
||||
completion(verification)
|
||||
}
|
||||
}
|
||||
|
||||
private func failureSummary(_ result: VerificationResult) -> String {
|
||||
switch result {
|
||||
case .proxyNotRunning: return "代理未能启动,请检查代理状态"
|
||||
case .verificationInProgress: return "已有检测正在进行,请稍候"
|
||||
case .verificationSuperseded: return "检测期间位置已更新,本次结果已取消,请重新检测"
|
||||
case .certNotTrusted: return "CA 证书未安装或未信任,请重新安装证书并开启信任"
|
||||
case .wifiProxyNotConfigured: return "WiFi 代理未配置,请在系统设置中配置 127.0.0.1:8888"
|
||||
case .coordinateWriteFailed: return "坐标写入失败,请重试"
|
||||
case .patchFailed: return "坐标改写验证失败,可能是证书过期或不匹配"
|
||||
case .success: return ""
|
||||
case .certNotTrusted: return "证书尚未安装或信任"
|
||||
case .wifiProxyNotConfigured: return "Wi-Fi 代理未正确设置"
|
||||
case .proxyNotRunning: return "本地代理未能启动"
|
||||
case .verificationInProgress: return "检测仍在进行"
|
||||
case .verificationSuperseded: return "检测结果已过期"
|
||||
case .coordinateWriteFailed: return "坐标写入失败"
|
||||
case .patchFailed: return "定位改写检测失败"
|
||||
case .success: return "环境检测通过"
|
||||
}
|
||||
}
|
||||
|
||||
private func failureRemedyStep(_ result: VerificationResult) -> SetupStep? {
|
||||
switch result {
|
||||
case .certNotTrusted: return .cert
|
||||
case .wifiProxyNotConfigured: return .proxy
|
||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded,
|
||||
.coordinateWriteFailed, .patchFailed: return nil
|
||||
case .success: return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private func openSettings(_ destination: SystemSettingsDestination) {
|
||||
SystemSettingsNavigator.open(destination) { fallbackHint in
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol LocationActionProxying: AnyObject {
|
||||
var isRunning: Bool { get }
|
||||
func start() async throws
|
||||
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64
|
||||
}
|
||||
|
||||
extension ProxyManager: LocationActionProxying {}
|
||||
|
||||
@MainActor
|
||||
protocol LocationActionSettingsStoring: AnyObject {
|
||||
func load() -> WlocSettings?
|
||||
func save(_ settings: WlocSettings)
|
||||
func clear()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DeviceWlocSettingsStorage: LocationActionSettingsStoring {
|
||||
func load() -> WlocSettings? { WlocSettingsStore.load() }
|
||||
func save(_ settings: WlocSettings) { WlocSettingsStore.save(settings) }
|
||||
func clear() { WlocSettingsStore.clear() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LocationActionCoordinator: ObservableObject {
|
||||
@Published private(set) var state: LocationActionState = .idle
|
||||
@Published private(set) var virtualLocationEnabled = false
|
||||
@Published private(set) var message = ""
|
||||
|
||||
private let proxy = ProxyManager.shared
|
||||
private let proxy: any LocationActionProxying
|
||||
private let settings: any LocationActionSettingsStoring
|
||||
|
||||
init() {
|
||||
self.virtualLocationEnabled = WlocSettingsStore.load()?.enabled == true
|
||||
self.proxy = ProxyManager.shared
|
||||
self.settings = DeviceWlocSettingsStorage()
|
||||
self.virtualLocationEnabled = settings.load()?.enabled == true
|
||||
}
|
||||
|
||||
init(proxy: any LocationActionProxying, settings: any LocationActionSettingsStoring) {
|
||||
self.proxy = proxy
|
||||
self.settings = settings
|
||||
self.virtualLocationEnabled = settings.load()?.enabled == true
|
||||
}
|
||||
|
||||
func apply(_ favorite: FavoriteLocation) async -> Bool {
|
||||
@@ -41,8 +73,8 @@ final class LocationActionCoordinator: ObservableObject {
|
||||
|
||||
func clear() {
|
||||
guard !state.isBusy else { return }
|
||||
proxy.setCoords(lat: 0, lon: 0, enabled: false)
|
||||
WlocSettingsStore.clear()
|
||||
_ = proxy.setCoords(lat: 0, lon: 0, enabled: false, accuracy: 25)
|
||||
settings.clear()
|
||||
state = .idle
|
||||
virtualLocationEnabled = false
|
||||
message = "已恢复真实定位"
|
||||
@@ -56,21 +88,28 @@ final class LocationActionCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
private func commit(_ favorite: FavoriteLocation) -> Bool {
|
||||
// MKMapView 在中国地区使用高德瓦片(GCJ-02),返回的坐标是 GCJ-02。
|
||||
// 但 Apple wloc 定位服务使用 WGS-84,因此写入代理前需要转换为 WGS-84。
|
||||
let wgs = CoordinateConverter.gcj02ToWgs84(lat: favorite.latitude, lon: favorite.longitude)
|
||||
WlocSettingsStore.save(WlocSettings(
|
||||
longitude: wgs.lon,
|
||||
latitude: wgs.lat,
|
||||
// WLOC 合约固定使用持久化的 WGS-84 值,不依赖当前地图地图坐标标准。
|
||||
let wgs = favorite.coordinatePair.wgs84
|
||||
let value = WlocSettings(
|
||||
longitude: wgs.longitude,
|
||||
latitude: wgs.latitude,
|
||||
accuracy: favorite.accuracy,
|
||||
enabled: true
|
||||
))
|
||||
proxy.setCoords(
|
||||
lat: wgs.lat,
|
||||
lon: wgs.lon,
|
||||
)
|
||||
settings.save(value)
|
||||
_ = proxy.setCoords(
|
||||
lat: wgs.latitude,
|
||||
lon: wgs.longitude,
|
||||
enabled: true,
|
||||
accuracy: favorite.accuracy
|
||||
)
|
||||
RuntimeLogger.info("APP", "坐标转换", "设置虚拟定位坐标", details: [
|
||||
"WLOC写入标准": CoordinateConverter.MapCoordinateSystem.wgs84.diagnosticName,
|
||||
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
|
||||
"目标所在区域": CoordinateConverter.usesGCJ02ServiceArea(lat: wgs.latitude, lon: wgs.longitude) ? "国内转换区域" : "国外非转换区域",
|
||||
"取值字段": "coordinatePair.wgs84",
|
||||
"accuracy": String(favorite.accuracy)
|
||||
])
|
||||
state = .idle
|
||||
virtualLocationEnabled = true
|
||||
message = "虚拟定位已开启"
|
||||
|
||||
@@ -131,12 +131,17 @@ final class MapLocationState: ObservableObject {
|
||||
private var nextRealtimeIntentID: UInt64 = 0
|
||||
private var latestRealtimeIntentID: UInt64 = 0
|
||||
|
||||
init(initialCoordinate: CLLocationCoordinate2D, initialViewportMeters: CLLocationDistance = 1_000) {
|
||||
init(
|
||||
initialCoordinate: CLLocationCoordinate2D,
|
||||
initialViewportMeters: CLLocationDistance = 1_000,
|
||||
initialSource: MapSelectionSource = .initial,
|
||||
initialName: String? = nil
|
||||
) {
|
||||
viewportMeters = initialViewportMeters
|
||||
selection = MapSelection(
|
||||
coordinate: initialCoordinate,
|
||||
source: .initial,
|
||||
explicitName: nil,
|
||||
source: initialSource,
|
||||
explicitName: initialName?.nonEmpty,
|
||||
revision: nextSelectionRevision
|
||||
)
|
||||
}
|
||||
@@ -217,6 +222,13 @@ final class MapLocationState: ObservableObject {
|
||||
if coordinate == nil { realtimeLocation = nil }
|
||||
}
|
||||
|
||||
/// Discards a blue-point sample represented in a superseded MapKit
|
||||
/// coordinate system. The next native callback repopulates the cache.
|
||||
func clearRealtimeLocationForMapCoordinateSystemChange() {
|
||||
realtimeLocation = nil
|
||||
realtimeCoordinate = nil
|
||||
}
|
||||
|
||||
func updateExplicitName(_ name: String, forFavoriteID favoriteID: UUID) {
|
||||
guard selection.source == .favorite(favoriteID) else { return }
|
||||
selection = MapSelection(
|
||||
@@ -231,6 +243,20 @@ final class MapLocationState: ObservableObject {
|
||||
viewportMeters = max(50, distanceMeters)
|
||||
}
|
||||
|
||||
/// Updates only the map representation of the current physical selection.
|
||||
/// This preserves revision/source so an in-flight user action is not invalidated.
|
||||
func reprojectSelectionForMapCoordinateSystemChange(_ coordinate: CLLocationCoordinate2D) {
|
||||
guard CLLocationCoordinate2DIsValid(coordinate),
|
||||
!selection.coordinate.isApproximatelyEqual(to: coordinate) else { return }
|
||||
selection = MapSelection(
|
||||
coordinate: coordinate,
|
||||
source: selection.source,
|
||||
explicitName: selection.explicitName,
|
||||
revision: selection.revision
|
||||
)
|
||||
issueCameraCommand(.focus(coordinate: coordinate, distanceMeters: viewportMeters))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
|
||||
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
|
||||
@@ -288,45 +314,132 @@ extension CLLocationCoordinate2D {
|
||||
|
||||
// MARK: - 持久化存储
|
||||
|
||||
struct LastCoordinate: Codable, Equatable {
|
||||
let coordinatePair: CoordinatePair
|
||||
let zoomMeters: CLLocationDistance
|
||||
|
||||
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
|
||||
coordinatePair.coordinate(for: mapCoordinateSystem)
|
||||
}
|
||||
}
|
||||
|
||||
enum LastCoordinateStore {
|
||||
private struct StoredPosition: Codable {
|
||||
let coordinatePair: CoordinatePair
|
||||
let zoomMeters: CLLocationDistance
|
||||
}
|
||||
|
||||
private static let positionKey = "lastMapPositionV1"
|
||||
private static let legacyLatKey = "lastMapLat"
|
||||
private static let legacyLonKey = "lastMapLon"
|
||||
|
||||
static func save(
|
||||
mapCoordinate: CLLocationCoordinate2D,
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
|
||||
zoomMeters: CLLocationDistance,
|
||||
defaults: UserDefaults = AppGroup.defaults
|
||||
) {
|
||||
save(
|
||||
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
|
||||
zoomMeters: zoomMeters,
|
||||
defaults: defaults
|
||||
)
|
||||
}
|
||||
|
||||
static func save(
|
||||
coordinatePair: CoordinatePair,
|
||||
zoomMeters: CLLocationDistance,
|
||||
defaults: UserDefaults = AppGroup.defaults
|
||||
) {
|
||||
do {
|
||||
let value = StoredPosition(coordinatePair: coordinatePair, zoomMeters: max(50, zoomMeters))
|
||||
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
|
||||
ViewportStore.save(value.zoomMeters, defaults: defaults)
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "地图", "保存当前图钉失败", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
static func load(defaults: UserDefaults = AppGroup.defaults) -> LastCoordinate? {
|
||||
if let data = defaults.data(forKey: positionKey),
|
||||
let value = try? JSONDecoder().decode(StoredPosition.self, from: data) {
|
||||
return LastCoordinate(coordinatePair: value.coordinatePair, zoomMeters: value.zoomMeters)
|
||||
}
|
||||
|
||||
let legacyLatitude = defaults.double(forKey: legacyLatKey)
|
||||
let legacyLongitude = defaults.double(forKey: legacyLonKey)
|
||||
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
|
||||
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else {
|
||||
return nil
|
||||
}
|
||||
return LastCoordinate(
|
||||
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
|
||||
zoomMeters: ViewportStore.load(defaults: defaults) ?? 1_000
|
||||
)
|
||||
}
|
||||
|
||||
static func updateZoom(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
|
||||
guard let current = load(defaults: defaults) else { return }
|
||||
save(coordinatePair: current.coordinatePair, zoomMeters: meters, defaults: defaults)
|
||||
}
|
||||
|
||||
static func migrateLegacyCoordinate(
|
||||
defaults: UserDefaults = AppGroup.defaults,
|
||||
legacyDefaults: UserDefaults = .standard
|
||||
) throws {
|
||||
guard defaults.data(forKey: positionKey) == nil else { return }
|
||||
let legacyLatitude = legacyDefaults.double(forKey: legacyLatKey)
|
||||
let legacyLongitude = legacyDefaults.double(forKey: legacyLonKey)
|
||||
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
|
||||
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else { return }
|
||||
let value = StoredPosition(
|
||||
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
|
||||
zoomMeters: ViewportStore.load(defaults: legacyDefaults) ?? 1_000
|
||||
)
|
||||
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum ViewportStore {
|
||||
private static let key = "mapViewportMeters"
|
||||
static func save(_ meters: CLLocationDistance) {
|
||||
UserDefaults.standard.set(meters, forKey: key)
|
||||
|
||||
static func save(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
|
||||
let value = max(50, meters)
|
||||
defaults.set(value, forKey: key)
|
||||
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(value)])
|
||||
}
|
||||
/// 取持久化缩放值;未存过返回 nil
|
||||
static func load() -> CLLocationDistance? {
|
||||
let v = UserDefaults.standard.double(forKey: key)
|
||||
return v > 0 ? v : nil
|
||||
|
||||
static func load(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance? {
|
||||
let value = defaults.double(forKey: key)
|
||||
return value > 0 ? value : nil
|
||||
}
|
||||
/// 取持久化缩放值,取不到返回默认 1km 并立即存储
|
||||
static func loadOrDefault() -> CLLocationDistance {
|
||||
if let v = load() { return v }
|
||||
|
||||
static func loadOrDefault(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance {
|
||||
if let value = load(defaults: defaults) { return value }
|
||||
let fallback: CLLocationDistance = 1_000
|
||||
save(fallback)
|
||||
save(fallback, defaults: defaults)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
struct LastCoordinate {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) }
|
||||
var isValid: Bool { CLLocationCoordinate2DIsValid(coordinate) && (latitude != 0 || longitude != 0) }
|
||||
}
|
||||
enum CoordinateStorageMigration {
|
||||
private static let versionKey = "coordinateStorageMigrationVersion"
|
||||
static let currentVersion = 1
|
||||
|
||||
enum LastCoordinateStore {
|
||||
private static let latKey = "lastMapLat"
|
||||
private static let lonKey = "lastMapLon"
|
||||
static func save(lat: Double, lon: Double) {
|
||||
UserDefaults.standard.set(lat, forKey: latKey)
|
||||
UserDefaults.standard.set(lon, forKey: lonKey)
|
||||
}
|
||||
/// 取持久化坐标,未存过或无效返回 nil
|
||||
static func load() -> LastCoordinate? {
|
||||
let c = LastCoordinate(
|
||||
latitude: UserDefaults.standard.double(forKey: latKey),
|
||||
longitude: UserDefaults.standard.double(forKey: lonKey)
|
||||
static func migrateIfNeeded(
|
||||
favorites: FavoriteLocationStore,
|
||||
defaults: UserDefaults = AppGroup.defaults,
|
||||
legacyDefaults: UserDefaults = .standard
|
||||
) throws {
|
||||
guard defaults.integer(forKey: versionKey) < currentVersion else { return }
|
||||
// Pre-migration map pin and viewport values lived in UserDefaults.standard;
|
||||
// favorites already lived in App Group defaults.
|
||||
try LastCoordinateStore.migrateLegacyCoordinate(
|
||||
defaults: defaults,
|
||||
legacyDefaults: legacyDefaults
|
||||
)
|
||||
return c.isValid ? c : nil
|
||||
try favorites.migrateLegacyCoordinates()
|
||||
defaults.set(currentVersion, forKey: versionKey)
|
||||
RuntimeLogger.info("APP", "坐标转换", "旧坐标数据迁移完成")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,15 @@ enum MapZoomMath {
|
||||
|
||||
struct MapViewRepresentable: UIViewRepresentable {
|
||||
let selection: MapSelection
|
||||
let initialViewportMeters: CLLocationDistance
|
||||
let cameraCommand: MapCameraCommand?
|
||||
let onRealtimeLocationChanged: (CLLocation) -> Void
|
||||
let onUserCenterChanged: (CLLocationCoordinate2D, CLLocationDistance) -> Void
|
||||
let onViewportChanged: (CLLocationDistance) -> Void
|
||||
let onMapTap: (CLLocationCoordinate2D) -> Void
|
||||
let onUserZoomChanged: ((CLLocationDistance) -> Void)?
|
||||
var onZoomIn: (() -> Void)?
|
||||
var onZoomOut: (() -> Void)?
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
@@ -45,7 +48,12 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
let map = MKMapView()
|
||||
map.delegate = context.coordinator
|
||||
map.showsUserLocation = true
|
||||
let initialDistance = ViewportStore.loadOrDefault()
|
||||
let initialDistance = max(50, initialViewportMeters)
|
||||
RuntimeLogger.info("APP", "地图", "makeUIView", details: [
|
||||
"zoom": String(initialDistance),
|
||||
"初始坐标来源": String(describing: selection.source),
|
||||
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
map.setRegion(
|
||||
MKCoordinateRegion(
|
||||
center: selection.coordinate,
|
||||
@@ -55,10 +63,83 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
animated: false
|
||||
)
|
||||
|
||||
// Center pin — positioned relative to geographic center, not Auto Layout
|
||||
let pinSize: CGFloat = 38
|
||||
let sizeCfg = UIImage.SymbolConfiguration(pointSize: pinSize, weight: .semibold)
|
||||
let paletteCfg = UIImage.SymbolConfiguration(paletteColors: [.white, .red])
|
||||
let pinImage = UIImage(systemName: "mappin",
|
||||
withConfiguration: sizeCfg.applying(paletteCfg))
|
||||
let pin = UIImageView(image: pinImage)
|
||||
pin.frame = CGRect(x: 0, y: 0, width: pinSize, height: pinSize)
|
||||
pin.isUserInteractionEnabled = false
|
||||
pin.layer.shadowColor = UIColor.black.cgColor
|
||||
pin.layer.shadowOpacity = 0.28
|
||||
pin.layer.shadowRadius = 5
|
||||
pin.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
map.addSubview(pin)
|
||||
context.coordinator.centerPin = pin
|
||||
|
||||
// Zoom controls — UIKit subviews inside MKMapView, move with the map
|
||||
let zoomStack = UIStackView()
|
||||
zoomStack.axis = .vertical
|
||||
zoomStack.spacing = 0
|
||||
zoomStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
zoomStack.alignment = .center
|
||||
zoomStack.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.8)
|
||||
zoomStack.layer.cornerRadius = 13
|
||||
zoomStack.layer.shadowColor = UIColor.black.cgColor
|
||||
zoomStack.layer.shadowOpacity = 0.18
|
||||
zoomStack.layer.shadowRadius = 7
|
||||
zoomStack.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
|
||||
let zoomInBtn = UIButton(type: .system)
|
||||
zoomInBtn.setImage(UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
|
||||
zoomInBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomInTapped), for: .touchUpInside)
|
||||
zoomInBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
zoomInBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
|
||||
let zoomLabel = UILabel()
|
||||
let roundedDesc = UIFont.systemFont(ofSize: 9, weight: .semibold).fontDescriptor.withDesign(.rounded)
|
||||
zoomLabel.font = roundedDesc.flatMap { UIFont(descriptor: $0, size: 9) } ?? .systemFont(ofSize: 9, weight: .semibold)
|
||||
zoomLabel.textColor = .secondaryLabel
|
||||
zoomLabel.textAlignment = .center
|
||||
zoomLabel.adjustsFontSizeToFitWidth = true
|
||||
zoomLabel.minimumScaleFactor = 0.7
|
||||
zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: initialDistance)
|
||||
zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
context.coordinator.zoomLabel = zoomLabel
|
||||
|
||||
let zoomOutBtn = UIButton(type: .system)
|
||||
zoomOutBtn.setImage(UIImage(systemName: "minus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal)
|
||||
zoomOutBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomOutTapped), for: .touchUpInside)
|
||||
zoomOutBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
zoomOutBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true
|
||||
|
||||
let sep1 = UIView(); sep1.translatesAutoresizingMaskIntoConstraints = false
|
||||
sep1.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
|
||||
sep1.backgroundColor = .separator
|
||||
sep1.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
let sep2 = UIView(); sep2.translatesAutoresizingMaskIntoConstraints = false
|
||||
sep2.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
|
||||
sep2.backgroundColor = .separator
|
||||
sep2.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
||||
|
||||
zoomStack.addArrangedSubview(zoomInBtn)
|
||||
zoomStack.addArrangedSubview(sep1)
|
||||
zoomStack.addArrangedSubview(zoomLabel)
|
||||
zoomStack.addArrangedSubview(sep2)
|
||||
zoomStack.addArrangedSubview(zoomOutBtn)
|
||||
map.addSubview(zoomStack)
|
||||
NSLayoutConstraint.activate([
|
||||
zoomStack.leadingAnchor.constraint(equalTo: map.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
||||
zoomStack.topAnchor.constraint(equalTo: map.safeAreaLayoutGuide.topAnchor, constant: 130),
|
||||
])
|
||||
|
||||
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||
tap.cancelsTouchesInView = false
|
||||
map.addGestureRecognizer(tap)
|
||||
context.coordinator.map = map
|
||||
context.coordinator.setupKeyboardObservers()
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -76,11 +157,48 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
private var activeCommandIsZoom = false
|
||||
private var regionChangeWasUserDriven = false
|
||||
private var isPinchZoom = false
|
||||
weak var zoomLabel: UILabel?
|
||||
weak var centerPin: UIImageView?
|
||||
private let pinSize: CGFloat = 38
|
||||
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
|
||||
private var userDotDiameter: CGFloat = 20
|
||||
private var keyboardObserverTokens: [NSObjectProtocol] = []
|
||||
private var lastForwardedRealtimeTimestamp: Date?
|
||||
|
||||
deinit {
|
||||
keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver)
|
||||
}
|
||||
|
||||
@objc func zoomInTapped() { parent.onZoomIn?() }
|
||||
@objc func zoomOutTapped() { parent.onZoomOut?() }
|
||||
|
||||
init(parent: MapViewRepresentable) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
private func updatePinPosition(on mapView: MKMapView) {
|
||||
guard let pin = centerPin else { return }
|
||||
let centerPt = mapView.convert(mapView.centerCoordinate, toPointTo: mapView)
|
||||
// pin 尖在底边,上移自身一半 + 蓝点半径对齐
|
||||
pin.center = CGPoint(
|
||||
x: centerPt.x,
|
||||
y: centerPt.y - pinSize / 2 + 5
|
||||
)
|
||||
}
|
||||
|
||||
func setupKeyboardObservers() {
|
||||
guard keyboardObserverTokens.isEmpty else { return }
|
||||
let nc = NotificationCenter.default
|
||||
keyboardObserverTokens.append(nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self, let map = self.map else { return }
|
||||
self.updatePinPosition(on: map)
|
||||
})
|
||||
keyboardObserverTokens.append(nc.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self, let map = self.map else { return }
|
||||
self.updatePinPosition(on: map)
|
||||
})
|
||||
}
|
||||
|
||||
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
|
||||
guard let command, command.id != lastConsumedCommandID else { return }
|
||||
lastConsumedCommandID = command.id
|
||||
@@ -92,8 +210,7 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
let region: MKCoordinateRegion
|
||||
switch command.kind {
|
||||
case let .focus(coordinate, _):
|
||||
// 保持当前 span 不变,只移动中心点,避免 latitudinalMeters
|
||||
// 与 visibleVerticalDistance 之间因屏幕宽高比引入 2x 漂移
|
||||
// 保持当前 span 不变,避免正方形 region 在竖屏 inflate
|
||||
region = MKCoordinateRegion(center: coordinate, span: map.region.span)
|
||||
case let .zoom(factor):
|
||||
region = MKCoordinateRegion(
|
||||
@@ -111,11 +228,36 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
parent.onMapTap(map.convert(point, toCoordinateFrom: map))
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
|
||||
for view in views where view.annotation is MKUserLocation {
|
||||
// 取蓝点实际大小,隐藏精度圈
|
||||
userDotDiameter = view.bounds.width
|
||||
for sub in view.subviews where sub.bounds.width > userDotDiameter + 4 {
|
||||
sub.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||
guard let location = userLocation.location,
|
||||
CLLocationCoordinate2DIsValid(location.coordinate),
|
||||
location.horizontalAccuracy >= 0 else { return }
|
||||
parent.onRealtimeLocationChanged(location)
|
||||
guard let location = visibleUserLocationSample(userLocation) else {
|
||||
RuntimeLogger.warning("APP", "实时定位", "MapKit 蓝点更新但 location 为空", details: [
|
||||
"来源": "MKMapView.didUpdate"
|
||||
])
|
||||
return
|
||||
}
|
||||
guard CLLocationCoordinate2DIsValid(location.coordinate) else {
|
||||
RealtimeLocationTrace.log("拒绝 MapKit 蓝点样本:坐标无效", location: location, details: [
|
||||
"来源": "MKMapView.didUpdate"
|
||||
], level: .warning)
|
||||
return
|
||||
}
|
||||
guard location.horizontalAccuracy >= 0 else {
|
||||
RealtimeLocationTrace.log("拒绝 MapKit 蓝点样本:水平精度无效", location: location, details: [
|
||||
"来源": "MKMapView.didUpdate"
|
||||
], level: .warning)
|
||||
return
|
||||
}
|
||||
forwardRealtimeLocation(location)
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
|
||||
@@ -143,6 +285,15 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
|
||||
let distance = visibleVerticalDistance(in: mapView)
|
||||
parent.onViewportChanged(distance)
|
||||
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
|
||||
updatePinPosition(on: mapView)
|
||||
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
|
||||
if let ul = visibleUserLocationSample(mapView.userLocation),
|
||||
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
|
||||
if lastForwardedRealtimeTimestamp.map({ ul.timestamp > $0 }) ?? true {
|
||||
forwardRealtimeLocation(ul)
|
||||
}
|
||||
}
|
||||
|
||||
let userZoomed: Bool
|
||||
if activeCameraCommandID != nil {
|
||||
@@ -165,6 +316,28 @@ struct MapViewRepresentable: UIViewRepresentable {
|
||||
regionChangeWasUserDriven = false
|
||||
}
|
||||
|
||||
private func forwardRealtimeLocation(_ location: CLLocation) {
|
||||
lastForwardedRealtimeTimestamp = location.timestamp
|
||||
parent.onRealtimeLocationChanged(location)
|
||||
}
|
||||
|
||||
/// `MKUserLocation.coordinate` is the coordinate of the blue-point
|
||||
/// annotation in the current map representation. Keep the native
|
||||
/// accuracy/timestamp metadata, but do not replace it with the raw
|
||||
/// Core Location coordinate carried by `location.coordinate`.
|
||||
private func visibleUserLocationSample(_ userLocation: MKUserLocation) -> CLLocation? {
|
||||
guard let native = userLocation.location else { return nil }
|
||||
return CLLocation(
|
||||
coordinate: userLocation.coordinate,
|
||||
altitude: native.altitude,
|
||||
horizontalAccuracy: native.horizontalAccuracy,
|
||||
verticalAccuracy: native.verticalAccuracy,
|
||||
course: native.course,
|
||||
speed: native.speed,
|
||||
timestamp: native.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private func visibleVerticalDistance(in map: MKMapView) -> CLLocationDistance {
|
||||
let centerX = map.bounds.midX
|
||||
let north = map.convert(CGPoint(x: centerX, y: map.bounds.minY), toCoordinateFrom: map)
|
||||
|
||||
@@ -25,6 +25,9 @@ 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))
|
||||
@@ -59,8 +62,7 @@ final class ProxyManager: ObservableObject {
|
||||
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
|
||||
"revision": String(coordinateRevision),
|
||||
"enabled": String(enabled),
|
||||
"lat": String(lat),
|
||||
"lon": String(lon)
|
||||
"accuracy": String(accuracy)
|
||||
])
|
||||
return coordinateRevision
|
||||
}
|
||||
@@ -117,15 +119,31 @@ final class ProxyManager: ObservableObject {
|
||||
return (Double(r.r0), Double(r.r1), r.r2 != 0)
|
||||
}
|
||||
|
||||
func openCertificateDownload() async {
|
||||
@discardableResult
|
||||
func openCertificateDownload() async -> Bool {
|
||||
do {
|
||||
if !isRunning { try await start() }
|
||||
// 本地代理直接提供 CA 证书下载
|
||||
guard let url = URL(string: "http://127.0.0.1:8888/cert") else { return }
|
||||
await MainActor.run { UIApplication.shared.open(url) }
|
||||
guard let url = URL(string: "http://127.0.0.1:8888/cert") else {
|
||||
error = "证书下载地址无效"
|
||||
return false
|
||||
}
|
||||
let opened = await withCheckedContinuation { continuation in
|
||||
UIApplication.shared.open(url, options: [:]) { accepted in
|
||||
continuation.resume(returning: accepted)
|
||||
}
|
||||
}
|
||||
guard opened else {
|
||||
error = "系统无法打开证书下载页面,请稍后重试"
|
||||
RuntimeLogger.warning("APP", "Certificate", "系统拒绝打开证书下载地址")
|
||||
return false
|
||||
}
|
||||
error = nil
|
||||
return true
|
||||
} catch {
|
||||
self.error = "启动代理失败: \(error.localizedDescription)"
|
||||
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
private let driver: RealtimeLocationDriving
|
||||
private let oneShotTimeoutNanoseconds: UInt64
|
||||
private let fallbackTimeoutNanoseconds: UInt64
|
||||
private let cacheMaxAge: TimeInterval
|
||||
private let cacheMaxAge: TimeInterval = 20
|
||||
private var nextRequestID: UInt64 = 0
|
||||
private var activeRequest: ActiveRequest?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
@@ -69,13 +69,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
init(
|
||||
driver: RealtimeLocationDriving,
|
||||
oneShotTimeoutNanoseconds: UInt64 = 1_500_000_000,
|
||||
fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000,
|
||||
cacheMaxAge: TimeInterval = 20
|
||||
fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000
|
||||
) {
|
||||
self.driver = driver
|
||||
self.oneShotTimeoutNanoseconds = oneShotTimeoutNanoseconds
|
||||
self.fallbackTimeoutNanoseconds = fallbackTimeoutNanoseconds
|
||||
self.cacheMaxAge = cacheMaxAge
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
super.init()
|
||||
driver.delegate = self
|
||||
@@ -94,21 +92,31 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
|
||||
func requestLocation() async -> CLLocationCoordinate2D? {
|
||||
guard activeRequest == nil else {
|
||||
RuntimeLogger.warning("APP", "定位", "忽略重复实时定位请求")
|
||||
RuntimeLogger.warning("APP", "实时定位", "忽略重复 CLLocationManager 请求", details: [
|
||||
"活动requestID": activeRequest.map { String($0.id) } ?? "nil",
|
||||
"活动阶段": activeRequest.map { phaseName($0.phase) } ?? "nil"
|
||||
])
|
||||
return nil
|
||||
}
|
||||
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求入口", details: [
|
||||
"授权状态": authorizationName(authorizationStatus),
|
||||
"内存缓存存在": String(location != nil),
|
||||
"系统缓存存在": String(driver.location != nil)
|
||||
])
|
||||
guard authorizationStatus != .denied, authorizationStatus != .restricted else {
|
||||
RuntimeLogger.warning("APP", "实时定位", "授权状态不允许定位", details: [
|
||||
"授权状态": authorizationName(authorizationStatus)
|
||||
])
|
||||
return nil
|
||||
}
|
||||
|
||||
if let cached = freshestCachedLocation() {
|
||||
location = cached
|
||||
RuntimeLogger.info("APP", "定位", "使用系统缓存实时定位", details: [
|
||||
"age": String(format: "%.2f", max(0, -cached.timestamp.timeIntervalSinceNow)),
|
||||
"lat": String(cached.coordinate.latitude),
|
||||
"lon": String(cached.coordinate.longitude)
|
||||
RealtimeLocationTrace.log("使用 CLLocationManager 新鲜缓存", location: cached, details: [
|
||||
"来源": "manager-memory-or-system",
|
||||
"缓存上限秒": String(Int(cacheMaxAge))
|
||||
])
|
||||
return cached.coordinate
|
||||
}
|
||||
@@ -116,7 +124,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
nextRequestID &+= 1
|
||||
let requestID = nextRequestID
|
||||
isRequesting = true
|
||||
RuntimeLogger.info("APP", "定位", "请求实时定位…", details: ["requestID": String(requestID)])
|
||||
RuntimeLogger.info("APP", "实时定位", "创建 CLLocationManager 请求", details: [
|
||||
"requestID": String(requestID),
|
||||
"授权状态": authorizationName(authorizationStatus),
|
||||
"初始阶段": "awaitingAuthorization"
|
||||
])
|
||||
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
@@ -128,6 +140,9 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
)
|
||||
|
||||
if authorizationStatus == .notDetermined {
|
||||
RuntimeLogger.info("APP", "实时定位", "请求前台定位授权", details: [
|
||||
"requestID": String(requestID)
|
||||
])
|
||||
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
||||
driver.requestWhenInUseAuthorization()
|
||||
} else {
|
||||
@@ -155,6 +170,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
MainActor.assumeIsolated {
|
||||
authorizationStatus = driver.authorizationStatus
|
||||
RuntimeLogger.info("APP", "实时定位", "定位授权状态变化", details: [
|
||||
"授权状态": authorizationName(authorizationStatus),
|
||||
"requestID": activeRequest.map { String($0.id) } ?? "nil",
|
||||
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
|
||||
])
|
||||
switch authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
if let request = activeRequest, request.phase == .awaitingAuthorization {
|
||||
@@ -172,6 +192,27 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
MainActor.assumeIsolated {
|
||||
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 返回样本批次", details: [
|
||||
"样本数": String(locations.count),
|
||||
"requestID": activeRequest.map { String($0.id) } ?? "nil",
|
||||
"阶段": activeRequest.map { phaseName($0.phase) } ?? "idle"
|
||||
])
|
||||
for (index, candidate) in locations.enumerated() {
|
||||
let valid = Self.isValid(candidate)
|
||||
let freshEnough = activeRequest?.startedAt.map {
|
||||
candidate.timestamp >= $0.addingTimeInterval(-1)
|
||||
} ?? false
|
||||
RealtimeLocationTrace.log(
|
||||
valid ? "检查 CLLocationManager 样本" : "拒绝 CLLocationManager 样本:坐标或精度无效",
|
||||
location: candidate,
|
||||
details: [
|
||||
"批次索引": String(index),
|
||||
"基础校验通过": String(valid),
|
||||
"满足当前请求时间窗": String(freshEnough)
|
||||
],
|
||||
level: valid ? .info : .warning
|
||||
)
|
||||
}
|
||||
let validLocations = locations.filter(Self.isValid)
|
||||
if let latestValid = validLocations.last {
|
||||
location = latestValid
|
||||
@@ -183,8 +224,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
let latest = validLocations.last(where: {
|
||||
$0.timestamp >= startedAt.addingTimeInterval(-1)
|
||||
}) else {
|
||||
if let request = activeRequest, request.phase != .awaitingAuthorization {
|
||||
RuntimeLogger.warning("APP", "实时定位", "本批次没有可完成当前请求的样本", details: [
|
||||
"requestID": String(request.id),
|
||||
"阶段": phaseName(request.phase),
|
||||
"有效样本数": String(validLocations.count)
|
||||
])
|
||||
}
|
||||
return
|
||||
}
|
||||
RealtimeLocationTrace.log("接受 CLLocationManager 样本并完成请求", location: latest, details: [
|
||||
"requestID": String(request.id),
|
||||
"阶段": phaseName(request.phase)
|
||||
])
|
||||
finishRequest(id: request.id, coordinate: latest.coordinate)
|
||||
}
|
||||
}
|
||||
@@ -213,8 +265,19 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
}
|
||||
|
||||
private func freshestCachedLocation(now: Date = Date()) -> CLLocation? {
|
||||
[location, driver.location]
|
||||
.compactMap { $0 }
|
||||
let candidates = [location, driver.location].compactMap { $0 }
|
||||
for candidate in candidates {
|
||||
let valid = Self.isValid(candidate)
|
||||
let age = abs(candidate.timestamp.timeIntervalSince(now))
|
||||
if !valid || age > cacheMaxAge {
|
||||
RealtimeLocationTrace.log("跳过 CLLocationManager 缓存样本", location: candidate, details: [
|
||||
"基础校验通过": String(valid),
|
||||
"缓存时效通过": String(age <= cacheMaxAge),
|
||||
"缓存上限秒": String(Int(cacheMaxAge))
|
||||
], level: .warning)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
.filter(Self.isValid)
|
||||
.filter { abs($0.timestamp.timeIntervalSince(now)) <= cacheMaxAge }
|
||||
.max(by: { $0.timestamp < $1.timestamp })
|
||||
@@ -260,6 +323,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
request.phase = .oneShot
|
||||
request.startedAt = Date()
|
||||
activeRequest = request
|
||||
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 单次定位", details: [
|
||||
"requestID": String(requestID),
|
||||
"超时毫秒": String(oneShotTimeoutNanoseconds / 1_000_000)
|
||||
])
|
||||
scheduleTimeout(for: requestID, nanoseconds: oneShotTimeoutNanoseconds)
|
||||
driver.requestLocation()
|
||||
}
|
||||
@@ -270,6 +337,10 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
request.phase == .oneShot else { return }
|
||||
request.phase = .continuousFallback
|
||||
activeRequest = request
|
||||
RuntimeLogger.info("APP", "实时定位", "开始 CLLocationManager 持续定位兜底", details: [
|
||||
"requestID": String(requestID),
|
||||
"超时毫秒": String(fallbackTimeoutNanoseconds / 1_000_000)
|
||||
])
|
||||
driver.startUpdatingLocation()
|
||||
scheduleTimeout(for: requestID, nanoseconds: fallbackTimeoutNanoseconds)
|
||||
}
|
||||
@@ -287,15 +358,38 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
||||
activeRequest = nil
|
||||
isRequesting = false
|
||||
|
||||
if let coordinate {
|
||||
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
|
||||
if coordinate != nil {
|
||||
RuntimeLogger.info("APP", "实时定位", "CLLocationManager 请求完成", details: [
|
||||
"requestID": String(requestID),
|
||||
"lat": String(coordinate.latitude),
|
||||
"lon": String(coordinate.longitude)
|
||||
"最终阶段": phaseName(request.phase),
|
||||
"有坐标": "true"
|
||||
])
|
||||
} else {
|
||||
RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)])
|
||||
RuntimeLogger.warning("APP", "实时定位", "CLLocationManager 请求结束但没有坐标", details: [
|
||||
"requestID": String(requestID),
|
||||
"最终阶段": phaseName(request.phase),
|
||||
"有坐标": "false"
|
||||
])
|
||||
}
|
||||
request.continuation.resume(returning: coordinate)
|
||||
}
|
||||
|
||||
private func phaseName(_ phase: RequestPhase) -> String {
|
||||
switch phase {
|
||||
case .awaitingAuthorization: return "awaitingAuthorization"
|
||||
case .oneShot: return "oneShot"
|
||||
case .continuousFallback: return "continuousFallback"
|
||||
}
|
||||
}
|
||||
|
||||
private func authorizationName(_ status: CLAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .authorizedAlways: return "authorizedAlways"
|
||||
case .authorizedWhenInUse: return "authorizedWhenInUse"
|
||||
@unknown default: return "unknown(\(status.rawValue))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,71 @@ struct SettingsView: View {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
@ObservedObject var actions: LocationActionCoordinator
|
||||
@ObservedObject private var proxy = ProxyManager.shared
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var activeTip: TipKind?
|
||||
@State private var proxyOperationError = ""
|
||||
@State private var proxyOperationAlertTitle = "代理操作失败"
|
||||
@State private var modeOperationRunning = false
|
||||
@State private var copiedClient: ThirdPartyProxyClient?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("状态") {
|
||||
HStack {
|
||||
Label("代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
|
||||
Spacer()
|
||||
Toggle("", isOn: proxyBinding).labelsHidden()
|
||||
.tint(.blue)
|
||||
Section("运行模式") {
|
||||
Picker("模式", selection: runtimeModeBinding) {
|
||||
ForEach(ProxyRuntimeMode.allCases) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Label("虚拟定位", systemImage: actions.virtualLocationEnabled ? "location.fill" : "location.slash")
|
||||
Spacer()
|
||||
Text(actions.virtualLocationEnabled ? "已开启" : "已关闭").foregroundStyle(.secondary)
|
||||
.pickerStyle(.inline)
|
||||
.disabled(modeOperationRunning || actions.state.isBusy || thirdPartyProxy.isRequesting)
|
||||
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
Label("测试模式:仅 Shadowrocket 当前可测试", systemImage: "testtube.2")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Section("说明") {
|
||||
Section("状态") {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
HStack {
|
||||
Label("本机代理", systemImage: proxy.isRunning ? "play.circle.fill" : "stop.circle")
|
||||
Spacer()
|
||||
Toggle("", isOn: proxyBinding).labelsHidden()
|
||||
.tint(.blue)
|
||||
.disabled(actions.state.isBusy)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Label("第三方模块", systemImage: thirdPartyStatusIcon)
|
||||
Spacer()
|
||||
Text(thirdPartyStatusText).foregroundStyle(.secondary)
|
||||
}
|
||||
Button {
|
||||
detectThirdPartyConnection()
|
||||
} label: {
|
||||
if thirdPartyProxy.isRequesting {
|
||||
HStack { ProgressView(); Text("正在检测…") }
|
||||
} else {
|
||||
Label("检测连接", systemImage: "network")
|
||||
}
|
||||
}
|
||||
.disabled(thirdPartyProxy.isRequesting)
|
||||
}
|
||||
HStack {
|
||||
Label("虚拟定位", systemImage: virtualLocationIsActive ? "location.fill" : "location.slash")
|
||||
Spacer()
|
||||
Text(virtualLocationStatusText).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
thirdPartyConfigurationSection
|
||||
} else {
|
||||
Section("说明") {
|
||||
Button {
|
||||
activeTip = .activation
|
||||
} label: {
|
||||
@@ -39,23 +84,22 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("工作原理") {
|
||||
Text("""
|
||||
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
||||
|
||||
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
""")
|
||||
Text(workflowDescription)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("应用") {
|
||||
Button {
|
||||
setup.needsSetup = true
|
||||
} label: {
|
||||
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
Button {
|
||||
setup.requestSetup()
|
||||
} label: {
|
||||
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
}
|
||||
valueRow("版本", value: versionText)
|
||||
}
|
||||
@@ -100,6 +144,14 @@ struct SettingsView: View {
|
||||
.sheet(item: $activeTip) { kind in
|
||||
TipSheetView(kind: kind)
|
||||
}
|
||||
.alert(proxyOperationAlertTitle, isPresented: Binding(
|
||||
get: { !proxyOperationError.isEmpty },
|
||||
set: { if !$0 { proxyOperationError = "" } }
|
||||
)) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text(proxyOperationError)
|
||||
}
|
||||
}
|
||||
|
||||
private func valueRow(_ title: String, value: String) -> some View {
|
||||
@@ -116,11 +168,185 @@ struct SettingsView: View {
|
||||
Binding(get: { proxy.isRunning }, set: { on in
|
||||
Task {
|
||||
if on {
|
||||
do { try await proxy.start() } catch { proxy.error = error.localizedDescription }
|
||||
do {
|
||||
try await proxy.start()
|
||||
} catch {
|
||||
proxy.error = error.localizedDescription
|
||||
proxyOperationAlertTitle = "代理操作失败"
|
||||
proxyOperationError = error.localizedDescription
|
||||
}
|
||||
} else {
|
||||
if actions.virtualLocationEnabled {
|
||||
actions.clear()
|
||||
RuntimeLogger.info("APP", "Settings", "关闭代理前已同步关闭虚拟定位")
|
||||
}
|
||||
proxy.stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var runtimeModeBinding: Binding<ProxyRuntimeMode> {
|
||||
Binding(
|
||||
get: { runtimeMode.mode },
|
||||
set: { newMode in switchRuntimeMode(to: newMode) }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thirdPartyConfigurationSection: some View {
|
||||
Section("第三方代理配置") {
|
||||
Picker("客户端", selection: Binding(
|
||||
get: { thirdPartyClient.selectedClient },
|
||||
set: { thirdPartyClient.select($0) }
|
||||
)) {
|
||||
ForEach(ThirdPartyProxyClient.allCases) { client in
|
||||
Text(client.name).tag(client)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("验证状态")
|
||||
Spacer()
|
||||
Text(thirdPartyClient.selectedClient.verificationText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(thirdPartyClient.selectedClient == .shadowrocket ? .green : .orange)
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = thirdPartyClient.selectedClient.subscriptionURL.absoluteString
|
||||
copiedClient = thirdPartyClient.selectedClient
|
||||
} label: {
|
||||
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制订阅链接" : "复制订阅链接", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
openThirdPartyClient(thirdPartyClient.selectedClient)
|
||||
} label: {
|
||||
Label("打开 \(thirdPartyClient.selectedClient.name)", systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
|
||||
Button {
|
||||
setup.requestThirdPartySetup()
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("重新打开配置引导", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
|
||||
if thirdPartyClient.selectedClient == .egern {
|
||||
Text("Egern 直接使用 Surge 的 .sgmodule 模块。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
} else if thirdPartyClient.selectedClient == .stash {
|
||||
Text("Stash 直接订阅 .stoverride,不要通过 Script Hub 转换。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("复制链接后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyStatusIcon: String {
|
||||
switch thirdPartyProxy.connectionState {
|
||||
case .unknown: return "questionmark.circle"
|
||||
case .connected: return "checkmark.circle.fill"
|
||||
case .failed: return "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyStatusText: String {
|
||||
switch thirdPartyProxy.connectionState {
|
||||
case .unknown: return "未检测"
|
||||
case .connected(let active): return active ? "已连接,有坐标" : "已连接,无坐标"
|
||||
case .failed: return "连接失败"
|
||||
}
|
||||
}
|
||||
|
||||
private var virtualLocationStatusText: String {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
return actions.virtualLocationEnabled ? "已开启" : "已关闭"
|
||||
}
|
||||
if case .connected(let active) = thirdPartyProxy.connectionState {
|
||||
return active ? "第三方已保存" : "未保存"
|
||||
}
|
||||
return "未知"
|
||||
}
|
||||
|
||||
private var workflowDescription: String {
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
return "App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。"
|
||||
}
|
||||
return """
|
||||
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
||||
|
||||
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
"""
|
||||
}
|
||||
|
||||
private func switchRuntimeMode(to newMode: ProxyRuntimeMode) {
|
||||
guard newMode != runtimeMode.mode, !modeOperationRunning else { return }
|
||||
modeOperationRunning = true
|
||||
Task { @MainActor in
|
||||
defer { modeOperationRunning = false }
|
||||
switch newMode {
|
||||
case .thirdParty:
|
||||
if actions.virtualLocationEnabled { actions.clear() }
|
||||
proxy.stop()
|
||||
setup.completeSetup()
|
||||
runtimeMode.setMode(.thirdParty)
|
||||
setup.requestThirdPartySetup()
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "已切换到第三方代理模式。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,并按引导导入第三方配置。"
|
||||
case .localWiFi:
|
||||
do {
|
||||
try await thirdPartyProxy.clear()
|
||||
} catch {
|
||||
RuntimeLogger.warning("APP", "Mode", "切换 APP 模式前无法清除第三方坐标", details: [
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
}
|
||||
runtimeMode.setMode(.localWiFi)
|
||||
await setup.prepareLocalServices()
|
||||
setup.requestSetup()
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "已切换到 APP 模式。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detectThirdPartyConnection() {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
if !response.success, response.error?.contains("无已保存") != true {
|
||||
proxyOperationAlertTitle = "检测失败"
|
||||
proxyOperationError = response.error ?? "第三方代理模块返回失败"
|
||||
}
|
||||
} catch {
|
||||
proxyOperationAlertTitle = "检测失败"
|
||||
proxyOperationError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
|
||||
guard let url = client.launchURL else { return }
|
||||
UIApplication.shared.open(url, options: [:]) { opened in
|
||||
guard !opened else { return }
|
||||
Task { @MainActor in
|
||||
proxyOperationAlertTitle = "无法打开客户端"
|
||||
proxyOperationError = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var virtualLocationIsActive: Bool {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
return actions.virtualLocationEnabled
|
||||
}
|
||||
if case .connected(let active) = thirdPartyProxy.connectionState {
|
||||
return active
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ final class SetupCoordinator: ObservableObject {
|
||||
@Published var testLog = ""
|
||||
// 启动检测失败才弹引导页;检测通过则保持 false
|
||||
@Published var needsSetup = false
|
||||
@Published private(set) var setupStep: SetupStep = .proxy
|
||||
|
||||
let certificateStore = CertificateAuthorityStore()
|
||||
let proxy = ProxyManager.shared
|
||||
@@ -24,79 +25,56 @@ final class SetupCoordinator: ObservableObject {
|
||||
|
||||
var canModify: Bool { proxy.isRunning && trustState == .trusted }
|
||||
|
||||
func refreshTrust() async {
|
||||
trustState = .checking
|
||||
message = "正在检测…"
|
||||
testLog = ""
|
||||
/// Local services are prepared before the setup UI so the environment test
|
||||
/// can distinguish Wi-Fi proxy configuration from CA trust failures.
|
||||
func prepareLocalServices() async {
|
||||
do {
|
||||
_ = try certificateStore.ensure()
|
||||
if !proxy.isRunning { try await proxy.start() }
|
||||
// 强制走代理 POST 到 Apple 定位接口:TLS 成功 = CA 信任
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 5
|
||||
config.timeoutIntervalForResource = 8
|
||||
config.connectionProxyDictionary = [
|
||||
kCFNetworkProxiesHTTPEnable as String: true,
|
||||
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
|
||||
kCFNetworkProxiesHTTPPort as String: 8888,
|
||||
]
|
||||
// 用当前保存的虚拟定位坐标做测试;没有则用默认坐标
|
||||
let saved = WlocSettingsStore.load()
|
||||
let testLat = saved?.latitude ?? 22.543099
|
||||
let testLon = saved?.longitude ?? 113.934576
|
||||
let testAccuracy = saved?.accuracy ?? 25
|
||||
let req = makeWlocRequest()
|
||||
let (_, resp) = try await URLSession(configuration: config).data(for: req)
|
||||
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
// 400 = Apple 拒绝了测试请求体,但 TLS 握手成功 = 证书已信任
|
||||
if status == 0 {
|
||||
trustState = .unavailable
|
||||
message = "代理链路异常,未收到响应"
|
||||
return
|
||||
}
|
||||
// 走同一套改写验证:Go Core 内模拟 Apple 响应并确认坐标被改写
|
||||
let patchResult = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: testAccuracy)
|
||||
if patchResult.hasPrefix("ok:") {
|
||||
trustState = .trusted
|
||||
message = "✓ 定位环境正常(返回 \(status))"
|
||||
} else {
|
||||
trustState = .unavailable
|
||||
message = "定位数据改写验证失败:\(patchResult)"
|
||||
}
|
||||
} catch {
|
||||
message = "本地代理初始化失败:\(error.localizedDescription)"
|
||||
RuntimeLogger.error("APP", "Startup", "本地服务初始化失败", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func applyVerificationResult(_ result: VerificationResult) {
|
||||
switch result {
|
||||
case .success:
|
||||
trustState = .trusted
|
||||
needsSetup = false
|
||||
message = "✓ 定位环境正常"
|
||||
case .certNotTrusted:
|
||||
trustState = .unavailable
|
||||
let ns = error as NSError
|
||||
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
|
||||
message = "CA 证书未信任,请去「设置→通用→关于→证书信任设置」开启或重新安装"
|
||||
} else if ns.domain == NSURLErrorDomain && ns.code == -1001 {
|
||||
message = "检测超时,请检查代理是否正常"
|
||||
} else if ns.domain == NSURLErrorDomain && ns.code == -1200 {
|
||||
message = "代理未启动或无法连接"
|
||||
} else {
|
||||
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
|
||||
}
|
||||
setupStep = .cert
|
||||
needsSetup = true
|
||||
message = "CA 证书未安装或未信任"
|
||||
default:
|
||||
trustState = .unavailable
|
||||
setupStep = .proxy
|
||||
needsSetup = true
|
||||
message = "Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888"
|
||||
}
|
||||
needsSetup = !canModify
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive() {}
|
||||
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
|
||||
func completeSetup() { needsSetup = false }
|
||||
func requestSetup() { needsSetup = true }
|
||||
func requestModeSelection() {
|
||||
setupStep = .mode
|
||||
needsSetup = true
|
||||
}
|
||||
func requestThirdPartySetup() {
|
||||
setupStep = .thirdPartyClient
|
||||
needsSetup = true
|
||||
}
|
||||
func requestSetup() {
|
||||
setupStep = .proxy
|
||||
needsSetup = true
|
||||
}
|
||||
|
||||
// MARK: - Step-by-step verification test
|
||||
|
||||
private func makeWlocRequest() -> URLRequest {
|
||||
var req = URLRequest(url: URL(string: "https://gs-loc.apple.com/clls/wloc")!)
|
||||
req.httpMethod = "POST"
|
||||
req.httpBody = CoreBridge.testWlocRequestData()
|
||||
req.setValue("application/x-protobuf", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("wloc/1.0", forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("application/x-protobuf", forHTTPHeaderField: "Accept")
|
||||
return req
|
||||
}
|
||||
|
||||
func runVerificationTest(testLat: Double = 22.543099, testLon: Double = 113.934576) async -> VerificationResult {
|
||||
func runVerificationTest() async -> VerificationResult {
|
||||
guard !isVerificationRunning else { return .verificationInProgress }
|
||||
isVerificationRunning = true
|
||||
defer { isVerificationRunning = false }
|
||||
@@ -107,7 +85,6 @@ final class SetupCoordinator: ObservableObject {
|
||||
log("======== 代理验证测试 ========")
|
||||
log("App 版本: \(appVersion)")
|
||||
log("系统版本: iOS \(UIDevice.current.systemVersion)")
|
||||
log("测试目标: lat=\(testLat), lon=\(testLon)")
|
||||
log("")
|
||||
|
||||
// Step A: Proxy running
|
||||
@@ -126,18 +103,6 @@ final class SetupCoordinator: ObservableObject {
|
||||
}
|
||||
collectProxyLogs(since: stepAStart, to: log)
|
||||
|
||||
// Verification must never overwrite a newer location action.
|
||||
let previousCoordinates = proxy.coordinateSnapshot(
|
||||
accuracy: WlocSettingsStore.load()?.accuracy ?? 25
|
||||
)
|
||||
var verificationCoordinateRevision: UInt64?
|
||||
defer {
|
||||
if let revision = verificationCoordinateRevision {
|
||||
let restored = proxy.restoreCoords(previousCoordinates, ifUnchangedSince: revision)
|
||||
log(restored ? " ↩ 已恢复验证前的代理坐标" : " ↩ 检测到更新位置,跳过旧坐标恢复")
|
||||
}
|
||||
}
|
||||
|
||||
// Step B: Combined CA + WiFi proxy check (single request)
|
||||
log("")
|
||||
log("[步骤 B] 检测证书与 WiFi 代理…")
|
||||
@@ -161,63 +126,48 @@ final class SetupCoordinator: ObservableObject {
|
||||
if body == verifyToken {
|
||||
log(" ✓ 证书已信任,WiFi 代理已配置")
|
||||
} else {
|
||||
log(" ✗ 响应不匹配 (HTTP \(statusCode)),WiFi 代理未配置")
|
||||
log(" 收到: \(body.prefix(100))")
|
||||
log(" ✗ 响应不匹配: HTTP \(statusCode), \(data.count) bytes,WiFi 代理未配置")
|
||||
return .wifiProxyNotConfigured
|
||||
}
|
||||
} catch {
|
||||
let ns = error as NSError
|
||||
let msg = error.localizedDescription
|
||||
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)")
|
||||
if ns.domain == NSURLErrorDomain && ns.code == -1202 {
|
||||
log(" TLS 握手被拒,CA 证书未信任")
|
||||
return .certNotTrusted
|
||||
}
|
||||
if msg.contains("TLS") {
|
||||
log(" 包含 TLS → 证书问题")
|
||||
if isCertificateTrustError(nsError: ns, message: msg) {
|
||||
log(" TLS/证书校验失败,CA 证书未信任")
|
||||
return .certNotTrusted
|
||||
}
|
||||
return .wifiProxyNotConfigured
|
||||
}
|
||||
collectProxyLogs(since: stepBStart, to: log)
|
||||
|
||||
// Step C: Write and verify coordinates
|
||||
log("[步骤 C] 写入测试坐标并验证…")
|
||||
guard let testCoordinateRevision = proxy.setCoordsIfUnchanged(
|
||||
lat: testLat,
|
||||
lon: testLon,
|
||||
enabled: true,
|
||||
accuracy: 25,
|
||||
expectedRevision: previousCoordinates.revision
|
||||
) else {
|
||||
log(" ↪ 检测到更新位置,取消过期验证坐标写入")
|
||||
return .verificationSuperseded
|
||||
}
|
||||
verificationCoordinateRevision = testCoordinateRevision
|
||||
let coords = proxy.getCoords()
|
||||
if coords.enabled && abs(coords.lat - testLat) < 0.001 && abs(coords.lon - testLon) < 0.001 {
|
||||
log(" ✓ 坐标写入成功: lat=\(coords.lat) lon=\(coords.lon)")
|
||||
} else {
|
||||
log(" ✗ 坐标验证失败: enabled=\(coords.enabled) lat=\(coords.lat) lon=\(coords.lon)")
|
||||
return .coordinateWriteFailed("写入后回读不一致")
|
||||
}
|
||||
|
||||
// Step D: verify data rewriting via Go test patch
|
||||
log("[步骤 D] 验证定位数据改写…")
|
||||
let result = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: 25)
|
||||
log(" \(result)")
|
||||
if result.hasPrefix("ok:") {
|
||||
log(" ✓ 定位数据改写验证通过")
|
||||
} else {
|
||||
log(" ✗ 定位数据改写失败")
|
||||
return .patchFailed(result)
|
||||
}
|
||||
|
||||
log("")
|
||||
log("======== 环境检测通过 ✓ ========")
|
||||
return .success
|
||||
}
|
||||
|
||||
/// Classifies TLS trust failures without relying on localized error text alone.
|
||||
private func isCertificateTrustError(nsError: NSError, message: String) -> Bool {
|
||||
if nsError.domain == NSURLErrorDomain {
|
||||
let trustErrorCodes: Set<Int> = [
|
||||
-1200, // secure connection failed
|
||||
-1201, // server certificate has bad date
|
||||
-1202, // server certificate untrusted
|
||||
-1203, // server certificate has unknown root
|
||||
-1204, // server certificate not yet valid
|
||||
-1205, // client certificate rejected
|
||||
-1206, // client certificate required
|
||||
]
|
||||
return trustErrorCodes.contains(nsError.code)
|
||||
}
|
||||
|
||||
let normalized = message.lowercased()
|
||||
return normalized.contains("tls")
|
||||
|| normalized.contains("ssl")
|
||||
|| normalized.contains("certificate")
|
||||
|| normalized.contains("证书")
|
||||
}
|
||||
|
||||
/// 拉取 Go 代理的详细日志(CONNECT/请求/上游响应/改写结果)到 testLog
|
||||
private func collectProxyLogs(since date: Date, to log: (String) -> Void) {
|
||||
CoreBridge.flushLogs(category: "Proxy")
|
||||
|
||||
@@ -4,7 +4,6 @@ enum TipKind: String, Identifiable {
|
||||
case activation = "生效说明"
|
||||
case deactivation = "失效说明"
|
||||
case removeProxy = "关闭 WiFi 代理"
|
||||
case certificate = "证书问题"
|
||||
case proxySetup = "配置代理"
|
||||
case rewriteFailed = "改写失败"
|
||||
var id: String { rawValue }
|
||||
@@ -12,6 +11,7 @@ enum TipKind: String, Identifiable {
|
||||
|
||||
struct TipSheetView: View {
|
||||
let kind: TipKind
|
||||
var runtimeMode: ProxyRuntimeMode = .localWiFi
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -19,10 +19,9 @@ struct TipSheetView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
switch kind {
|
||||
case .activation: ActivationTipContent(dismiss: { dismiss() })
|
||||
case .deactivation: DeactivationTipContent(dismiss: { dismiss() })
|
||||
case .activation: ActivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
|
||||
case .deactivation: DeactivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
|
||||
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
|
||||
case .certificate: CertificateTipContent(dismiss: { dismiss() })
|
||||
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
|
||||
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
|
||||
}
|
||||
@@ -67,15 +66,19 @@ private struct TipCloseButton: View {
|
||||
// MARK: - 生效说明
|
||||
|
||||
struct ActivationTipContent: View {
|
||||
var runtimeMode: ProxyRuntimeMode = .localWiFi
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("让虚拟定位生效", systemImage: "checklist")) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if runtimeMode == .thirdParty {
|
||||
step(0, "确认第三方代理已开启", "保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。")
|
||||
}
|
||||
step(1, "开启飞行模式", "从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。")
|
||||
step(2, "关闭 Wi‑Fi", "从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。")
|
||||
systemStep(3, "关闭系统定位服务", "打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。")
|
||||
step(4, "打开 Wi‑Fi,启动虚拟定位", "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
|
||||
step(4, "打开 Wi‑Fi,启动虚拟定位", runtimeMode == .thirdParty ? "从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" : "从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。")
|
||||
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
|
||||
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。")
|
||||
}.padding(.vertical, 4)
|
||||
@@ -119,6 +122,7 @@ struct ActivationTipContent: View {
|
||||
// MARK: - 失效说明
|
||||
|
||||
struct DeactivationTipContent: View {
|
||||
var runtimeMode: ProxyRuntimeMode = .localWiFi
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -127,7 +131,11 @@ struct DeactivationTipContent: View {
|
||||
step(1, "开启飞行模式", "从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。")
|
||||
step(2, "关闭 Wi‑Fi", "从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。")
|
||||
systemStep(3, "关闭系统定位服务", "打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。")
|
||||
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
|
||||
if runtimeMode == .thirdParty {
|
||||
step(4, "确认坐标已清除", "App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。")
|
||||
} else {
|
||||
systemStep(4, "打开 Wi‑Fi,移除代理", "从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。")
|
||||
}
|
||||
step(5, "关闭飞行模式", "从控制中心关闭飞行模式。等待 2 秒。")
|
||||
systemStep(6, "重新开启定位服务", "再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。")
|
||||
}.padding(.vertical, 4)
|
||||
@@ -185,24 +193,6 @@ 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 {
|
||||
|
||||
@@ -41,6 +41,18 @@ 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 {
|
||||
@@ -64,13 +76,12 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
|
||||
//export wloccore_stopproxy
|
||||
func wloccore_stopproxy(h C.uintptr_t) C.int {
|
||||
logEvent("stopproxy requested")
|
||||
handle := cgo.Handle(h)
|
||||
srv, ok := handle.Value().(*http.Server)
|
||||
handle.Delete()
|
||||
srv, handle, ok := proxyForHandle(h)
|
||||
if !ok {
|
||||
logEvent("stopproxy failed: invalid handle")
|
||||
return 1
|
||||
}
|
||||
handle.Delete()
|
||||
if err := stopProxy(srv); err != nil {
|
||||
logEvent("stopproxy failed: " + err.Error())
|
||||
return 2
|
||||
@@ -79,6 +90,20 @@ 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()
|
||||
@@ -87,7 +112,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) + " lat=" + strconv.FormatFloat(float64(lat), 'f', 6, 64) + " lon=" + strconv.FormatFloat(float64(lon), 'f', 6, 64) + " accuracy=" + strconv.Itoa(int(accuracy)))
|
||||
logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " accuracy=" + strconv.Itoa(int(accuracy)))
|
||||
}
|
||||
|
||||
//export wloccore_getcoords
|
||||
@@ -127,12 +152,17 @@ func wloccore_startcertserver(certData, keyData *C.char) C.uintptr_t {
|
||||
return C.uintptr_t(cgo.NewHandle(server))
|
||||
}
|
||||
|
||||
func certificateServerForHandle(h C.uintptr_t) (*certificateServer, cgo.Handle, bool) {
|
||||
func certificateServerForHandle(h C.uintptr_t) (server *certificateServer, handle cgo.Handle, ok bool) {
|
||||
if h == 0 {
|
||||
return nil, 0, false
|
||||
}
|
||||
handle := cgo.Handle(h)
|
||||
server, ok := handle.Value().(*certificateServer)
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
server, handle, ok = nil, 0, false
|
||||
}
|
||||
}()
|
||||
handle = cgo.Handle(h)
|
||||
server, ok = handle.Value().(*certificateServer)
|
||||
return server, handle, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
func deterministicReader() io.Reader {
|
||||
seed := sha256.Sum256([]byte("paopao-location-spoofer-ca-v1"))
|
||||
src := rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8])))
|
||||
return rand.New(src)
|
||||
func randomSerialNumber() (*big.Int, error) {
|
||||
// Keep the serial positive and within the RFC 5280 recommended 20-octet bound.
|
||||
limit := new(big.Int).Lsh(big.NewInt(1), 159)
|
||||
return rand.Int(rand.Reader, limit)
|
||||
}
|
||||
|
||||
func generateCA() (certPEM, keyPEM []byte, err error) {
|
||||
rng := deterministicReader()
|
||||
privateKey, err := rsa.GenerateKey(rng, 2048)
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
serialNumber, err := randomSerialNumber()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"WLOC"},
|
||||
CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"),
|
||||
@@ -41,7 +42,7 @@ func generateCA() (certPEM, keyPEM []byte, err error) {
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rng, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -33,3 +33,17 @@ func TestGenerateCA(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCAUsesUniquePrivateKeys(t *testing.T) {
|
||||
_, firstKey, err := generateCA()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, secondKey, err := generateCA()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(firstKey) == string(secondKey) {
|
||||
t.Fatal("generated CA private keys must not be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -95,10 +94,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
||||
}
|
||||
if r.URL.Path == "/coords" {
|
||||
stateMu.Lock()
|
||||
enabled, lat, lon := currentEnabled, currentLat, currentLon
|
||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||
stateMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, currentAccuracy)))
|
||||
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, accuracy)))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
|
||||
@@ -146,20 +145,16 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
||||
logEvent("CONNECT " + host + " -> MITM (verify)")
|
||||
return mitmAction, host
|
||||
}
|
||||
logEvent("CONNECT " + host + " -> passthrough")
|
||||
// Global Wi-Fi proxy mode sends all HTTPS CONNECT traffic here. Logging
|
||||
// unrelated passthrough hosts creates high-volume noise and can disclose
|
||||
// browsing destinations; diagnostics only retain WLOC and verify traffic.
|
||||
return goproxy.OkConnect, host
|
||||
})
|
||||
}
|
||||
|
||||
proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||
body := []byte(nil)
|
||||
if req.Body != nil {
|
||||
body, _ = io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
logEvent(fmt.Sprintf("proxy request target=%s method=%s path=%s size=%d body_prefix=%s",
|
||||
req.Host, req.Method, req.URL.Path, len(body), hexPrefix(body, 64)))
|
||||
// Do not buffer or log arbitrary global-proxy traffic. Keep requests streaming
|
||||
// and do not persist unrelated request content in diagnostics.
|
||||
return serveLocalRequests(req, ctx)
|
||||
})
|
||||
proxy.OnResponse().DoFunc(patchWlocResponse)
|
||||
@@ -176,7 +171,7 @@ func serveLocalRequests(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request
|
||||
}
|
||||
if (h == "baidu.com" || h == "www.baidu.com") && strings.HasPrefix(req.URL.Path, "/paopao-verify-") {
|
||||
token := strings.TrimPrefix(req.URL.Path, "/paopao-verify-")
|
||||
logEvent("verify request path=" + req.URL.Path + " token=" + token)
|
||||
logEvent("verify request received")
|
||||
if checkVerifyToken(token) {
|
||||
resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token)
|
||||
resp.Header.Set("Cache-Control", "no-store")
|
||||
@@ -229,41 +224,36 @@ 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(originalBody)
|
||||
originalBody.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(originalBody, maxPatchBodyBytes+1))
|
||||
if err != nil {
|
||||
logEvent("wloc response read failed: " + err.Error())
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), originalBody))
|
||||
return resp
|
||||
}
|
||||
logEvent(fmt.Sprintf("wloc upstream response status=%d size=%d headers=[%s] body_prefix=%s",
|
||||
resp.StatusCode, len(body), summarizeHeaders(resp.Header), hexPrefix(body, 64)))
|
||||
if int64(len(body)) > maxPatchBodyBytes {
|
||||
logEvent("wloc response passed through: body exceeds patch limit")
|
||||
resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), originalBody))
|
||||
return resp
|
||||
}
|
||||
originalBody.Close()
|
||||
|
||||
if !enabled {
|
||||
logEvent("wloc upstream response passed through (spoofing disabled)")
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logEvent(fmt.Sprintf("wloc upstream response passed through (status %d != 200)", resp.StatusCode))
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
if len(body) == 0 {
|
||||
logEvent("wloc upstream response empty, passed through")
|
||||
if !enabled || resp.StatusCode != http.StatusOK || len(body) == 0 {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
|
||||
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy})
|
||||
if err != nil {
|
||||
logEvent("wloc patch skipped: " + err.Error())
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
if bytes.Equal(patched, body) {
|
||||
logEvent("wloc patch produced identical body, passed through")
|
||||
if err != nil || bytes.Equal(patched, body) {
|
||||
if err != nil {
|
||||
logEvent("wloc patch skipped: " + err.Error())
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
@@ -273,30 +263,10 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Transfer-Encoding")
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
||||
logEvent(fmt.Sprintf("wloc patched target=%.6f,%.6f accuracy=%d locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d body_prefix=%s",
|
||||
lat, lon, accuracy, stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched), hexPrefix(patched, 64)))
|
||||
logEvent(fmt.Sprintf("wloc patched locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d", stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched)))
|
||||
return resp
|
||||
}
|
||||
|
||||
func hexPrefix(b []byte, n int) string {
|
||||
if len(b) > n {
|
||||
b = b[:n]
|
||||
}
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
func summarizeHeaders(h http.Header) string {
|
||||
if len(h) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(h))
|
||||
for k, v := range h {
|
||||
parts = append(parts, k+"="+strings.Join(v, ","))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func generateProxyMobileConfig() string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
||||
@@ -4,7 +4,13 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -108,3 +114,109 @@ func TestTransparentBodyUnchanged(t *testing.T) {
|
||||
t.Fatal("expected non-patchable body to error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchWlocResponsePassesThroughOversizedBody(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("x"), (1<<20)+1)
|
||||
req := httptest.NewRequest(http.MethodPost, "https://gs-loc.apple.com/clls/wloc", nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Request: req,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||
ContentLength: int64(len(payload)),
|
||||
}
|
||||
|
||||
patched := patchWlocResponse(resp, nil)
|
||||
got, err := io.ReadAll(patched.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatal("oversized WLOC response was changed or truncated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeLocalRequestsKeepsUnrelatedRequestBodyStreaming(t *testing.T) {
|
||||
const secret = "body-must-not-be-buffered-or-logged"
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.com/upload", bytes.NewBufferString(secret))
|
||||
returned, response := serveLocalRequests(req, nil)
|
||||
if response != nil {
|
||||
t.Fatalf("unexpected local response: %d", response.StatusCode)
|
||||
}
|
||||
got, err := io.ReadAll(returned.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != secret {
|
||||
t.Fatalf("request body changed: got %q", got)
|
||||
}
|
||||
if logs := drainLogs(); bytes.Contains([]byte(logs), []byte(secret)) {
|
||||
t.Fatal("request body leaked into diagnostic logs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordsEndpointReturnsAtomicSnapshot(t *testing.T) {
|
||||
stateMu.Lock()
|
||||
previousLat, previousLon := currentLat, currentLon
|
||||
previousEnabled, previousAccuracy := currentEnabled, currentAccuracy
|
||||
currentLat, currentLon, currentEnabled, currentAccuracy = 0, 0, false, 0
|
||||
stateMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stateMu.Lock()
|
||||
currentLat, currentLon = previousLat, previousLon
|
||||
currentEnabled, currentAccuracy = previousEnabled, previousAccuracy
|
||||
stateMu.Unlock()
|
||||
})
|
||||
|
||||
handler := newProxy(nil).NonproxyHandler
|
||||
const updates = 20_000
|
||||
const readers = 8
|
||||
const readsPerReader = 2_500
|
||||
|
||||
var writers sync.WaitGroup
|
||||
writers.Add(1)
|
||||
go func() {
|
||||
defer writers.Done()
|
||||
for i := 1; i <= updates; i++ {
|
||||
stateMu.Lock()
|
||||
currentLat = float64(i)
|
||||
currentLon = -float64(i)
|
||||
currentEnabled = i%2 == 0
|
||||
currentAccuracy = i
|
||||
stateMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
errs := make(chan error, readers)
|
||||
var readersGroup sync.WaitGroup
|
||||
for range readers {
|
||||
readersGroup.Add(1)
|
||||
go func() {
|
||||
defer readersGroup.Done()
|
||||
for i := 0; i < readsPerReader; i++ {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://proxy.local/coords", nil))
|
||||
var snapshot struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Accuracy int `json:"accuracy"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &snapshot); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if snapshot.Lat != 0 && (snapshot.Lon != -snapshot.Lat || snapshot.Accuracy != int(snapshot.Lat)) {
|
||||
errs <- fmt.Errorf("torn coordinate snapshot: %+v", snapshot)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
readersGroup.Wait()
|
||||
writers.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,162 +2,393 @@
|
||||
|
||||
# 📍 Location Spoofer
|
||||
|
||||
### iOS Location Spoofer · DingTalk · WeChat · Apple Watch Region Unlock · Fake GPS
|
||||
### iOS Location Service Research & Testing Framework
|
||||
|
||||
**No VPN, no jailbreak — run a local HTTP proxy on your iPhone to rewrite Apple location responses.**<br>
|
||||
Works with DingTalk check-in, WeChat location sharing, and any app that uses system location. Map selection, real-time location, environment verification, certificate setup, and runtime logs in a single app.
|
||||
An open-source project for **iOS location-service research, software development testing, and QA validation**.
|
||||
|
||||
The project uses either an on-device proxy or a third-party proxy client to simulate selected Apple location-service
|
||||
responses in a controlled test environment.
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#why-no-vpn)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md)
|
||||
|
||||
<img src="images/主界面.jpg" alt="Location Spoofer iOS Fake GPS main interface" width="380">
|
||||
[Features](#feature-overview) ·
|
||||
[How It Works](#how-it-works) ·
|
||||
[Quick Start](#quick-start) ·
|
||||
[Build](#building-the-project) ·
|
||||
[中文](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is intended for education, security research, and testing on your own devices. It installs a locally generated CA certificate and requires a manual HTTP proxy on the current Wi‑Fi network. Please understand the risks and follow applicable laws and service terms.
|
||||
> Use this project only for education, research, testing on devices you own, software development, and QA validation.
|
||||
>
|
||||
> Use it only on devices, networks, and software environments that you own or are authorized to test. Follow applicable
|
||||
> laws, network policies, and service terms.
|
||||
>
|
||||
> The project does not guarantee compatibility with every iOS version or third-party app, and it does not promise to
|
||||
> bypass third-party security controls, business restrictions, or service rules.
|
||||
|
||||
## Credits
|
||||
## Project Scope
|
||||
|
||||
The core location-response rewriting approach and Go implementation are based on [Yu9191/wloc](https://github.com/Yu9191/wloc). This project adds a SwiftUI interface, MapKit selection, certificate and proxy guidance, environment verification, favorites, and diagnostics.
|
||||
Location Spoofer is a tool for studying iOS location-service behavior and testing location-dependent software.
|
||||
|
||||
## Why Location Spoofer?
|
||||
It provides:
|
||||
|
||||
Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailbroken device, Location Spoofer keeps the control flow on the iPhone itself.
|
||||
- Native map selection and location-scenario switching;
|
||||
- Controlled simulation of selected Apple location-service responses;
|
||||
- App Mode and Third-party Proxy Mode;
|
||||
- Map coordinate detection with paired WGS-84 and GCJ-02 values;
|
||||
- Environment checks, runtime logs, and diagnostics;
|
||||
- Favorite locations and restoration of the previous map state.
|
||||
|
||||
| Feature | Description |
|
||||
The project does not modify the target app's source code and does not provide telemetry, remote control, or data
|
||||
collection services.
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| 🚫 **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. |
|
||||
| Shadowrocket | Currently used for on-device testing |
|
||||
| Surge | Configuration provided, not fully verified |
|
||||
| Quantumult X | Configuration provided, not fully verified |
|
||||
| Loon | Configuration provided, not fully verified |
|
||||
| Stash | Configuration provided, not fully verified |
|
||||
| Egern | Uses the Surge module, not fully verified |
|
||||
|
||||
## Screenshots
|
||||
Module snapshots and provenance:
|
||||
|
||||
| Main Interface | Apple Maps | Amap | Apple Watch |
|
||||
|---|---|---|---|
|
||||
|  |  |  |  |
|
||||
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
## Key Features
|
||||
|
||||
- **iOS Location Spoofer / Fake GPS**: Apply the selected coordinate to the local proxy that rewrites location responses, compatible with DingTalk check-in, WeChat location sharing, and more.
|
||||
- **Native real-time location**: The map displays MapKit's own blue dot — no extra "fake real-time" overlay.
|
||||
- **Concurrency-safe selection**: Pan, tap, search, favorites, and async location respect the user's latest intent; stale results won't overwrite newer selections.
|
||||
- **Hierarchical place names**: POI, street, or road at close zoom; neighborhood, district, city, or province at wider zoom.
|
||||
- **Map scale display**: Zoom controls show the current visible range.
|
||||
- **Favorites with quick switch**: Save frequent coordinates and see which location is about to be applied.
|
||||
- **Setup guide**: Certificate download, installation, full trust, Wi‑Fi HTTP proxy, activation, and deactivation instructions.
|
||||
- **Built-in diagnostics**: Verification flow and structured runtime logs.
|
||||
The selected client owns its certificates, MITM configuration, and proxy switches. Review third-party modules and
|
||||
scripts before importing them.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install the App
|
||||
|
||||
- Download a build from [Releases](https://github.com/xweiba/location-spoofer/releases) and self-sign; or
|
||||
- Build from source on macOS with Xcode — see the [build guide](docs/BUILD.md).
|
||||
You can use:
|
||||
|
||||
Detailed steps in the [self-signing guide](docs/SELF-SIGNING.md).
|
||||
- Your own Apple Developer signing environment;
|
||||
- A self-signing tool suitable for personal testing;
|
||||
- The unsigned IPA published in Releases;
|
||||
- A source build produced on macOS using the [build guide](docs/BUILD.md).
|
||||
|
||||
### 2. Install & Trust the CA
|
||||
Free self-signing environments may not provide Network Extension capabilities. App Mode therefore uses an on-device
|
||||
proxy plus a manual Wi-Fi HTTP proxy and does not depend on a VPN component.
|
||||
|
||||
Follow the first-setup wizard to download the profile, then:
|
||||
#### Self-Signing Instructions
|
||||
|
||||
Release assets are unsigned IPA files and must be installed on an iPhone with a self-signing tool:
|
||||
|
||||
1. **Enable sideloading support**: On iOS 16 or newer, open Settings → Privacy & Security → Developer Mode, enable it,
|
||||
then restart and confirm when prompted. iOS 15 does not have this switch, so skip this step.
|
||||
2. **Download the IPA**: Open this project's [Releases](https://github.com/xweiba/location-spoofer/releases) and download
|
||||
the latest `PaopaoLocationSpoofer-unsigned.ipa`.
|
||||
3. **Prepare signing software**: Download the appropriate Impactor build from
|
||||
[Impactor Releases](https://github.com/claration/Impactor/releases). Other tools that support self-signing and installing
|
||||
IPA files, such as Aisi Assistant, may also be used.
|
||||
4. **Connect and install**: Connect the iPhone to the computer with a USB cable, choose “Trust This Computer” on the
|
||||
phone, select the downloaded IPA in the signing software, and follow that tool's instructions to sign and install it.
|
||||
|
||||
Impactor supports Windows, macOS, and Linux. If Windows cannot detect the device, install the Apple device drivers supplied
|
||||
with iTunes first. Aisi Assistant is third-party software; obtain it from its official channel and evaluate its account,
|
||||
certificate, and privacy risks yourself.
|
||||
|
||||
After installation, if iOS blocks the app from opening, go to Settings → General → VPN & Device Management and trust the
|
||||
corresponding developer app. A free Apple ID signature normally expires after seven days and must then be renewed.
|
||||
|
||||
### 2. First Launch
|
||||
|
||||
1. Choose App Mode or Third-party Proxy Mode;
|
||||
2. Complete the corresponding in-app setup;
|
||||
3. Run the environment check;
|
||||
4. Search, tap, or drag on the map to select a test location;
|
||||
5. Enable the test location and verify the result in the authorized test environment.
|
||||
|
||||
### 3. Restore the Real Location
|
||||
|
||||
App Mode:
|
||||
|
||||
1. Stop the test location;
|
||||
2. Disable the manual HTTP proxy on the current Wi-Fi network;
|
||||
3. Follow the in-app instructions to refresh the location environment.
|
||||
|
||||
Third-party Proxy Mode:
|
||||
|
||||
1. Clear the WLOC coordinates from the app;
|
||||
2. Disable the corresponding module or proxy in the third-party client;
|
||||
3. Restore HTTPS decryption and proxy settings according to the client documentation.
|
||||
|
||||
Location caches may take time to refresh. Restart the device if the system or target app continues to show an old
|
||||
location.
|
||||
|
||||
## Coordinate Handling
|
||||
|
||||
The project stores two coordinate representations:
|
||||
|
||||
- WGS-84: the international standard used for WLOC writes;
|
||||
- GCJ-02: the domestic map representation used where required.
|
||||
|
||||
MapKit does not expose a public API that reports whether its current runtime output uses GCJ-02 or WGS-84. The project
|
||||
uses a fixed-anchor query to resolve the active representation and performs controlled refreshes after blue-dot changes
|
||||
and explicit user actions.
|
||||
|
||||
Each write boundary stores a complete WGS-84/GCJ-02 pair. Rendering selects the field matching the confirmed map
|
||||
representation instead of repeatedly converting an already typed value.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
Settings → General → VPN & Device Management → install WLOC CA
|
||||
Settings → General → About → Certificate Trust Settings → enable full trust
|
||||
App/ SwiftUI interface, MapKit, location, and runtime flow
|
||||
Core/ Go proxy, certificate server, and location-response handling
|
||||
Shared/ Coordinates, favorites, logs, configuration, and shared models
|
||||
Resources/ Info.plist, Entitlements, and resources
|
||||
Config/ Build configuration
|
||||
Scripts/ Build, packaging, and validation scripts
|
||||
Tests/ XCTest and Shell contract tests
|
||||
docs/ Build, module, and release documentation
|
||||
```
|
||||
|
||||
### 3. Configure the Current Wi‑Fi Proxy
|
||||
## Building the Project
|
||||
|
||||
On the current Wi‑Fi's proxy settings, choose "Manual":
|
||||
Source builds require:
|
||||
|
||||
```text
|
||||
Server: 127.0.0.1
|
||||
Port: 8888
|
||||
Authentication: off
|
||||
```
|
||||
- macOS;
|
||||
- Xcode;
|
||||
- Xcode Command Line Tools;
|
||||
- XcodeGen;
|
||||
- Go 1.23 or newer.
|
||||
|
||||
### 4. Select a Location & Enable
|
||||
|
||||
1. Search, tap, or drag the map to pick a location; tap the real-time location button to jump to the MapKit blue dot.
|
||||
2. Tap "Start Spoofing" and wait for the environment check to pass.
|
||||
3. Follow the 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
|
||||
Building the iOS app directly on Windows is not supported.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xweiba/location-spoofer.git
|
||||
cd location-spoofer
|
||||
|
||||
./build.sh
|
||||
```
|
||||
|
||||
The unsigned IPA is at:
|
||||
Build and run Simulator tests:
|
||||
|
||||
```bash
|
||||
./build.sh --test
|
||||
```
|
||||
|
||||
The build script generates an unsigned IPA:
|
||||
|
||||
```text
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
```text
|
||||
App/ SwiftUI, MapKit, location and setup flow
|
||||
Core/ Go local proxy and location response rewriting
|
||||
Shared/ Favorites, settings, logs, and shared models
|
||||
Resources/ Info.plist, Entitlements, and icons
|
||||
Scripts/ Build, signing, and verification scripts
|
||||
Tests/ XCTest and Bash contract tests
|
||||
docs/ Build, self-signing, and changelog documentation
|
||||
```
|
||||
Deploy it to a test device using your own signing and installation process.
|
||||
|
||||
## Documentation & Feedback
|
||||
## Privacy and Security Boundaries
|
||||
|
||||
- The project contains no telemetry or remote-control service;
|
||||
- The project does not automatically upload location data;
|
||||
- Runtime logs remain in the device App Group container and retain only the latest three days;
|
||||
- Issue reports are copied by the user before being submitted to GitHub;
|
||||
- App Mode accesses the local proxy and the environment-verification URL;
|
||||
- Third-party Proxy Mode may access the upstream module URL and the WLOC configuration endpoint;
|
||||
- The CA private key generated by the app is stored in the device Keychain;
|
||||
- Third-party MITM, certificates, and proxy behavior are owned by the selected client.
|
||||
|
||||
Do not post real locations, authentication information, CA private keys, or complete sensitive logs in public issues.
|
||||
|
||||
## Limitations
|
||||
|
||||
- iOS updates may change location-service behavior;
|
||||
- MapKit coordinate output can vary with the system, region, and location environment;
|
||||
- System location caches may delay visible changes;
|
||||
- Each third-party proxy client requires separate compatibility testing;
|
||||
- Not every app uses the same location API;
|
||||
- Not every app or service accepts test coordinates;
|
||||
- Behavior is not guaranteed across every network, device model, or iOS version.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome for:
|
||||
|
||||
- Bug reports;
|
||||
- Feature requests;
|
||||
- Compatibility results;
|
||||
- Performance improvements;
|
||||
- Documentation improvements;
|
||||
- Additional tests.
|
||||
|
||||
When filing an issue, include:
|
||||
|
||||
- iOS version;
|
||||
- Device model;
|
||||
- Runtime mode;
|
||||
- Reproduction steps;
|
||||
- Sanitized runtime logs;
|
||||
- Whether a third-party proxy client was used.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Build guide](docs/BUILD.md)
|
||||
- [Self-signing guide](docs/SELF-SIGNING.md)
|
||||
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Changelog](docs/CHANGELOG.md)
|
||||
- [中文文档](README.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
When reporting issues, please include reproduction steps, iOS version, device model, and sanitized runtime logs.
|
||||
## Feature Preview
|
||||
|
||||
## Links
|
||||
These screenshots show the main interface and selected on-device test scenarios. Actual results depend on the iOS
|
||||
version, network environment, system caches, and the target app's location strategy; they are not a compatibility
|
||||
guarantee for every app or release.
|
||||
|
||||
**LinuxDo** — [https://linux.do](https://linux.do/)
|
||||
<table>
|
||||
<tr>
|
||||
<th>Main interface</th>
|
||||
<th>Apple Maps test</th>
|
||||
<th>Amap test</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="Location Spoofer map selection interface" width="220"></td>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="Apple Maps location test scenario" width="220"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="Amap location test scenario" width="220"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>WeChat test</th>
|
||||
<th>DingTalk test</th>
|
||||
<th>Apple Watch scenario test</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/微信.jpg" alt="WeChat location test scenario" width="220"></td>
|
||||
<td><img src="images/钉钉.jpg" alt="DingTalk location test scenario" width="220"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch region-feature test scenario" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Acknowledgements and Links
|
||||
|
||||
The core location-response handling approach, Go implementation, and third-party modules are based on:
|
||||
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
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.
|
||||
|
||||
@@ -2,173 +2,384 @@
|
||||
|
||||
# 📍 Location Spoofer
|
||||
|
||||
### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS
|
||||
### iOS Location Service Research & Testing Framework
|
||||
|
||||
**无需 VPN、无需越狱,在 iPhone 本机通过 Wi‑Fi HTTP 代理改写 Apple 定位响应。**<br>
|
||||
可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。
|
||||
一个用于 **iOS 定位服务研究、软件开发测试和 QA 验证** 的开源项目。
|
||||
|
||||
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
|
||||
不同地理位置和定位场景下的行为。
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#为什么不需要-vpn)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md)
|
||||
|
||||
<img src="images/主界面.jpg" alt="Location Spoofer iOS 虚拟定位 Fake GPS 主界面" width="380">
|
||||
[功能概览](#功能概览) ·
|
||||
[工作原理](#工作原理) ·
|
||||
[快速开始](#快速开始) ·
|
||||
[构建项目](#构建项目) ·
|
||||
[English](README.en.md)
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本项目用于学习、安全研究与自有设备测试。它会安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。
|
||||
> 本项目用于学习研究、自有设备测试、软件开发和 QA 验证。
|
||||
>
|
||||
> 请仅在你拥有或获得授权的设备、网络和软件环境中使用,并遵守当地法律法规、网络管理规定以及相关服务条
|
||||
> 款。
|
||||
>
|
||||
> 本项目不保证兼容所有 iOS 版本或第三方应用,也不承诺绕过第三方应用的安全策略、业务限制或服务规则。
|
||||
|
||||
## 致谢
|
||||
## 项目定位
|
||||
|
||||
核心定位响应改写思路与 Go 实现来源于 [Yu9191/wloc](https://github.com/Yu9191/wloc)。本项目在此基础上增加 SwiftUI 界面、MapKit 选点、证书与代理引导、环境验证、收藏和诊断能力。
|
||||
Location Spoofer 是一个面向 iOS 定位服务行为研究和开发测试的工具。
|
||||
|
||||
## 为什么选择 Location Spoofer?
|
||||
它提供:
|
||||
|
||||
很多 iOS 虚拟定位工具依赖电脑常驻、开发者调试、VPN 或越狱。本项目采用不同路线:在 iPhone 本机运行 Go 代理,仅对 Apple 定位服务目标请求进行处理。
|
||||
- 原生地图选点和位置场景切换;
|
||||
- Apple 定位服务响应的测试环境模拟;
|
||||
- 本地代理和第三方代理两种运行模式;
|
||||
- 坐标标准识别与 WGS-84 / GCJ-02 双坐标管理;
|
||||
- 环境检测、运行日志和问题诊断;
|
||||
- 收藏位置和上次地图状态恢复。
|
||||
|
||||
| 特性 | 说明 |
|
||||
项目不修改目标 App 的源代码,也不提供远程控制或数据采集服务。
|
||||
|
||||
## 功能概览
|
||||
|
||||
- **原生地图交互**
|
||||
- 使用 MapKit 显示地图和系统蓝点;
|
||||
- 支持搜索、点击选点、拖动地图中心和缩放;
|
||||
- 支持收藏位置和恢复上次选点;
|
||||
- 当前选点同时显示国内坐标和国际坐标,可分别复制。
|
||||
|
||||
- **定位服务响应模拟**
|
||||
- 通过代理层处理指定的 Apple 定位服务请求;
|
||||
- 在测试环境中返回选定的坐标数据;
|
||||
- 不需要修改目标 App 代码。
|
||||
|
||||
- **双运行模式**
|
||||
- APP 模式:在设备内运行 Go 代理,仅支持当前 Wi-Fi 网络;
|
||||
- 第三方代理模式:通过支持的代理客户端覆盖 Wi-Fi、4G 或 5G,具体能力取决于客户端。
|
||||
|
||||
- **环境检测**
|
||||
- APP 模式检测本地代理、证书信任和请求链路;
|
||||
- 第三方代理模式检测 WLOC 配置接口和模块响应;
|
||||
- 失败时提供对应的配置或诊断入口。
|
||||
|
||||
- **开发调试**
|
||||
- 运行日志;
|
||||
- 日志复制和清理;
|
||||
- 坐标标准变化记录;
|
||||
- 脱敏问题报告生成。
|
||||
|
||||
## 工作原理
|
||||
|
||||
### APP 模式
|
||||
|
||||
APP 模式在设备内运行本地 Go 代理,并通过当前 Wi-Fi 的手动 HTTP 代理让指定请求经过本地代理。
|
||||
|
||||
```text
|
||||
iOS 定位请求
|
||||
│
|
||||
│ 当前 Wi-Fi 手动 HTTP 代理
|
||||
▼
|
||||
设备内 wloccore Go 代理
|
||||
│
|
||||
│ 处理指定 Apple 定位服务请求
|
||||
▼
|
||||
Apple 定位服务响应
|
||||
│
|
||||
│ 测试坐标响应
|
||||
▼
|
||||
系统和应用读取定位结果
|
||||
```
|
||||
|
||||
APP 模式:
|
||||
|
||||
- 不创建 Network Extension;
|
||||
- 不显示或占用系统 VPN;
|
||||
- 只覆盖当前 Wi-Fi 网络;
|
||||
- 需要配置当前 Wi-Fi 的手动 HTTP 代理;
|
||||
- 需要安装并信任 App 生成的本机 CA;
|
||||
- 代理只处理项目定义的 Apple 定位服务和环境验证请求,不是通用网络抓包工具。
|
||||
|
||||
### 第三方代理模式
|
||||
|
||||
第三方代理模式不启动 App 内置 Go 代理,也不使用 App 生成的 CA。
|
||||
|
||||
```text
|
||||
地图选点
|
||||
│
|
||||
│ WGS-84 坐标
|
||||
▼
|
||||
WLOC 配置接口
|
||||
│
|
||||
▼
|
||||
第三方代理客户端保存配置
|
||||
│
|
||||
▼
|
||||
第三方代理客户端处理定位服务请求
|
||||
```
|
||||
|
||||
在该模式下:
|
||||
|
||||
- App 负责地图选点、收藏、坐标同步和清除;
|
||||
- 第三方客户端负责代理/VPN、MITM、证书和规则执行;
|
||||
- 坐标持久化由第三方客户端负责;
|
||||
- 是否支持 Wi-Fi、4G 或 5G 取决于客户端;
|
||||
- App 关闭后,第三方客户端中的配置可能继续生效。
|
||||
|
||||
不要同时启用 APP 模式代理和第三方代理模式,避免两个代理链路互相干扰。
|
||||
|
||||
## 运行模式
|
||||
|
||||
### APP 模式
|
||||
|
||||
适用于:
|
||||
|
||||
- 只使用 Wi-Fi 的设备测试;
|
||||
- 不依赖第三方代理客户端的本地验证;
|
||||
- 需要在 App 内完成代理、证书和环境检测的场景。
|
||||
|
||||
使用条件:
|
||||
|
||||
- iOS 设备;
|
||||
- 当前 Wi-Fi 支持手动 HTTP 代理;
|
||||
- 安装并信任本机 CA;
|
||||
- 在 App 内完成代理配置和环境检测。
|
||||
|
||||
### 第三方代理模式
|
||||
|
||||
适用于:
|
||||
|
||||
- 需要 Wi-Fi、4G 或 5G 网络覆盖的测试;
|
||||
- 已经使用支持模块或脚本的代理客户端;
|
||||
- 希望由第三方客户端继续保持代理配置的场景。
|
||||
|
||||
当前客户端状态:
|
||||
|
||||
| 客户端 | 状态 |
|
||||
|---|---|
|
||||
| 🚫 **无 VPN** | 不创建 VPN 隧道,仅使用定位权限,无需后台刷新、通知等额外权限 |
|
||||
| 📱 **无需越狱** | 支持自行签名安装,最低部署目标为 iOS 15 |
|
||||
| 🗺️ **原生地图体验** | 使用 Apple 地图同款蓝点,搜索、点击、拖动选点体验与 Apple 地图一致 |
|
||||
| 📍 **系统级虚拟定位** | 支持钉钉、微信、Apple 地图、高德等 App 的虚拟实时定位 |
|
||||
| 🔍 **可见缩放范围** | 左侧缩放控件显示当前可视范围,地点名称随级别自动适配 |
|
||||
| 🧪 **环境检测** | 检查代理、CA 信任、Wi‑Fi 接管、坐标写入与响应改写 |
|
||||
| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 |
|
||||
| Shadowrocket | 当前用于真机测试 |
|
||||
| Surge | 已提供配置,尚未完整验证 |
|
||||
| Quantumult X | 已提供配置,尚未完整验证 |
|
||||
| Loon | 已提供配置,尚未完整验证 |
|
||||
| Stash | 已提供配置,尚未完整验证 |
|
||||
| Egern | 使用 Surge 模块,尚未完整验证 |
|
||||
|
||||
## 效果预览
|
||||
相关模块快照和来源记录:
|
||||
|
||||
<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>
|
||||
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **iOS 虚拟定位 / Fake GPS**:将当前地图选点应用到本机定位响应改写代理,适配钉钉打卡、微信位置共享等场景。
|
||||
- **原生实时位置**:地图显示 MapKit 自带蓝点,不再由 App 额外绘制实时位置标记。
|
||||
- **并发安全选点**:拖动、点击、搜索、收藏和异步定位按用户最新意图处理,旧结果不会覆盖新选点。
|
||||
- **地点名称分级**:近距离显示 POI、门牌或道路;拉远后显示社区、区县、城市或省份。
|
||||
- **地图范围显示**:缩放控件中显示 `180 m`、`2.5 km`、`126 km` 等当前可视范围。
|
||||
- **收藏与快速切换**:保存常用坐标,并明确显示当前准备应用的位置。
|
||||
- **配置引导**:提供证书安装、完全信任、Wi‑Fi HTTP 代理、生效与恢复说明。
|
||||
- **问题诊断**:内置验证流程和结构化运行日志。
|
||||
第三方客户端、证书、MITM 和代理开关由客户端自身负责。导入任何第三方模块前,请先审查其配置和脚本内容。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 App
|
||||
### 1. 安装应用
|
||||
|
||||
- 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 获取构建产物并自行签名;或
|
||||
- 在 macOS + Xcode 环境按[构建说明](docs/BUILD.md)编译。
|
||||
你可以使用:
|
||||
|
||||
详细步骤见[自签安装说明](docs/SELF-SIGNING.md)。
|
||||
- 自己的 Apple Developer 签名环境;
|
||||
- 适合个人测试的自签工具;
|
||||
- 项目 Releases 中的未签名 IPA;
|
||||
- 在 macOS 上按[构建说明](docs/BUILD.md)自行构建。
|
||||
|
||||
### 2. 安装并信任 CA
|
||||
免费自签环境可能无法使用 Network Extension,因此 APP 模式采用设备内本地代理和 Wi-Fi 手动代理,不依赖
|
||||
VPN 组件。
|
||||
|
||||
按首次引导下载描述文件,然后完成:
|
||||
#### 自签安装说明
|
||||
|
||||
Release 附件是未签名 IPA,需要使用自签工具安装到 iPhone:
|
||||
|
||||
1. **开启自签支持**:iOS 16 及以上版本前往“设置 → 隐私与安全性 → 开发者模式”,开启后按系统提示重启并
|
||||
确认;iOS 15 没有此开关,可跳过本步。
|
||||
2. **下载 IPA**:前往本项目的 [Releases](https://github.com/xweiba/location-spoofer/releases),下载最新的
|
||||
`PaopaoLocationSpoofer-unsigned.ipa`。
|
||||
3. **准备自签软件**:前往 [Impactor Releases](https://github.com/claration/Impactor/releases) 下载对应系统
|
||||
版本的 Impactor;也可以使用爱思助手等支持 IPA 自签安装的软件。
|
||||
4. **连接并安装**:使用 USB 数据线连接 iPhone 与电脑,在手机上选择“信任此电脑”,然后在自签软件中选择
|
||||
刚下载的 IPA,根据软件提示完成签名与安装。
|
||||
|
||||
Impactor 支持 Windows、macOS 和 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
|
||||
设置 → 通用 → VPN 与设备管理 → 安装 WLOC CA
|
||||
设置 → 通用 → 关于本机 → 证书信任设置 → 完全信任
|
||||
App/ SwiftUI 界面、MapKit、定位和运行流程
|
||||
Core/ Go 代理、证书服务和定位响应处理
|
||||
Shared/ 坐标、收藏、日志、配置和共享模型
|
||||
Resources/ Info.plist、Entitlements 和资源文件
|
||||
Config/ 构建配置
|
||||
Scripts/ 构建、打包和验证脚本
|
||||
Tests/ XCTest 和 Shell contract tests
|
||||
docs/ 构建、模块和版本文档
|
||||
```
|
||||
|
||||
### 3. 配置当前 Wi‑Fi 代理
|
||||
## 构建项目
|
||||
|
||||
在当前 Wi‑Fi 的"配置代理"中选择"手动":
|
||||
源码构建需要 macOS 环境:
|
||||
|
||||
```text
|
||||
服务器:127.0.0.1
|
||||
端口:8888
|
||||
鉴定:关闭
|
||||
```
|
||||
- macOS;
|
||||
- Xcode;
|
||||
- Xcode Command Line Tools;
|
||||
- XcodeGen;
|
||||
- Go 1.23 或更高版本。
|
||||
|
||||
### 4. 选点并启用
|
||||
|
||||
1. 搜索、点击或拖动地图选择位置;点击实时位置按钮可回到 MapKit 蓝点。
|
||||
2. 点击"开始虚拟定位",等待环境检测通过。
|
||||
3. 按 App 内"生效说明"刷新飞行模式、Wi‑Fi 和定位服务状态。
|
||||
4. 打开 Apple 地图或目标 App 验证结果。
|
||||
|
||||
### 5. 恢复真实位置
|
||||
|
||||
停止虚拟定位,关闭当前 Wi‑Fi 的手动代理,并按 App 内"失效说明"刷新系统定位缓存。若系统仍保留旧缓存,请重启设备后再检查。
|
||||
|
||||
## 为什么不需要 VPN?
|
||||
|
||||
```text
|
||||
iPhone 定位请求
|
||||
│ 当前 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。
|
||||
|
||||
## 构建与项目结构
|
||||
当前项目不支持在 Windows 上直接构建 iOS 应用。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xweiba/location-spoofer.git
|
||||
cd location-spoofer
|
||||
|
||||
./build.sh
|
||||
```
|
||||
|
||||
构建产物默认位于:
|
||||
运行构建并执行 Simulator 测试:
|
||||
|
||||
```bash
|
||||
./build.sh --test
|
||||
```
|
||||
|
||||
构建脚本会生成未签名 IPA:
|
||||
|
||||
```text
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
```text
|
||||
App/ SwiftUI、MapKit、定位和配置流程
|
||||
Core/ Go 本机代理与定位响应改写
|
||||
Shared/ 收藏、设置、日志和共享模型
|
||||
Resources/ Info.plist、Entitlements 与图标
|
||||
Scripts/ 构建、签名和检查脚本
|
||||
Tests/ XCTest 与 Bash 契约测试
|
||||
docs/ 构建、自签和版本更新文档
|
||||
```
|
||||
之后需要使用你自己的签名和安装流程部署到测试设备。
|
||||
|
||||
## 文档与反馈
|
||||
## 隐私与安全边界
|
||||
|
||||
- 项目不包含遥测或远程控制服务;
|
||||
- 项目不会自动上传用户位置数据;
|
||||
- 运行日志保存在设备 App Group 容器中,并自动保留近三天;
|
||||
- 问题报告需要用户主动复制后提交到 GitHub;
|
||||
- APP 模式会访问本机代理和环境验证地址;
|
||||
- 第三方代理模式可能访问上游模块地址和 WLOC 配置接口;
|
||||
- App 生成的 CA 私钥保存在设备 Keychain 中;
|
||||
- 第三方客户端模块、MITM 和证书链路由用户选择的客户端负责。
|
||||
|
||||
请不要把真实位置、认证信息、证书私钥或完整敏感日志提交到公开 Issue。
|
||||
|
||||
## 限制
|
||||
|
||||
- iOS 系统版本变化可能影响定位服务行为;
|
||||
- MapKit 的坐标返回标准可能随系统、地区和定位环境变化;
|
||||
- 系统定位存在缓存,切换位置后不一定立即生效;
|
||||
- 第三方代理客户端的兼容性和规则行为需要分别验证;
|
||||
- 不保证所有应用都使用同一种定位 API;
|
||||
- 不保证所有应用或服务都接受测试坐标;
|
||||
- 不保证在所有网络环境、设备型号和 iOS 版本上表现一致。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交:
|
||||
|
||||
- Bug Report;
|
||||
- Feature Request;
|
||||
- 兼容性测试结果;
|
||||
- 性能改进;
|
||||
- 文档改进;
|
||||
- 测试补充。
|
||||
|
||||
提交 Issue 时建议包含:
|
||||
|
||||
- iOS 版本;
|
||||
- 设备型号;
|
||||
- 使用的运行模式;
|
||||
- 复现步骤;
|
||||
- 脱敏后的运行日志;
|
||||
- 是否使用第三方代理客户端。
|
||||
|
||||
## 文档
|
||||
|
||||
- [构建说明](docs/BUILD.md)
|
||||
- [自签安装](docs/SELF-SIGNING.md)
|
||||
- [v1.0.0 更新日志](docs/CHANGELOG.md)
|
||||
- [English README](README.en.md)
|
||||
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
|
||||
- [更新日志](docs/CHANGELOG.md)
|
||||
- [英文文档](README.en.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
反馈问题时,请附上复现步骤、iOS 版本、设备型号及已脱敏的运行日志。
|
||||
## 功能预览
|
||||
|
||||
## 友链
|
||||
以下截图用于展示主界面和部分真机测试场景。实际结果会受到 iOS 版本、网络环境、系统缓存和目标应用定位策略
|
||||
影响,不代表对所有应用或版本作出兼容性保证。
|
||||
|
||||
**LinuxDo** — [https://linux.do](https://linux.do/)
|
||||
<table>
|
||||
<tr>
|
||||
<th>应用主界面</th>
|
||||
<th>Apple 地图测试</th>
|
||||
<th>高德地图测试</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="Location Spoofer 地图选点主界面" width="220"></td>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="Apple 地图定位测试场景" width="220"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="高德地图定位测试场景" width="220"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>微信测试</th>
|
||||
<th>钉钉测试</th>
|
||||
<th>Apple Watch 场景测试</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/微信.jpg" alt="微信定位测试场景" width="220"></td>
|
||||
<td><img src="images/钉钉.jpg" alt="钉钉定位测试场景" width="220"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能测试场景" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 致谢与友链
|
||||
|
||||
核心定位响应处理思路、Go 实现和第三方模块参考自:
|
||||
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
友链:
|
||||
|
||||
- [LINUX DO](https://linux.do/)
|
||||
|
||||
感谢开源社区中参与 iOS 定位服务研究、网络代理和移动端测试工具建设的贡献者。
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>App-Prefs</string>
|
||||
<string>shadowrocket</string>
|
||||
<string>surge</string>
|
||||
<string>quantumult-x</string>
|
||||
<string>loon</string>
|
||||
<string>stash</string>
|
||||
<string>egern</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
@@ -33,7 +39,7 @@
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<array>
|
||||
<string>group.com.paopaolabs.location-spoofer</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,11 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
||||
#!author=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
|
||||
@@ -0,0 +1,19 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f
|
||||
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!openUrl=https://wloc-pages.pages.dev/
|
||||
|
||||
[Argument]
|
||||
longitude = input, "113.94114", tag=经度(在线选点优先)
|
||||
latitude = input, "22.544577", tag=纬度(在线选点优先)
|
||||
accuracy = input, "25", tag=精度(米)
|
||||
logLevel = select, "info", "off", "error", "warn", "debug", "all", tag=日志级别
|
||||
|
||||
[Script]
|
||||
http-response ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC, argument=[{longitude},{latitude},{accuracy},{logLevel}]
|
||||
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js, timeout=10, tag=WLOC Settings
|
||||
|
||||
[MITM]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -0,0 +1,13 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 (Shadowrocket 小火箭) | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
||||
#!category=Tools
|
||||
|
||||
[Script]
|
||||
Apple WLOC = type=http-response,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc,requires-body=1,binary-body-mode=1,max-size=0,timeout=30,script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js,argument=longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
|
||||
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save,requires-body=0,max-size=0,timeout=10,script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -0,0 +1,14 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!category=Tools
|
||||
#!arguments=经度:113.94114, 纬度:22.544577, 精度:25, 日志级别:info
|
||||
#!arguments-desc=经度/纬度: 默认坐标(在线选点储存后优先)\n精度: GPS精度(米)\n日志级别: off/error/warn/info/debug/all\n\n使用方法: 打开选点页面 -> 选位置 -> 储存到设备
|
||||
|
||||
[Script]
|
||||
Apple WLOC = type=http-response, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc", requires-body=1, binary-body-mode=1, max-size=0, timeout=30, script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js, argument=longitude={{{经度}}}&latitude={{{纬度}}}&accuracy={{{精度}}}&logLevel={{{日志级别}}}
|
||||
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save", requires-body=0, max-size=0, timeout=10, script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Apple WLOC 定位修改
|
||||
desc: "修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/"
|
||||
author: Yu9191 Rewrite
|
||||
homepage: https://github.com/Yu9191/wloc
|
||||
icon: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
||||
category: Tools
|
||||
|
||||
http:
|
||||
mitm:
|
||||
- "gs-loc.apple.com"
|
||||
- "gs-loc-cn.apple.com"
|
||||
script:
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc
|
||||
name: WLOC.Location
|
||||
type: response
|
||||
require-body: true
|
||||
binary-mode: true
|
||||
max-size: 0
|
||||
timeout: 30
|
||||
argument: longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save
|
||||
name: WLOC.Settings
|
||||
type: request
|
||||
require-body: false
|
||||
timeout: 10
|
||||
|
||||
script-providers:
|
||||
WLOC.Location:
|
||||
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js
|
||||
interval: 86400
|
||||
WLOC.Settings:
|
||||
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
||||
interval: 86400
|
||||
@@ -4,21 +4,32 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CORE="$ROOT/Core"
|
||||
BUILD="$CORE/build"
|
||||
|
||||
IOS_SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
|
||||
MIN_IOS_VERSION="15.0"
|
||||
|
||||
export CGO_ENABLED=1
|
||||
export CGO_CFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
|
||||
export CGO_LDFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
|
||||
export GOOS="ios"
|
||||
export GOARCH="arm64"
|
||||
build_archive() {
|
||||
local sdk="$1"
|
||||
local min_flag="$2"
|
||||
local output="$3"
|
||||
local sdk_path
|
||||
sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path)"
|
||||
|
||||
mkdir -p "$BUILD"
|
||||
CGO_ENABLED=1 \
|
||||
CGO_CFLAGS="-arch arm64 -isysroot $sdk_path $min_flag" \
|
||||
CGO_LDFLAGS="-arch arm64 -isysroot $sdk_path $min_flag" \
|
||||
GOOS=ios GOARCH=arm64 \
|
||||
go build -buildmode=c-archive -ldflags="-s -w" -o "$output" .
|
||||
}
|
||||
|
||||
rm -rf "$BUILD"
|
||||
mkdir -p "$BUILD/iphoneos" "$BUILD/iphonesimulator"
|
||||
cd "$CORE"
|
||||
go mod download
|
||||
go build -buildmode=c-archive -ldflags="-s -w" -o "$BUILD/libwloccore.a" .
|
||||
cp "$BUILD/libwloccore.h" "$ROOT/Core/wloccore.h"
|
||||
test -s "$ROOT/Core/wloccore.h"
|
||||
|
||||
echo "Built $BUILD/libwloccore.a"
|
||||
build_archive iphoneos "-miphoneos-version-min=$MIN_IOS_VERSION" "$BUILD/iphoneos/libwloccore.a"
|
||||
build_archive iphonesimulator "-mios-simulator-version-min=$MIN_IOS_VERSION" "$BUILD/iphonesimulator/libwloccore.a"
|
||||
cp "$BUILD/iphoneos/libwloccore.h" "$ROOT/Core/wloccore.h"
|
||||
|
||||
test -s "$BUILD/iphoneos/libwloccore.a"
|
||||
test -s "$BUILD/iphonesimulator/libwloccore.a"
|
||||
test -s "$ROOT/Core/wloccore.h"
|
||||
echo "Built device and simulator Core archives"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Usage: $0 v<major>.<minor>.<patch>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
|
||||
echo "Tag $VERSION already exists; generate the archive before creating the tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREVIOUS_TAG="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)"
|
||||
if [[ -n "$PREVIOUS_TAG" ]]; then
|
||||
RANGE="$PREVIOUS_TAG..HEAD"
|
||||
RANGE_LABEL="$PREVIOUS_TAG..$VERSION"
|
||||
else
|
||||
RANGE="HEAD"
|
||||
RANGE_LABEL="initial..$VERSION"
|
||||
fi
|
||||
|
||||
COMMITS="$(git log "$RANGE" --no-merges --format='- `%h` %s')"
|
||||
if [[ -z "$COMMITS" ]]; then
|
||||
echo "No commits found in $RANGE; refusing to create empty release notes." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT="docs/releases/${VERSION}.md"
|
||||
mkdir -p "$(dirname "$OUTPUT")"
|
||||
cat > "$OUTPUT" <<EOF
|
||||
# ${VERSION}
|
||||
|
||||
发布日期:$(date +%F)
|
||||
|
||||
## 提交变更总结
|
||||
|
||||
${COMMITS}
|
||||
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID \`com.paopaolabs.location-spoofer\`、App Group \`group.com.paopaolabs.location-spoofer\` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
<!-- commit-range: ${RANGE_LABEL} -->
|
||||
EOF
|
||||
|
||||
echo "Generated $OUTPUT from $RANGE"
|
||||
@@ -14,5 +14,7 @@
|
||||
</array>
|
||||
<key>get-task-allow</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -43,3 +43,76 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ final class BackgroundKeepAlive {
|
||||
guard isActive, let info = notification.userInfo,
|
||||
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
type == AVAudioSession.InterruptionType.ended.rawValue else { return }
|
||||
start()
|
||||
restartAfterInterruption()
|
||||
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
|
||||
}
|
||||
|
||||
@@ -29,19 +29,28 @@ final class BackgroundKeepAlive {
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "KeepAlive", "音频会话失败", error: error)
|
||||
isActive = false
|
||||
return
|
||||
}
|
||||
|
||||
let eng = AVAudioEngine()
|
||||
let player = AVAudioPlayerNode()
|
||||
eng.attach(player)
|
||||
let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)!
|
||||
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3)!
|
||||
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1),
|
||||
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3) else {
|
||||
RuntimeLogger.error("APP", "KeepAlive", "无法创建静音音频缓冲区")
|
||||
isActive = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
return
|
||||
}
|
||||
buf.frameLength = 44100 * 3
|
||||
eng.connect(player, to: eng.mainMixerNode, format: fmt)
|
||||
eng.prepare()
|
||||
do { try eng.start() } catch {
|
||||
RuntimeLogger.error("APP", "KeepAlive", "引擎启动失败", error: error)
|
||||
isActive = false; return
|
||||
isActive = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
return
|
||||
}
|
||||
player.scheduleBuffer(buf, at: nil, options: .loops)
|
||||
player.play()
|
||||
@@ -50,6 +59,16 @@ final class BackgroundKeepAlive {
|
||||
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
|
||||
}
|
||||
|
||||
private func restartAfterInterruption() {
|
||||
guard isActive else { return }
|
||||
playerNode?.stop()
|
||||
engine?.stop()
|
||||
playerNode = nil
|
||||
engine = nil
|
||||
isActive = false
|
||||
start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isActive else { return }
|
||||
isActive = false
|
||||
|
||||
@@ -1,46 +1,178 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol CertificateAuthorityKeychain {
|
||||
func load() throws -> CertificateAuthority?
|
||||
func save(_ authority: CertificateAuthority) throws
|
||||
func remove() throws
|
||||
}
|
||||
|
||||
enum CertificateAuthorityStoreError: LocalizedError {
|
||||
case invalidAuthority
|
||||
case keychain(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAuthority: return "本地 CA 证书或私钥无效"
|
||||
case let .keychain(status): return "无法写入设备钥匙串(\(status))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class DeviceCertificateAuthorityKeychain: CertificateAuthorityKeychain {
|
||||
private enum Item {
|
||||
static let service = "com.paopaolabs.location-spoofer.certificate-authority"
|
||||
static let certificateAccount = "root-ca-certificate"
|
||||
static let keyAccount = "root-ca-private-key"
|
||||
}
|
||||
|
||||
func load() throws -> CertificateAuthority? {
|
||||
guard let certPEM = try load(account: Item.certificateAccount),
|
||||
let keyPEM = try load(account: Item.keyAccount) else {
|
||||
return nil
|
||||
}
|
||||
return CertificateAuthority(certPEM: certPEM, keyPEM: keyPEM)
|
||||
}
|
||||
|
||||
func save(_ authority: CertificateAuthority) throws {
|
||||
try remove()
|
||||
do {
|
||||
try save(authority.certPEM, account: Item.certificateAccount)
|
||||
try save(authority.keyPEM, account: Item.keyAccount)
|
||||
} catch {
|
||||
try? remove()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func remove() throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw CertificateAuthorityStoreError.keychain(status)
|
||||
}
|
||||
}
|
||||
|
||||
private func load(account: String) throws -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
throw CertificateAuthorityStoreError.keychain(status)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func save(_ value: String, account: String) throws {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||||
}
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw CertificateAuthorityStoreError.keychain(status) }
|
||||
}
|
||||
}
|
||||
|
||||
final class CertificateAuthorityStore {
|
||||
private let directory: URL
|
||||
private let generator: () throws -> CertificateAuthority
|
||||
private let validator: (CertificateAuthority) -> Bool
|
||||
private let keychain: CertificateAuthorityKeychain
|
||||
private let certificateURL: URL
|
||||
private let keyURL: URL
|
||||
|
||||
init(directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true), generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority) {
|
||||
init(
|
||||
directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true),
|
||||
keychain: CertificateAuthorityKeychain = DeviceCertificateAuthorityKeychain(),
|
||||
generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority,
|
||||
validator: @escaping (CertificateAuthority) -> Bool = CoreBridge.isValidCertificateAuthority
|
||||
) {
|
||||
self.directory = directory
|
||||
self.keychain = keychain
|
||||
self.generator = generator
|
||||
self.validator = validator
|
||||
self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
|
||||
self.keyURL = directory.appendingPathComponent("ca-key.pem")
|
||||
}
|
||||
|
||||
func ensure() throws -> CertificateAuthority {
|
||||
if let current = try load() {
|
||||
RuntimeLogger.debug("SHARED", "Certificate.store", "读取已有 CA 文件", details: ["directory": directory.path])
|
||||
return current
|
||||
if let authority = try loadValidKeychainAuthority() {
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.debug("SHARED", "Certificate.store", "复用设备钥匙串中的 CA")
|
||||
return authority
|
||||
}
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "未找到 CA 文件,开始生成", details: ["directory": directory.path])
|
||||
|
||||
if let legacy = try loadLegacyAuthority(), validator(legacy) {
|
||||
try keychain.save(legacy)
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "旧 CA 已迁移到设备钥匙串")
|
||||
return legacy
|
||||
}
|
||||
|
||||
let authority = try generator()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
guard let certificateData = authority.certPEM.data(using: .utf8),
|
||||
let keyData = authority.keyPEM.data(using: .utf8) else {
|
||||
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||||
}
|
||||
try certificateData.write(to: certificateURL, options: .atomic)
|
||||
try keyData.write(to: keyURL, options: .atomic)
|
||||
applyCompleteProtection(to: certificateURL)
|
||||
applyCompleteProtection(to: keyURL)
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "CA 文件写入完成", details: ["directory": directory.path])
|
||||
guard validator(authority) else { throw CertificateAuthorityStoreError.invalidAuthority }
|
||||
try keychain.save(authority)
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "已生成并保存设备专属 CA")
|
||||
return authority
|
||||
}
|
||||
|
||||
func load() throws -> CertificateAuthority? {
|
||||
guard FileManager.default.fileExists(atPath: certificateURL.path), FileManager.default.fileExists(atPath: keyURL.path) else { return nil }
|
||||
return CertificateAuthority(certPEM: try String(contentsOf: certificateURL, encoding: .utf8), keyPEM: try String(contentsOf: keyURL, encoding: .utf8))
|
||||
try loadValidKeychainAuthority()
|
||||
}
|
||||
|
||||
private func applyCompleteProtection(to url: URL) {
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path)
|
||||
#endif
|
||||
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() {
|
||||
let fileManager = FileManager.default
|
||||
// Remove private material first. Each item is retried on later launches
|
||||
// when a valid Keychain authority is available.
|
||||
for url in [keyURL, certificateURL] where fileManager.fileExists(atPath: url.path) {
|
||||
do {
|
||||
try fileManager.removeItem(at: url)
|
||||
} catch {
|
||||
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
|
||||
}
|
||||
}
|
||||
try? fileManager.removeItem(at: directory)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ final class CertificateTrustVerifier {
|
||||
/// Check whether a CA certificate (given as PEM data) is installed and fully trusted
|
||||
/// by the system. Uses SecTrust evaluation against system anchors only — no network needed.
|
||||
static func isCACertificateTrusted(certPEM: String) -> Bool {
|
||||
guard let certData = certPEM.data(using: .utf8),
|
||||
let cert = SecCertificateCreateWithData(nil, certData as CFData) else {
|
||||
guard let pemData = certPEM.data(using: .utf8),
|
||||
let block = pemData.pemCertificateBlock,
|
||||
let cert = SecCertificateCreateWithData(nil, block as CFData) else {
|
||||
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
|
||||
return false
|
||||
}
|
||||
@@ -38,3 +39,13 @@ final class CertificateTrustVerifier {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
var pemCertificateBlock: Data? {
|
||||
guard let text = String(data: self, encoding: .utf8),
|
||||
let begin = text.range(of: "-----BEGIN CERTIFICATE-----"),
|
||||
let end = text.range(of: "-----END CERTIFICATE-----") else { return nil }
|
||||
let body = text[begin.upperBound..<end.lowerBound].components(separatedBy: .whitespacesAndNewlines).joined()
|
||||
return Data(base64Encoded: body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,387 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import MapKit
|
||||
|
||||
struct CoordinatePair: Codable, Equatable {
|
||||
static let currentConversionVersion = 1
|
||||
|
||||
struct Value: Codable, Equatable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
|
||||
var coordinate: CLLocationCoordinate2D {
|
||||
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
}
|
||||
|
||||
let wgs84: Value
|
||||
let gcj02: Value
|
||||
let conversionVersion: Int
|
||||
|
||||
init(wgs84: Value, gcj02: Value, conversionVersion: Int = CoordinatePair.currentConversionVersion) {
|
||||
self.wgs84 = wgs84
|
||||
self.gcj02 = gcj02
|
||||
self.conversionVersion = conversionVersion
|
||||
}
|
||||
|
||||
init(mapCoordinate: CLLocationCoordinate2D, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) {
|
||||
self = CoordinateConverter.coordinatePair(
|
||||
lat: mapCoordinate.latitude,
|
||||
lon: mapCoordinate.longitude,
|
||||
mapCoordinateSystem: mapCoordinateSystem
|
||||
)
|
||||
}
|
||||
|
||||
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
|
||||
switch mapCoordinateSystem {
|
||||
case .wgs84: return wgs84.coordinate
|
||||
case .gcj02: return gcj02.coordinate
|
||||
}
|
||||
}
|
||||
|
||||
func matchesWGS84(latitude: Double, longitude: Double, tolerance: Double = 0.0001) -> Bool {
|
||||
abs(wgs84.latitude - latitude) <= tolerance
|
||||
&& abs(wgs84.longitude - longitude) <= tolerance
|
||||
}
|
||||
}
|
||||
|
||||
/// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。
|
||||
///
|
||||
/// 在中国地区,MKMapView 使用高德 (AutoNavi) 瓦片数据(GCJ-02 坐标系),
|
||||
/// 因此从 MKMapView 的 `centerCoordinate`、`convert(point:toCoordinateFrom:)`
|
||||
/// 等方法返回的坐标也是 GCJ-02。但 CoreLocation / CLLocationManager 返回的
|
||||
/// 以及 Apple wloc 定位服务使用的都是 WGS-84。
|
||||
///
|
||||
/// 虚拟定位的坐标流中:
|
||||
/// - 地图 UI 层(显示、选点):GCJ-02(与瓦片一致)
|
||||
/// - 代理写出层(wloc 响应改写):WGS-84
|
||||
///
|
||||
/// 因此需要在坐标从地图 UI 进入代理之前做 GCJ-02 → WGS-84 转换。
|
||||
/// MapKit does not expose a public API for its active coordinate reference system.
|
||||
/// The app resolves it with a bounded heuristic, then stores both WGS-84 and
|
||||
/// GCJ-02 representations at each write boundary so replay does not convert again.
|
||||
enum CoordinateConverter {
|
||||
// 椭球参数 (Krasovsky 1940)
|
||||
private static let a = 6378245.0
|
||||
private static let ee = 0.00669342162296594323
|
||||
|
||||
/// 坐标类型
|
||||
enum MapCoordinateSystem: String {
|
||||
case gcj02 = "GCJ-02"
|
||||
case wgs84 = "WGS-84"
|
||||
|
||||
var diagnosticName: String {
|
||||
switch self {
|
||||
case .gcj02: return "国内标准(GCJ-02)"
|
||||
case .wgs84: return "国际标准(WGS-84)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CoordinateRepresentationDiagnosis: Equatable {
|
||||
let inferredSystem: MapCoordinateSystem?
|
||||
let distanceToWGS84: CLLocationDistance
|
||||
let distanceToGCJ02: CLLocationDistance
|
||||
|
||||
var inferredName: String {
|
||||
inferredSystem?.diagnosticName ?? "无法判定"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 地图坐标标准
|
||||
|
||||
struct MapCoordinateSystemChange: Equatable {
|
||||
let previous: MapCoordinateSystem
|
||||
let current: MapCoordinateSystem
|
||||
}
|
||||
|
||||
enum RuntimeMapCoordinateSystemRefreshResult: Equatable {
|
||||
case unchanged(MapCoordinateSystem)
|
||||
case changed(MapCoordinateSystemChange)
|
||||
case unavailable(reason: String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
/// 当前 Apple 地图坐标标准。检测不可用时使用国内 GCJ-02 作为兜底。
|
||||
@MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02
|
||||
@MainActor private static var mapCoordinateSystemCheckPending = false
|
||||
@MainActor private(set) static var initialMapCoordinateSystemUsedFallback = true
|
||||
|
||||
/// Startup gate: only request realtime location if the public MapKit probe
|
||||
/// cannot resolve a coordinate type. This guarantees a finite answer before
|
||||
/// persisted map positions are replayed.
|
||||
@MainActor
|
||||
static func resolveInitialMapCoordinateSystem() async -> MapCoordinateSystem {
|
||||
guard !mapCoordinateSystemCheckPending else {
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测已有请求进行中")
|
||||
return currentMapCoordinateSystem
|
||||
}
|
||||
mapCoordinateSystemCheckPending = true
|
||||
defer { mapCoordinateSystemCheckPending = false }
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测开始", details: [
|
||||
"锚点": "22.283819,114.158439",
|
||||
"判定规则": "首条名称=林士街→GCJ-02,否则→WGS-84",
|
||||
"缓存": "false"
|
||||
])
|
||||
|
||||
let nextType: MapCoordinateSystem
|
||||
switch await fixedAnchorCoordinateSystemProbe() {
|
||||
case let .response(name, count):
|
||||
nextType = mapCoordinateSystem(forFixedAnchorFirstResultName: name)
|
||||
initialMapCoordinateSystemUsedFallback = false
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"命中林士街": String(name == "林士街"),
|
||||
"最终标准": nextType.rawValue,
|
||||
"结果来源": "固定锚点"
|
||||
])
|
||||
case .unavailable(let reason), .timedOut(let reason):
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测不可用,开始实时定位兜底", details: [
|
||||
"原因": reason
|
||||
])
|
||||
let realtime = await RealtimeLocationManager.shared.requestLocation()
|
||||
if let realtime,
|
||||
CLLocationCoordinate2DIsValid(realtime),
|
||||
!usesGCJ02ServiceArea(lat: realtime.latitude, lon: realtime.longitude) {
|
||||
nextType = .wgs84
|
||||
} else {
|
||||
nextType = .gcj02
|
||||
}
|
||||
initialMapCoordinateSystemUsedFallback = true
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测使用兜底结果", details: [
|
||||
"探测失败原因": reason,
|
||||
"实时定位存在": String(realtime != nil),
|
||||
"最终标准": nextType.rawValue,
|
||||
"结果来源": realtime == nil ? "默认国内标准" : "实时定位服务区域"
|
||||
])
|
||||
case .cancelled:
|
||||
initialMapCoordinateSystemUsedFallback = true
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测被取消,保留默认国内标准", details: [
|
||||
"最终标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return currentMapCoordinateSystem
|
||||
}
|
||||
|
||||
currentMapCoordinateSystem = nextType
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准已确定,允许创建地图", details: [
|
||||
"最终标准": nextType.rawValue,
|
||||
"使用兜底": String(initialMapCoordinateSystemUsedFallback),
|
||||
"缓存": "false"
|
||||
])
|
||||
return nextType
|
||||
}
|
||||
|
||||
/// Re-runs the fixed-anchor MapKit behavior probe while the map is alive.
|
||||
/// Runtime failures preserve the last confirmed type: a potentially spoofed
|
||||
/// Core Location sample is not authoritative for MapKit's representation.
|
||||
@MainActor
|
||||
static func refreshRuntimeMapCoordinateSystem(reason: String) async -> RuntimeMapCoordinateSystemRefreshResult {
|
||||
guard !mapCoordinateSystemCheckPending else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测合并到进行中请求", details: [
|
||||
"触发原因": reason,
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return .unchanged(currentMapCoordinateSystem)
|
||||
}
|
||||
mapCoordinateSystemCheckPending = true
|
||||
defer { mapCoordinateSystemCheckPending = false }
|
||||
|
||||
let previous = currentMapCoordinateSystem
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测开始", details: [
|
||||
"触发原因": reason,
|
||||
"检测前标准": previous.rawValue,
|
||||
"锚点": "22.283819,114.158439",
|
||||
"缓存": "false"
|
||||
])
|
||||
|
||||
switch await fixedAnchorCoordinateSystemProbe() {
|
||||
case let .response(name, count):
|
||||
guard !Task.isCancelled else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测结果已过期,取消写入", details: [
|
||||
"触发原因": reason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .cancelled
|
||||
}
|
||||
let detected = mapCoordinateSystem(forFixedAnchorFirstResultName: name)
|
||||
initialMapCoordinateSystemUsedFallback = false
|
||||
guard detected != previous else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测完成,标准未变化", details: [
|
||||
"触发原因": reason,
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"确认标准": detected.rawValue
|
||||
])
|
||||
return .unchanged(detected)
|
||||
}
|
||||
let change = MapCoordinateSystemChange(previous: previous, current: detected)
|
||||
currentMapCoordinateSystem = detected
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测发现切换", details: [
|
||||
"触发原因": reason,
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"from": previous.rawValue,
|
||||
"to": detected.rawValue
|
||||
])
|
||||
return .changed(change)
|
||||
case .unavailable(let failureReason), .timedOut(let failureReason):
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测失败,保留当前标准", details: [
|
||||
"触发原因": reason,
|
||||
"原因": failureReason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .unavailable(reason: failureReason)
|
||||
case .cancelled:
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测已取消", details: [
|
||||
"触发原因": reason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .cancelled
|
||||
}
|
||||
}
|
||||
|
||||
static func mapCoordinateSystem(forFixedAnchorFirstResultName name: String) -> MapCoordinateSystem {
|
||||
name == "林士街" ? .gcj02 : .wgs84
|
||||
}
|
||||
|
||||
/// A user-requested realtime sample is WGS-84 and can correct a provisional
|
||||
/// startup map coordinate system without altering persisted coordinate pairs.
|
||||
@MainActor
|
||||
static func correctMapCoordinateSystemUsingRealtime(_ coordinate: CLLocationCoordinate2D) -> MapCoordinateSystemChange? {
|
||||
guard CLLocationCoordinate2DIsValid(coordinate) else { return nil }
|
||||
guard initialMapCoordinateSystemUsedFallback else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "实时定位不覆盖固定锚点的明确检测结果", details: [
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return nil
|
||||
}
|
||||
let next: MapCoordinateSystem = usesGCJ02ServiceArea(lat: coordinate.latitude, lon: coordinate.longitude) ? .gcj02 : .wgs84
|
||||
guard next != currentMapCoordinateSystem else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "实时定位确认兜底地图坐标标准无需修正", details: [
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return nil
|
||||
}
|
||||
let change = MapCoordinateSystemChange(previous: currentMapCoordinateSystem, current: next)
|
||||
currentMapCoordinateSystem = next
|
||||
RuntimeLogger.warning("APP", "坐标转换", "实时定位修正启动兜底地图坐标标准", details: [
|
||||
"from": change.previous.rawValue,
|
||||
"to": change.current.rawValue
|
||||
])
|
||||
return change
|
||||
}
|
||||
|
||||
private static func fixedAnchorCoordinateSystemProbe() async -> MapCoordinateSystemProbeResult {
|
||||
let request = MKLocalSearch.Request()
|
||||
request.naturalLanguageQuery = "22.283819, 114.158439"
|
||||
let search = MKLocalSearch(request: request)
|
||||
let resolver = MapCoordinateSystemProbeResolver()
|
||||
|
||||
return await withTaskCancellationHandler(operation: {
|
||||
await withCheckedContinuation { continuation in
|
||||
let timeout = DispatchWorkItem {
|
||||
search.cancel()
|
||||
resolver.resolve(.timedOut(reason: "固定锚点查询超过5秒"))
|
||||
}
|
||||
resolver.install(continuation, timeout: timeout)
|
||||
guard !resolver.isResolved else { return }
|
||||
search.start { response, error in
|
||||
if let error {
|
||||
let nsError = error as NSError
|
||||
resolver.resolve(.unavailable(
|
||||
reason: "\(nsError.domain)(\(nsError.code)): \(nsError.localizedDescription)"
|
||||
))
|
||||
return
|
||||
}
|
||||
let items = response?.mapItems ?? []
|
||||
guard let first = items.first,
|
||||
let name = first.name?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty else {
|
||||
resolver.resolve(.unavailable(reason: "固定锚点查询返回空结果"))
|
||||
return
|
||||
}
|
||||
resolver.resolve(.response(name: name, count: items.count))
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout)
|
||||
}
|
||||
}, onCancel: {
|
||||
search.cancel()
|
||||
resolver.resolve(.cancelled)
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the complete persisted pair once at the map input boundary.
|
||||
static func coordinatePair(lat: Double, lon: Double, mapCoordinateSystem: MapCoordinateSystem) -> CoordinatePair {
|
||||
let raw = CoordinatePair.Value(latitude: lat, longitude: lon)
|
||||
switch mapCoordinateSystem {
|
||||
case .wgs84:
|
||||
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
|
||||
return CoordinatePair(
|
||||
wgs84: raw,
|
||||
gcj02: .init(latitude: gcj.lat, longitude: gcj.lon)
|
||||
)
|
||||
case .gcj02:
|
||||
let wgs = gcj02ToWgs84(lat: lat, lon: lon)
|
||||
return CoordinatePair(
|
||||
wgs84: .init(latitude: wgs.lat, longitude: wgs.lon),
|
||||
gcj02: raw
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy raw domestic map values were historically displayed as GCJ-02;
|
||||
/// overseas values were WGS-84 and stay identity coordinates.
|
||||
static func legacyCoordinatePair(lat: Double, lon: Double) -> CoordinatePair {
|
||||
let type: MapCoordinateSystem = usesGCJ02ServiceArea(lat: lat, lon: lon) ? .gcj02 : .wgs84
|
||||
return coordinatePair(lat: lat, lon: lon, mapCoordinateSystem: type)
|
||||
}
|
||||
|
||||
// MARK: - 工具
|
||||
|
||||
/// Haversine 距离(米)
|
||||
static func distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
||||
let r = 6371000.0
|
||||
let dLat = (lat2 - lat1) * .pi / 180.0
|
||||
let dLon = (lon2 - lon1) * .pi / 180.0
|
||||
let a = sin(dLat / 2) * sin(dLat / 2)
|
||||
+ cos(lat1 * .pi / 180.0) * cos(lat2 * .pi / 180.0)
|
||||
* sin(dLon / 2) * sin(dLon / 2)
|
||||
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
}
|
||||
|
||||
/// Identifies which stored representation a runtime sample most closely
|
||||
/// resembles. Overseas identity pairs and unrelated samples stay ambiguous.
|
||||
static func diagnoseRepresentation(
|
||||
sample: CLLocationCoordinate2D,
|
||||
pair: CoordinatePair,
|
||||
maximumDistance: CLLocationDistance = 1_000,
|
||||
minimumSeparation: CLLocationDistance = 30
|
||||
) -> CoordinateRepresentationDiagnosis {
|
||||
let distanceToWGS84 = distance(
|
||||
lat1: sample.latitude,
|
||||
lon1: sample.longitude,
|
||||
lat2: pair.wgs84.latitude,
|
||||
lon2: pair.wgs84.longitude
|
||||
)
|
||||
let distanceToGCJ02 = distance(
|
||||
lat1: sample.latitude,
|
||||
lon1: sample.longitude,
|
||||
lat2: pair.gcj02.latitude,
|
||||
lon2: pair.gcj02.longitude
|
||||
)
|
||||
let nearestDistance = min(distanceToWGS84, distanceToGCJ02)
|
||||
let separation = abs(distanceToWGS84 - distanceToGCJ02)
|
||||
let inferredSystem: MapCoordinateSystem?
|
||||
if nearestDistance > maximumDistance || separation < minimumSeparation {
|
||||
inferredSystem = nil
|
||||
} else {
|
||||
inferredSystem = distanceToWGS84 < distanceToGCJ02 ? .wgs84 : .gcj02
|
||||
}
|
||||
return CoordinateRepresentationDiagnosis(
|
||||
inferredSystem: inferredSystem,
|
||||
distanceToWGS84: distanceToWGS84,
|
||||
distanceToGCJ02: distanceToGCJ02
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 核心转换
|
||||
|
||||
/// GCJ-02 → WGS-84(迭代法,精度优于 0.5 米)
|
||||
static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
|
||||
var wgsLat = lat
|
||||
var wgsLon = lon
|
||||
// 两次迭代足以收敛到亚米级精度
|
||||
for _ in 0..<2 {
|
||||
let d = delta(lat: wgsLat, lon: wgsLon)
|
||||
wgsLat = lat - d.lat
|
||||
@@ -30,6 +390,25 @@ enum CoordinateConverter {
|
||||
return (wgsLat, wgsLon)
|
||||
}
|
||||
|
||||
/// WGS-84 → GCJ-02
|
||||
static func wgs84ToGcj02(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
|
||||
let d = delta(lat: lat, lon: lon)
|
||||
return (lat + d.lat, lon + d.lon)
|
||||
}
|
||||
|
||||
/// Keep the GCJ-02 service region explicit. Outside this region, conversion
|
||||
/// is identity so the domestic fallback can never shift an overseas value.
|
||||
static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool {
|
||||
let mainland = lat >= 0.8293 && lat <= 55.8271 && lon >= 72.004 && lon <= 137.8347
|
||||
let hongKong = lat >= 22.13 && lat <= 22.57 && lon >= 113.82 && lon <= 114.45
|
||||
let macao = lat >= 22.05 && lat <= 22.25 && lon >= 113.52 && lon <= 113.65
|
||||
let taiwan = lat >= 21.75 && lat <= 25.35 && lon >= 119.30 && lon <= 122.10
|
||||
return mainland || hongKong || macao || taiwan
|
||||
}
|
||||
|
||||
// MARK: - 内部
|
||||
|
||||
/// 计算偏移量 (WGS-84 → GCJ-02 的增量)
|
||||
private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
let dLat = transformLat(x: lon - 105.0, y: lat - 35.0)
|
||||
@@ -60,3 +439,56 @@ enum CoordinateConverter {
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
private enum MapCoordinateSystemProbeResult {
|
||||
case response(name: String, count: Int)
|
||||
case unavailable(reason: String)
|
||||
case timedOut(reason: String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private final class MapCoordinateSystemProbeResolver: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var result: MapCoordinateSystemProbeResult?
|
||||
private var continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>?
|
||||
private var timeout: DispatchWorkItem?
|
||||
|
||||
var isResolved: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return result != nil
|
||||
}
|
||||
|
||||
func install(
|
||||
_ continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>,
|
||||
timeout: DispatchWorkItem
|
||||
) {
|
||||
lock.lock()
|
||||
if let result {
|
||||
lock.unlock()
|
||||
timeout.cancel()
|
||||
continuation.resume(returning: result)
|
||||
return
|
||||
}
|
||||
self.continuation = continuation
|
||||
self.timeout = timeout
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func resolve(_ nextResult: MapCoordinateSystemProbeResult) {
|
||||
lock.lock()
|
||||
guard result == nil else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
result = nextResult
|
||||
let continuation = continuation
|
||||
let timeout = timeout
|
||||
self.continuation = nil
|
||||
self.timeout = nil
|
||||
lock.unlock()
|
||||
|
||||
timeout?.cancel()
|
||||
continuation?.resume(returning: nextResult)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ enum CoreBridgeError: LocalizedError {
|
||||
}
|
||||
|
||||
enum CoreBridge {
|
||||
static func isValidCertificateAuthority(_ authority: CertificateAuthority) -> Bool {
|
||||
authority.certPEM.withCString { certificate in
|
||||
authority.keyPEM.withCString { key in
|
||||
wloccore_validateca(UnsafeMutablePointer(mutating: certificate), UnsafeMutablePointer(mutating: key)) != 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func generateCertificateAuthority() throws -> CertificateAuthority {
|
||||
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
|
||||
let result = wloccore_generateca()
|
||||
@@ -41,21 +49,6 @@ enum CoreBridge {
|
||||
return String(cString: ptr)
|
||||
}
|
||||
|
||||
/// 构造接近真实设备格式的 wloc 请求体(多个 WiFi AP + 蜂窝基站)。
|
||||
static func testWlocRequestData() -> Data {
|
||||
guard let ptr = wloccore_testrequesthex() else { return Data([0x0a, 0x02, 0x08, 0x01]) }
|
||||
defer { free(ptr) }
|
||||
let hex = String(cString: ptr)
|
||||
var data = Data()
|
||||
var idx = hex.startIndex
|
||||
while idx < hex.endIndex {
|
||||
let end = hex.index(idx, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
|
||||
if let b = UInt8(hex[idx..<end], radix: 16) { data.append(b) }
|
||||
idx = end
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func flushLogs(category: String) {
|
||||
guard let pointer = wloccore_drainlogs() else { return }
|
||||
defer { free(pointer) }
|
||||
|
||||
@@ -1,21 +1,79 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct FavoriteLocation: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var latitude: Double
|
||||
var longitude: Double
|
||||
var coordinatePair: CoordinatePair
|
||||
var accuracy: Int
|
||||
var createdAt: Date
|
||||
private var wasDecodedFromLegacyCoordinates = false
|
||||
|
||||
init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double, accuracy: Int, createdAt: Date = Date()) {
|
||||
/// WGS-84 compatibility accessor. WLOC consumers must use this value.
|
||||
var latitude: Double { coordinatePair.wgs84.latitude }
|
||||
var longitude: Double { coordinatePair.wgs84.longitude }
|
||||
var isLegacyCoordinateRecord: Bool { wasDecodedFromLegacyCoordinates }
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
coordinatePair: CoordinatePair,
|
||||
accuracy: Int,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.coordinatePair = coordinatePair
|
||||
self.accuracy = accuracy
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracy: Int,
|
||||
createdAt: Date = Date(),
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02
|
||||
) {
|
||||
self.init(
|
||||
id: id,
|
||||
name: name,
|
||||
coordinatePair: CoordinateConverter.coordinatePair(lat: latitude, lon: longitude, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, coordinatePair, accuracy, createdAt, latitude, longitude
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
accuracy = try container.decode(Int.self, forKey: .accuracy)
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
if let pair = try container.decodeIfPresent(CoordinatePair.self, forKey: .coordinatePair) {
|
||||
coordinatePair = pair
|
||||
} else {
|
||||
let latitude = try container.decode(Double.self, forKey: .latitude)
|
||||
let longitude = try container.decode(Double.self, forKey: .longitude)
|
||||
coordinatePair = CoordinateConverter.legacyCoordinatePair(lat: latitude, lon: longitude)
|
||||
wasDecodedFromLegacyCoordinates = true
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(coordinatePair, forKey: .coordinatePair)
|
||||
try container.encode(accuracy, forKey: .accuracy)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
struct MapConfiguration: Equatable {
|
||||
@@ -52,27 +110,72 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(name: String, latitude: Double, longitude: Double, accuracy: Int) -> FavoriteLocation {
|
||||
let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy)
|
||||
// 去重:相同坐标删除旧数据,新数据插入顶部
|
||||
func save(
|
||||
name: String,
|
||||
mapCoordinate: CLLocationCoordinate2D,
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
|
||||
accuracy: Int
|
||||
) -> FavoriteLocation {
|
||||
save(
|
||||
FavoriteLocation(
|
||||
name: name,
|
||||
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Saves a coordinate pair whose source representation was already typed
|
||||
/// before an asynchronous map-coordinate-system refresh.
|
||||
@discardableResult
|
||||
func save(name: String, coordinatePair: CoordinatePair, accuracy: Int) -> FavoriteLocation {
|
||||
save(FavoriteLocation(name: name, coordinatePair: coordinatePair, accuracy: accuracy))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func save(_ favorite: FavoriteLocation) -> FavoriteLocation {
|
||||
favorites.removeAll {
|
||||
abs($0.latitude - favorite.latitude) < 0.000001 && abs($0.longitude - favorite.longitude) < 0.000001
|
||||
abs($0.coordinatePair.wgs84.latitude - favorite.coordinatePair.wgs84.latitude) < 0.000001
|
||||
&& abs($0.coordinatePair.wgs84.longitude - favorite.coordinatePair.wgs84.longitude) < 0.000001
|
||||
}
|
||||
favorites.insert(favorite, at: 0)
|
||||
select(favorite.id)
|
||||
persist()
|
||||
persistIgnoringFailure()
|
||||
return favorite
|
||||
}
|
||||
|
||||
/// Compatibility entry point for callers that already own raw map values.
|
||||
@discardableResult
|
||||
func save(name: String, latitude: Double, longitude: Double, accuracy: Int, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02) -> FavoriteLocation {
|
||||
save(
|
||||
name: name,
|
||||
mapCoordinate: .init(latitude: latitude, longitude: longitude),
|
||||
mapCoordinateSystem: mapCoordinateSystem,
|
||||
accuracy: accuracy
|
||||
)
|
||||
}
|
||||
|
||||
func select(_ id: UUID?) {
|
||||
selectedFavoriteID = id
|
||||
defaults.set(id?.uuidString, forKey: Keys.selectedID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectMatching(coordinatePair: CoordinatePair) -> FavoriteLocation? {
|
||||
let matchingFavorite = favorites.first {
|
||||
$0.coordinatePair.matchesWGS84(
|
||||
latitude: coordinatePair.wgs84.latitude,
|
||||
longitude: coordinatePair.wgs84.longitude
|
||||
)
|
||||
}
|
||||
select(matchingFavorite?.id)
|
||||
return matchingFavorite
|
||||
}
|
||||
|
||||
func rename(_ id: UUID, to name: String) {
|
||||
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
|
||||
favorites[idx].name = name
|
||||
persist()
|
||||
persistIgnoringFailure()
|
||||
}
|
||||
|
||||
func delete(_ favorite: FavoriteLocation) {
|
||||
@@ -80,11 +183,23 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
if selectedFavoriteID == favorite.id {
|
||||
select(favorites.first?.id)
|
||||
}
|
||||
persist()
|
||||
persistIgnoringFailure()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(favorites) else { return }
|
||||
defaults.set(data, forKey: Keys.favorites)
|
||||
func migrateLegacyCoordinates() throws {
|
||||
guard favorites.contains(where: \.isLegacyCoordinateRecord) else { return }
|
||||
try persist()
|
||||
}
|
||||
|
||||
private func persistIgnoringFailure() {
|
||||
do {
|
||||
try persist()
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "收藏", "保存收藏失败", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() throws {
|
||||
defaults.set(try JSONEncoder().encode(favorites), forKey: Keys.favorites)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import Network
|
||||
import Foundation
|
||||
import SystemConfiguration.CaptiveNetwork
|
||||
|
||||
enum WiFiChangeReason: String {
|
||||
case reconnected = "Wi-Fi 恢复连接"
|
||||
case interfaceChanged = "网络接口切换到 Wi-Fi"
|
||||
case ssidChanged = "SSID 发生变化"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NetworkMonitor: ObservableObject {
|
||||
@@ -7,20 +14,99 @@ final class NetworkMonitor: ObservableObject {
|
||||
|
||||
@Published private(set) var isSatisfied = true
|
||||
@Published private(set) var isWiFiEnabled = true
|
||||
@Published private(set) var currentSSID: String?
|
||||
|
||||
/// Wi-Fi 重连、接口切换或 SSID 变化时触发。调用方必须在离开页面时移除订阅。
|
||||
private var wifiChangeHandlers: [UUID: @MainActor (WiFiChangeReason) -> Void] = [:]
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private var ssidTimer: Timer?
|
||||
private var wasSatisfied = true
|
||||
private var wasWiFiEnabled = true
|
||||
private var hasReceivedInitialPath = false
|
||||
private var lastKnownSSID: String?
|
||||
|
||||
private init() {
|
||||
let initialSSID = Self.fetchSSID()
|
||||
currentSSID = initialSSID
|
||||
lastKnownSSID = initialSSID
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let satisfied = path.status == .satisfied
|
||||
let wifi = path.usesInterfaceType(.wifi)
|
||||
Task { @MainActor in
|
||||
self?.isSatisfied = satisfied
|
||||
self?.isWiFiEnabled = wifi
|
||||
guard let self else { return }
|
||||
let reason: WiFiChangeReason?
|
||||
if !self.hasReceivedInitialPath {
|
||||
// NWPathMonitor 的首次回调只是状态基线,不是网络切换。
|
||||
self.hasReceivedInitialPath = true
|
||||
reason = nil
|
||||
} else if satisfied && wifi && !self.wasSatisfied {
|
||||
reason = .reconnected
|
||||
} else if satisfied && wifi && !self.wasWiFiEnabled {
|
||||
// 蜂窝网络和 Wi-Fi 都可能是 satisfied,不能只比较 status。
|
||||
reason = .interfaceChanged
|
||||
} else {
|
||||
reason = nil
|
||||
}
|
||||
self.wasSatisfied = satisfied
|
||||
self.wasWiFiEnabled = wifi
|
||||
self.isSatisfied = satisfied
|
||||
self.isWiFiEnabled = wifi
|
||||
if let reason {
|
||||
self.notifyWiFiChanged(reason: reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: .main)
|
||||
startSSIDPolling()
|
||||
}
|
||||
|
||||
var isAirplaneMode: Bool { !isSatisfied }
|
||||
/// Registers a Wi-Fi-change observer and returns a token that must be removed.
|
||||
@discardableResult
|
||||
func observeWiFiChanges(_ handler: @escaping @MainActor (WiFiChangeReason) -> Void) -> UUID {
|
||||
let token = UUID()
|
||||
wifiChangeHandlers[token] = handler
|
||||
return token
|
||||
}
|
||||
|
||||
func removeWiFiChangeObserver(_ token: UUID) {
|
||||
wifiChangeHandlers.removeValue(forKey: token)
|
||||
}
|
||||
|
||||
private func notifyWiFiChanged(reason: WiFiChangeReason) {
|
||||
for handler in wifiChangeHandlers.values {
|
||||
handler(reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func startSSIDPolling() {
|
||||
ssidTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let ssid = Self.fetchSSID()
|
||||
self.currentSSID = ssid
|
||||
guard let ssid else { return }
|
||||
guard let previousSSID = self.lastKnownSSID else {
|
||||
// 首次取得 SSID 只是建立基线,不能当作用户切换了 Wi-Fi。
|
||||
self.lastKnownSSID = ssid
|
||||
return
|
||||
}
|
||||
if ssid != previousSSID {
|
||||
self.lastKnownSSID = ssid
|
||||
self.notifyWiFiChanged(reason: .ssidChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchSSID() -> String? {
|
||||
guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil }
|
||||
for iface in interfaces {
|
||||
if let info = CNCopyCurrentNetworkInfo(iface as CFString) as? [String: Any],
|
||||
let ssid = info[kCNNetworkInfoKeySSID as String] as? String {
|
||||
return ssid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum ProxyRuntimeMode: String, CaseIterable, Codable, Identifiable {
|
||||
case localWiFi
|
||||
case thirdParty
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .localWiFi: return "APP模式"
|
||||
case .thirdParty: return "第三方代理模式"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProxyRuntimeModeStore: ObservableObject {
|
||||
static let shared = ProxyRuntimeModeStore()
|
||||
|
||||
private enum Key {
|
||||
static let runtimeMode = "proxyRuntimeMode"
|
||||
static let hasSelectedRuntimeMode = "hasSelectedProxyRuntimeMode"
|
||||
}
|
||||
|
||||
@Published private(set) var mode: ProxyRuntimeMode
|
||||
@Published private(set) var hasSelectedMode: Bool
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
self.mode = defaults.string(forKey: Key.runtimeMode)
|
||||
.flatMap(ProxyRuntimeMode.init(rawValue:)) ?? .localWiFi
|
||||
self.hasSelectedMode = defaults.bool(forKey: Key.hasSelectedRuntimeMode)
|
||||
}
|
||||
|
||||
func setMode(_ mode: ProxyRuntimeMode) {
|
||||
let changed = self.mode != mode
|
||||
self.mode = mode
|
||||
hasSelectedMode = true
|
||||
defaults.set(mode.rawValue, forKey: Key.runtimeMode)
|
||||
defaults.set(true, forKey: Key.hasSelectedRuntimeMode)
|
||||
if changed {
|
||||
RuntimeLogger.info("APP", "Mode", "代理运行模式已切换", details: [
|
||||
"模式": mode.displayName
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct RuntimeLogEntry: Codable, Identifiable, Equatable {
|
||||
@@ -49,11 +50,17 @@ enum RuntimeLogStore {
|
||||
private static let decoder = JSONDecoder()
|
||||
private static let encoder = JSONEncoder()
|
||||
private static let maximumBytes: UInt64 = 1_500_000
|
||||
static let retentionInterval: TimeInterval = 3 * 24 * 60 * 60
|
||||
private static let pruningInterval: TimeInterval = 60 * 60
|
||||
private static var lastPrunedAt: Date?
|
||||
|
||||
static func append(_ entry: RuntimeLogEntry) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
let now = Date()
|
||||
try pruneExpiredLogsIfNeeded(now: now)
|
||||
guard entry.timestamp >= retentionCutoff(now: now) else { return }
|
||||
let url = try logURL(for: entry.source)
|
||||
try rotateIfNeeded(url)
|
||||
var data = try encoder.encode(entry)
|
||||
@@ -75,7 +82,8 @@ enum RuntimeLogStore {
|
||||
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
let directory = logDirectory
|
||||
try? pruneExpiredLogs(in: directory, now: Date())
|
||||
guard let urls = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
@@ -90,12 +98,12 @@ enum RuntimeLogStore {
|
||||
static func clearAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
try? FileManager.default.removeItem(at: logDirectory)
|
||||
lastPrunedAt = nil
|
||||
}
|
||||
|
||||
private static func logURL(for source: String) throws -> URL {
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
let directory = logDirectory
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
|
||||
.replacingOccurrences(of: "/", with: "-")
|
||||
@@ -103,6 +111,56 @@ enum RuntimeLogStore {
|
||||
return directory.appendingPathComponent("\(process)-\(safeSource).jsonl")
|
||||
}
|
||||
|
||||
private static var logDirectory: URL {
|
||||
AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
}
|
||||
|
||||
static func retentionCutoff(now: Date) -> Date {
|
||||
now.addingTimeInterval(-retentionInterval)
|
||||
}
|
||||
|
||||
private static func pruneExpiredLogsIfNeeded(now: Date) throws {
|
||||
if let lastPrunedAt,
|
||||
now >= lastPrunedAt,
|
||||
now.timeIntervalSince(lastPrunedAt) < pruningInterval {
|
||||
return
|
||||
}
|
||||
try pruneExpiredLogs(in: logDirectory, now: now)
|
||||
}
|
||||
|
||||
private static func pruneExpiredLogs(in directory: URL, now: Date) throws {
|
||||
guard FileManager.default.fileExists(atPath: directory.path) else {
|
||||
lastPrunedAt = now
|
||||
return
|
||||
}
|
||||
let urls = try FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
).filter { $0.pathExtension == "jsonl" }
|
||||
|
||||
for url in urls {
|
||||
let entries = readEntries(url)
|
||||
let retained = retainedEntries(entries, now: now)
|
||||
guard retained.count != entries.count else { continue }
|
||||
guard !retained.isEmpty else {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
continue
|
||||
}
|
||||
var data = Data()
|
||||
for entry in retained {
|
||||
data.append(try encoder.encode(entry))
|
||||
data.append(0x0A)
|
||||
}
|
||||
try data.write(to: url, options: .atomic)
|
||||
}
|
||||
lastPrunedAt = now
|
||||
}
|
||||
|
||||
static func retainedEntries(_ entries: [RuntimeLogEntry], now: Date) -> [RuntimeLogEntry] {
|
||||
let cutoff = retentionCutoff(now: now)
|
||||
return entries.filter { $0.timestamp >= cutoff }
|
||||
}
|
||||
|
||||
private static func rotateIfNeeded(_ url: URL) throws {
|
||||
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attributes[.size] as? NSNumber,
|
||||
@@ -162,3 +220,67 @@ enum RuntimeLogger {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Realtime-location diagnostics keep precise coordinates out of the persisted,
|
||||
/// exportable log. Exact values are printed only by DEBUG builds for local Xcode
|
||||
/// debugging.
|
||||
enum RealtimeLocationTrace {
|
||||
static func log(
|
||||
_ message: String,
|
||||
location: CLLocation,
|
||||
details: [String: String] = [:],
|
||||
level: RuntimeLogEntry.Level = .info
|
||||
) {
|
||||
var metadata = details
|
||||
metadata["样本时间"] = ISO8601DateFormatter().string(from: location.timestamp)
|
||||
metadata["样本年龄秒"] = format(Date().timeIntervalSince(location.timestamp))
|
||||
metadata["水平精度米"] = format(location.horizontalAccuracy)
|
||||
metadata["垂直精度米"] = format(location.verticalAccuracy)
|
||||
metadata["海拔米"] = format(location.altitude)
|
||||
metadata["坐标有效"] = String(CLLocationCoordinate2DIsValid(location.coordinate))
|
||||
persist(level, message: message, details: metadata)
|
||||
debugCoordinate(message, location: location, details: metadata)
|
||||
}
|
||||
|
||||
static func coordinate(
|
||||
_ message: String,
|
||||
coordinate: CLLocationCoordinate2D,
|
||||
details: [String: String] = [:]
|
||||
) {
|
||||
#if DEBUG
|
||||
let suffix = details.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
.joined(separator: " ")
|
||||
let latitude = String(format: "%.8f", coordinate.latitude)
|
||||
let longitude = String(format: "%.8f", coordinate.longitude)
|
||||
let metadata = suffix.isEmpty ? "" : " \(suffix)"
|
||||
print("[RealtimeLocation] \(message) latitude=\(latitude) longitude=\(longitude)\(metadata)")
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func persist(
|
||||
_ level: RuntimeLogEntry.Level,
|
||||
message: String,
|
||||
details: [String: String]
|
||||
) {
|
||||
switch level {
|
||||
case .debug: RuntimeLogger.debug("APP", "实时定位", message, details: details)
|
||||
case .info: RuntimeLogger.info("APP", "实时定位", message, details: details)
|
||||
case .warning: RuntimeLogger.warning("APP", "实时定位", message, details: details)
|
||||
case .error: RuntimeLogger.error("APP", "实时定位", message, details: details)
|
||||
}
|
||||
}
|
||||
|
||||
private static func debugCoordinate(
|
||||
_ message: String,
|
||||
location: CLLocation,
|
||||
details: [String: String]
|
||||
) {
|
||||
coordinate(message, coordinate: location.coordinate, details: details)
|
||||
}
|
||||
|
||||
private static func format(_ value: Double) -> String {
|
||||
guard value.isFinite else { return String(value) }
|
||||
return String(format: "%.3f", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import Foundation
|
||||
|
||||
struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
|
||||
let success: Bool
|
||||
let longitude: Double?
|
||||
let latitude: Double?
|
||||
let accuracy: Int?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyConnectionState: Equatable {
|
||||
case unknown
|
||||
case connected(active: Bool)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyError: LocalizedError, Equatable {
|
||||
case invalidResponse
|
||||
case moduleNotIntercepted
|
||||
case rejected(String)
|
||||
case coordinateMismatch
|
||||
case network(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
return "第三方代理返回了无法识别的数据"
|
||||
case .moduleNotIntercepted:
|
||||
return "请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接"
|
||||
case .rejected(let message):
|
||||
return message
|
||||
case .coordinateMismatch:
|
||||
return "第三方代理保存的坐标与当前选点不一致"
|
||||
case .network(let message):
|
||||
return "第三方代理请求失败:\(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol ThirdPartyProxyRequesting {
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: ThirdPartyProxyRequesting {}
|
||||
|
||||
@MainActor
|
||||
final class ThirdPartyProxyManager: ObservableObject {
|
||||
static let shared = ThirdPartyProxyManager()
|
||||
static let interceptionHostname = "gs-loc.apple.com"
|
||||
|
||||
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
||||
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
|
||||
@Published private(set) var isRequesting = false
|
||||
private let requester: any ThirdPartyProxyRequesting
|
||||
private let endpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
|
||||
|
||||
init(requester: (any ThirdPartyProxyRequesting)? = nil) {
|
||||
if let requester {
|
||||
self.requester = requester
|
||||
} else {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
configuration.urlCache = nil
|
||||
configuration.timeoutIntervalForRequest = 8
|
||||
configuration.timeoutIntervalForResource = 10
|
||||
self.requester = URLSession(configuration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
func query() async throws -> ThirdPartyProxySettingsResponse {
|
||||
let response = try await perform(action: .query)
|
||||
if response.success,
|
||||
response.latitude != nil,
|
||||
response.longitude != nil {
|
||||
activeSettings = response
|
||||
connectionState = .connected(active: true)
|
||||
} else if response.error?.contains("无已保存") == true {
|
||||
activeSettings = nil
|
||||
connectionState = .connected(active: false)
|
||||
} else {
|
||||
let error = ThirdPartyProxyError.rejected(response.error ?? "第三方代理查询失败")
|
||||
connectionState = .failed(error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func save(_ favorite: FavoriteLocation) async throws -> ThirdPartyProxySettingsResponse {
|
||||
let wgs84 = favorite.coordinatePair.wgs84
|
||||
let response = try await perform(action: .save(
|
||||
latitude: wgs84.latitude,
|
||||
longitude: wgs84.longitude,
|
||||
accuracy: favorite.accuracy
|
||||
))
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标")
|
||||
}
|
||||
guard let latitude = response.latitude,
|
||||
let longitude = response.longitude,
|
||||
abs(latitude - wgs84.latitude) <= 0.000_001,
|
||||
abs(longitude - wgs84.longitude) <= 0.000_001 else {
|
||||
throw ThirdPartyProxyError.coordinateMismatch
|
||||
}
|
||||
activeSettings = response
|
||||
connectionState = .connected(active: true)
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理已保存 WGS-84 坐标", details: [
|
||||
"坐标标准": "WGS-84",
|
||||
"取值字段": "coordinatePair.wgs84",
|
||||
"accuracy": String(favorite.accuracy)
|
||||
])
|
||||
return response
|
||||
}
|
||||
|
||||
func clear() async throws {
|
||||
let response = try await perform(action: .clear)
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理清除坐标失败")
|
||||
}
|
||||
activeSettings = nil
|
||||
connectionState = .connected(active: false)
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理坐标已清除")
|
||||
}
|
||||
|
||||
private enum Action {
|
||||
case query
|
||||
case save(latitude: Double, longitude: Double, accuracy: Int)
|
||||
case clear
|
||||
}
|
||||
|
||||
private func perform(action: Action) async throws -> ThirdPartyProxySettingsResponse {
|
||||
guard !isRequesting else {
|
||||
throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行")
|
||||
}
|
||||
isRequesting = true
|
||||
defer { isRequesting = false }
|
||||
|
||||
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
||||
switch action {
|
||||
case .query:
|
||||
components.queryItems = [URLQueryItem(name: "action", value: "query")]
|
||||
case .clear:
|
||||
components.queryItems = [URLQueryItem(name: "action", value: "clear")]
|
||||
case .save(let latitude, let longitude, let accuracy):
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "lon", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), longitude)),
|
||||
URLQueryItem(name: "lat", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), latitude)),
|
||||
URLQueryItem(name: "acc", value: String(accuracy))
|
||||
]
|
||||
}
|
||||
guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
request.timeoutInterval = 8
|
||||
|
||||
do {
|
||||
let (data, urlResponse) = try await requester.data(for: request)
|
||||
guard let http = urlResponse as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw ThirdPartyProxyError.moduleNotIntercepted
|
||||
}
|
||||
guard let response = try? JSONDecoder().decode(ThirdPartyProxySettingsResponse.self, from: data) else {
|
||||
throw ThirdPartyProxyError.moduleNotIntercepted
|
||||
}
|
||||
|
||||
return response
|
||||
} catch let error as ThirdPartyProxyError {
|
||||
connectionState = .failed(error.localizedDescription)
|
||||
RuntimeLogger.error("APP", "ThirdPartyProxy", "第三方代理请求失败", error: error)
|
||||
throw error
|
||||
} catch {
|
||||
let mapped = ThirdPartyProxyError.network(error.localizedDescription)
|
||||
connectionState = .failed(mapped.localizedDescription)
|
||||
RuntimeLogger.error("APP", "ThirdPartyProxy", "第三方代理请求失败", error: error)
|
||||
throw mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
case shadowrocket
|
||||
case surge
|
||||
case quantumultX
|
||||
case loon
|
||||
case stash
|
||||
case egern
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .shadowrocket: return "Shadowrocket"
|
||||
case .surge: return "Surge"
|
||||
case .quantumultX: return "Quantumult X"
|
||||
case .loon: return "Loon"
|
||||
case .stash: return "Stash"
|
||||
case .egern: return "Egern"
|
||||
}
|
||||
}
|
||||
|
||||
var verificationText: String {
|
||||
self == .shadowrocket ? "当前可测试" : "配置已提供,尚未验证"
|
||||
}
|
||||
|
||||
var moduleFileName: String {
|
||||
switch self {
|
||||
case .shadowrocket: return "wloc.module"
|
||||
case .surge, .egern: return "wloc.sgmodule"
|
||||
case .quantumultX: return "wloc.conf"
|
||||
case .loon: return "wloc.lpx"
|
||||
case .stash: return "wloc.stoverride"
|
||||
}
|
||||
}
|
||||
|
||||
var subscriptionURL: URL {
|
||||
let url: String
|
||||
switch self {
|
||||
case .surge, .egern:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule"
|
||||
case .quantumultX:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf"
|
||||
case .loon:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx"
|
||||
case .stash:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride"
|
||||
case .shadowrocket:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module"
|
||||
}
|
||||
return URL(string: url)!
|
||||
}
|
||||
|
||||
var launchURL: URL? {
|
||||
switch self {
|
||||
case .shadowrocket: return URL(string: "shadowrocket://")
|
||||
case .surge: return URL(string: "surge://")
|
||||
case .quantumultX: return URL(string: "quantumult-x://")
|
||||
case .loon: return URL(string: "loon://")
|
||||
case .stash: return URL(string: "stash://")
|
||||
case .egern: return URL(string: "egern://")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ThirdPartyProxyClientStore: ObservableObject {
|
||||
static let shared = ThirdPartyProxyClientStore()
|
||||
|
||||
private enum Key {
|
||||
static let selectedClient = "selectedThirdPartyProxyClient"
|
||||
}
|
||||
|
||||
@Published private(set) var selectedClient: ThirdPartyProxyClient
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
selectedClient = defaults.string(forKey: Key.selectedClient)
|
||||
.flatMap(ThirdPartyProxyClient.init(rawValue:)) ?? .shadowrocket
|
||||
}
|
||||
|
||||
func select(_ client: ThirdPartyProxyClient) {
|
||||
selectedClient = client
|
||||
defaults.set(client.rawValue, forKey: Key.selectedClient)
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,22 @@ enum VerificationResult: Equatable, Identifiable {
|
||||
switch self {
|
||||
case .success: return nil
|
||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
||||
case .certNotTrusted: return nil // 走完整引导页,不弹 tip
|
||||
case .certNotTrusted: return nil
|
||||
case .wifiProxyNotConfigured: return .proxySetup
|
||||
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Wi-Fi 变化后仍留在地图页显示的提醒。
|
||||
/// 证书失败必须进入完整证书安装引导,因此不在这里返回通用提示页。
|
||||
var wifiChangeReminderTipKind: TipKind? {
|
||||
switch self {
|
||||
case .proxyNotRunning:
|
||||
return .proxySetup
|
||||
case .certNotTrusted, .verificationInProgress, .verificationSuperseded:
|
||||
return nil
|
||||
default:
|
||||
return tipKind
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,139 @@ import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class CertificateAuthorityStoreTests: XCTestCase {
|
||||
func testEnsureCreatesOnceAndThenReusesExistingPair() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
private let validAuthority = CertificateAuthority(certPEM: "valid-cert", keyPEM: "valid-key")
|
||||
|
||||
func testEnsureCreatesOnceThenReusesValidKeychainPair() throws {
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
var generations = 0
|
||||
let store = CertificateAuthorityStore(directory: directory) {
|
||||
let store = makeStore(keychain: keychain) {
|
||||
generations += 1
|
||||
return CertificateAuthority(certPEM: "cert", keyPEM: "key")
|
||||
return self.validAuthority
|
||||
}
|
||||
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
|
||||
XCTAssertEqual(try store.ensure(), CertificateAuthority(certPEM: "cert", keyPEM: "key"))
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
XCTAssertEqual(generations, 1)
|
||||
}
|
||||
|
||||
func testEnsureMigratesValidLegacyPairThenDeletesLegacyFiles() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
let store = makeStore(directory: directory, keychain: keychain) {
|
||||
XCTFail("A valid legacy pair must be migrated instead of regenerated")
|
||||
return self.validAuthority
|
||||
}
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testFailedKeychainMigrationPreservesLegacyFiles() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.shouldFailSave = true
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertThrowsError(try store.ensure())
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testInvalidKeychainPairFallsBackToLegacyPair() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.stored = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
}
|
||||
|
||||
func testValidKeychainPairRetriesCleanupOfLegacyFiles() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.stored = validAuthority
|
||||
let store = makeStore(directory: directory, keychain: keychain) {
|
||||
XCTFail("A valid Keychain pair must not be regenerated")
|
||||
return self.validAuthority
|
||||
}
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testInvalidLegacyFilesAreRemovedOnlyAfterReplacementIsPersisted() throws {
|
||||
let invalidAuthority = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
|
||||
let directory = try makeLegacyDirectory(authority: invalidAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertEqual(try store.ensure(), validAuthority)
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testFailedReplacementPersistencePreservesInvalidLegacyFiles() throws {
|
||||
let invalidAuthority = CertificateAuthority(certPEM: "invalid-cert", keyPEM: "invalid-key")
|
||||
let directory = try makeLegacyDirectory(authority: invalidAuthority)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.shouldFailSave = true
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertThrowsError(try store.ensure())
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-cert.pem").path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private final class InMemoryCertificateAuthorityKeychain: CertificateAuthorityKeychain {
|
||||
var stored: CertificateAuthority?
|
||||
var shouldFailSave = false
|
||||
|
||||
func load() throws -> CertificateAuthority? { stored }
|
||||
|
||||
func save(_ authority: CertificateAuthority) throws {
|
||||
guard !shouldFailSave else { throw CertificateAuthorityStoreTestError.saveFailed }
|
||||
stored = authority
|
||||
}
|
||||
|
||||
func remove() throws {
|
||||
stored = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class CertificateTrustVerifierTests: XCTestCase {
|
||||
func testVerifierMapsFailedProbeToUnavailable() async {
|
||||
let verifier = CertificateTrustVerifier(probe: { _, _ in false })
|
||||
XCTAssertEqual(await verifier.verify(url: URL(string: "https://127.0.0.1:1/health")!, leafHash: "x"), .unavailable)
|
||||
func testVerifierRejectsMalformedPEM() {
|
||||
XCTAssertFalse(CertificateTrustVerifier.isCACertificateTrusted(certPEM: "not a certificate"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import XCTest
|
||||
import CoreLocation
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class FavoriteLocationStoreTests: XCTestCase {
|
||||
@@ -7,14 +8,195 @@ final class FavoriteLocationStoreTests: XCTestCase {
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
let favorite = store.save(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
let favorite = store.save(
|
||||
name: "深圳湾",
|
||||
mapCoordinate: .init(latitude: 22.494, longitude: 113.951),
|
||||
mapCoordinateSystem: .gcj02,
|
||||
accuracy: 20
|
||||
)
|
||||
|
||||
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
|
||||
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾")
|
||||
}
|
||||
|
||||
func testSelectingMatchingCoordinatePairRestoresFavoriteSelection() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
let favorite = store.save(
|
||||
name: "深圳湾",
|
||||
coordinatePair: CoordinateConverter.coordinatePair(
|
||||
lat: 22.491_438,
|
||||
lon: 113.945_702,
|
||||
mapCoordinateSystem: .wgs84
|
||||
),
|
||||
accuracy: 20
|
||||
)
|
||||
store.select(nil)
|
||||
|
||||
let nearbyRealtimePair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.491_488,
|
||||
lon: 113.945_752,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
XCTAssertEqual(store.selectMatching(coordinatePair: nearbyRealtimePair)?.id, favorite.id)
|
||||
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
|
||||
|
||||
let unrelatedPair = CoordinateConverter.coordinatePair(
|
||||
lat: 31.2304,
|
||||
lon: 121.4737,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
XCTAssertNil(store.selectMatching(coordinatePair: unrelatedPair))
|
||||
XCTAssertNil(store.selectedFavoriteID)
|
||||
}
|
||||
|
||||
func testFavoriteStoresBothFormsAndSelectsMatchingPairWithoutReadConversion() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let wgs = CLLocationCoordinate2D(latitude: 22.491_438, longitude: 113.945_702)
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
coordinatePair: .init(mapCoordinate: wgs, mapCoordinateSystem: .wgs84),
|
||||
accuracy: 20
|
||||
)
|
||||
|
||||
XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).latitude, wgs.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(favorite.coordinatePair.coordinate(for: .wgs84).longitude, wgs.longitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(favorite.coordinatePair.gcj02.latitude, wgs.latitude)
|
||||
XCTAssertNotEqual(favorite.coordinatePair.gcj02.longitude, wgs.longitude)
|
||||
}
|
||||
|
||||
func testSavingPrecomputedPairDoesNotReinterpretItAfterMapTypeRefresh() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.296_642,
|
||||
lon: 114.172_175,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
|
||||
let favorite = FavoriteLocationStore(defaults: defaults).save(
|
||||
name: "香港天文台",
|
||||
coordinatePair: pair,
|
||||
accuracy: 25
|
||||
)
|
||||
|
||||
XCTAssertEqual(favorite.coordinatePair, pair)
|
||||
}
|
||||
|
||||
func testLegacyFavoriteIsUpgradedAsDomesticGCJAndRewritten() throws {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let id = UUID()
|
||||
let createdAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let payload = LegacyFavoritePayload(
|
||||
id: id,
|
||||
name: "旧收藏",
|
||||
latitude: 22.544_577,
|
||||
longitude: 113.941_14,
|
||||
accuracy: 25,
|
||||
createdAt: createdAt
|
||||
)
|
||||
defaults.set(try JSONEncoder().encode([payload]), forKey: "favorite_locations")
|
||||
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertTrue(store.favorites[0].isLegacyCoordinateRecord)
|
||||
XCTAssertEqual(store.favorites[0].coordinatePair.gcj02.latitude, payload.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(store.favorites[0].coordinatePair.wgs84.longitude, payload.longitude)
|
||||
|
||||
try store.migrateLegacyCoordinates()
|
||||
let reloaded = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertEqual(reloaded.favorites[0].id, id)
|
||||
XCTAssertEqual(reloaded.favorites[0].name, "旧收藏")
|
||||
XCTAssertFalse(reloaded.favorites[0].isLegacyCoordinateRecord)
|
||||
}
|
||||
|
||||
func testOverseasPairUsesIdentityConversion() {
|
||||
let eiffelTower = CoordinateConverter.coordinatePair(lat: 48.858_37, lon: 2.294_481, mapCoordinateSystem: .wgs84)
|
||||
|
||||
XCTAssertEqual(eiffelTower.wgs84.latitude, eiffelTower.gcj02.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(eiffelTower.wgs84.longitude, eiffelTower.gcj02.longitude, accuracy: 0.000_000_1)
|
||||
}
|
||||
|
||||
func testDomesticMapCoordinateMatchesPreviouslyActivatedWGS84Value() {
|
||||
let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14)
|
||||
let pair = CoordinatePair(mapCoordinate: gcj, mapCoordinateSystem: .gcj02)
|
||||
|
||||
XCTAssertTrue(pair.matchesWGS84(
|
||||
latitude: pair.wgs84.latitude,
|
||||
longitude: pair.wgs84.longitude
|
||||
))
|
||||
XCTAssertFalse(pair.matchesWGS84(latitude: gcj.latitude, longitude: gcj.longitude))
|
||||
}
|
||||
|
||||
func testCoordinateRepresentationDiagnosisDistinguishesDomesticPair() {
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.539,
|
||||
lon: 113.934,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.diagnoseRepresentation(sample: pair.wgs84.coordinate, pair: pair).inferredSystem,
|
||||
.wgs84
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.diagnoseRepresentation(sample: pair.gcj02.coordinate, pair: pair).inferredSystem,
|
||||
.gcj02
|
||||
)
|
||||
}
|
||||
|
||||
func testCoordinateRepresentationDiagnosisKeepsOverseasIdentityPairAmbiguous() {
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 48.858_37,
|
||||
lon: 2.294_481,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
|
||||
XCTAssertNil(
|
||||
CoordinateConverter.diagnoseRepresentation(sample: pair.wgs84.coordinate, pair: pair).inferredSystem
|
||||
)
|
||||
}
|
||||
|
||||
func testCoordinateRepresentationDiagnosisRejectsUnrelatedSample() {
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.539,
|
||||
lon: 113.934,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
let unrelated = CLLocationCoordinate2D(latitude: 31.2304, longitude: 121.4737)
|
||||
|
||||
XCTAssertNil(CoordinateConverter.diagnoseRepresentation(sample: unrelated, pair: pair).inferredSystem)
|
||||
}
|
||||
|
||||
func testFixedAnchorResultNameUsesOneSharedMapTypeRule() {
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "林士街"),
|
||||
.gcj02
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "Connaught Road West"),
|
||||
.wgs84
|
||||
)
|
||||
}
|
||||
|
||||
func testMapConfigurationNeverRequestsRealUserLocation() {
|
||||
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
|
||||
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct LegacyFavoritePayload: Encodable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let accuracy: Int
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
@@ -3,136 +3,85 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class LocationActionCoordinatorTests: XCTestCase {
|
||||
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async {
|
||||
let events = EventLog()
|
||||
let trust = FakeTrust(canModify: true, events: events)
|
||||
let proxy = FakeProxy(activeForClear: false, events: events)
|
||||
let settings = FakeSettings(events: events)
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
let coordinator = LocationActionCoordinator()
|
||||
func testApplyStartsProxyAndWritesTheFavoriteWGS84Pair() async {
|
||||
let proxy = FakeLocationActionProxy()
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
latitude: 22.494,
|
||||
longitude: 113.951,
|
||||
accuracy: 20,
|
||||
mapCoordinateSystem: .gcj02
|
||||
)
|
||||
|
||||
let applied = await coordinator.apply(favorite)
|
||||
// LocationActionCoordinator doesn't take injected deps — just verify state
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertTrue(coordinator.virtualLocationEnabled)
|
||||
XCTAssertTrue(proxy.isRunning)
|
||||
XCTAssertEqual(proxy.lastCoordinates?.latitude, favorite.coordinatePair.wgs84.latitude)
|
||||
XCTAssertEqual(proxy.lastCoordinates?.longitude, favorite.coordinatePair.wgs84.longitude)
|
||||
XCTAssertEqual(settings.saved?.latitude, favorite.coordinatePair.wgs84.latitude)
|
||||
XCTAssertEqual(settings.saved?.longitude, favorite.coordinatePair.wgs84.longitude)
|
||||
XCTAssertTrue(settings.saved?.enabled == true)
|
||||
}
|
||||
|
||||
func testClearDoesNotConnectAnInactiveProxy() async {
|
||||
let events = EventLog()
|
||||
let coordinator = LocationActionCoordinator()
|
||||
func testClearWritesDisabledCoordinatesAndClearsSettings() {
|
||||
let proxy = FakeLocationActionProxy(isRunning: true)
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
|
||||
coordinator.clear()
|
||||
|
||||
XCTAssertEqual(proxy.lastCoordinates?.latitude, 0)
|
||||
XCTAssertEqual(proxy.lastCoordinates?.longitude, 0)
|
||||
XCTAssertFalse(proxy.lastCoordinates?.enabled ?? true)
|
||||
XCTAssertFalse(settings.saved?.enabled ?? true)
|
||||
XCTAssertFalse(coordinator.virtualLocationEnabled)
|
||||
}
|
||||
|
||||
func testBusyApplyRejectsASecondRequest() async {
|
||||
let coordinator = LocationActionCoordinator()
|
||||
func testApplyVerifiedRejectsInactiveProxyWithoutWritingCoordinates() {
|
||||
let proxy = FakeLocationActionProxy()
|
||||
let settings = FakeLocationActionSettingsStore()
|
||||
let coordinator = LocationActionCoordinator(proxy: proxy, settings: settings)
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||
|
||||
let first = Task { await coordinator.apply(favorite) }
|
||||
let secondApplied = await coordinator.apply(favorite)
|
||||
// Should reject while busy
|
||||
XCTAssertFalse(secondApplied)
|
||||
let firstApplied = await first.value
|
||||
// First one might succeed or fail depending on proxy state; just check no crash
|
||||
_ = firstApplied
|
||||
XCTAssertFalse(coordinator.applyVerified(favorite))
|
||||
XCTAssertNil(proxy.lastCoordinates)
|
||||
XCTAssertNil(settings.saved)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeTrust {
|
||||
var canModify: Bool
|
||||
let events: EventLog
|
||||
|
||||
init(canModify: Bool, events: EventLog) {
|
||||
self.canModify = canModify
|
||||
self.events = events
|
||||
private final class FakeLocationActionProxy: LocationActionProxying {
|
||||
struct Coordinates: Equatable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let enabled: Bool
|
||||
let accuracy: Int
|
||||
}
|
||||
|
||||
func refreshTrust() async {
|
||||
events.append("trust.refresh")
|
||||
var isRunning: Bool
|
||||
private(set) var lastCoordinates: Coordinates?
|
||||
|
||||
init(isRunning: Bool = false) {
|
||||
self.isRunning = isRunning
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int) -> UInt64 {
|
||||
lastCoordinates = Coordinates(latitude: lat, longitude: lon, enabled: enabled, accuracy: accuracy)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeProxy {
|
||||
let activeForClear: Bool
|
||||
let events: EventLog
|
||||
let connectGate: AsyncGate?
|
||||
|
||||
init(activeForClear: Bool, events: EventLog, connectGate: AsyncGate? = nil) {
|
||||
self.activeForClear = activeForClear
|
||||
self.events = events
|
||||
self.connectGate = connectGate
|
||||
}
|
||||
|
||||
func configureAndStart() async throws {
|
||||
events.append("proxy.connect")
|
||||
await connectGate?.blockUntilOpened()
|
||||
}
|
||||
|
||||
func stopAndWait() async throws {
|
||||
events.append("proxy.stop")
|
||||
}
|
||||
|
||||
func send(_ message: String) async throws -> String {
|
||||
events.append("proxy.send:\(message)")
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func isActiveForCoordinateClear() -> Bool { activeForClear }
|
||||
|
||||
func record(error: Error, action: String) {
|
||||
events.append("proxy.record:\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeSettings {
|
||||
var saved: WlocSettings?
|
||||
let events: EventLog
|
||||
|
||||
init(events: EventLog) { self.events = events }
|
||||
private final class FakeLocationActionSettingsStore: LocationActionSettingsStoring {
|
||||
private(set) var saved: WlocSettings?
|
||||
|
||||
func load() -> WlocSettings? { saved }
|
||||
|
||||
func save(_ settings: WlocSettings) {
|
||||
saved = settings
|
||||
events.append("settings.save.\(settings.enabled ? "enabled" : "disabled")")
|
||||
}
|
||||
|
||||
func clear() {
|
||||
saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false)
|
||||
events.append("settings.clear")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class EventLog {
|
||||
private(set) var values: [String] = []
|
||||
func append(_ event: String) { values.append(event) }
|
||||
}
|
||||
|
||||
private actor AsyncGate {
|
||||
private var opened = false
|
||||
private var blockedWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var waitUntilBlockedContinuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
func blockUntilOpened() async {
|
||||
guard !opened else { return }
|
||||
waitUntilBlockedContinuation?.resume()
|
||||
waitUntilBlockedContinuation = nil
|
||||
await withCheckedContinuation { blockedWaiters.append($0) }
|
||||
}
|
||||
|
||||
func waitUntilBlocked() async {
|
||||
guard !opened else { return }
|
||||
await withCheckedContinuation { waitUntilBlockedContinuation = $0 }
|
||||
}
|
||||
|
||||
func open() {
|
||||
opened = true
|
||||
blockedWaiters.forEach { $0.resume() }
|
||||
blockedWaiters.removeAll()
|
||||
}
|
||||
func save(_ settings: WlocSettings) { saved = settings }
|
||||
func clear() { saved = WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false) }
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ final class MapLocationStateTests: XCTestCase {
|
||||
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
|
||||
let accepted = state.acceptRealtimeLocation(
|
||||
.init(latitude: 39.90, longitude: 116.40),
|
||||
intent: request,
|
||||
focus: true
|
||||
intent: request
|
||||
)
|
||||
|
||||
XCTAssertFalse(accepted)
|
||||
@@ -53,7 +52,7 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertEqual(state.selection.source, .userPan)
|
||||
|
||||
let intent = state.beginRealtimeIntent()
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent, focus: true))
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent))
|
||||
XCTAssertEqual(state.selection.source, .realtime)
|
||||
}
|
||||
|
||||
@@ -174,20 +173,48 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertEqual(state.selection.source, .search)
|
||||
}
|
||||
|
||||
func testMapCoordinateSystemChangeClearsSupersededRealtimeSampleWithoutMovingSelection() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩")
|
||||
let selection = state.selection
|
||||
state.updateRealtimeLocation(CLLocation(latitude: 30.42, longitude: 114.25))
|
||||
|
||||
state.clearRealtimeLocationForMapCoordinateSystemChange()
|
||||
|
||||
XCTAssertNil(state.realtimeLocation)
|
||||
XCTAssertNil(state.realtimeCoordinate)
|
||||
XCTAssertEqual(state.selection, selection)
|
||||
}
|
||||
|
||||
func testRealtimeIntentCanImmediatelyAcceptNativeLocation() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25)
|
||||
state.updateRealtimeLocation(nativeLocation)
|
||||
let intent = state.beginRealtimeIntent()
|
||||
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, focus: true))
|
||||
XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent))
|
||||
XCTAssertEqual(state.selection.source, .realtime)
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001)
|
||||
guard case let .focus(coordinate, distance) = state.cameraCommand?.kind else {
|
||||
return XCTFail("Expected realtime focus command")
|
||||
XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it")
|
||||
}
|
||||
|
||||
func testMapCoordinateSystemReprojectionPreservesSelectionIdentityAndIssuesFocus() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let favoriteID = UUID()
|
||||
state.selectFavorite(.init(latitude: 22.55, longitude: 113.95), id: favoriteID, name: "测试收藏")
|
||||
let revision = state.selection.revision
|
||||
|
||||
state.reprojectSelectionForMapCoordinateSystemChange(.init(latitude: 22.54, longitude: 113.94))
|
||||
|
||||
XCTAssertEqual(state.selection.source, .favorite(favoriteID))
|
||||
XCTAssertEqual(state.selection.explicitName, "测试收藏")
|
||||
XCTAssertEqual(state.selection.revision, revision)
|
||||
XCTAssertEqual(state.selection.coordinate.latitude, 22.54, accuracy: 0.000001)
|
||||
guard case let .focus(coordinate, distanceMeters) = state.cameraCommand?.kind else {
|
||||
return XCTFail("Expected a focus command after map coordinate-system reprojection")
|
||||
}
|
||||
XCTAssertEqual(coordinate.latitude, 30.42, accuracy: 0.000001)
|
||||
XCTAssertEqual(distance, 200)
|
||||
XCTAssertEqual(coordinate.latitude, 22.54, accuracy: 0.000001)
|
||||
XCTAssertEqual(distanceMeters, state.viewportMeters)
|
||||
}
|
||||
|
||||
func testZoomMathScalesBothAxesInTheSameDirection() {
|
||||
@@ -207,4 +234,71 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 2_500), "2.5 km")
|
||||
XCTAssertEqual(MapZoomMath.viewportScaleLabel(distanceMeters: 126_000), "126 km")
|
||||
}
|
||||
|
||||
func testLastCoordinateStoreKeepsBothFormsAndZoom() {
|
||||
let suite = "MapLocationStateTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let gcj = CLLocationCoordinate2D(latitude: 22.544_577, longitude: 113.941_14)
|
||||
|
||||
LastCoordinateStore.save(mapCoordinate: gcj, mapCoordinateSystem: .gcj02, zoomMeters: 1_250, defaults: defaults)
|
||||
|
||||
guard let restored = LastCoordinateStore.load(defaults: defaults) else {
|
||||
return XCTFail("Expected a persisted current map pin")
|
||||
}
|
||||
XCTAssertEqual(restored.coordinate(for: .gcj02).latitude, gcj.latitude, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(restored.coordinate(for: .gcj02).longitude, gcj.longitude, accuracy: 0.000_000_1)
|
||||
XCTAssertNotEqual(restored.coordinate(for: .wgs84).longitude, gcj.longitude)
|
||||
XCTAssertEqual(restored.zoomMeters, 1_250)
|
||||
}
|
||||
|
||||
func testCoordinateMigrationUpgradesLegacyCurrentPinAndFavoritesBeforeSettingVersion() throws {
|
||||
let suite = "MapLocationStateTests.\(UUID().uuidString)"
|
||||
let legacySuite = "MapLocationStateTests.Legacy.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
let legacyDefaults = UserDefaults(suiteName: legacySuite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
defer { legacyDefaults.removePersistentDomain(forName: legacySuite) }
|
||||
let firstID = UUID()
|
||||
let secondID = UUID()
|
||||
let records = [
|
||||
CoordinateMigrationLegacyFavorite(id: firstID, name: "第一个", latitude: 22.544_577, longitude: 113.941_14, accuracy: 15, createdAt: .distantPast),
|
||||
CoordinateMigrationLegacyFavorite(id: secondID, name: "海外", latitude: 48.858_37, longitude: 2.294_481, accuracy: 30, createdAt: .distantFuture),
|
||||
]
|
||||
legacyDefaults.set(22.544_577, forKey: "lastMapLat")
|
||||
legacyDefaults.set(113.941_14, forKey: "lastMapLon")
|
||||
legacyDefaults.set(2_000.0, forKey: "mapViewportMeters")
|
||||
defaults.set(try JSONEncoder().encode(records), forKey: "favorite_locations")
|
||||
defaults.set(secondID.uuidString, forKey: "favorite_locations_selected_id")
|
||||
|
||||
let favorites = FavoriteLocationStore(defaults: defaults)
|
||||
try CoordinateStorageMigration.migrateIfNeeded(
|
||||
favorites: favorites,
|
||||
defaults: defaults,
|
||||
legacyDefaults: legacyDefaults
|
||||
)
|
||||
|
||||
XCTAssertEqual(defaults.integer(forKey: "coordinateStorageMigrationVersion"), CoordinateStorageMigration.currentVersion)
|
||||
guard let current = LastCoordinateStore.load(defaults: defaults) else {
|
||||
return XCTFail("Expected migrated current map pin")
|
||||
}
|
||||
XCTAssertEqual(current.coordinate(for: .gcj02).latitude, 22.544_577, accuracy: 0.000_000_1)
|
||||
XCTAssertEqual(current.zoomMeters, 2_000)
|
||||
|
||||
let reloaded = FavoriteLocationStore(defaults: defaults)
|
||||
XCTAssertEqual(reloaded.favorites.map(\.id), [firstID, secondID])
|
||||
XCTAssertEqual(reloaded.favorites.map(\.name), ["第一个", "海外"])
|
||||
XCTAssertEqual(reloaded.selectedFavoriteID, secondID)
|
||||
XCTAssertFalse(reloaded.favorites.contains(where: \.isLegacyCoordinateRecord))
|
||||
XCTAssertEqual(reloaded.favorites[1].coordinatePair.wgs84.latitude, reloaded.favorites[1].coordinatePair.gcj02.latitude, accuracy: 0.000_000_1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CoordinateMigrationLegacyFavorite: Encodable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let accuracy: Int
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
@MainActor
|
||||
final class ProxyRuntimeModeTests: XCTestCase {
|
||||
func testDefaultsToLocalWiFiAndPersistsThirdPartyMode() {
|
||||
let suiteName = "ProxyRuntimeModeTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let initial = ProxyRuntimeModeStore(defaults: defaults)
|
||||
XCTAssertEqual(initial.mode, .localWiFi)
|
||||
XCTAssertFalse(initial.hasSelectedMode)
|
||||
|
||||
initial.setMode(.thirdParty)
|
||||
let restored = ProxyRuntimeModeStore(defaults: defaults)
|
||||
XCTAssertEqual(restored.mode, .thirdParty)
|
||||
XCTAssertTrue(restored.hasSelectedMode)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ final class RealtimeLocationManagerTests: XCTestCase {
|
||||
|
||||
func testOneShotTimeoutTransitionsToContinuousFallback() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 5_000_000)
|
||||
let manager = RealtimeLocationManager(driver: driver, oneShotTimeoutNanoseconds: 5_000_000, fallbackTimeoutNanoseconds: 1_000_000_000)
|
||||
|
||||
let request = Task { await manager.requestLocation() }
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class RuntimeLogStoreTests: XCTestCase {
|
||||
func testRetentionCutoffIsExactlyThreeDays() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
|
||||
XCTAssertEqual(
|
||||
RuntimeLogStore.retentionCutoff(now: now),
|
||||
now.addingTimeInterval(-3 * 24 * 60 * 60)
|
||||
)
|
||||
}
|
||||
|
||||
func testRetentionKeepsCutoffAndNewerEntriesOnly() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let cutoff = RuntimeLogStore.retentionCutoff(now: now)
|
||||
let expired = RuntimeLogEntry(timestamp: cutoff.addingTimeInterval(-0.001), source: "APP", level: .info, category: "Test", message: "expired")
|
||||
let boundary = RuntimeLogEntry(timestamp: cutoff, source: "APP", level: .info, category: "Test", message: "boundary")
|
||||
let recent = RuntimeLogEntry(timestamp: now, source: "CORE", level: .warning, category: "Proxy", message: "recent")
|
||||
|
||||
XCTAssertEqual(
|
||||
RuntimeLogStore.retainedEntries([expired, boundary, recent], now: now),
|
||||
[boundary, recent]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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 testWiFiChangeMapsLocalProxyStartFailureToProxyReminder() {
|
||||
XCTAssertEqual(VerificationResult.proxyNotRunning.wifiChangeReminderTipKind, .proxySetup)
|
||||
}
|
||||
|
||||
func testWiFiChangeDoesNotPresentFailureForConcurrentVerification() {
|
||||
XCTAssertNil(VerificationResult.verificationInProgress.wifiChangeReminderTipKind)
|
||||
}
|
||||
|
||||
func testWiFiChangeCertificateFailureDoesNotUseGenericReminder() {
|
||||
XCTAssertNil(VerificationResult.certNotTrusted.wifiChangeReminderTipKind)
|
||||
XCTAssertNil(VerificationResult.certNotTrusted.tipKind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
@MainActor
|
||||
final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
func testQueryDistinguishesConnectedWithoutCoordinate() async throws {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
let response = try await manager.query()
|
||||
|
||||
XCTAssertFalse(response.success)
|
||||
XCTAssertEqual(manager.connectionState, .connected(active: false))
|
||||
XCTAssertEqual(requester.lastURL?.query, "action=query")
|
||||
}
|
||||
|
||||
func testSaveUsesFavoriteWGS84AndAcceptsMatchingResponse() async throws {
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
latitude: 22.494,
|
||||
longitude: 113.951,
|
||||
accuracy: 20,
|
||||
mapCoordinateSystem: .gcj02
|
||||
)
|
||||
let wgs84 = favorite.coordinatePair.wgs84
|
||||
let body = String(format: #"{"success":true,"longitude":%.8f,"latitude":%.8f,"accuracy":20}"#,
|
||||
locale: Locale(identifier: "en_US_POSIX"), wgs84.longitude, wgs84.latitude)
|
||||
let requester = FakeThirdPartyRequester(body: body)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
_ = try await manager.save(favorite)
|
||||
|
||||
let components = URLComponents(url: try XCTUnwrap(requester.lastURL), resolvingAgainstBaseURL: false)
|
||||
let values = Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") })
|
||||
let latitude = try XCTUnwrap(Double(values["lat"] ?? ""))
|
||||
let longitude = try XCTUnwrap(Double(values["lon"] ?? ""))
|
||||
XCTAssertEqual(latitude, wgs84.latitude, accuracy: 0.000_000_01)
|
||||
XCTAssertEqual(longitude, wgs84.longitude, accuracy: 0.000_000_01)
|
||||
XCTAssertEqual(values["acc"], "20")
|
||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||
}
|
||||
|
||||
func testSaveRejectsCoordinateMismatchWithoutMarkingActive() async {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":true,"longitude":1,"latitude":2,"accuracy":25}"#)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 25)
|
||||
|
||||
do {
|
||||
_ = try await manager.save(favorite)
|
||||
XCTFail("expected coordinate mismatch")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? ThirdPartyProxyError, .coordinateMismatch)
|
||||
}
|
||||
XCTAssertEqual(manager.connectionState, .unknown)
|
||||
XCTAssertNil(manager.activeSettings)
|
||||
}
|
||||
|
||||
func testMalformedResponseIsNotTreatedAsSuccess() async {
|
||||
let manager = ThirdPartyProxyManager(requester: FakeThirdPartyRequester(body: "not-json"))
|
||||
do {
|
||||
_ = try await manager.query()
|
||||
XCTFail("expected interception failure")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
|
||||
}
|
||||
}
|
||||
|
||||
func testClientLinksUseOfficialUpstreamModulesAndVerificationLabels() {
|
||||
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.verificationText, "当前可测试")
|
||||
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText.contains("尚未验证"))
|
||||
XCTAssertEqual(ThirdPartyProxyClient.egern.subscriptionURL, ThirdPartyProxyClient.surge.subscriptionURL)
|
||||
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.stoverride"))
|
||||
XCTAssertTrue(ThirdPartyProxyClient.shadowrocket.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.module"))
|
||||
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.launchURL?.scheme, "shadowrocket")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.surge.launchURL?.scheme, "surge")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.quantumultX.launchURL?.scheme, "quantumult-x")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.loon.launchURL?.scheme, "loon")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.stash.launchURL?.scheme, "stash")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.egern.launchURL?.scheme, "egern")
|
||||
}
|
||||
|
||||
func testSelectedClientPersists() {
|
||||
let suiteName = "ThirdPartyProxyClientStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let store = ThirdPartyProxyClientStore(defaults: defaults)
|
||||
XCTAssertEqual(store.selectedClient, .shadowrocket)
|
||||
|
||||
store.select(.stash)
|
||||
XCTAssertEqual(ThirdPartyProxyClientStore(defaults: defaults).selectedClient, .stash)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||
private let data: Data
|
||||
private(set) var lastURL: URL?
|
||||
|
||||
init(body: String) {
|
||||
data = Data(body.utf8)
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
lastURL = request.url
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"]
|
||||
)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class VirtualLocationTipPreferencesTests: XCTestCase {
|
||||
private var suites: [String] = []
|
||||
|
||||
override func tearDown() {
|
||||
for suite in suites {
|
||||
UserDefaults.standard.removePersistentDomain(forName: suite)
|
||||
}
|
||||
suites.removeAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSuppressionAppearsOnlyAfterThirdSuccessfulOperation() {
|
||||
let defaults = makeDefaults()
|
||||
let legacyDefaults = makeDefaults()
|
||||
let preferences = VirtualLocationTipPreferences(
|
||||
defaults: defaults,
|
||||
legacyDefaults: legacyDefaults
|
||||
)
|
||||
|
||||
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 1)
|
||||
XCTAssertFalse(preferences.canSuppress(.activation))
|
||||
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 2)
|
||||
XCTAssertFalse(preferences.canSuppress(.activation))
|
||||
XCTAssertEqual(preferences.recordSuccessfulOperation(.activation), 3)
|
||||
XCTAssertTrue(preferences.canSuppress(.activation))
|
||||
}
|
||||
|
||||
func testActivationAndDeactivationCountersAndSuppressionAreIndependent() {
|
||||
let defaults = makeDefaults()
|
||||
let preferences = VirtualLocationTipPreferences(
|
||||
defaults: defaults,
|
||||
legacyDefaults: makeDefaults()
|
||||
)
|
||||
|
||||
for _ in 0..<3 {
|
||||
preferences.recordSuccessfulOperation(.activation)
|
||||
}
|
||||
preferences.suppress(.activation)
|
||||
|
||||
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.activation))
|
||||
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
|
||||
XCTAssertFalse(preferences.canSuppress(.deactivation))
|
||||
|
||||
for _ in 0..<3 {
|
||||
preferences.recordSuccessfulOperation(.deactivation)
|
||||
}
|
||||
preferences.suppress(.deactivation)
|
||||
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.deactivation))
|
||||
}
|
||||
|
||||
func testSuppressionBeforeThirdOperationIsIgnored() {
|
||||
let preferences = VirtualLocationTipPreferences(
|
||||
defaults: makeDefaults(),
|
||||
legacyDefaults: makeDefaults()
|
||||
)
|
||||
|
||||
preferences.recordSuccessfulOperation(.deactivation)
|
||||
preferences.suppress(.deactivation)
|
||||
|
||||
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
|
||||
}
|
||||
|
||||
func testLegacyActivationSuppressionRemainsEffective() {
|
||||
let legacyDefaults = makeDefaults()
|
||||
legacyDefaults.set(true, forKey: "activationTipDisabled")
|
||||
let preferences = VirtualLocationTipPreferences(
|
||||
defaults: makeDefaults(),
|
||||
legacyDefaults: legacyDefaults
|
||||
)
|
||||
|
||||
XCTAssertFalse(preferences.shouldPresentAutomaticTip(.activation))
|
||||
XCTAssertTrue(preferences.shouldPresentAutomaticTip(.deactivation))
|
||||
}
|
||||
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suite = "VirtualLocationTipPreferencesTests.\(UUID().uuidString)"
|
||||
suites.append(suite)
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,11 @@ SETUP="$ROOT/App/SetupCoordinator.swift"
|
||||
PROXY="$ROOT/App/ProxyManager.swift"
|
||||
SETTINGS_NAVIGATOR="$ROOT/App/SystemSettingsNavigator.swift"
|
||||
DIAGNOSTICS="$ROOT/App/DiagnosticsView.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
CONVERTER="$ROOT/Shared/CoordinateConverter.swift"
|
||||
NETWORK_MONITOR="$ROOT/Shared/NetworkMonitor.swift"
|
||||
|
||||
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS"; do
|
||||
for file in "$MAP_HOME" "$MAP_STATE" "$MAP_BRIDGE" "$REALTIME" "$SETUP" "$PROXY" "$SETTINGS_NAVIGATOR" "$DIAGNOSTICS" "$CONTENT" "$CONVERTER" "$NETWORK_MONITOR"; do
|
||||
test -f "$file" || fail "missing required refactor file: $file"
|
||||
done
|
||||
|
||||
@@ -22,7 +25,11 @@ done
|
||||
! grep -q '@Binding var coordinate' "$MAP_BRIDGE" || fail "map bridge must not write a coordinate Binding"
|
||||
grep -q 'showsUserLocation = true' "$MAP_BRIDGE" || fail "MapKit native user location must be visible"
|
||||
grep -q 'didUpdate userLocation' "$MAP_BRIDGE" || fail "MapKit native user location must feed realtime state"
|
||||
grep -q 'coordinate: userLocation.coordinate' "$MAP_BRIDGE" || fail "visible MapKit blue-point samples must use MKUserLocation.coordinate"
|
||||
! grep -Eq 'userLocation\.location\??\.coordinate' "$MAP_BRIDGE" || fail "MapKit blue-point samples must not use the underlying Core Location coordinate"
|
||||
grep -q 'MapCameraCommand' "$MAP_BRIDGE" || fail "map bridge must consume MapCameraCommand"
|
||||
grep -q 'let initialViewportMeters:' "$MAP_BRIDGE" || fail "map bridge must receive initial viewport from MapLocationState"
|
||||
! grep -q 'ViewportStore.loadOrDefault()' "$MAP_BRIDGE" || fail "map bridge must not bypass MapLocationState for initial viewport"
|
||||
grep -q 'activeCameraCommandID' "$MAP_BRIDGE" || fail "programmatic map callbacks must be associated with the active camera command"
|
||||
grep -q 'UIPanGestureRecognizer' "$MAP_BRIDGE" || fail "map panning must be recognized explicitly"
|
||||
grep -q 'UIPinchGestureRecognizer' "$MAP_BRIDGE" || fail "pinch zoom must not be treated as a selected-center pan"
|
||||
@@ -40,19 +47,57 @@ grep -q 'case awaitingAuthorization' "$REALTIME" || fail "location requests must
|
||||
grep -q 'var location: CLLocation?' "$REALTIME" || fail "Core Location driver must expose its cached native sample"
|
||||
grep -q 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent"
|
||||
! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed"
|
||||
grep -q 'defer' "$SETUP" || fail "verification must restore temporary state with defer"
|
||||
grep -q 'restoreCoords' "$SETUP" || fail "verification must use revision-aware coordinate restoration"
|
||||
grep -q 'coordinateRevision' "$PROXY" || fail "proxy coordinate writes must be revisioned"
|
||||
grep -q 'setCoordsIfUnchanged' "$SETUP" || fail "verification must not overwrite a newer coordinate before its test write"
|
||||
grep -q 'applyVerified' "$MAP_HOME" || fail "verified location commits must be synchronous after revision validation"
|
||||
grep -q 'func applyVerificationResult' "$SETUP" || fail "verification results must have one setup-state reducer"
|
||||
grep -q 'case .success:' "$SETUP" || fail "successful verification must converge setup state"
|
||||
grep -q 'case .certNotTrusted:' "$SETUP" || fail "certificate failure must converge setup state"
|
||||
grep -q 'setupStep = .proxy' "$SETUP" || fail "proxy failure must converge setup state"
|
||||
grep -q 'realtimeRequestTask' "$MAP_HOME" || fail "realtime button requests must be synchronously serialized"
|
||||
grep -q 'RealtimeLocationRequestContext' "$MAP_HOME" || fail "a realtime button tap must retarget an in-flight startup request instead of being ignored"
|
||||
grep -q 'CLError.network' "$MAP_HOME" || fail "reverse geocoding network failures must use bounded retry"
|
||||
grep -q 'SystemSettingsNavigator' "$MAP_HOME" || fail "settings actions must use the shared navigator"
|
||||
grep -q '复制全部日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone copy button"
|
||||
grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear button"
|
||||
grep -q '日志自动清理,仅保留近 3 天' "$DIAGNOSTICS" || fail "diagnostics must disclose the three-day retention policy"
|
||||
grep -q 'retentionInterval: TimeInterval = 3 \* 24 \* 60 \* 60' "$ROOT/Shared/RuntimeLog.swift" || fail "runtime logs must retain only three days"
|
||||
if grep -q 'logEvent("CONNECT " + host + " -> passthrough")' "$ROOT/Core/proxy.go"; then
|
||||
fail "proxy diagnostics must not log unrelated passthrough CONNECT hosts"
|
||||
fi
|
||||
grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing"
|
||||
grep -q 'MARKETING_VERSION: "0.0.4"' "$ROOT/project.yml" || fail "marketing version must be 0.0.4"
|
||||
grep -q '## \[0.0.4\] — 待发布' "$ROOT/docs/CHANGELOG.md" || fail "0.0.4 pending changelog section is missing"
|
||||
grep -q 'await CoordinateConverter.resolveInitialMapCoordinateSystem()' "$CONTENT" || fail "map type must resolve before MapHomeView construction"
|
||||
grep -q 'refreshRuntimeMapCoordinateSystem(reason:' "$CONVERTER" || fail "fixed-anchor map type must support runtime refresh"
|
||||
grep -q 'scheduleBluePointMapCoordinateSystemRefresh()' "$MAP_HOME" || fail "native blue-point samples must trigger runtime map-type refresh while spoofing"
|
||||
! grep -A3 'private func scheduleBluePointMapCoordinateSystemRefresh' "$MAP_HOME" | grep -q 'spoofState == .active' || fail "blue-point map-type refresh must also detect the return to physical location"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "点击实时定位")' "$MAP_HOME" || fail "realtime button must await the coordinated map-type refresh"
|
||||
grep -q 'favorites.selectMatching(coordinatePair: pair)' "$MAP_HOME" || fail "realtime selection must restore a matching favorite selection"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "保存收藏")' "$MAP_HOME" || fail "favorite save must await the coordinated map-type refresh"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "App回到前台")' "$MAP_HOME" || fail "foreground recovery must reuse the coordinated map-type refresh"
|
||||
grep -q '地图坐标标准运行期检测结果已过期,取消写入' "$CONVERTER" || fail "cancelled runtime probes must not mutate the global map type"
|
||||
! grep -q 'source == .coreLocation.*correctMapCoordinateSystemUsingRealtime' "$MAP_HOME" || fail "runtime Core Location samples must not infer MapKit type"
|
||||
grep -q 'clearRealtimeLocationForMapCoordinateSystemChange' "$MAP_HOME" || fail "map-type changes must discard superseded blue-point samples"
|
||||
grep -q '地图坐标类型已变化' "$MAP_HOME" || fail "map-type changes must emit an explicit searchable business log"
|
||||
grep -q '图钉已按新类型重设' "$MAP_HOME" || fail "map-type change log must report pin reprojection"
|
||||
grep -q 'coordinateRow(label: "GCJ-02(国内)", system: .gcj02)' "$MAP_HOME" || fail "current selection panel must label the domestic coordinate as GCJ-02"
|
||||
grep -q 'coordinateRow(label: "WGS-84(国际)", system: .wgs84)' "$MAP_HOME" || fail "current selection panel must label the international coordinate as WGS-84"
|
||||
grep -q 'fixedSize(horizontal: true, vertical: false)' "$MAP_HOME" || fail "coordinate labels must keep their natural single-line width"
|
||||
grep -q 'minimumScaleFactor(0.72)' "$MAP_HOME" || fail "coordinate values must shrink to remain on one line"
|
||||
grep -q 'phase = .map' "$CONTENT" || fail "ContentView must explicitly gate MapHomeView construction"
|
||||
! grep -q 'startTileProbe' "$MAP_HOME" || fail "MapHomeView must not start a second fixed-anchor coordinate-system probe"
|
||||
! grep -q 'initializeMap()' "$MAP_HOME" || fail "MapHomeView must not replay a second map initialization from onAppear"
|
||||
grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime position must resolve before map construction"
|
||||
! grep -q 'lastTileCheck' "$CONVERTER" || fail "map coordinate-system detection must not use a time cache"
|
||||
! grep -q '跳过(缓存' "$CONVERTER" || fail "map coordinate-system detection must not skip using a cached result"
|
||||
! grep -q '瓦片检测' "$CONVERTER" || fail "coordinate-system probe logs must not claim to inspect map tiles"
|
||||
grep -q 'minimumCountForSuppression = 3' Shared/AppGroup.swift || fail "automatic tip suppression must require three successful operations"
|
||||
grep -q 'activeTip = .deactivation' "$MAP_HOME" || fail "manual deactivation help must use the non-suppressible generic tip sheet"
|
||||
grep -q 'stabilizationNanoseconds: UInt64 = 3_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait three seconds before environment verification"
|
||||
grep -q 'result.wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi proxy failures must use the background reminder mapping"
|
||||
grep -q 'if result == .certNotTrusted' "$MAP_HOME" || fail "Wi-Fi certificate failures must enter certificate setup"
|
||||
grep -q 'setup.applyVerificationResult(result)' "$MAP_HOME" || fail "Wi-Fi certificate failures must use the setup routing reducer"
|
||||
! 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 'onChange(of: net.isAirplaneMode)' "$MAP_HOME" || fail "airplane recovery must not race the Wi-Fi change verifier"
|
||||
grep -q 'hasReceivedInitialPath' "$NETWORK_MONITOR" || fail "initial network path must not be reported as a Wi-Fi switch"
|
||||
grep -q 'lastKnownSSID' "$NETWORK_MONITOR" || fail "SSID polling must preserve a baseline across temporary nil readings"
|
||||
|
||||
echo "PASS: map location state refactor contract"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/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"
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
MODE="$ROOT/Shared/ProxyRuntimeMode.swift"
|
||||
MANAGER="$ROOT/Shared/ThirdPartyProxyManager.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
SETUP="$ROOT/App/FirstSetupView.swift"
|
||||
SETTINGS="$ROOT/App/SettingsView.swift"
|
||||
MODULES="$ROOT/Resources/ThirdPartyProxyModules"
|
||||
|
||||
grep -q 'return "APP模式"' "$MODE" || fail "APP mode display name is missing"
|
||||
grep -q 'return "第三方代理模式"' "$MODE" || fail "third-party mode display name is missing"
|
||||
grep -q 'hasSelectedMode' "$MODE" || fail "first-launch mode selection must be persisted"
|
||||
grep -q 'guard runtimeMode.hasSelectedMode else' "$CONTENT" || fail "mode selection must gate startup"
|
||||
grep -q 'phase = .setup' "$CONTENT" || fail "first launch must enter setup before map construction"
|
||||
grep -q 'case thirdPartyClient' "$SETUP" || fail "third-party client selection step is missing"
|
||||
grep -q 'case thirdPartyImport' "$SETUP" || fail "third-party import step is missing"
|
||||
grep -q 'case thirdPartyTest' "$SETUP" || fail "third-party connection test step is missing"
|
||||
! grep -q '生成并导入配置文件' "$SETUP" || fail "setup must not offer file generation/import"
|
||||
grep -q '复制订阅地址' "$SETUP" || fail "subscription URL copy action is missing"
|
||||
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" || fail "setup must expose a client launch action"
|
||||
grep -Fq 'Label("打开 \(thirdPartyClient.selectedClient.name)"' "$SETTINGS" || fail "Settings must expose a client launch action"
|
||||
! grep -q '在浏览器打开模块文件' "$SETTINGS" || fail "Settings must not open the module URL as the primary client action"
|
||||
grep -q 'requestThirdPartySetup' "$SETTINGS" || fail "Settings must reopen third-party setup"
|
||||
|
||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
||||
done
|
||||
|
||||
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
|
||||
grep -q 'wloc.stoverride' "$MANAGER" || fail "Stash must use .stoverride directly"
|
||||
grep -q 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing"
|
||||
for scheme in surge quantumult-x loon stash egern; do
|
||||
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
|
||||
done
|
||||
grep -q '复制解密域名' "$SETUP" || fail "Shadowrocket MITM hostname copy action is missing"
|
||||
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
|
||||
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
|
||||
! grep -q 'ToolbarItem(placement: .navigationBarLeading)' "$SETUP" || fail "setup must not show a top-left navigation action"
|
||||
grep -q 'presentSuccessfulOperationTip(.activation)' "$ROOT/App/MapHomeView.swift" || fail "third-party save must present the activation tip"
|
||||
grep -q 'presentSuccessfulOperationTip(.deactivation)' "$ROOT/App/MapHomeView.swift" || fail "third-party clear must present the deactivation tip"
|
||||
grep -q 'if spoofState == .active' "$ROOT/App/MapHomeView.swift" || fail "manual help must follow the shared spoof state"
|
||||
grep -q 'MARKETING_VERSION: "1.0.2"' "$ROOT/project.yml" || fail "marketing version must be 1.0.2"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "3"' "$ROOT/project.yml" || fail "build version must be 3"
|
||||
|
||||
echo "PASS: third-party proxy mode contract"
|
||||
@@ -73,4 +73,4 @@ if [ "$run_tests" -eq 1 ]; then
|
||||
run_simulator_tests
|
||||
fi
|
||||
|
||||
echo "Next: sign with Impact (https://github.com/claration/Impact) and install on device."
|
||||
echo "Next: sign with Impactor (https://github.com/claration/Impactor) and install on device."
|
||||
|
||||
@@ -32,13 +32,27 @@ Output:
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
IPA 始终保持未签名。用 [Impact](https://github.com/claration/Impact) 签名安装即可。
|
||||
IPA 始终保持未签名。用 [Impactor](https://github.com/claration/Impactor) 签名安装即可。
|
||||
|
||||
## 发布验收
|
||||
|
||||
1. `./build.sh` 通过并输出未签名 IPA
|
||||
2. 用 Impact 签名后安装到设备
|
||||
3. 真机安装后,按引导下载 CA → 安装 → 信任,再配置 WiFi HTTP 代理 `127.0.0.1:8888`
|
||||
2. 用 Impactor 签名后安装到设备
|
||||
3. 真机安装后,先配置 WiFi HTTP 代理 `127.0.0.1:8888`,再按检测结果完成 CA 下载、安装和信任
|
||||
4. 环境检测通过后,选点开启虚拟定位
|
||||
5. 打开 Apple 地图验证定位是否变为虚拟位置
|
||||
6. 若失败,查看诊断页的日志信息
|
||||
|
||||
## 发布版本
|
||||
|
||||
发布前先生成版本归档并提交:
|
||||
|
||||
```bash
|
||||
./Scripts/generate-release-notes.sh v1.1.0
|
||||
git add docs/releases/v1.1.0.md
|
||||
git commit -m "docs: archive v1.1.0 release notes"
|
||||
git tag v1.1.0
|
||||
git push origin main v1.1.0
|
||||
```
|
||||
|
||||
归档文件根据“上一个版本标签到当前提交”的 commit subject 生成。GitHub Actions 会校验归档存在,并将文件正文直接作为 GitHub Release 内容;不会发布文档链接或缺失说明的占位 Release。
|
||||
|
||||
@@ -4,4 +4,6 @@
|
||||
|
||||
## 已发布
|
||||
|
||||
- [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,36 +0,0 @@
|
||||
# 自签安装指南
|
||||
|
||||
**免费 Apple ID 即可自签安装**,无需付费开发者账号。
|
||||
|
||||
## 原理
|
||||
|
||||
本项目只是一个本地 HTTP 代理配合 WiFi 手动代理,不涉及 VPN / Network Extension / Packet Tunnel Provider。无需特殊权限,个人免费 Apple ID 侧载完全可用。
|
||||
|
||||
## 构建未签名 IPA
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
输出:`dist/PaopaoLocationSpoofer-unsigned.ipa`
|
||||
|
||||
## 使用 Impact 签名安装
|
||||
|
||||
用 [Impact](https://github.com/claration/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 中的使用说明。
|
||||
@@ -0,0 +1,31 @@
|
||||
# Third-party proxy module snapshots
|
||||
|
||||
The files under `Resources/ThirdPartyProxyModules/` are bundled configuration
|
||||
snapshots from [Yu9191/wloc](https://github.com/Yu9191/wloc). They are retained
|
||||
in the App bundle for release provenance and offline inspection. The setup UI
|
||||
copies the official subscription URL instead of exporting these files.
|
||||
|
||||
- Upstream commit: `eec07a8dc8de6dbaee8eac1fb376e4d03020154a`
|
||||
- Snapshot date: 2026-08-06
|
||||
- Source directory: `modules/`
|
||||
|
||||
| Bundled file | Client |
|
||||
|---|---|
|
||||
| `wloc.module` | Shadowrocket |
|
||||
| `wloc.sgmodule` | Surge and Egern |
|
||||
| `wloc.conf` | Quantumult X |
|
||||
| `wloc.lpx` | Loon |
|
||||
| `wloc.stoverride` | Stash |
|
||||
|
||||
SHA-256:
|
||||
|
||||
```text
|
||||
bb5e17b60027704971660b0ea2df3560ceff973c27d43e7f2c2c18b48d368ac6 wloc.conf
|
||||
1fb451616fb17242849f72490f016afcdb8aa81a0b086f6dd5f94e1af3d58ee1 wloc.lpx
|
||||
97cab104056428aa0e90521c3bf2646e9739b0b4c83272b31790f99584bca89e wloc.module
|
||||
5d6b82c31316f4a7be65e3b8d2335f4338e01af98e262948118eefe63abf7034 wloc.sgmodule
|
||||
cb06593752db8b223dfa5cd1cbd089115fe3a541f5c8532491615923e83df2cb wloc.stoverride
|
||||
```
|
||||
|
||||
The official subscription URLs are the setup UI's import path. Egern reuses the
|
||||
Surge module. Stash imports `.stoverride` directly.
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- **🗺️ 原生地图体验**:Apple MapKit 蓝点实时定位,搜索地点、点击选点、拖动浏览与 Apple 地图一致
|
||||
- **📍 虚拟定位引擎**:基于本地 HTTP 代理 MITM 方案,无需 VPN、无需越狱,对钉钉、微信及任意系统定位 App 生效
|
||||
- **🧪 完整设置引导**:证书安装 → WiFi 代理配置 → 环境验证,逐步检测代理、CA 信任、坐标写入与响应改写
|
||||
- **🧪 完整设置引导**:WiFi 代理配置 → 证书安装与信任 → 环境验证,逐步检查本地代理、CA 信任和 WiFi 代理链路
|
||||
- **✈️ 飞行模式缓存清除**:一键化操作引导,按步骤刷新飞行模式、WiFi 和定位服务状态
|
||||
- **⭐ 收藏与快速切换**:保存常用坐标,一键跳转
|
||||
- **🧾 诊断日志**:实时查看定位请求和改写状态,每条独立可复制
|
||||
@@ -16,14 +16,14 @@
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| iOS | 15.0+ |
|
||||
| 安装 | 自行签名(推荐 [Impact](https://github.com/claration/Impact)) |
|
||||
| 安装 | 自行签名(推荐 [Impactor](https://github.com/claration/Impactor)) |
|
||||
| 网络 | 可手动配置 HTTP 代理的 WiFi |
|
||||
|
||||
### 安装
|
||||
### 自签安装
|
||||
|
||||
1. 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 下载 `PaopaoLocationSpoofer-unsigned.ipa`
|
||||
2. 使用 [Impact](https://github.com/claration/Impact) 签名安装
|
||||
3. 按 App 内引导完成证书安装和 WiFi 代理配置
|
||||
2. 使用 [Impactor](https://github.com/claration/Impactor) 签名安装
|
||||
3. 签名时保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements
|
||||
|
||||
### 致谢
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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) -->
|
||||
@@ -0,0 +1,32 @@
|
||||
# v1.0.2
|
||||
|
||||
发布日期:2026-08-07
|
||||
|
||||
## 主要更新
|
||||
|
||||
- 修复虚拟定位跨越中国大陆与境外后,MapKit 坐标标准变化可能导致的地图选点、蓝点和收藏位置偏移。
|
||||
- 增加运行期固定锚点刷新与状态清理,坐标标准切换后会按已保存的 WGS-84 / GCJ-02 坐标对重新显示图钉。
|
||||
- 点击实时定位时,如果当前位置与已保存收藏匹配,会恢复对应收藏的选中状态。
|
||||
- 当前选点面板同时显示 `GCJ-02(国内)` 与 `WGS-84(国际)` 坐标,两行可独立复制,并在窄屏自动调整宽度。
|
||||
|
||||
## 文档与安装
|
||||
|
||||
- 重写中英文 README,统一项目定位、双运行模式、构建要求、隐私边界和限制说明。
|
||||
- 补充 iOS 开发者模式、Impactor/第三方自签工具、USB 安装、设备驱动和 7 天免费签名有效期说明。
|
||||
- 增加完整功能预览,包含主界面和现有真机测试截图。
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
- APP 模式仍使用设备内 Go 代理和当前 Wi-Fi 的手动 HTTP 代理,不创建 Network Extension 或系统 VPN。
|
||||
- 第三方代理模式仍由所选客户端负责代理/VPN、MITM、证书和坐标持久化。
|
||||
- MapKit 和系统定位存在运行时差异与缓存,本版本改进了坐标一致性,但不保证兼容所有 iOS 版本或第三方应用。
|
||||
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 [Impactor](https://github.com/claration/Impactor/releases) 完成签名安装,无需付费开发者账号。
|
||||
- iOS 16 及以上版本需要在“设置 → 隐私与安全性 → 开发者模式”中开启开发者模式;iOS 15 可跳过。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
- 第三方自签软件请从官方渠道获取,并自行评估账号、证书和隐私风险。
|
||||
|
||||
<!-- commit-range: v1.0.1..v1.0.2 -->
|
||||
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 347 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 913 KiB After Width: | Height: | Size: 317 KiB |
@@ -8,8 +8,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "0.0.4"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MARKETING_VERSION: "1.0.2"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
@@ -32,13 +32,17 @@ targets:
|
||||
HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/Core"
|
||||
SWIFT_OBJC_BRIDGING_HEADER: App/PaopaoLocationSpoofer-Bridging-Header.h
|
||||
OTHER_LDFLAGS: "$(inherited) -lwloccore"
|
||||
LIBRARY_SEARCH_PATHS: "$(PROJECT_DIR)/Core/build"
|
||||
LIBRARY_SEARCH_PATHS: "$(PROJECT_DIR)/Core/build/$(PLATFORM_NAME)"
|
||||
|
||||
PaopaoLocationSpooferTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Tests/PaopaoLocationSpooferTests
|
||||
settings:
|
||||
base:
|
||||
HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/Core"
|
||||
SWIFT_OBJC_BRIDGING_HEADER: App/PaopaoLocationSpoofer-Bridging-Header.h
|
||||
dependencies:
|
||||
- target: PaopaoLocationSpoofer
|
||||
|
||||
|
||||