Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
)
|
||||
}
|
||||
@@ -231,6 +236,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 +307,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
### iOS Location Spoofer · DingTalk · WeChat · Apple Watch Region Unlock · Fake GPS
|
||||
|
||||
**No VPN, no jailbreak — run a local HTTP proxy on your iPhone to rewrite Apple location responses.**<br>
|
||||
**No jailbreak — use App Mode's on-device Wi-Fi HTTP proxy or Third-party Proxy Mode (Wi-Fi/4G/5G) to rewrite Apple location responses.**<br>
|
||||
Works with DingTalk check-in, WeChat location sharing, and any app that uses system location. Map selection, real-time location, environment verification, certificate setup, and runtime logs in a single app.
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#why-no-vpn)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#why-no-vpn)
|
||||
|
||||
[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md)
|
||||
|
||||
@@ -20,7 +20,7 @@ Works with DingTalk check-in, WeChat location sharing, and any app that uses sys
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is intended for education, security research, and testing on your own devices. It installs a locally generated CA certificate and requires a manual HTTP proxy on the current Wi‑Fi network. Please understand the risks and follow applicable laws and service terms.
|
||||
> This project is intended for education, security research, and testing on your own devices. App Mode installs a locally generated CA and requires a manual HTTP proxy on the current Wi-Fi network. In Third-party Proxy Mode, the selected client owns certificate, MITM, and proxy/VPN setup. Please understand the risks and follow applicable laws and service terms.
|
||||
|
||||
## Credits
|
||||
|
||||
@@ -32,7 +32,7 @@ Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailb
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| 🚫 **No VPN** | No VPN tunnel — uses only location permission, no background refresh or notification access required. |
|
||||
| 🔀 **Two runtime modes** | Stable App Mode, plus a Third-party Proxy Mode under testing for Wi-Fi, 4G, and 5G. |
|
||||
| 📱 **No jailbreak** | Can be installed through self-signing; minimum deployment target is iOS 15. |
|
||||
| 🗺️ **Native Maps experience** | The same blue dot and selection gestures as Apple Maps — search, tap, and drag. |
|
||||
| 📍 **System-level location spoofing** | Works with DingTalk, WeChat, Apple Maps, Amap, and other apps for real-time fake GPS. |
|
||||
@@ -54,8 +54,9 @@ Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailb
|
||||
- **Hierarchical place names**: POI, street, or road at close zoom; neighborhood, district, city, or province at wider zoom.
|
||||
- **Map scale display**: Zoom controls show the current visible range.
|
||||
- **Favorites with quick switch**: Save frequent coordinates and see which location is about to be applied.
|
||||
- **Setup guide**: Certificate download, installation, full trust, Wi‑Fi HTTP proxy, activation, and deactivation instructions.
|
||||
- **Mode-specific setup**: Choose a mode first; App Mode guides local proxy and CA setup, while Third-party Proxy Mode guides client selection, configuration import, and API verification.
|
||||
- **Built-in diagnostics**: Verification flow and structured runtime logs.
|
||||
- **Third-party Proxy Mode (testing)**: Send a favorite or current pin as WGS-84 to a supported proxy module; the proxy client persists it after this App exits.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -64,9 +65,21 @@ Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailb
|
||||
- Download a build from [Releases](https://github.com/xweiba/location-spoofer/releases) and self-sign; or
|
||||
- Build from source on macOS with Xcode — see the [build guide](docs/BUILD.md).
|
||||
|
||||
Detailed steps in the [self-signing guide](docs/SELF-SIGNING.md).
|
||||
The release asset is an unsigned IPA. Sign it with [Impact](https://github.com/claration/Impactor) before installation. Keep the app Bundle ID `com.paopaolabs.location-spoofer`, the App Group `group.com.paopaolabs.location-spoofer`, and the declared entitlements unchanged. A free Apple ID signature normally expires after seven days and must then be renewed.
|
||||
|
||||
### 2. Install & Trust the CA
|
||||
### 2. Choose a Runtime Mode
|
||||
|
||||
#### App Mode
|
||||
|
||||
Choose App Mode during first launch, then configure `127.0.0.1:8888` and fully trust the locally generated CA. It has no third-party client dependency but supports Wi-Fi only. Free self-signed apps cannot use the VPN/Network Extension capability required for cellular interception, so this mode uses the current Wi-Fi's manual HTTP proxy.
|
||||
|
||||
#### Third-party Proxy Mode
|
||||
|
||||
The App handles map selection, favorites, and WGS-84 coordinate delivery. A third-party proxy client handles WLOC interception, MITM, and persistence over Wi-Fi, 4G, or 5G. This mode skips the App's local proxy and CA checks.
|
||||
|
||||
Shadowrocket is the only client currently available for device testing. Surge, Quantumult X, Loon, Stash, and Egern configurations are provided but unverified. The App lets the user copy the official subscription URL and open the selected client; the URL is then pasted into that client's module, rewrite, or override subscription UI. Configuration snapshots remain bundled for release provenance and offline inspection, but the setup UI does not export files. Egern uses the Surge module, and Stash imports `.stoverride` directly without Script Hub conversion. The third-party client—not this App—owns MITM, certificate, and proxy/VPN setup. Snapshot provenance is recorded in [the module snapshot document](docs/THIRD_PARTY_MODULES.md).
|
||||
|
||||
### 3. Local-mode CA Setup
|
||||
|
||||
Follow the first-setup wizard to download the profile, then:
|
||||
|
||||
@@ -75,7 +88,7 @@ Settings → General → VPN & Device Management → install WLOC CA
|
||||
Settings → General → About → Certificate Trust Settings → enable full trust
|
||||
```
|
||||
|
||||
### 3. Configure the Current Wi‑Fi Proxy
|
||||
Configure the current Wi-Fi proxy before installing the CA:
|
||||
|
||||
On the current Wi‑Fi's proxy settings, choose "Manual":
|
||||
|
||||
@@ -88,7 +101,7 @@ Authentication: off
|
||||
### 4. Select a Location & Enable
|
||||
|
||||
1. Search, tap, or drag the map to pick a location; tap the real-time location button to jump to the MapKit blue dot.
|
||||
2. Tap "Start Spoofing" and wait for the environment check to pass.
|
||||
2. In App Mode, tap “Start Spoofing” and wait for verification. In Third-party Proxy Mode, tap “Sync to Third-party Proxy.”
|
||||
3. Follow the in‑app activation instructions to refresh airplane mode, Wi‑Fi, and location services.
|
||||
4. Open Apple Maps or your target app to verify.
|
||||
|
||||
@@ -111,7 +124,7 @@ Apple location service response
|
||||
The system and applications receive the modified result
|
||||
```
|
||||
|
||||
The project does not use Network Extension to create a VPN tunnel — there is no VPN icon and no VPN slot occupied. **However, you still need to configure the current 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.
|
||||
App Mode does not use Network Extension, so it does not occupy the VPN slot. **App Mode still requires the current Wi-Fi HTTP proxy and the locally generated CA.** Third-party Proxy Mode delegates proxy/VPN and MITM handling to the selected proxy client and may cover cellular networks. Do not enable both interception paths at once.
|
||||
|
||||
## Compatibility
|
||||
|
||||
@@ -121,7 +134,7 @@ The project does not use Network Extension to create a VPN tunnel — there is n
|
||||
| Build | macOS, Xcode, XcodeGen |
|
||||
| Swift | 5.9 |
|
||||
| Go | 1.23+ |
|
||||
| Network | Wi‑Fi with manual HTTP proxy support |
|
||||
| Network | Local mode: Wi-Fi with manual HTTP proxy support; third-party test mode: client-dependent Wi-Fi/4G/5G |
|
||||
| Installation | Self-sign or use release builds |
|
||||
|
||||
Actual behavior may vary with iOS version, network conditions, system location cache, device model, and the target app's own location strategy. Compatibility with every iOS version or third-party app is not guaranteed.
|
||||
@@ -151,7 +164,6 @@ docs/ Build, self-signing, and changelog documentation
|
||||
## Documentation & Feedback
|
||||
|
||||
- [Build guide](docs/BUILD.md)
|
||||
- [Self-signing guide](docs/SELF-SIGNING.md)
|
||||
- [Changelog](docs/CHANGELOG.md)
|
||||
- [中文文档](README.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS
|
||||
|
||||
**无需 VPN、无需越狱,在 iPhone 本机通过 Wi‑Fi HTTP 代理改写 Apple 定位响应。**<br>
|
||||
**无需越狱;可使用 APP模式的本机 Wi‑Fi HTTP 代理,或第三方代理模式(支持 Wi‑Fi/4G/5G)改写 Apple 定位响应。**<br>
|
||||
可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#为什么不需要-vpn)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#为什么不需要-vpn)
|
||||
|
||||
[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本项目用于学习、安全研究与自有设备测试。它会安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。
|
||||
> 本项目用于学习、安全研究与自有设备测试。APP模式需要安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理;第三方代理模式的证书、MITM 和代理/VPN 连接由所选客户端处理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。
|
||||
|
||||
## 致谢
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
|
||||
| 特性 | 说明 |
|
||||
|---|---|
|
||||
| 🚫 **无 VPN** | 不创建 VPN 隧道,仅使用定位权限,无需后台刷新、通知等额外权限 |
|
||||
| 🔀 **双运行模式** | APP模式无需第三方客户端、支持 Wi‑Fi;第三方代理模式可覆盖 Wi‑Fi、4G 和 5G |
|
||||
| 📱 **无需越狱** | 支持自行签名安装,最低部署目标为 iOS 15 |
|
||||
| 🗺️ **原生地图体验** | 使用 Apple 地图同款蓝点,搜索、点击、拖动选点体验与 Apple 地图一致 |
|
||||
| 📍 **系统级虚拟定位** | 支持钉钉、微信、Apple 地图、高德等 App 的虚拟实时定位 |
|
||||
| 🔍 **可见缩放范围** | 左侧缩放控件显示当前可视范围,地点名称随级别自动适配 |
|
||||
| 🧪 **环境检测** | 检查代理、CA 信任、Wi‑Fi 接管、坐标写入与响应改写 |
|
||||
| 🧪 **环境检测** | 检查本地代理、CA 证书信任与 Wi‑Fi 代理链路 |
|
||||
| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 |
|
||||
|
||||
## 效果预览
|
||||
@@ -45,15 +45,23 @@
|
||||
<table>
|
||||
<tr>
|
||||
<th>应用主界面</th>
|
||||
<th>钉钉</th>
|
||||
<th>微信</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="iOS 虚拟定位应用主界面" width="180"></td>
|
||||
<td><img src="images/钉钉.jpg" alt="钉钉虚拟定位打卡" width="180"></td>
|
||||
<td><img src="images/微信.jpg" alt="微信虚拟定位" width="180"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="Apple Maps 定位效果" width="180"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="高德地图定位效果" width="180"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能验证" width="180"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -65,8 +73,9 @@
|
||||
- **地点名称分级**:近距离显示 POI、门牌或道路;拉远后显示社区、区县、城市或省份。
|
||||
- **地图范围显示**:缩放控件中显示 `180 m`、`2.5 km`、`126 km` 等当前可视范围。
|
||||
- **收藏与快速切换**:保存常用坐标,并明确显示当前准备应用的位置。
|
||||
- **配置引导**:提供证书安装、完全信任、Wi‑Fi HTTP 代理、生效与恢复说明。
|
||||
- **分流配置引导**:首次启动先选择模式;APP模式引导本机代理和 CA,第三方代理模式引导客户端、配置导入和接口检测。
|
||||
- **问题诊断**:内置验证流程和结构化运行日志。
|
||||
- **第三方代理模式(测试)**:内置各客户端模块配置,把收藏或当前选点的 WGS-84 坐标发送到第三方代理模块;代理客户端持久化坐标,关闭本 App 后仍可继续生效。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -75,9 +84,46 @@
|
||||
- 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 获取构建产物并自行签名;或
|
||||
- 在 macOS + Xcode 环境按[构建说明](docs/BUILD.md)编译。
|
||||
|
||||
详细步骤见[自签安装说明](docs/SELF-SIGNING.md)。
|
||||
#### 自签安装
|
||||
|
||||
### 2. 安装并信任 CA
|
||||
免费 Apple ID 即可侧载,无需付费开发者账号。本项目不使用 VPN、Network Extension 或 Packet Tunnel Provider,但签名工具仍需保留 App 的能力与标识。
|
||||
|
||||
需要自行构建时执行:
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
输出文件为 `dist/PaopaoLocationSpoofer-unsigned.ipa`。也可以直接下载 Release 附带的未签名 IPA,然后使用 [Impact](https://github.com/claration/Impactor) 签名并安装到设备。
|
||||
|
||||
签名时不要修改以下标识,也不要移除 App Group 和 Wi-Fi 信息能力:
|
||||
|
||||
| 组件 | 标识 |
|
||||
|---|---|
|
||||
| 主 App Bundle ID | `com.paopaolabs.location-spoofer` |
|
||||
| App Group | `group.com.paopaolabs.location-spoofer` |
|
||||
|
||||
免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装;这是 Apple 的侧载限制,不是 App 的证书失效。App 生成的 WLOC CA 私钥保存在 iOS 钥匙串中:使用相同 Bundle ID 和钥匙串访问范围重装时通常可继续复用,但卸载、系统清理或签名能力变化后不保证保留。
|
||||
|
||||
### 2. 选择运行模式
|
||||
|
||||
#### APP模式
|
||||
|
||||
首次打开后选择 APP模式,再按 App 内引导配置代理和 CA。APP模式没有第三方代理客户端依赖,但只支持 Wi‑Fi。免费自签应用无法使用此功能所需的 VPN/Network Extension 能力,因此使用当前 Wi‑Fi 的手动 HTTP 代理实现流量接入。
|
||||
|
||||
##### 配置当前 Wi‑Fi 代理
|
||||
|
||||
首次打开后,先在当前 Wi‑Fi 的“配置代理”中选择“手动”:
|
||||
|
||||
```text
|
||||
服务器:127.0.0.1
|
||||
端口:8888
|
||||
鉴定:关闭
|
||||
```
|
||||
|
||||
返回 App 后运行环境检测;如果设备尚未信任 CA,App 会自动进入证书初始化。
|
||||
|
||||
##### 安装并信任 CA
|
||||
|
||||
按首次引导下载描述文件,然后完成:
|
||||
|
||||
@@ -86,24 +132,33 @@
|
||||
设置 → 通用 → 关于本机 → 证书信任设置 → 完全信任
|
||||
```
|
||||
|
||||
### 3. 配置当前 Wi‑Fi 代理
|
||||
#### 第三方代理模式
|
||||
|
||||
在当前 Wi‑Fi 的"配置代理"中选择"手动":
|
||||
此模式由 App 负责地图选点、收藏和发送 WGS-84 坐标;WLOC 拦截、MITM 与坐标持久化由第三方代理客户端负责。它不会启动本机 Go 代理,不检查或使用 App 的 CA,也不要求配置 `127.0.0.1:8888`,可用于 Wi‑Fi、4G 和 5G。
|
||||
|
||||
```text
|
||||
服务器:127.0.0.1
|
||||
端口:8888
|
||||
鉴定:关闭
|
||||
```
|
||||
首次引导或“设置 → 运行模式”切换后,选择客户端。App 提供官方订阅地址复制和客户端跳转按钮;在对应客户端的模块、重写或覆写订阅入口粘贴地址并导入。仓库和 App 包内仍保留以下模块配置快照,用于版本归档和离线核对:
|
||||
|
||||
### 4. 选点并启用
|
||||
| 客户端 | 状态 | 模块 |
|
||||
|---|---|---|
|
||||
| Shadowrocket(小火箭) | **当前可测试** | [wloc.module](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module) |
|
||||
| Surge | 配置已提供,尚未验证 | [wloc.sgmodule](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule) |
|
||||
| Quantumult X | 配置已提供,尚未验证 | [wloc.conf](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf) |
|
||||
| Loon | 配置已提供,尚未验证 | [wloc.lpx](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx) |
|
||||
| Stash | 配置已提供,尚未验证 | [wloc.stoverride](https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride) |
|
||||
| Egern | 配置已提供,尚未验证 | 直接使用 Surge 模块 |
|
||||
|
||||
Stash 应直接订阅 `.stoverride`,无需通过 Script Hub 转换。Egern 复用 Surge 配置。导入后还需要按对应客户端自己的流程启用模块、MITM、证书和代理/VPN 连接;这些状态不由本 App 管理。App 只调用 WLOC 配置接口查询和同步坐标,“检测连接”仅在查询返回模块 JSON 时判定成功,不会把普通 HTTP 200 当作成功。
|
||||
|
||||
> 第三方代理模式目前是测试模式。内置模块快照来源及版本记录见 [第三方模块说明](docs/THIRD_PARTY_MODULES.md);模块引用的运行脚本仍由第三方客户端按配置访问。上游更新可能改变行为;当前仅计划使用 Shadowrocket 做真机验证。
|
||||
|
||||
### 3. 选点并启用
|
||||
|
||||
1. 搜索、点击或拖动地图选择位置;点击实时位置按钮可回到 MapKit 蓝点。
|
||||
2. 点击"开始虚拟定位",等待环境检测通过。
|
||||
2. APP 模式点击“开始虚拟定位”并等待环境检测;第三方代理模式点击“同步到第三方代理”。
|
||||
3. 按 App 内"生效说明"刷新飞行模式、Wi‑Fi 和定位服务状态。
|
||||
4. 打开 Apple 地图或目标 App 验证结果。
|
||||
|
||||
### 5. 恢复真实位置
|
||||
### 4. 恢复真实位置
|
||||
|
||||
停止虚拟定位,关闭当前 Wi‑Fi 的手动代理,并按 App 内"失效说明"刷新系统定位缓存。若系统仍保留旧缓存,请重启设备后再检查。
|
||||
|
||||
@@ -122,7 +177,7 @@ iPhone 定位请求
|
||||
系统与应用读取定位结果
|
||||
```
|
||||
|
||||
项目不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**但它仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 切换 Wi‑Fi 后需要重新检查代理设置;停止使用后应及时关闭手动代理。
|
||||
APP 模式不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**APP 模式仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 第三方代理模式则由所选代理客户端管理代理/VPN 和 MITM,可覆盖蜂窝网络;两种模式不得同时拦截 WLOC 请求。
|
||||
|
||||
## 兼容性
|
||||
|
||||
@@ -132,7 +187,7 @@ iPhone 定位请求
|
||||
| 构建 | macOS、Xcode、XcodeGen |
|
||||
| Swift | 5.9 |
|
||||
| Go | 1.23+ |
|
||||
| 网络 | 可手动配置 HTTP 代理的 Wi‑Fi |
|
||||
| 网络 | APP 模式:可手动配置 HTTP 代理的 Wi‑Fi;第三方代理模式(测试):取决于代理客户端,可覆盖 Wi‑Fi/4G/5G |
|
||||
| 安装 | 自行签名或使用 Releases 构建产物 |
|
||||
|
||||
效果会受到 iOS 版本、网络、系统定位缓存和目标 App 自身策略影响,不承诺兼容所有系统或第三方 App。
|
||||
@@ -156,14 +211,13 @@ Shared/ 收藏、设置、日志和共享模型
|
||||
Resources/ Info.plist、Entitlements 与图标
|
||||
Scripts/ 构建、签名和检查脚本
|
||||
Tests/ XCTest 与 Bash 契约测试
|
||||
docs/ 构建、自签和版本更新文档
|
||||
docs/ 构建和版本发布归档
|
||||
```
|
||||
|
||||
## 文档与反馈
|
||||
|
||||
- [构建说明](docs/BUILD.md)
|
||||
- [自签安装](docs/SELF-SIGNING.md)
|
||||
- [v1.0.0 更新日志](docs/CHANGELOG.md)
|
||||
- [更新日志](docs/CHANGELOG.md)
|
||||
- [English README](README.en.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
|
||||
@@ -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 和 Impact 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID \`com.paopaolabs.location-spoofer\`、App Group \`group.com.paopaolabs.location-spoofer\` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
<!-- commit-range: ${RANGE_LABEL} -->
|
||||
EOF
|
||||
|
||||
echo "Generated $OUTPUT from $RANGE"
|
||||
@@ -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,307 @@
|
||||
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
|
||||
}
|
||||
|
||||
/// 当前 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 = name == "林士街" ? .gcj02 : .wgs84
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 +310,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 +359,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,18 +110,38 @@ 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 {
|
||||
let favorite = FavoriteLocation(
|
||||
name: name,
|
||||
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy
|
||||
)
|
||||
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)
|
||||
@@ -72,7 +150,7 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
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 +158,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,132 @@ 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 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 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 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)
|
||||
}
|
||||
|
||||
@@ -180,14 +179,29 @@ final class MapLocationStateTests: XCTestCase {
|
||||
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 +221,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,41 @@ 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 '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,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
MODE="$ROOT/Shared/ProxyRuntimeMode.swift"
|
||||
MANAGER="$ROOT/Shared/ThirdPartyProxyManager.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
SETUP="$ROOT/App/FirstSetupView.swift"
|
||||
SETTINGS="$ROOT/App/SettingsView.swift"
|
||||
MODULES="$ROOT/Resources/ThirdPartyProxyModules"
|
||||
|
||||
grep -q 'return "APP模式"' "$MODE" || fail "APP mode display name is missing"
|
||||
grep -q 'return "第三方代理模式"' "$MODE" || fail "third-party mode display name is missing"
|
||||
grep -q 'hasSelectedMode' "$MODE" || fail "first-launch mode selection must be persisted"
|
||||
grep -q 'guard runtimeMode.hasSelectedMode else' "$CONTENT" || fail "mode selection must gate startup"
|
||||
grep -q 'phase = .setup' "$CONTENT" || fail "first launch must enter setup before map construction"
|
||||
grep -q 'case thirdPartyClient' "$SETUP" || fail "third-party client selection step is missing"
|
||||
grep -q 'case thirdPartyImport' "$SETUP" || fail "third-party import step is missing"
|
||||
grep -q 'case thirdPartyTest' "$SETUP" || fail "third-party connection test step is missing"
|
||||
! grep -q '生成并导入配置文件' "$SETUP" || fail "setup must not offer file generation/import"
|
||||
grep -q '复制订阅地址' "$SETUP" || fail "subscription URL copy action is missing"
|
||||
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" || fail "setup must expose a client launch action"
|
||||
grep -Fq 'Label("打开 \(thirdPartyClient.selectedClient.name)"' "$SETTINGS" || fail "Settings must expose a client launch action"
|
||||
! grep -q '在浏览器打开模块文件' "$SETTINGS" || fail "Settings must not open the module URL as the primary client action"
|
||||
grep -q 'requestThirdPartySetup' "$SETTINGS" || fail "Settings must reopen third-party setup"
|
||||
|
||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
||||
done
|
||||
|
||||
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
|
||||
grep -q 'wloc.stoverride' "$MANAGER" || fail "Stash must use .stoverride directly"
|
||||
grep -q 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing"
|
||||
for scheme in surge quantumult-x loon stash egern; do
|
||||
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
|
||||
done
|
||||
grep -q '复制解密域名' "$SETUP" || fail "Shadowrocket MITM hostname copy action is missing"
|
||||
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
|
||||
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
|
||||
! grep -q 'ToolbarItem(placement: .navigationBarLeading)' "$SETUP" || fail "setup must not show a top-left navigation action"
|
||||
grep -q 'presentSuccessfulOperationTip(.activation)' "$ROOT/App/MapHomeView.swift" || fail "third-party save must present the activation tip"
|
||||
grep -q 'presentSuccessfulOperationTip(.deactivation)' "$ROOT/App/MapHomeView.swift" || fail "third-party clear must present the deactivation tip"
|
||||
grep -q 'if spoofState == .active' "$ROOT/App/MapHomeView.swift" || fail "manual help must follow the shared spoof state"
|
||||
grep -q 'MARKETING_VERSION: "1.0.1"' "$ROOT/project.yml" || fail "marketing version must be 1.0.1"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "2"' "$ROOT/project.yml" || fail "build version must be 2"
|
||||
|
||||
echo "PASS: third-party proxy mode contract"
|
||||
@@ -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 Impact (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 始终保持未签名。用 [Impact](https://github.com/claration/Impactor) 签名安装即可。
|
||||
|
||||
## 发布验收
|
||||
|
||||
1. `./build.sh` 通过并输出未签名 IPA
|
||||
2. 用 Impact 签名后安装到设备
|
||||
3. 真机安装后,按引导下载 CA → 安装 → 信任,再配置 WiFi HTTP 代理 `127.0.0.1:8888`
|
||||
3. 真机安装后,先配置 WiFi HTTP 代理 `127.0.0.1:8888`,再按检测结果完成 CA 下载、安装和信任
|
||||
4. 环境检测通过后,选点开启虚拟定位
|
||||
5. 打开 Apple 地图验证定位是否变为虚拟位置
|
||||
6. 若失败,查看诊断页的日志信息
|
||||
|
||||
## 发布版本
|
||||
|
||||
发布前先生成版本归档并提交:
|
||||
|
||||
```bash
|
||||
./Scripts/generate-release-notes.sh v1.1.0
|
||||
git add docs/releases/v1.1.0.md
|
||||
git commit -m "docs: archive v1.1.0 release notes"
|
||||
git tag v1.1.0
|
||||
git push origin main v1.1.0
|
||||
```
|
||||
|
||||
归档文件根据“上一个版本标签到当前提交”的 commit subject 生成。GitHub Actions 会校验归档存在,并将文件正文直接作为 GitHub Release 内容;不会发布文档链接或缺失说明的占位 Release。
|
||||
|
||||
@@ -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)) |
|
||||
| 安装 | 自行签名(推荐 [Impact](https://github.com/claration/Impactor)) |
|
||||
| 网络 | 可手动配置 HTTP 代理的 WiFi |
|
||||
|
||||
### 安装
|
||||
### 自签安装
|
||||
|
||||
1. 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 下载 `PaopaoLocationSpoofer-unsigned.ipa`
|
||||
2. 使用 [Impact](https://github.com/claration/Impact) 签名安装
|
||||
3. 按 App 内引导完成证书安装和 WiFi 代理配置
|
||||
2. 使用 [Impact](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 和 Impact 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 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) -->
|
||||
|
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.1"
|
||||
CURRENT_PROJECT_VERSION: "2"
|
||||
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
|
||||
|
||||
|
||||