Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27af50375d | ||
|
|
a2f46dd166 | ||
|
|
b0d2f0e8e4 | ||
|
|
7cf38363cf | ||
|
|
a6688b4a49 | ||
|
|
893f674004 | ||
|
|
8cc65172b2 | ||
|
|
b07fa61195 | ||
|
|
3499f080d7 | ||
|
|
c7ae14d4b2 | ||
|
|
a3f39a93e7 | ||
|
|
811f9c9343 | ||
|
|
8bd15eb13c | ||
|
|
b0c60673af | ||
|
|
111080ad1a | ||
|
|
8acc33186c | ||
|
|
7721443484 | ||
|
|
7e362e6b6c | ||
|
|
d11550228c | ||
|
|
104df4e4a2 | ||
|
|
f38a4ebff1 | ||
|
|
bae701d4c6 |
@@ -0,0 +1,59 @@
|
||||
title: "[第三方配置分享] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢分享已验证的第三方客户端配置。请先移除账号、订阅、密码、设备标识、真实位置等敏感信息。
|
||||
截图除敏感信息遮挡外不要添加箭头、编号或说明文字,维护者会统一裁剪和标注。
|
||||
- type: dropdown
|
||||
id: client
|
||||
attributes:
|
||||
label: 第三方客户端
|
||||
options:
|
||||
- Shadowrocket
|
||||
- Surge
|
||||
- Quantumult X
|
||||
- Loon
|
||||
- Stash
|
||||
- Egern
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: client-version
|
||||
attributes:
|
||||
label: 第三方客户端版本
|
||||
placeholder: 例如 2.2.70
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: ios-version
|
||||
attributes:
|
||||
label: iOS 版本
|
||||
placeholder: 例如 iOS 26.5
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: setup-steps
|
||||
attributes:
|
||||
label: 配置步骤
|
||||
description: 请描述模块导入、证书或解密设置、代理连接和最终验证过程。
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图与补充说明
|
||||
description: 可以直接拖入脱敏原图,并说明每张图片对应的步骤。
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: attribution
|
||||
attributes:
|
||||
label: README 收录署名
|
||||
options:
|
||||
- label: 匿名收录,不在 README 展示投稿账号
|
||||
required: false
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Bug 报告
|
||||
description: 报告 App 功能异常和可复现问题
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请优先在 App 的“设置 → 支持 → 报告 Bug”中生成诊断报告,再粘贴到下方字段。
|
||||
提交前请移除真实位置、账号、订阅、密码、证书私钥和其他敏感信息。
|
||||
- type: textarea
|
||||
id: app-report
|
||||
attributes:
|
||||
label: App 生成的诊断报告
|
||||
description: 粘贴 App 自动复制的完整报告;如果无法进入 App,请按占位格式手动填写。
|
||||
placeholder: |
|
||||
### 环境信息
|
||||
App 版本:
|
||||
系统版本:
|
||||
运行模式:
|
||||
第三方客户端:
|
||||
可复现环境:
|
||||
|
||||
### 问题描述
|
||||
|
||||
### 诊断日志
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或录屏
|
||||
description: 可选。请先遮挡账号、通知、真实位置和其他敏感信息。
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: confirmations
|
||||
attributes:
|
||||
label: 提交确认
|
||||
options:
|
||||
- label: 我已搜索现有 Issues,确认没有相同问题
|
||||
required: true
|
||||
- label: 我已移除真实位置、认证信息、订阅地址、证书私钥和其他敏感信息
|
||||
required: true
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 使用帮助
|
||||
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9
|
||||
about: 安装、证书、代理配置、环境检测和问题排查
|
||||
- name: 功能建议
|
||||
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE
|
||||
about: 提交功能改进、交互优化和新场景建议
|
||||
- name: 第三方配置分享
|
||||
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB
|
||||
about: 分享已验证的第三方客户端模块、配置步骤和脱敏截图
|
||||
@@ -23,5 +23,9 @@ Wloc.xcodeproj/
|
||||
.github/*
|
||||
!.github/workflows/
|
||||
!.github/workflows/**
|
||||
!.github/DISCUSSION_TEMPLATE/
|
||||
!.github/DISCUSSION_TEMPLATE/**
|
||||
!.github/ISSUE_TEMPLATE/
|
||||
!.github/ISSUE_TEMPLATE/**
|
||||
Core/core
|
||||
docs/superpowers/
|
||||
|
||||
@@ -7,15 +7,17 @@ struct BugReportView: View {
|
||||
@State private var isReproducible = true
|
||||
@State private var isRunning = false
|
||||
@State private var showCopiedAlert = false
|
||||
@State private var githubDestination: SafariDestination?
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// 说明
|
||||
Text("遇到问题时,在这里生成 Issue 报告。系统会运行一次诊断测试,将测试结果和你的描述一起复制到剪切板,然后跳转到 GitHub 提交。")
|
||||
Text("遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板。跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -54,7 +56,7 @@ struct BugReportView: View {
|
||||
if isRunning {
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
Text(isRunning ? "正在生成报告…" : "生成 Issue 报告")
|
||||
Text(isRunning ? "正在生成报告…" : "生成 Bug 报告")
|
||||
.font(.body.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -67,20 +69,21 @@ struct BugReportView: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.navigationTitle("报告 Issue")
|
||||
.navigationTitle("报告 Bug")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("已生成", isPresented: $showCopiedAlert) {
|
||||
Button("跳转到 GitHub") {
|
||||
dismiss()
|
||||
if let url = URL(string: "https://github.com/xweiba/location-spoofer/issues/new") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
Button("打开 GitHub 表单") {
|
||||
githubDestination = SafariDestination(url: GitHubSubmission.bugReportURL)
|
||||
}
|
||||
Button("稍后再说", role: .cancel) {
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("Issue 报告已复制到剪切板。请在 GitHub Issues 页面粘贴并提交。")
|
||||
Text("Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。")
|
||||
}
|
||||
.sheet(item: $githubDestination) { destination in
|
||||
SafariView(url: destination.url)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +118,7 @@ struct BugReportView: View {
|
||||
App 版本: \(appVersion)
|
||||
系统版本: iOS \(systemVersion)
|
||||
运行模式: \(runtimeMode.mode.displayName)
|
||||
第三方客户端: \(runtimeMode.mode == .thirdParty ? thirdPartyClient.selectedClient.name : "不适用")
|
||||
可复现环境: \(isReproducible ? "是" : "否")
|
||||
|
||||
### 问题描述
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@StateObject private var setup = SetupCoordinator()
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var remoteConfiguration = AppRemoteConfigurationStore.shared
|
||||
@State private var phase: AppPhase = .splash
|
||||
@State private var verifiedDuringInitialSetup = false
|
||||
@AppStorage("setupCompleted") private var setupCompleted = false
|
||||
@State private var updatePrompt: AppUpdatePrompt?
|
||||
@State private var requiredUpdatePrompt: AppUpdatePrompt?
|
||||
|
||||
enum AppPhase { case splash, setup, map }
|
||||
|
||||
@@ -29,14 +32,19 @@ struct ContentView: View {
|
||||
MapHomeView(setup: setup)
|
||||
}
|
||||
.fullScreenCover(isPresented: $setup.needsSetup) {
|
||||
FirstSetupView(setup: setup, onComplete: {
|
||||
setupCompleted = true
|
||||
setup.completeSetup()
|
||||
})
|
||||
FirstSetupView(setup: setup, onComplete: finishPresentedSetup)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await bootstrap() }
|
||||
.task { await checkForUpdates() }
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
guard newPhase == .active, let requiredUpdatePrompt else { return }
|
||||
updatePrompt = requiredUpdatePrompt
|
||||
}
|
||||
.alert(item: $updatePrompt) { prompt in
|
||||
updateAlert(for: prompt)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -50,14 +58,14 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
let launchMode = runtimeMode.mode
|
||||
guard setupCompleted else {
|
||||
guard runtimeMode.isInitialized(launchMode) else {
|
||||
if launchMode == .localWiFi {
|
||||
await setup.prepareLocalServices()
|
||||
setup.requestSetup()
|
||||
} else {
|
||||
ProxyManager.shared.stop()
|
||||
BackgroundKeepAlive.shared.stop()
|
||||
setup.requestThirdPartySetup()
|
||||
setup.requestThirdPartyOnboarding()
|
||||
}
|
||||
phase = .setup
|
||||
return
|
||||
@@ -67,7 +75,7 @@ struct ContentView: View {
|
||||
await setup.prepareLocalServices()
|
||||
} else {
|
||||
ProxyManager.shared.stop()
|
||||
setup.completeSetup()
|
||||
BackgroundKeepAlive.shared.stop()
|
||||
RuntimeLogger.info("APP", "Startup", "第三方代理测试模式:跳过本地 CA、代理和环境检测")
|
||||
}
|
||||
do {
|
||||
@@ -118,26 +126,89 @@ struct ContentView: View {
|
||||
}
|
||||
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()
|
||||
}
|
||||
setup.completeSetup()
|
||||
RuntimeLogger.info("APP", "Startup", "启动门禁全部完成,现在创建 MapHomeView")
|
||||
phase = .map
|
||||
}
|
||||
|
||||
private func finishInitialSetup() {
|
||||
verifiedDuringInitialSetup = runtimeMode.mode == .localWiFi
|
||||
setupCompleted = true
|
||||
let completedMode = runtimeMode.mode
|
||||
runtimeMode.markInitialized(completedMode)
|
||||
setup.completeSetup()
|
||||
phase = .splash
|
||||
Task { await bootstrap() }
|
||||
}
|
||||
|
||||
private func finishPresentedSetup() {
|
||||
runtimeMode.markInitialized(runtimeMode.mode)
|
||||
setup.completeSetup()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func checkForUpdates() async {
|
||||
let currentVersion = Bundle.main.object(
|
||||
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||
) as? String ?? AppRemoteConfiguration.fallback.latestVersion
|
||||
let configuration: AppRemoteConfiguration
|
||||
if let remote = await AppRemoteConfigurationService.fetch() {
|
||||
remoteConfiguration.apply(remote)
|
||||
configuration = remote
|
||||
} else {
|
||||
configuration = remoteConfiguration.configuration
|
||||
}
|
||||
guard let pendingPrompt = configuration.updatePrompt(currentVersion: currentVersion) else { return }
|
||||
let releaseNotes = await AppRemoteConfigurationService.fetchReleaseNotes(
|
||||
version: pendingPrompt.latestVersion
|
||||
)
|
||||
guard !Task.isCancelled,
|
||||
let prompt = configuration.updatePrompt(
|
||||
currentVersion: currentVersion,
|
||||
releaseNotes: releaseNotes
|
||||
) else {
|
||||
return
|
||||
}
|
||||
if prompt.requirement == .required {
|
||||
requiredUpdatePrompt = prompt
|
||||
}
|
||||
updatePrompt = prompt
|
||||
}
|
||||
|
||||
private func updateAlert(for prompt: AppUpdatePrompt) -> Alert {
|
||||
switch prompt.requirement {
|
||||
case .required:
|
||||
let details = prompt.releaseNotes
|
||||
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
|
||||
return Alert(
|
||||
title: Text("需要更新"),
|
||||
message: Text(
|
||||
"当前版本 \(prompt.currentVersion) 已停止支持,请更新到 \(prompt.latestVersion) 后继续使用。\n\n\(details)"
|
||||
),
|
||||
dismissButton: .default(Text("立即更新")) {
|
||||
openUpdatePage(requiredPrompt: prompt)
|
||||
}
|
||||
)
|
||||
case .recommended:
|
||||
let details = prompt.releaseNotes
|
||||
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
|
||||
return Alert(
|
||||
title: Text("发现新版本"),
|
||||
message: Text(
|
||||
"当前版本 \(prompt.currentVersion),最新版本 \(prompt.latestVersion)。\n\n\(details)"
|
||||
),
|
||||
primaryButton: .default(Text("前往更新")) {
|
||||
openUpdatePage(requiredPrompt: nil)
|
||||
},
|
||||
secondaryButton: .cancel(Text("稍后"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func openUpdatePage(requiredPrompt: AppUpdatePrompt?) {
|
||||
UIApplication.shared.open(AppRemoteConfigurationService.releasesURL)
|
||||
guard requiredPrompt != nil else { return }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||||
updatePrompt = self.requiredUpdatePrompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private struct SetupScreenshotPreview: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
let title: String
|
||||
}
|
||||
|
||||
private struct CertificateDownloadDestination: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
private struct ThirdPartyConnectionTestFailure {
|
||||
let message: String
|
||||
}
|
||||
|
||||
enum SetupStep: Int, CaseIterable {
|
||||
case mode
|
||||
@@ -6,7 +22,6 @@ enum SetupStep: Int, CaseIterable {
|
||||
case cert
|
||||
case thirdPartyClient
|
||||
case thirdPartyImport
|
||||
case thirdPartyTest
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -14,8 +29,7 @@ enum SetupStep: Int, CaseIterable {
|
||||
case .proxy: return "配置 Wi-Fi 代理"
|
||||
case .cert: return "初始化 CA 证书"
|
||||
case .thirdPartyClient: return "选择客户端"
|
||||
case .thirdPartyImport: return "导入配置"
|
||||
case .thirdPartyTest: return "连接检测"
|
||||
case .thirdPartyImport: return "导入并检测"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,11 +54,27 @@ struct FirstSetupView: View {
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
@State private var copiedSubscriptionURL = false
|
||||
@State private var copiedMITMHostname = false
|
||||
@State private var screenshotPreview: SetupScreenshotPreview?
|
||||
@State private var certificateDownloadDestination: CertificateDownloadDestination?
|
||||
@State private var thirdPartyTestFailure: ThirdPartyConnectionTestFailure?
|
||||
@State private var showThirdPartyRepairReason: Bool
|
||||
@State private var showsVerificationResult: Bool
|
||||
@State private var showsThirdPartyFailureLog: Bool
|
||||
|
||||
init(setup: SetupCoordinator, onComplete: @escaping () -> Void) {
|
||||
self.setup = setup
|
||||
self.onComplete = onComplete
|
||||
_step = State(initialValue: setup.setupStep)
|
||||
_showThirdPartyRepairReason = State(
|
||||
initialValue: setup.setupStep == .thirdPartyImport && !setup.message.isEmpty
|
||||
)
|
||||
_showsVerificationResult = State(
|
||||
initialValue: [.proxy, .cert].contains(setup.setupStep)
|
||||
&& setup.lastVerificationResult != nil
|
||||
)
|
||||
_showsThirdPartyFailureLog = State(
|
||||
initialValue: setup.setupStep == .thirdPartyImport && !setup.message.isEmpty
|
||||
)
|
||||
}
|
||||
|
||||
private var certificateStepsComplete: Bool { downloadedDone && installedDone && trustedDone }
|
||||
@@ -56,30 +86,49 @@ struct FirstSetupView: View {
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
progress
|
||||
ScrollView {
|
||||
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
|
||||
ScrollViewReader { scrollProxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch step {
|
||||
case .mode: modeStep
|
||||
case .proxy: proxyStep
|
||||
case .cert: certificateStep
|
||||
case .thirdPartyClient: thirdPartyClientStep
|
||||
case .thirdPartyImport: thirdPartyImportStep
|
||||
}
|
||||
if let displayedVerificationResult { resultView(displayedVerificationResult) }
|
||||
}
|
||||
if let result { resultView(result) }
|
||||
.padding(20)
|
||||
}
|
||||
.onChange(of: thirdPartyFailureLog) { failureLog in
|
||||
guard step == .thirdPartyImport, failureLog != nil else { return }
|
||||
DispatchQueue.main.async {
|
||||
withAnimation {
|
||||
scrollProxy.scrollTo("thirdPartyFailureLog", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: step) { _ in
|
||||
showsVerificationResult = false
|
||||
showsThirdPartyFailureLog = false
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
Divider()
|
||||
VStack(spacing: 10) {
|
||||
primaryAction
|
||||
if step != .mode {
|
||||
Button("上一步") { returnToPreviousStep() }
|
||||
.disabled(isVerifying || thirdPartyProxy.isRequesting)
|
||||
if step != .mode {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
returnToPreviousStep()
|
||||
} label: {
|
||||
Label("上一步", systemImage: "chevron.left")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isVerifying || thirdPartyProxy.isRequesting)
|
||||
Spacer(minLength: 12)
|
||||
primaryAction
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.navigationTitle("开始使用")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -92,6 +141,28 @@ struct FirstSetupView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(item: $screenshotPreview) { preview in
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
Image(uiImage: preview.image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(preview.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成") { screenshotPreview = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $certificateDownloadDestination) { destination in
|
||||
SafariView(url: destination.url)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.alert("无法直接跳转", isPresented: Binding(
|
||||
get: { !manualHint.isEmpty },
|
||||
set: { if !$0 { manualHint = "" } }
|
||||
@@ -133,11 +204,33 @@ struct FirstSetupView: View {
|
||||
return [.mode]
|
||||
case .proxy, .cert:
|
||||
return [.mode, .proxy, .cert]
|
||||
case .thirdPartyClient, .thirdPartyImport, .thirdPartyTest:
|
||||
return [.mode, .thirdPartyClient, .thirdPartyImport, .thirdPartyTest]
|
||||
case .thirdPartyClient, .thirdPartyImport:
|
||||
return [.mode, .thirdPartyClient, .thirdPartyImport]
|
||||
}
|
||||
}
|
||||
|
||||
private var displayedVerificationResult: VerificationResult? {
|
||||
guard showsVerificationResult else { return nil }
|
||||
return result ?? setup.lastVerificationResult
|
||||
}
|
||||
|
||||
private var thirdPartyFailureLog: String? {
|
||||
guard showsThirdPartyFailureLog else { return nil }
|
||||
if let thirdPartyTestFailure {
|
||||
return thirdPartyTestFailure.message
|
||||
}
|
||||
guard !setup.message.isEmpty else { return nil }
|
||||
return """
|
||||
======== 第三方代理运行检测 ========
|
||||
当前客户端:\(thirdPartyClient.selectedClient.name)
|
||||
触发来源:地图或设置中的第三方代理操作
|
||||
请求动作:WLOC 配置接口
|
||||
检测结果:失败
|
||||
错误详情:\(setup.message)
|
||||
处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接。
|
||||
"""
|
||||
}
|
||||
|
||||
private var modeStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("选择运行模式")
|
||||
@@ -217,12 +310,23 @@ struct FirstSetupView: View {
|
||||
|
||||
private var proxyStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !setup.message.isEmpty {
|
||||
Label(setup.message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GroupBox(label: Label("先配置 Wi-Fi 系统代理", systemImage: "wifi")) {
|
||||
Text("在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。完成后点击下方「我已配置,开始检测」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。")
|
||||
Text("在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。配置后点击下方「完成」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
setupScreenshot(
|
||||
assetName: "AppModeWiFiProxy",
|
||||
title: "Wi-Fi 代理设置",
|
||||
caption: "1 选择手动,2 填写服务器 127.0.0.1,3 填写端口 8888。"
|
||||
)
|
||||
HStack(spacing: 12) {
|
||||
Button { UIPasteboard.general.string = "127.0.0.1:8888" } label: {
|
||||
Label("复制地址", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
|
||||
@@ -241,15 +345,16 @@ struct FirstSetupView: View {
|
||||
certificateCard(
|
||||
title: "第 1 步:下载证书",
|
||||
icon: "arrow.down.circle",
|
||||
description: "下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。Safari 出现配置描述文件下载提示时,选择「允许」。",
|
||||
actionTitle: "去下载",
|
||||
description: "下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。App 会弹出 Safari 下载页;出现配置描述文件下载提示时,选择「允许」。",
|
||||
actionTitle: "打开下载页",
|
||||
actionIcon: "arrow.down.circle.fill",
|
||||
complete: downloadedDone,
|
||||
action: {
|
||||
Task {
|
||||
let opened = await setup.proxy.openCertificateDownload()
|
||||
if !opened {
|
||||
setupActionError = setup.proxy.error ?? "无法打开证书下载页面,请查看诊断日志"
|
||||
if let url = await setup.proxy.prepareCertificateDownloadURL() {
|
||||
certificateDownloadDestination = CertificateDownloadDestination(url: url)
|
||||
} else {
|
||||
setupActionError = setup.proxy.error ?? "无法准备证书下载页面,请查看诊断日志"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -258,28 +363,43 @@ struct FirstSetupView: View {
|
||||
certificateCard(
|
||||
title: "第 2 步:安装证书",
|
||||
icon: "square.and.arrow.down",
|
||||
description: "下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 WLOC CA 并完成安装。",
|
||||
description: "下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 Location Spoofer CA 并完成安装。",
|
||||
actionTitle: "去安装",
|
||||
actionIcon: "gearshape",
|
||||
complete: installedDone,
|
||||
action: { openSettings(.general) },
|
||||
markComplete: { installedDone = true }
|
||||
)
|
||||
setupScreenshot(
|
||||
assetName: "AppModeCertificateInstall",
|
||||
title: "安装证书",
|
||||
caption: "1 在「VPN 与设备管理」中打开 Location Spoofer CA 描述文件并完成安装。"
|
||||
)
|
||||
certificateCard(
|
||||
title: "第 3 步:信任证书",
|
||||
icon: "shield.checkered",
|
||||
description: "安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 WLOC CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。",
|
||||
description: "安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 Location Spoofer CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。",
|
||||
actionTitle: "去信任",
|
||||
actionIcon: "shield.checkered",
|
||||
complete: trustedDone,
|
||||
action: { openSettings(.general) },
|
||||
markComplete: { trustedDone = true }
|
||||
)
|
||||
setupScreenshot(
|
||||
assetName: "AppModeCertificateTrust",
|
||||
title: "信任证书",
|
||||
caption: "1 在「证书信任设置」中为 Location Spoofer CA 开启完全信任。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyClientStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !setup.message.isEmpty {
|
||||
Label(setup.message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
GroupBox(label: Label("选择第三方代理客户端", systemImage: "app.badge.checkmark")) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(ThirdPartyProxyClient.allCases) { client in
|
||||
@@ -289,9 +409,11 @@ struct FirstSetupView: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(client.name).foregroundStyle(.primary)
|
||||
Text(client.verificationText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(client == .shadowrocket ? .green : .orange)
|
||||
if let verificationText = client.verificationText {
|
||||
Text(verificationText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: thirdPartyClient.selectedClient == client ? "checkmark.circle.fill" : "circle")
|
||||
@@ -305,30 +427,79 @@ struct FirstSetupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("Egern 直接使用 Surge 模块。Stash 直接订阅 .stoverride,不需要 Script Hub 转换。除 Shadowrocket 外,当前仅提供配置,尚未完成真机验证。")
|
||||
Text("除 Shadowrocket 外,当前客户端配置尚未完成真机验证,页面只提供模块导入入口和通用配置提醒。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
DisclosureGroup("第三方客户端适配说明") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("工作原理")
|
||||
.font(.subheadline.bold())
|
||||
Text("App 不连接远程坐标服务器,而是向 Apple 域名发起一个约定请求。第三方客户端需要在本机拦截该请求、保存 WGS-84 坐标并返回 JSON;定位模块再读取同一份数据,修改 Apple WLOC 响应。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("配置接口")
|
||||
.font(.subheadline.bold())
|
||||
Text(ThirdPartyProxyManager.configurationEndpoint.absoluteString)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
Text("""
|
||||
查询:GET ?action=query
|
||||
保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>
|
||||
清除:GET ?action=clear
|
||||
""")
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
|
||||
Text("返回格式")
|
||||
.font(.subheadline.bold())
|
||||
Text("""
|
||||
成功:{"success":true,"longitude":113.0,"latitude":22.0,"accuracy":25}
|
||||
失败:{"success":false,"error":"错误说明"}
|
||||
""")
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
|
||||
Text("适配要求")
|
||||
.font(.subheadline.bold())
|
||||
Text("客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 gs-loc.apple.com / gs-loc-cn.apple.com 的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyImportStep: some View {
|
||||
let client = thirdPartyClient.selectedClient
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
if showThirdPartyRepairReason {
|
||||
Label(
|
||||
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。",
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
GroupBox(label: Label("第 1 步:导入 \(client.name) 模块", systemImage: "square.and.arrow.down")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(importInstructions(for: client))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
instructionRow(1, "复制 \(client.name) 的模块订阅地址。")
|
||||
Button {
|
||||
UIPasteboard.general.string = client.subscriptionURL.absoluteString
|
||||
copiedSubscriptionURL = true
|
||||
} label: {
|
||||
Label(copiedSubscriptionURL ? "已复制订阅地址" : "复制订阅地址", systemImage: "doc.on.doc")
|
||||
Label(copiedSubscriptionURL ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
instructionRow(2, client == .shadowrocket
|
||||
? "打开 Shadowrocket,进入“配置 → 模块”。"
|
||||
: "打开 \(client.name)。")
|
||||
Button {
|
||||
openThirdPartyClient(client)
|
||||
} label: {
|
||||
@@ -336,15 +507,46 @@ struct FirstSetupView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
if client == .shadowrocket {
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketConfigDetails",
|
||||
title: "进入 Shadowrocket 配置",
|
||||
caption: "1 点击「模块」进入模块列表,2 可打开当前本地配置详情。"
|
||||
)
|
||||
}
|
||||
|
||||
if client == .shadowrocket {
|
||||
instructionRow(3, "点击右上角“+”,粘贴模块订阅地址并导入,然后确认模块已启用。")
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketModuleImport",
|
||||
title: "导入 Shadowrocket 模块",
|
||||
caption: "1 点击右上角加号导入模块,2 确认模块已启用。"
|
||||
)
|
||||
} else {
|
||||
instructionRow(3, "在 \(client.name) 中导入刚才复制的模块订阅地址。")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if client == .shadowrocket {
|
||||
shadowrocketHTTPSDecryptionGuide
|
||||
} else {
|
||||
Text("请复制订阅地址,在客户端的模块、重写或覆写订阅入口中添加。证书、MITM、VPN 和代理连接请按第三方客户端自己的流程配置。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
GroupBox(label: Label("第 2 步:完成 \(client.name) 配置", systemImage: "slider.horizontal.3")) {
|
||||
Text("请在 \(client.name) 中完成相应配置。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
if let thirdPartyFailureLog {
|
||||
testResultView(
|
||||
success: false,
|
||||
title: "接口连接失败",
|
||||
log: thirdPartyFailureLog
|
||||
)
|
||||
.id("thirdPartyFailureLog")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,6 +557,11 @@ struct FirstSetupView: View {
|
||||
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
|
||||
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
|
||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketHTTPSDecryption",
|
||||
title: "配置 HTTPS 解密",
|
||||
caption: "1 开启 HTTPS 解密,2 添加 gs-loc.apple.com,3 打开证书设置。"
|
||||
)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostname
|
||||
@@ -366,6 +573,11 @@ struct FirstSetupView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketHTTPSCA",
|
||||
title: "授权 Shadowrocket 证书",
|
||||
caption: "1 打开 Shadowrocket 证书项并按提示安装、授权。"
|
||||
)
|
||||
instructionRow(5, "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。")
|
||||
|
||||
Button {
|
||||
@@ -397,11 +609,44 @@ struct FirstSetupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func importInstructions(for client: ThirdPartyProxyClient) -> String {
|
||||
if client == .shadowrocket {
|
||||
return "先复制订阅地址。然后打开 Shadowrocket,进入“配置 → 模块”,点击右上角“+”,粘贴订阅地址并完成导入。导入完成后,模块配置由 Shadowrocket 保存,不依赖本 App 持续运行。"
|
||||
@ViewBuilder
|
||||
private func setupScreenshot(
|
||||
assetName: String,
|
||||
title: String,
|
||||
caption: String
|
||||
) -> some View {
|
||||
if let image = UIImage(named: assetName) {
|
||||
Button {
|
||||
screenshotPreview = SetupScreenshotPreview(
|
||||
image: image,
|
||||
title: title
|
||||
)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
Text(caption)
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.secondary.opacity(0.18))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(title):\(caption)")
|
||||
.accessibilityHint("轻点查看大图")
|
||||
}
|
||||
return "先复制订阅地址,然后打开 \(client.name),在模块、重写或覆写订阅入口中粘贴并导入。配置由 \(client.name) 保存,不依赖本 App 持续运行。"
|
||||
}
|
||||
|
||||
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
|
||||
@@ -414,18 +659,6 @@ struct FirstSetupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 = ""
|
||||
@@ -437,9 +670,8 @@ struct FirstSetupView: View {
|
||||
case .cert:
|
||||
step = .proxy
|
||||
case .thirdPartyImport:
|
||||
thirdPartyTestFailure = nil
|
||||
step = .thirdPartyClient
|
||||
case .thirdPartyTest:
|
||||
step = .thirdPartyImport
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,12 +715,20 @@ struct FirstSetupView: View {
|
||||
@ViewBuilder
|
||||
private func resultView(_ result: VerificationResult) -> some View {
|
||||
let success = result.isSuccess
|
||||
testResultView(
|
||||
success: success,
|
||||
title: success ? "环境检测通过" : failureSummary(result),
|
||||
log: setup.testLog
|
||||
)
|
||||
}
|
||||
|
||||
private func testResultView(success: Bool, title: String, log: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label(success ? "环境检测通过" : failureSummary(result), systemImage: success ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
Label(title, 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)
|
||||
Text(log).font(.caption.monospaced()).textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(8)
|
||||
Button {
|
||||
@@ -512,7 +752,7 @@ struct FirstSetupView: View {
|
||||
Button {
|
||||
verifyAfterProxyConfirmation()
|
||||
} label: {
|
||||
actionLabel("我已配置,开始检测")
|
||||
actionLabel("完成")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isVerifying)
|
||||
@@ -520,23 +760,22 @@ struct FirstSetupView: View {
|
||||
Button {
|
||||
verifyAfterCertificateConfirmation()
|
||||
} label: {
|
||||
actionLabel("确认完成,重新检测")
|
||||
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)
|
||||
Button {
|
||||
step = .thirdPartyImport
|
||||
} label: {
|
||||
actionLabel("完成")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
} else {
|
||||
Button {
|
||||
verifyThirdPartyConnection()
|
||||
} label: {
|
||||
actionLabel("检测接口连接")
|
||||
actionLabel("完成")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isVerifying || thirdPartyProxy.isRequesting)
|
||||
@@ -564,23 +803,84 @@ struct FirstSetupView: View {
|
||||
|
||||
private func verifyThirdPartyConnection() {
|
||||
guard !isVerifying else { return }
|
||||
let client = thirdPartyClient.selectedClient
|
||||
let startedAt = Date()
|
||||
isVerifying = true
|
||||
result = nil
|
||||
thirdPartyTestFailure = nil
|
||||
showsThirdPartyFailureLog = false
|
||||
setup.message = ""
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "开始第三方代理连接检测", details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
"检查范围": "模块拦截、MITM、代理/VPN连接"
|
||||
])
|
||||
Task { @MainActor in
|
||||
defer { isVerifying = false }
|
||||
do {
|
||||
_ = try await thirdPartyProxy.query()
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let elapsedMilliseconds = Int(Date().timeIntervalSince(startedAt) * 1_000)
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理连接检测通过", details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
"连接状态": response.latitude == nil || response.longitude == nil ? "已连接,无保存坐标" : "已连接,有保存坐标",
|
||||
"耗时毫秒": String(elapsedMilliseconds)
|
||||
])
|
||||
onComplete()
|
||||
} catch {
|
||||
setupActionError = error.localizedDescription
|
||||
let elapsedMilliseconds = Int(Date().timeIntervalSince(startedAt) * 1_000)
|
||||
let connectionState = thirdPartyConnectionStateDescription
|
||||
let errorType = String(describing: type(of: error))
|
||||
RuntimeLogger.error(
|
||||
"APP",
|
||||
"ThirdPartyProxy",
|
||||
"第三方代理连接检测失败",
|
||||
error: error,
|
||||
details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
"连接状态": connectionState,
|
||||
"耗时毫秒": String(elapsedMilliseconds),
|
||||
"错误类型": errorType,
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
]
|
||||
)
|
||||
thirdPartyTestFailure = ThirdPartyConnectionTestFailure(
|
||||
message: """
|
||||
======== 第三方代理连接检测 ========
|
||||
当前客户端:\(client.name)
|
||||
请求动作:WLOC query
|
||||
检查范围:模块拦截、MITM、证书、代理/VPN 连接
|
||||
连接状态:\(connectionState)
|
||||
检测结果:失败
|
||||
耗时:\(elapsedMilliseconds) ms
|
||||
错误类型:\(errorType)
|
||||
错误详情:\(error.localizedDescription)
|
||||
处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接后重试。
|
||||
"""
|
||||
)
|
||||
showsThirdPartyFailureLog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var thirdPartyConnectionStateDescription: String {
|
||||
switch thirdPartyProxy.connectionState {
|
||||
case .unknown:
|
||||
return "未检测"
|
||||
case .connected(let active):
|
||||
return active ? "已连接,有保存坐标" : "已连接,无保存坐标"
|
||||
case .failed(let message):
|
||||
return "连接失败(\(message))"
|
||||
}
|
||||
}
|
||||
|
||||
private func actionLabel(_ title: String) -> some View {
|
||||
HStack {
|
||||
if isVerifying { ProgressView().tint(.white).controlSize(.small) }
|
||||
Text(title).frame(maxWidth: .infinity)
|
||||
Text(title)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,11 +910,13 @@ struct FirstSetupView: View {
|
||||
guard !isVerifying else { return }
|
||||
isVerifying = true
|
||||
result = nil
|
||||
showsVerificationResult = false
|
||||
Task {
|
||||
let verification = await setup.runVerificationTest()
|
||||
setup.applyVerificationResult(verification)
|
||||
guard !Task.isCancelled else { return }
|
||||
result = verification
|
||||
showsVerificationResult = true
|
||||
isVerifying = false
|
||||
completion(verification)
|
||||
}
|
||||
|
||||
@@ -46,12 +46,15 @@ private enum RealtimeCoordinateSource: Equatable {
|
||||
}
|
||||
|
||||
struct MapHomeView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
@StateObject private var favorites: FavoriteLocationStore
|
||||
@StateObject private var actions = LocationActionCoordinator()
|
||||
@ObservedObject private var proxy = ProxyManager.shared
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
@ObservedObject private var remoteConfiguration = AppRemoteConfigurationStore.shared
|
||||
@StateObject private var realtime = RealtimeLocationManager.shared
|
||||
@StateObject private var mapState: MapLocationState
|
||||
@ObservedObject private var net = NetworkMonitor.shared
|
||||
@@ -68,6 +71,11 @@ struct MapHomeView: View {
|
||||
@State private var activeTip: TipKind?
|
||||
@State private var manualHint = ""
|
||||
private let tipPreferences = VirtualLocationTipPreferences()
|
||||
private let communityPromptPreferences = ThirdPartyCommunityPromptPreferences()
|
||||
@State private var pendingCommunityContributionClient: ThirdPartyProxyClient?
|
||||
@State private var communityContributionClient: ThirdPartyProxyClient?
|
||||
@State private var showCommunityTemplateCopied = false
|
||||
@State private var githubDestination: SafariDestination?
|
||||
@State private var editingFavorite: FavoriteLocation?
|
||||
@State private var editName = ""
|
||||
@State private var reverseGeocodeTask: Task<Void, Never>?
|
||||
@@ -78,10 +86,15 @@ struct MapHomeView: View {
|
||||
@State private var wifiChangeObserverToken: UUID?
|
||||
@State private var wifiVerificationTask: Task<Void, Never>?
|
||||
@State private var wifiVerificationID: UUID?
|
||||
@State private var copyConfirmed = false
|
||||
@State private var copiedCoordinateSystem: CoordinateConverter.MapCoordinateSystem?
|
||||
@State private var spoofState: SpoofState = .idle
|
||||
@State private var locationOperationTask: Task<Void, Never>?
|
||||
@State private var locationOperationID: UInt64 = 0
|
||||
@State private var mapCoordinateSystemRefreshTask: Task<Void, Never>?
|
||||
@State private var mapCoordinateSystemRefreshID: UInt64 = 0
|
||||
@State private var bluePointRefreshPending = false
|
||||
@State private var realtimeButtonTask: Task<Void, Never>?
|
||||
@State private var favoriteSaveTask: Task<Void, Never>?
|
||||
// 激活时的坐标(本地存,绕过 C 桥接层精度丢失)
|
||||
@State private var activeSpoofLat: Double?
|
||||
@State private var activeSpoofLon: Double?
|
||||
@@ -232,7 +245,7 @@ struct MapHomeView: View {
|
||||
.shadow(color: .black.opacity(0.2), radius: 6, y: 3)
|
||||
}
|
||||
}
|
||||
.disabled(realtimeRequestTask != nil || realtime.isRequesting)
|
||||
.disabled(realtimeButtonTask != nil || realtimeRequestTask != nil || realtime.isRequesting)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
@@ -255,6 +268,47 @@ struct MapHomeView: View {
|
||||
.sheet(item: $activeTip) { kind in
|
||||
TipSheetView(kind: kind, runtimeMode: runtimeMode.mode)
|
||||
}
|
||||
.sheet(item: $githubDestination) { destination in
|
||||
SafariView(url: destination.url)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.alert("社区分享成功配置?", isPresented: Binding(
|
||||
get: { communityContributionClient != nil },
|
||||
set: { if !$0 { communityContributionClient = nil } }
|
||||
)) {
|
||||
Button("去提交") {
|
||||
guard let client = communityContributionClient else { return }
|
||||
UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate(
|
||||
for: client,
|
||||
systemVersion: UIDevice.current.systemVersion
|
||||
)
|
||||
openCommunityContributionPage()
|
||||
}
|
||||
Button("复制模板") {
|
||||
guard let client = communityContributionClient else { return }
|
||||
UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate(
|
||||
for: client,
|
||||
systemVersion: UIDevice.current.systemVersion
|
||||
)
|
||||
showCommunityTemplateCopied = true
|
||||
}
|
||||
if communityPromptPreferences.canSuppress() {
|
||||
Button("不再提示", role: .cancel) {
|
||||
communityPromptPreferences.suppress()
|
||||
}
|
||||
} else {
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
} message: {
|
||||
Text(
|
||||
"你正在使用 \(communityContributionClient?.name ?? "第三方客户端")。点击“去提交”会先复制投稿模板,并在 App 内打开社区页面。采纳后将收录到 README,可选择是否匿名署名。"
|
||||
)
|
||||
}
|
||||
.alert("已复制投稿模板", isPresented: $showCommunityTemplateCopied) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text("如果 GitHub 登录或浏览器跳转后模板没有自动填充,可以直接粘贴。")
|
||||
}
|
||||
.alert("无法直接跳转", isPresented: Binding(
|
||||
get: { !manualHint.isEmpty },
|
||||
set: { if !$0 { manualHint = "" } }
|
||||
@@ -263,8 +317,6 @@ struct MapHomeView: View {
|
||||
} message: { Text(manualHint) }
|
||||
.onAppear {
|
||||
startMapRuntimeOnce()
|
||||
// ContentView performs the startup environment test before this map
|
||||
// view is constructed. Do not immediately run it again here.
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
registerWiFiChangeObserver()
|
||||
} else {
|
||||
@@ -279,6 +331,19 @@ struct MapHomeView: View {
|
||||
wifiVerificationTask?.cancel()
|
||||
wifiVerificationTask = nil
|
||||
wifiVerificationID = nil
|
||||
mapCoordinateSystemRefreshTask?.cancel()
|
||||
mapCoordinateSystemRefreshTask = nil
|
||||
bluePointRefreshPending = false
|
||||
realtimeButtonTask?.cancel()
|
||||
realtimeButtonTask = nil
|
||||
favoriteSaveTask?.cancel()
|
||||
favoriteSaveTask = nil
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
guard phase == .active else { return }
|
||||
Task { @MainActor in
|
||||
await awaitCoordinatedMapCoordinateSystemRefresh(reason: "App回到前台")
|
||||
}
|
||||
}
|
||||
.onChange(of: proxy.isRunning) { running in
|
||||
if runtimeMode.mode == .localWiFi, !running && spoofState == .active {
|
||||
@@ -286,6 +351,13 @@ struct MapHomeView: View {
|
||||
actions.clear()
|
||||
}
|
||||
}
|
||||
.onChange(of: showEnableTip) { isPresented in
|
||||
guard !isPresented, let client = pendingCommunityContributionClient else { return }
|
||||
pendingCommunityContributionClient = nil
|
||||
DispatchQueue.main.async {
|
||||
communityContributionClient = client
|
||||
}
|
||||
}
|
||||
.onChange(of: runtimeMode.mode) { mode in
|
||||
locationOperationTask?.cancel()
|
||||
locationOperationTask = nil
|
||||
@@ -412,27 +484,8 @@ struct MapHomeView: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(mapState.displayName ?? "当前选点").font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
Text(String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(copyConfirmed ? .green : .secondary)
|
||||
.onTapGesture {
|
||||
let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude)
|
||||
UIPasteboard.general.string = text
|
||||
RuntimeLogger.info("APP", "地图", "已复制坐标")
|
||||
copyConfirmed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false }
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if copyConfirmed {
|
||||
Text("已复制")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.green, in: Capsule())
|
||||
.offset(y: -24)
|
||||
}
|
||||
}
|
||||
coordinateRow(label: "GCJ-02(国内)", system: .gcj02)
|
||||
coordinateRow(label: "WGS-84(国际)", system: .wgs84)
|
||||
}
|
||||
Spacer()
|
||||
// 帮助说明按钮
|
||||
@@ -457,23 +510,7 @@ struct MapHomeView: View {
|
||||
favorites.select(nil)
|
||||
return
|
||||
}
|
||||
let snapshot = currentSelectionFavorite
|
||||
RuntimeLogger.info("APP", "坐标转换", "保存当前选点为收藏", details: [
|
||||
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
|
||||
"输入字段": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
|
||||
"持久化字段": "国际标准(WGS-84)+国内标准(GCJ-02)"
|
||||
])
|
||||
let favorite = favorites.save(
|
||||
name: snapshot.name,
|
||||
mapCoordinate: mapState.selection.coordinate,
|
||||
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem,
|
||||
accuracy: snapshot.accuracy
|
||||
)
|
||||
mapState.selectFavorite(
|
||||
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
|
||||
id: favorite.id,
|
||||
name: favorite.name
|
||||
)
|
||||
saveCurrentSelectionAsFavorite()
|
||||
} label: {
|
||||
Image(systemName: favorites.selectedFavoriteID != nil ? "star.fill" : "star")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
@@ -482,6 +519,7 @@ struct MapHomeView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(favorites.selectedFavoriteID != nil ? .orange : .gray)
|
||||
.disabled(favoriteSaveTask != nil)
|
||||
.accessibilityLabel(favorites.selectedFavoriteID != nil ? "已收藏,点击取消收藏" : "收藏当前选点")
|
||||
}
|
||||
// 收藏
|
||||
@@ -603,17 +641,31 @@ struct MapHomeView: View {
|
||||
activeSpoofLat = response.latitude
|
||||
activeSpoofLon = response.longitude
|
||||
RuntimeLogger.info("APP", "定位", "第三方代理坐标同步成功", details: [
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"坐标标准": "WGS-84",
|
||||
"客户端模式": "测试模式",
|
||||
"选点期间发生变化": String(selectionRevision != mapState.selection.revision)
|
||||
])
|
||||
presentSuccessfulOperationTip(.activation)
|
||||
queueCommunityContributionPrompt(for: thirdPartyClient.selectedClient)
|
||||
} catch {
|
||||
guard operationID == locationOperationID else { return }
|
||||
// A failed replacement does not clear the coordinate that
|
||||
// was already persisted inside the third-party client.
|
||||
spoofState = wasActive ? .active : .idle
|
||||
manualHint = error.localizedDescription
|
||||
RuntimeLogger.error(
|
||||
"APP",
|
||||
"ThirdPartyProxy",
|
||||
"同步坐标到第三方客户端失败",
|
||||
error: error,
|
||||
details: [
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC save",
|
||||
"恢复状态": wasActive ? "保留原第三方坐标" : "保持未启用",
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
]
|
||||
)
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
}
|
||||
if operationID == locationOperationID {
|
||||
locationOperationTask = nil
|
||||
@@ -655,12 +707,13 @@ struct MapHomeView: View {
|
||||
"result": result.id,
|
||||
"spoofState": String(describing: spoofState)
|
||||
])
|
||||
if result == .certNotTrusted {
|
||||
RuntimeLogger.warning("APP", "定位", "开启前检测发现证书异常,进入证书安装引导")
|
||||
if result != .verificationInProgress,
|
||||
result != .verificationSuperseded {
|
||||
RuntimeLogger.warning("APP", "定位", "开启前检测失败,进入对应环境引导", details: [
|
||||
"结果": result.id
|
||||
])
|
||||
activeTip = nil
|
||||
setup.applyVerificationResult(result)
|
||||
} else if let tip = result.tipKind {
|
||||
activeTip = tip
|
||||
}
|
||||
}
|
||||
locationOperationTask = nil
|
||||
@@ -682,7 +735,19 @@ struct MapHomeView: View {
|
||||
presentSuccessfulOperationTip(.deactivation)
|
||||
} catch {
|
||||
spoofState = .active
|
||||
manualHint = error.localizedDescription
|
||||
RuntimeLogger.error(
|
||||
"APP",
|
||||
"ThirdPartyProxy",
|
||||
"清除第三方客户端坐标失败",
|
||||
error: error,
|
||||
details: [
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC clear",
|
||||
"恢复状态": "保留已启用状态",
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
]
|
||||
)
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
}
|
||||
locationOperationTask = nil
|
||||
}
|
||||
@@ -715,6 +780,23 @@ struct MapHomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func queueCommunityContributionPrompt(for client: ThirdPartyProxyClient) {
|
||||
guard remoteConfiguration.requestsCommunityPrompt(for: client),
|
||||
communityPromptPreferences.shouldPresent() else {
|
||||
return
|
||||
}
|
||||
communityPromptPreferences.recordPresentation()
|
||||
if showEnableTip {
|
||||
pendingCommunityContributionClient = client
|
||||
} else {
|
||||
communityContributionClient = client
|
||||
}
|
||||
}
|
||||
|
||||
private func openCommunityContributionPage() {
|
||||
githubDestination = SafariDestination(url: GitHubSubmission.communityContributionURL)
|
||||
}
|
||||
|
||||
|
||||
private func favoriteChip(_ f: FavoriteLocation) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
@@ -761,6 +843,62 @@ struct MapHomeView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var currentSelectionPair: CoordinatePair {
|
||||
if let stored = LastCoordinateStore.load(),
|
||||
stored.coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
|
||||
.isApproximatelyEqual(to: mapState.selection.coordinate) {
|
||||
return stored.coordinatePair
|
||||
}
|
||||
return CoordinatePair(
|
||||
mapCoordinate: mapState.selection.coordinate,
|
||||
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
|
||||
)
|
||||
}
|
||||
|
||||
private func coordinateRow(
|
||||
label: String,
|
||||
system: CoordinateConverter.MapCoordinateSystem
|
||||
) -> some View {
|
||||
let coordinate = currentSelectionPair.coordinate(for: system)
|
||||
let text = String(format: "%.6f, %.6f", coordinate.latitude, coordinate.longitude)
|
||||
return HStack(spacing: 6) {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Text(text)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(copiedCoordinateSystem == system ? .green : .secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
.allowsTightening(true)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
UIPasteboard.general.string = text
|
||||
copiedCoordinateSystem = system
|
||||
RuntimeLogger.info("APP", "地图", "已复制坐标", details: [
|
||||
"坐标标准": system.diagnosticName
|
||||
])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
if copiedCoordinateSystem == system { copiedCoordinateSystem = nil }
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if copiedCoordinateSystem == system {
|
||||
Text("已复制")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.green, in: Capsule())
|
||||
.offset(y: -24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var testFavorite: FavoriteLocation { currentSelectionFavorite }
|
||||
|
||||
private func startMapRuntimeOnce() {
|
||||
@@ -774,19 +912,152 @@ struct MapHomeView: View {
|
||||
scheduleGeocode(pair: pair, revision: mapState.selection.revision)
|
||||
}
|
||||
|
||||
private func reprojectMapSelection(for change: CoordinateConverter.MapCoordinateSystemChange) {
|
||||
@discardableResult
|
||||
private func reprojectMapSelection(for change: CoordinateConverter.MapCoordinateSystemChange) -> Bool {
|
||||
// Every current selection is persisted as a complete coordinate pair at
|
||||
// its input boundary. Replaying the matching stored representation
|
||||
// avoids a second GCJ/WGS conversion and its accumulated offset.
|
||||
guard let stored = LastCoordinateStore.load() else {
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准切换时未找到当前选点缓存")
|
||||
return
|
||||
return false
|
||||
}
|
||||
mapState.reprojectSelectionForMapCoordinateSystemChange(stored.coordinate(for: change.current))
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准切换后已使用缓存坐标对回显当前选点", details: [
|
||||
"from": change.previous.rawValue,
|
||||
"to": change.current.rawValue
|
||||
])
|
||||
return true
|
||||
}
|
||||
|
||||
private func applyRuntimeMapCoordinateSystemChange(
|
||||
_ change: CoordinateConverter.MapCoordinateSystemChange,
|
||||
reason: String
|
||||
) {
|
||||
geocodeDebounceTask?.cancel()
|
||||
reverseGeocodeTask?.cancel()
|
||||
searchRequestID &+= 1
|
||||
isSearching = false
|
||||
searchResults = []
|
||||
searchError = ""
|
||||
realtimeRequestTask?.cancel()
|
||||
realtimeRequestTask = nil
|
||||
realtimeRequestContext = nil
|
||||
mapState.clearRealtimeLocationForMapCoordinateSystemChange()
|
||||
let pinWasReprojected = reprojectMapSelection(for: change)
|
||||
if let stored = LastCoordinateStore.load() {
|
||||
scheduleGeocode(pair: stored.coordinatePair, revision: mapState.selection.revision)
|
||||
}
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标类型已变化", details: [
|
||||
"触发原因": reason,
|
||||
"旧类型": change.previous.diagnosticName,
|
||||
"新类型": change.current.diagnosticName,
|
||||
"图钉已按新类型重设": String(pinWasReprojected),
|
||||
"蓝点缓存": "已清理",
|
||||
"搜索结果": "已清理",
|
||||
"异步地理编码": "已重置"
|
||||
])
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func refreshRuntimeMapCoordinateSystem(reason: String) async -> Bool {
|
||||
let result = await CoordinateConverter.refreshRuntimeMapCoordinateSystem(reason: reason)
|
||||
guard !Task.isCancelled else { return false }
|
||||
switch result {
|
||||
case .changed(let change):
|
||||
applyRuntimeMapCoordinateSystemChange(change, reason: reason)
|
||||
return true
|
||||
case .unchanged:
|
||||
return true
|
||||
case .unavailable, .cancelled:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleBluePointMapCoordinateSystemRefresh() {
|
||||
guard mapCoordinateSystemRefreshTask == nil else {
|
||||
bluePointRefreshPending = true
|
||||
return
|
||||
}
|
||||
mapCoordinateSystemRefreshID &+= 1
|
||||
let refreshID = mapCoordinateSystemRefreshID
|
||||
mapCoordinateSystemRefreshTask = Task { @MainActor in
|
||||
defer {
|
||||
if refreshID == mapCoordinateSystemRefreshID {
|
||||
let needsAnotherRefresh = bluePointRefreshPending && !Task.isCancelled
|
||||
mapCoordinateSystemRefreshTask = nil
|
||||
bluePointRefreshPending = false
|
||||
if needsAnotherRefresh {
|
||||
scheduleBluePointMapCoordinateSystemRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Coalesce the didUpdate/regionDidChange pair generated by one
|
||||
// native location sample without caching the probe result.
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled, refreshID == mapCoordinateSystemRefreshID else { return }
|
||||
var attempt = 0
|
||||
repeat {
|
||||
bluePointRefreshPending = false
|
||||
_ = await refreshRuntimeMapCoordinateSystem(reason: "MapKit蓝点新样本")
|
||||
attempt += 1
|
||||
} while !Task.isCancelled
|
||||
&& refreshID == mapCoordinateSystemRefreshID
|
||||
&& bluePointRefreshPending
|
||||
&& attempt < 2
|
||||
}
|
||||
}
|
||||
|
||||
private func awaitCoordinatedMapCoordinateSystemRefresh(reason: String) async {
|
||||
if let pendingRefresh = mapCoordinateSystemRefreshTask {
|
||||
await pendingRefresh.value
|
||||
return
|
||||
}
|
||||
mapCoordinateSystemRefreshID &+= 1
|
||||
let refreshID = mapCoordinateSystemRefreshID
|
||||
let task = Task { @MainActor in
|
||||
_ = await refreshRuntimeMapCoordinateSystem(reason: reason)
|
||||
}
|
||||
mapCoordinateSystemRefreshTask = task
|
||||
await task.value
|
||||
if refreshID == mapCoordinateSystemRefreshID {
|
||||
mapCoordinateSystemRefreshTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCurrentSelectionAsFavorite() {
|
||||
guard favoriteSaveTask == nil else { return }
|
||||
let snapshot = currentSelectionFavorite
|
||||
// Preserve the pair created when this selection entered the map. If
|
||||
// the runtime probe changes type, replay its other stored field instead
|
||||
// of reinterpreting the old visible coordinate as the new type.
|
||||
let pair = currentSelectionPair
|
||||
let selectionRevision = mapState.selection.revision
|
||||
favoriteSaveTask = Task { @MainActor in
|
||||
defer { favoriteSaveTask = nil }
|
||||
await awaitCoordinatedMapCoordinateSystemRefresh(reason: "保存收藏")
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
guard mapState.selection.revision == selectionRevision else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "取消保存收藏:检测期间当前选点已变化")
|
||||
return
|
||||
}
|
||||
RuntimeLogger.info("APP", "坐标转换", "保存当前选点为收藏", details: [
|
||||
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName,
|
||||
"持久化字段": "国际标准(WGS-84)+国内标准(GCJ-02)"
|
||||
])
|
||||
let favorite = favorites.save(
|
||||
name: snapshot.name,
|
||||
coordinatePair: pair,
|
||||
accuracy: snapshot.accuracy
|
||||
)
|
||||
mapState.selectFavorite(
|
||||
pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
|
||||
id: favorite.id,
|
||||
name: favorite.name
|
||||
)
|
||||
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: mapState.viewportMeters)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerWiFiChangeObserver() {
|
||||
@@ -816,14 +1087,21 @@ struct MapHomeView: View {
|
||||
} else {
|
||||
spoofState = .idle
|
||||
RuntimeLogger.warning("APP", "ThirdPartyProxy", "第三方代理查询返回失败", details: [
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC query",
|
||||
"错误": response.error ?? "未知错误"
|
||||
])
|
||||
setup.requestThirdPartySetup(message: response.error ?? "第三方代理查询失败")
|
||||
}
|
||||
} catch {
|
||||
spoofState = .idle
|
||||
RuntimeLogger.warning("APP", "ThirdPartyProxy", "启动后第三方代理状态查询失败", details: [
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC query",
|
||||
"连接状态": String(describing: thirdPartyProxy.connectionState),
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
}
|
||||
locationOperationTask = nil
|
||||
}
|
||||
@@ -865,7 +1143,8 @@ struct MapHomeView: View {
|
||||
"网络可用": String(net.isSatisfied),
|
||||
"Wi-Fi接口": String(net.isWiFiEnabled)
|
||||
])
|
||||
activeTip = .proxySetup
|
||||
activeTip = nil
|
||||
setup.requestSetup(message: "当前未连接可用的 Wi-Fi,请连接 Wi-Fi 后配置 127.0.0.1:8888 手动代理。")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -892,21 +1171,15 @@ struct MapHomeView: View {
|
||||
continue
|
||||
}
|
||||
|
||||
if result == .certNotTrusted {
|
||||
RuntimeLogger.warning("APP", "WiFi", "后台环境检测发现证书异常,进入证书安装引导")
|
||||
activeTip = nil
|
||||
setup.applyVerificationResult(result)
|
||||
return
|
||||
}
|
||||
|
||||
let tip = result.wifiChangeReminderTipKind
|
||||
RuntimeLogger.info("APP", "WiFi", "后台环境检测完成", details: [
|
||||
"结果": result.id,
|
||||
"success": String(result.isSuccess),
|
||||
"提示": tip?.rawValue ?? "无"
|
||||
"success": String(result.isSuccess)
|
||||
])
|
||||
if !result.isSuccess, let tip {
|
||||
activeTip = tip
|
||||
if !result.isSuccess,
|
||||
result != .verificationInProgress,
|
||||
result != .verificationSuperseded {
|
||||
activeTip = nil
|
||||
setup.applyVerificationResult(result)
|
||||
} else if result == .verificationInProgress {
|
||||
RuntimeLogger.warning("APP", "WiFi", "环境检测连续被占用,本次不重复弹窗")
|
||||
}
|
||||
@@ -916,6 +1189,18 @@ struct MapHomeView: View {
|
||||
}
|
||||
|
||||
private func requestRealtimeLocation() {
|
||||
guard realtimeButtonTask == nil else { return }
|
||||
realtimeButtonTask = Task { @MainActor in
|
||||
defer { realtimeButtonTask = nil }
|
||||
await awaitCoordinatedMapCoordinateSystemRefresh(reason: "点击实时定位")
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
performRealtimeLocationRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private func performRealtimeLocationRequest() {
|
||||
let intent = mapState.beginRealtimeIntent()
|
||||
RuntimeLogger.info("APP", "实时定位", "用户点击实时定位", details: [
|
||||
"intentID": String(intent.id),
|
||||
@@ -951,6 +1236,7 @@ struct MapHomeView: View {
|
||||
private func handleNativeRealtimeLocation(_ location: CLLocation) {
|
||||
mapState.updateRealtimeLocation(location)
|
||||
logSpoofCoordinateDiagnosisIfNeeded(location)
|
||||
scheduleBluePointMapCoordinateSystemRefresh()
|
||||
guard let context = realtimeRequestContext else {
|
||||
return
|
||||
}
|
||||
@@ -1079,14 +1365,7 @@ struct MapHomeView: View {
|
||||
sourceDescription: String
|
||||
) {
|
||||
let currentViewport = mapState.viewportMeters
|
||||
let previousMapCoordinateSystem = CoordinateConverter.currentMapCoordinateSystem
|
||||
let sourceCoordinateSystem = source.coordinateSystem
|
||||
let mapCoordinateSystemChange = source == .coreLocation
|
||||
? CoordinateConverter.correctMapCoordinateSystemUsingRealtime(coordinate)
|
||||
: nil
|
||||
if let change = mapCoordinateSystemChange {
|
||||
reprojectMapSelection(for: change)
|
||||
}
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: coordinate.latitude,
|
||||
lon: coordinate.longitude,
|
||||
@@ -1103,9 +1382,7 @@ struct MapHomeView: View {
|
||||
"intentID": String(intent.id),
|
||||
"intent选点revision": String(intent.selectionRevision),
|
||||
"当前选点revision": String(mapState.selection.revision),
|
||||
"修正前地图标准": previousMapCoordinateSystem.rawValue,
|
||||
"修正后地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
|
||||
"地图标准发生修正": String(mapCoordinateSystemChange != nil),
|
||||
"App已确认地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
|
||||
"accepted": String(accepted),
|
||||
"显示坐标字段": CoordinateConverter.currentMapCoordinateSystem.rawValue,
|
||||
"持久化字段": "WGS-84+GCJ-02"
|
||||
@@ -1122,7 +1399,7 @@ struct MapHomeView: View {
|
||||
mapState.focusSelection(distanceMeters: currentViewport)
|
||||
// Persist both forms once from the explicitly typed input boundary.
|
||||
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: currentViewport)
|
||||
favorites.select(nil)
|
||||
favorites.selectMatching(coordinatePair: pair)
|
||||
scheduleGeocode(pair: pair, revision: mapState.selection.revision)
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +222,13 @@ final class MapLocationState: ObservableObject {
|
||||
if coordinate == nil { realtimeLocation = nil }
|
||||
}
|
||||
|
||||
/// Discards a blue-point sample represented in a superseded MapKit
|
||||
/// coordinate system. The next native callback repopulates the cache.
|
||||
func clearRealtimeLocationForMapCoordinateSystemChange() {
|
||||
realtimeLocation = nil
|
||||
realtimeCoordinate = nil
|
||||
}
|
||||
|
||||
func updateExplicitName(_ name: String, forFavoriteID favoriteID: UUID) {
|
||||
guard selection.source == .favorite(favoriteID) else { return }
|
||||
selection = MapSelection(
|
||||
|
||||
@@ -119,31 +119,19 @@ final class ProxyManager: ObservableObject {
|
||||
return (Double(r.r0), Double(r.r1), r.r2 != 0)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func openCertificateDownload() async -> Bool {
|
||||
func prepareCertificateDownloadURL() async -> URL? {
|
||||
do {
|
||||
if !isRunning { try await start() }
|
||||
// 本地代理直接提供 CA 证书下载
|
||||
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
|
||||
return nil
|
||||
}
|
||||
error = nil
|
||||
return true
|
||||
return url
|
||||
} catch {
|
||||
self.error = "启动代理失败: \(error.localizedDescription)"
|
||||
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
|
||||
return false
|
||||
RuntimeLogger.error("APP", "Certificate", "准备证书下载失败", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import SafariServices
|
||||
import SwiftUI
|
||||
|
||||
struct SafariDestination: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
struct SafariView: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||
let controller = SFSafariViewController(url: url)
|
||||
controller.dismissButtonStyle = .close
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ struct SettingsView: View {
|
||||
@State private var proxyOperationAlertTitle = "代理操作失败"
|
||||
@State private var modeOperationRunning = false
|
||||
@State private var copiedClient: ThirdPartyProxyClient?
|
||||
@State private var showCertificateResetConfirmation = false
|
||||
@State private var githubDestination: SafariDestination?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -25,11 +27,6 @@ struct SettingsView: View {
|
||||
.pickerStyle(.inline)
|
||||
.disabled(modeOperationRunning || actions.state.isBusy || thirdPartyProxy.isRequesting)
|
||||
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
Label("测试模式:仅 Shadowrocket 当前可测试", systemImage: "testtube.2")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Section("状态") {
|
||||
@@ -69,22 +66,23 @@ struct SettingsView: View {
|
||||
thirdPartyConfigurationSection
|
||||
} else {
|
||||
Section("说明") {
|
||||
Button {
|
||||
activeTip = .activation
|
||||
} label: {
|
||||
Label("生效说明", systemImage: "checklist")
|
||||
}
|
||||
Button {
|
||||
activeTip = .deactivation
|
||||
} label: {
|
||||
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
|
||||
}
|
||||
Button {
|
||||
activeTip = .removeProxy
|
||||
} label: {
|
||||
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
|
||||
}
|
||||
Button {
|
||||
activeTip = .activation
|
||||
} label: {
|
||||
Label("生效说明", systemImage: "checklist")
|
||||
}
|
||||
Button {
|
||||
activeTip = .deactivation
|
||||
} label: {
|
||||
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
|
||||
}
|
||||
Button {
|
||||
activeTip = .removeProxy
|
||||
} label: {
|
||||
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Section("工作原理") {
|
||||
@@ -104,11 +102,52 @@ struct SettingsView: View {
|
||||
valueRow("版本", value: versionText)
|
||||
}
|
||||
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
Section("证书") {
|
||||
Button(role: .destructive) {
|
||||
showCertificateResetConfirmation = true
|
||||
} label: {
|
||||
Label("重置证书", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
.disabled(modeOperationRunning || actions.state.isBusy)
|
||||
|
||||
Text("仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("支持") {
|
||||
NavigationLink {
|
||||
BugReportView(setup: setup)
|
||||
} label: {
|
||||
Label("报告 Issue", systemImage: "ladybug")
|
||||
Label("报告 Bug", systemImage: "ladybug")
|
||||
}
|
||||
|
||||
Button {
|
||||
githubDestination = SafariDestination(url: GitHubSubmission.usageHelpURL)
|
||||
} label: {
|
||||
Label("使用帮助", systemImage: "questionmark.circle")
|
||||
}
|
||||
|
||||
Button {
|
||||
githubDestination = SafariDestination(url: GitHubSubmission.featureRequestURL)
|
||||
} label: {
|
||||
Label("功能建议", systemImage: "lightbulb")
|
||||
}
|
||||
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
Button {
|
||||
UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate(
|
||||
for: thirdPartyClient.selectedClient,
|
||||
systemVersion: UIDevice.current.systemVersion
|
||||
)
|
||||
githubDestination = SafariDestination(
|
||||
url: GitHubSubmission.communityContributionURL
|
||||
)
|
||||
} label: {
|
||||
Label("分享第三方配置", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +183,10 @@ struct SettingsView: View {
|
||||
.sheet(item: $activeTip) { kind in
|
||||
TipSheetView(kind: kind)
|
||||
}
|
||||
.sheet(item: $githubDestination) { destination in
|
||||
SafariView(url: destination.url)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.alert(proxyOperationAlertTitle, isPresented: Binding(
|
||||
get: { !proxyOperationError.isEmpty },
|
||||
set: { if !$0 { proxyOperationError = "" } }
|
||||
@@ -152,6 +195,18 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text(proxyOperationError)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"重置证书?",
|
||||
isPresented: $showCertificateResetConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("重置并生成新证书", role: .destructive) {
|
||||
resetCertificateAuthority()
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
} message: {
|
||||
Text("当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。")
|
||||
}
|
||||
}
|
||||
|
||||
private func valueRow(_ title: String, value: String) -> some View {
|
||||
@@ -205,19 +260,21 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("验证状态")
|
||||
Spacer()
|
||||
Text(thirdPartyClient.selectedClient.verificationText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(thirdPartyClient.selectedClient == .shadowrocket ? .green : .orange)
|
||||
if let verificationText = thirdPartyClient.selectedClient.verificationText {
|
||||
HStack {
|
||||
Text("验证状态")
|
||||
Spacer()
|
||||
Text(verificationText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = thirdPartyClient.selectedClient.subscriptionURL.absoluteString
|
||||
copiedClient = thirdPartyClient.selectedClient
|
||||
} label: {
|
||||
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制订阅链接" : "复制订阅链接", systemImage: "doc.on.doc")
|
||||
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -227,7 +284,7 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
setup.requestThirdPartySetup()
|
||||
setup.requestThirdPartyOnboarding()
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("重新打开配置引导", systemImage: "arrow.clockwise.circle")
|
||||
@@ -241,7 +298,7 @@ struct SettingsView: View {
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("复制链接后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -294,9 +351,18 @@ struct SettingsView: View {
|
||||
proxy.stop()
|
||||
setup.completeSetup()
|
||||
runtimeMode.setMode(.thirdParty)
|
||||
setup.requestThirdPartySetup()
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "已切换到第三方代理模式。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,并按引导导入第三方配置。"
|
||||
if runtimeMode.isInitialized(.thirdParty) {
|
||||
do {
|
||||
_ = try await thirdPartyProxy.query()
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。"
|
||||
} catch {
|
||||
openThirdPartySetup(for: error)
|
||||
}
|
||||
} else {
|
||||
setup.requestThirdPartyOnboarding()
|
||||
dismiss()
|
||||
}
|
||||
case .localWiFi:
|
||||
do {
|
||||
try await thirdPartyProxy.clear()
|
||||
@@ -307,23 +373,80 @@ struct SettingsView: View {
|
||||
}
|
||||
runtimeMode.setMode(.localWiFi)
|
||||
await setup.prepareLocalServices()
|
||||
setup.requestSetup()
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "已切换到 APP 模式。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
|
||||
if runtimeMode.isInitialized(.localWiFi) {
|
||||
let result = await setup.runVerificationTest()
|
||||
setup.applyVerificationResult(result)
|
||||
if result.isSuccess {
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
setup.requestSetup()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detectThirdPartyConnection() {
|
||||
let client = thirdPartyClient.selectedClient
|
||||
let startedAt = Date()
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
if !response.success, response.error?.contains("无已保存") != true {
|
||||
proxyOperationAlertTitle = "检测失败"
|
||||
proxyOperationError = response.error ?? "第三方代理模块返回失败"
|
||||
}
|
||||
_ = try await thirdPartyProxy.query()
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "设置页第三方连接检测通过", details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000))
|
||||
])
|
||||
runtimeMode.markInitialized(.thirdParty)
|
||||
} catch {
|
||||
proxyOperationAlertTitle = "检测失败"
|
||||
RuntimeLogger.error(
|
||||
"APP",
|
||||
"ThirdPartyProxy",
|
||||
"设置页第三方连接检测失败",
|
||||
error: error,
|
||||
details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
"连接状态": String(describing: thirdPartyProxy.connectionState),
|
||||
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000)),
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
]
|
||||
)
|
||||
openThirdPartySetup(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openThirdPartySetup(for error: Error) {
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func resetCertificateAuthority() {
|
||||
guard runtimeMode.mode == .localWiFi, !modeOperationRunning else { return }
|
||||
modeOperationRunning = true
|
||||
Task { @MainActor in
|
||||
defer { modeOperationRunning = false }
|
||||
if actions.virtualLocationEnabled {
|
||||
actions.clear()
|
||||
}
|
||||
proxy.stop()
|
||||
do {
|
||||
try setup.certificateStore.reset()
|
||||
runtimeMode.resetInitialization(.localWiFi)
|
||||
guard await setup.prepareLocalServices() else {
|
||||
proxyOperationAlertTitle = "证书重置失败"
|
||||
proxyOperationError = setup.message
|
||||
return
|
||||
}
|
||||
setup.requestCertificateSetup()
|
||||
dismiss()
|
||||
} catch {
|
||||
proxyOperationAlertTitle = "证书重置失败"
|
||||
proxyOperationError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ final class SetupCoordinator: ObservableObject {
|
||||
@Published var message = ""
|
||||
@Published var isBrowsingWithoutTrust = false
|
||||
@Published var testLog = ""
|
||||
@Published private(set) var lastVerificationResult: VerificationResult?
|
||||
// 启动检测失败才弹引导页;检测通过则保持 false
|
||||
@Published var needsSetup = false
|
||||
@Published private(set) var setupStep: SetupStep = .proxy
|
||||
@@ -27,17 +28,22 @@ final class SetupCoordinator: ObservableObject {
|
||||
|
||||
/// 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 {
|
||||
@discardableResult
|
||||
func prepareLocalServices() async -> Bool {
|
||||
do {
|
||||
_ = try certificateStore.ensure()
|
||||
if !proxy.isRunning { try await proxy.start() }
|
||||
message = ""
|
||||
return true
|
||||
} catch {
|
||||
message = "本地代理初始化失败:\(error.localizedDescription)"
|
||||
RuntimeLogger.error("APP", "Startup", "本地服务初始化失败", error: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func applyVerificationResult(_ result: VerificationResult) {
|
||||
lastVerificationResult = result
|
||||
switch result {
|
||||
case .success:
|
||||
trustState = .trusted
|
||||
@@ -48,6 +54,8 @@ final class SetupCoordinator: ObservableObject {
|
||||
setupStep = .cert
|
||||
needsSetup = true
|
||||
message = "CA 证书未安装或未信任"
|
||||
case .verificationInProgress, .verificationSuperseded:
|
||||
break
|
||||
default:
|
||||
trustState = .unavailable
|
||||
setupStep = .proxy
|
||||
@@ -60,14 +68,32 @@ final class SetupCoordinator: ObservableObject {
|
||||
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
|
||||
func completeSetup() { needsSetup = false }
|
||||
func requestModeSelection() {
|
||||
lastVerificationResult = nil
|
||||
message = ""
|
||||
setupStep = .mode
|
||||
needsSetup = true
|
||||
}
|
||||
func requestThirdPartySetup() {
|
||||
func requestThirdPartyOnboarding() {
|
||||
lastVerificationResult = nil
|
||||
message = ""
|
||||
setupStep = .thirdPartyClient
|
||||
needsSetup = true
|
||||
}
|
||||
func requestSetup() {
|
||||
func requestThirdPartySetup(message: String) {
|
||||
lastVerificationResult = nil
|
||||
self.message = message
|
||||
setupStep = .thirdPartyImport
|
||||
needsSetup = true
|
||||
}
|
||||
func requestCertificateSetup() {
|
||||
lastVerificationResult = nil
|
||||
message = ""
|
||||
setupStep = .cert
|
||||
needsSetup = true
|
||||
}
|
||||
func requestSetup(message: String = "") {
|
||||
lastVerificationResult = nil
|
||||
self.message = message
|
||||
setupStep = .proxy
|
||||
needsSetup = true
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ enum TipKind: String, Identifiable {
|
||||
case activation = "生效说明"
|
||||
case deactivation = "失效说明"
|
||||
case removeProxy = "关闭 WiFi 代理"
|
||||
case proxySetup = "配置代理"
|
||||
case rewriteFailed = "改写失败"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -22,8 +20,6 @@ struct TipSheetView: View {
|
||||
case .activation: ActivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
|
||||
case .deactivation: DeactivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
|
||||
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
|
||||
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
|
||||
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
|
||||
}
|
||||
}.padding(16)
|
||||
}
|
||||
@@ -52,17 +48,6 @@ private func openSettings(_ destination: SystemSettingsDestination) {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 共享组件
|
||||
|
||||
private struct TipCloseButton: View {
|
||||
let action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 生效说明
|
||||
|
||||
struct ActivationTipContent: View {
|
||||
@@ -84,9 +69,13 @@ struct ActivationTipContent: View {
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
|
||||
GroupBox(label: Label("还是无法生效?", systemImage: "exclamationmark.triangle")) {
|
||||
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。")
|
||||
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -141,8 +130,13 @@ struct DeactivationTipContent: View {
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
|
||||
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。").font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
|
||||
GroupBox(label: Label("还是无法取消?", systemImage: "exclamationmark.triangle")) {
|
||||
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -192,50 +186,3 @@ struct RemoveProxyTipContent: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WiFi 代理配置
|
||||
|
||||
struct ProxySetupTipContent: View {
|
||||
@State private var copied = false
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("WiFi 代理未配置", systemImage: "wifi")) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("系统定位请求没有经过本地代理,请按以下步骤配置:\n\n1. 打开「设置 → 无线局域网」\n2. 确认已连接到正确的 WiFi(不是蜂窝数据)\n3. 点击当前 WiFi 右侧 (i) 图标\n4. 滑到底部「HTTP 代理」→ 选择「手动」\n5. 在「服务器」填写下面的地址,端口填写 8888\n6. 点右上角「存储」")
|
||||
.font(.caption).foregroundStyle(.primary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("127.0.0.1:8888").font(.caption.monospaced().bold()).foregroundStyle(.blue)
|
||||
Spacer()
|
||||
Button {
|
||||
UIPasteboard.general.string = "127.0.0.1:8888"
|
||||
copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
|
||||
} label: {
|
||||
Label(copied ? "已复制" : "复制地址", systemImage: copied ? "checkmark" : "doc.on.doc").font(.caption)
|
||||
}.buttonStyle(.bordered).tint(copied ? .green : .blue)
|
||||
}.padding(10).background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Button { openSettings(.wifi) } label: {
|
||||
Text("去设置 WiFi 代理").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
}.buttonStyle(.borderedProminent).tint(.blue)
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 改写失败
|
||||
|
||||
struct RewriteFailedTipContent: View {
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GroupBox(label: Label("坐标改写失败", systemImage: "exclamationmark.triangle")) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("虚拟定位已连接,但坐标替换没有成功。可能原因:\n\n• 代理刚启动不久,数据还没开始改写(等待几秒后重试)\n• CA 证书与当前 App 版本不匹配\n• iOS 系统安全策略限制\n\n建议操作:\n1. 完全停止虚拟定位,再重新开启\n2. 删除旧证书,重新生成并安装\n3. 在诊断页查看详细日志")
|
||||
.font(.caption).foregroundStyle(.primary)
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ func generateCA() (certPEM, keyPEM []byte, err error) {
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"WLOC"},
|
||||
CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"),
|
||||
Organization: []string{"Location Spoofer"},
|
||||
CommonName: "Location Spoofer CA",
|
||||
},
|
||||
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
NotAfter: time.Date(2045, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
|
||||
@@ -15,17 +15,16 @@ func TestGenerateCA(t *testing.T) {
|
||||
if len(certPEM) == 0 || len(keyPEM) == 0 {
|
||||
t.Fatal("empty CA output")
|
||||
}
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
t.Fatal("invalid cert PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert := parseCertificatePEM(t, certPEM)
|
||||
if !cert.IsCA {
|
||||
t.Fatal("certificate is not a CA")
|
||||
}
|
||||
if cert.Subject.CommonName != "Location Spoofer CA" {
|
||||
t.Fatalf("unexpected CA common name: %q", cert.Subject.CommonName)
|
||||
}
|
||||
if len(cert.Subject.Organization) != 1 || cert.Subject.Organization[0] != "Location Spoofer" {
|
||||
t.Fatalf("unexpected CA organization: %v", cert.Subject.Organization)
|
||||
}
|
||||
if time.Until(cert.NotAfter).Hours() < 360*24 {
|
||||
t.Fatal("CA validity is too short")
|
||||
}
|
||||
@@ -34,16 +33,34 @@ func TestGenerateCA(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCAUsesUniquePrivateKeys(t *testing.T) {
|
||||
_, firstKey, err := generateCA()
|
||||
func TestGenerateCAUsesUniquePrivateKeysAndSerialNumbers(t *testing.T) {
|
||||
firstCertPEM, firstKey, err := generateCA()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, secondKey, err := generateCA()
|
||||
secondCertPEM, secondKey, err := generateCA()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(firstKey) == string(secondKey) {
|
||||
t.Fatal("generated CA private keys must not be deterministic")
|
||||
}
|
||||
firstCert := parseCertificatePEM(t, firstCertPEM)
|
||||
secondCert := parseCertificatePEM(t, secondCertPEM)
|
||||
if firstCert.SerialNumber.Cmp(secondCert.SerialNumber) == 0 {
|
||||
t.Fatal("generated CA serial numbers must not be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func parseCertificatePEM(t *testing.T, certPEM []byte) *x509.Certificate {
|
||||
t.Helper()
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
t.Fatal("invalid cert PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ type wireField struct {
|
||||
|
||||
var macPattern = regexp.MustCompile(`^[0-9a-fA-F]{1,2}(:[0-9a-fA-F]{1,2}){5}$`)
|
||||
|
||||
var wlocMarker = []byte{0, 0, 0, 1, 0, 0}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -314,6 +316,99 @@ func patchWlocPayload(payload []byte, c wlocCoords, st *patchStats) ([]byte, boo
|
||||
return out, changed, nil
|
||||
}
|
||||
|
||||
func parseARPCPayloadBounds(body []byte) (lengthOffset, payloadOffset, payloadEnd int, err error) {
|
||||
if len(body) < 2 {
|
||||
return 0, 0, 0, errors.New("ARPC body too short")
|
||||
}
|
||||
|
||||
offset := 2 // version
|
||||
for range 3 {
|
||||
if offset+2 > len(body) {
|
||||
return 0, 0, 0, errors.New("truncated ARPC string length")
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(body[offset : offset+2]))
|
||||
offset += 2
|
||||
if length > len(body)-offset {
|
||||
return 0, 0, 0, errors.New("truncated ARPC string")
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
|
||||
const functionAndLengthBytes = 8
|
||||
if offset+functionAndLengthBytes > len(body) {
|
||||
return 0, 0, 0, errors.New("truncated ARPC header")
|
||||
}
|
||||
lengthOffset = offset + 4
|
||||
payloadOffset = lengthOffset + 4
|
||||
payloadLength := uint64(binary.BigEndian.Uint32(body[lengthOffset:payloadOffset]))
|
||||
if payloadLength == 0 || payloadLength > uint64(len(body)-payloadOffset) {
|
||||
return 0, 0, 0, errors.New("invalid ARPC payload length")
|
||||
}
|
||||
return lengthOffset, payloadOffset, payloadOffset + int(payloadLength), nil
|
||||
}
|
||||
|
||||
func patchARPCFrame(body []byte, c wlocCoords) ([]byte, patchStats, error) {
|
||||
lengthOffset, payloadOffset, payloadEnd, err := parseARPCPayloadBounds(body)
|
||||
if err != nil {
|
||||
return nil, patchStats{}, err
|
||||
}
|
||||
|
||||
var st patchStats
|
||||
payload := body[payloadOffset:payloadEnd]
|
||||
newPayload, changed, err := patchWlocPayload(payload, c, &st)
|
||||
if err != nil {
|
||||
return nil, patchStats{}, err
|
||||
}
|
||||
if !changed || bytes.Equal(newPayload, payload) {
|
||||
return nil, patchStats{}, errors.New("ARPC envelope has no patchable wloc payload")
|
||||
}
|
||||
|
||||
var lenBytes [4]byte
|
||||
binary.BigEndian.PutUint32(lenBytes[:], uint32(len(newPayload)))
|
||||
out := append(cloneBytes(body[:lengthOffset]), lenBytes[:]...)
|
||||
out = append(out, newPayload...)
|
||||
out = append(out, body[payloadEnd:]...)
|
||||
return out, st, nil
|
||||
}
|
||||
|
||||
func patchMarkerFrame(body []byte, c wlocCoords) ([]byte, patchStats, error) {
|
||||
markerOffset := bytes.Index(body, wlocMarker)
|
||||
if markerOffset < 0 {
|
||||
return nil, patchStats{}, errors.New("wloc marker not found")
|
||||
}
|
||||
|
||||
lengthOffset := markerOffset + len(wlocMarker)
|
||||
payloadOffset := lengthOffset + 2
|
||||
if payloadOffset > len(body) {
|
||||
return nil, patchStats{}, errors.New("truncated marker frame")
|
||||
}
|
||||
payloadLength := int(binary.BigEndian.Uint16(body[lengthOffset:payloadOffset]))
|
||||
if payloadLength == 0 || payloadLength > len(body)-payloadOffset {
|
||||
return nil, patchStats{}, errors.New("invalid marker payload length")
|
||||
}
|
||||
payloadEnd := payloadOffset + payloadLength
|
||||
|
||||
var st patchStats
|
||||
payload := body[payloadOffset:payloadEnd]
|
||||
newPayload, changed, err := patchWlocPayload(payload, c, &st)
|
||||
if err != nil {
|
||||
return nil, patchStats{}, err
|
||||
}
|
||||
if !changed || bytes.Equal(newPayload, payload) {
|
||||
return nil, patchStats{}, errors.New("marker frame has no patchable wloc payload")
|
||||
}
|
||||
if len(newPayload) > 65535 {
|
||||
return nil, patchStats{}, errors.New("patched marker payload too large")
|
||||
}
|
||||
|
||||
var lenBytes [2]byte
|
||||
binary.BigEndian.PutUint16(lenBytes[:], uint16(len(newPayload)))
|
||||
out := append(cloneBytes(body[:lengthOffset]), lenBytes[:]...)
|
||||
out = append(out, newPayload...)
|
||||
out = append(out, body[payloadEnd:]...)
|
||||
return out, st, nil
|
||||
}
|
||||
|
||||
func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte, patchStats, error) {
|
||||
if len(body) < offset+10 {
|
||||
return nil, *st, fmt.Errorf("body too short: %d, base=%d", len(body), offset)
|
||||
@@ -352,6 +447,13 @@ func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte,
|
||||
}
|
||||
|
||||
func patchWlocBody(body []byte, c wlocCoords) ([]byte, patchStats, error) {
|
||||
if out, st, err := patchARPCFrame(body, c); err == nil {
|
||||
return out, st, nil
|
||||
}
|
||||
if out, st, err := patchMarkerFrame(body, c); err == nil {
|
||||
return out, st, nil
|
||||
}
|
||||
|
||||
var st patchStats
|
||||
offsets := []int{0, 2, 4, 6, 8, 10, 12, 14, 16}
|
||||
seen := map[int]bool{}
|
||||
|
||||
@@ -44,6 +44,29 @@ func testFrame(payload []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func testARPCFrame(payload, suffix []byte) ([]byte, int) {
|
||||
var out []byte
|
||||
var version [2]byte
|
||||
binary.BigEndian.PutUint16(version[:], 1)
|
||||
out = append(out, version[:]...)
|
||||
for _, value := range [][]byte{[]byte("zh_CN"), []byte("com.apple.locationd"), []byte("20A123")} {
|
||||
var length [2]byte
|
||||
binary.BigEndian.PutUint16(length[:], uint16(len(value)))
|
||||
out = append(out, length[:]...)
|
||||
out = append(out, value...)
|
||||
}
|
||||
var functionID [4]byte
|
||||
binary.BigEndian.PutUint32(functionID[:], 1)
|
||||
out = append(out, functionID[:]...)
|
||||
lengthOffset := len(out)
|
||||
var payloadLength [4]byte
|
||||
binary.BigEndian.PutUint32(payloadLength[:], uint32(len(payload)))
|
||||
out = append(out, payloadLength[:]...)
|
||||
out = append(out, payload...)
|
||||
out = append(out, suffix...)
|
||||
return out, lengthOffset
|
||||
}
|
||||
|
||||
func TestPatchWifiLocation(t *testing.T) {
|
||||
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
body := testFrame(payload)
|
||||
@@ -69,20 +92,121 @@ func TestPatchWifiLocation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPatchCellLocation(t *testing.T) {
|
||||
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
|
||||
payload := writeLengthDelimited(22, cell)
|
||||
body := testFrame(payload)
|
||||
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
|
||||
for _, field := range []int{22, 24} {
|
||||
t.Run(fmt.Sprintf("field_%d", field), func(t *testing.T) {
|
||||
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
|
||||
payload := writeLengthDelimited(field, cell)
|
||||
body := testFrame(payload)
|
||||
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Cell != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
if bytes.Equal(patched, body) {
|
||||
t.Fatal("body was not patched")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchARPCFramePreservesEnvelopeAndSuffix(t *testing.T) {
|
||||
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
suffix := []byte{0xde, 0xad, 0xbe, 0xef}
|
||||
body, lengthOffset := testARPCFrame(payload, suffix)
|
||||
originalPrefix := cloneBytes(body[:lengthOffset])
|
||||
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Cell != 1 || stats.Locations != 1 {
|
||||
if stats.WiFi != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
if bytes.Equal(patched, body) {
|
||||
t.Fatal("body was not patched")
|
||||
if !bytes.Equal(patched[:lengthOffset], originalPrefix) {
|
||||
t.Fatal("ARPC metadata changed")
|
||||
}
|
||||
newLength := int(binary.BigEndian.Uint32(patched[lengthOffset : lengthOffset+4]))
|
||||
if newLength == len(payload) {
|
||||
t.Fatal("ARPC payload length was not updated")
|
||||
}
|
||||
if !bytes.Equal(patched[lengthOffset+4+newLength:], suffix) {
|
||||
t.Fatal("ARPC suffix changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchARPCPayloadLargerThanUint16(t *testing.T) {
|
||||
padding := writeLengthDelimited(99, bytes.Repeat([]byte{0x7f}, 70_000))
|
||||
location := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
payload := append(padding, location...)
|
||||
body, lengthOffset := testARPCFrame(payload, nil)
|
||||
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.WiFi != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
newLength := int(binary.BigEndian.Uint32(patched[lengthOffset : lengthOffset+4]))
|
||||
if newLength <= 65535 {
|
||||
t.Fatalf("expected 32-bit ARPC payload length, got %d", newLength)
|
||||
}
|
||||
if !bytes.Contains(patched[lengthOffset+4:lengthOffset+4+newLength], padding) {
|
||||
t.Fatal("unknown ARPC payload field changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchMarkerFramePreservesPrefixAndSuffix(t *testing.T) {
|
||||
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
prefix := []byte{0xaa, 0xbb, 0xcc}
|
||||
suffix := []byte{0xdd, 0xee}
|
||||
var length [2]byte
|
||||
binary.BigEndian.PutUint16(length[:], uint16(len(payload)))
|
||||
body := append(cloneBytes(prefix), wlocMarker...)
|
||||
body = append(body, length[:]...)
|
||||
body = append(body, payload...)
|
||||
body = append(body, suffix...)
|
||||
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.WiFi != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
lengthOffset := len(prefix) + len(wlocMarker)
|
||||
newLength := int(binary.BigEndian.Uint16(patched[lengthOffset : lengthOffset+2]))
|
||||
if newLength == len(payload) {
|
||||
t.Fatal("marker payload length was not updated")
|
||||
}
|
||||
if !bytes.Equal(patched[:len(prefix)], prefix) {
|
||||
t.Fatal("marker prefix changed")
|
||||
}
|
||||
if !bytes.Equal(patched[lengthOffset+2+newLength:], suffix) {
|
||||
t.Fatal("marker suffix changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchBareWlocPayload(t *testing.T) {
|
||||
body := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.WiFi != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
if len(patched) > 0 && bytes.HasPrefix(patched, []byte{0, 1, 0, 0}) {
|
||||
t.Fatal("bare payload was unexpectedly wrapped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,174 +2,458 @@
|
||||
|
||||
# 📍 Location Spoofer
|
||||
|
||||
### iOS Location Spoofer · DingTalk · WeChat · Apple Watch Region Unlock · Fake GPS
|
||||
### iOS Location Service Research & Testing Framework
|
||||
|
||||
**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.
|
||||
An open-source project for **iOS location-service research, software development testing, and QA validation**.
|
||||
|
||||
The project uses either an on-device proxy or a third-party proxy client to simulate selected Apple location-service
|
||||
responses in a controlled test environment.
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#why-no-vpn)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[Features](#key-features) · [Quick Start](#quick-start) · [中文](README.md) · [Changelog](docs/CHANGELOG.md)
|
||||
|
||||
<img src="images/主界面.jpg" alt="Location Spoofer iOS Fake GPS main interface" width="380">
|
||||
[Features](#feature-overview) ·
|
||||
[How It Works](#how-it-works) ·
|
||||
[Quick Start](#quick-start) ·
|
||||
[Build](#building-the-project) ·
|
||||
[中文](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is intended for education, security research, and testing on your own devices. 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.
|
||||
> Use this project only for education, research, testing on devices you own, software development, and QA validation.
|
||||
>
|
||||
> Use it only on devices, networks, and software environments that you own or are authorized to test. Follow applicable
|
||||
> laws, network policies, and service terms.
|
||||
>
|
||||
> The project does not guarantee compatibility with every iOS version or third-party app, and it does not promise to
|
||||
> bypass third-party security controls, business restrictions, or service rules.
|
||||
|
||||
## Credits
|
||||
## Project Scope
|
||||
|
||||
The core location-response rewriting approach and Go implementation are based on [Yu9191/wloc](https://github.com/Yu9191/wloc). This project adds a SwiftUI interface, MapKit selection, certificate and proxy guidance, environment verification, favorites, and diagnostics.
|
||||
Location Spoofer is a tool for studying iOS location-service behavior and testing location-dependent software.
|
||||
|
||||
## Why Location Spoofer?
|
||||
It provides:
|
||||
|
||||
Unlike tools that require a computer to stay connected, a VPN tunnel, or a jailbroken device, Location Spoofer keeps the control flow on the iPhone itself.
|
||||
- Native map selection and location-scenario switching;
|
||||
- Controlled simulation of selected Apple location-service responses;
|
||||
- App Mode and Third-party Proxy Mode;
|
||||
- Map coordinate detection with paired WGS-84 and GCJ-02 values;
|
||||
- Environment checks, runtime logs, and diagnostics;
|
||||
- Favorite locations and restoration of the previous map state.
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| 🔀 **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. |
|
||||
| 🔍 **Visible map scale** | Left-side controls show the current visible range; place name adapts to zoom level. |
|
||||
| 🧪 **Environment verification** | Checks proxy, CA trust, Wi‑Fi interception, coordinate write, and response rewrite. |
|
||||
| 🧾 **Diagnostics** | Per-entry copy for easy sharing and debugging. |
|
||||
The project does not modify the target app's source code and does not provide telemetry, remote control, or data
|
||||
collection services.
|
||||
|
||||
## Screenshots
|
||||
## Feature Overview
|
||||
|
||||
| Main Interface | Apple Maps | Amap | Apple Watch |
|
||||
|---|---|---|---|
|
||||
|  |  |  |  |
|
||||
- **Native map interaction**
|
||||
- Uses MapKit for the map and system blue dot;
|
||||
- Supports search, map taps, center-point dragging, and zooming;
|
||||
- Supports favorites and restoration of the latest selection;
|
||||
- Shows both domestic and international coordinate representations with separate copy actions.
|
||||
|
||||
## Key Features
|
||||
- **Location-service response simulation**
|
||||
- Processes only the Apple location-service requests defined by the project;
|
||||
- Returns the selected coordinates in a controlled test environment;
|
||||
- Does not require changes to the target app.
|
||||
|
||||
- **iOS Location Spoofer / Fake GPS**: Apply the selected coordinate to the local proxy that rewrites location responses, compatible with DingTalk check-in, WeChat location sharing, and more.
|
||||
- **Native real-time location**: The map displays MapKit's own blue dot — no extra "fake real-time" overlay.
|
||||
- **Concurrency-safe selection**: Pan, tap, search, favorites, and async location respect the user's latest intent; stale results won't overwrite newer selections.
|
||||
- **Hierarchical place names**: POI, street, or road at close zoom; neighborhood, district, city, or province at wider zoom.
|
||||
- **Map scale display**: Zoom controls show the current visible range.
|
||||
- **Favorites with quick switch**: Save frequent coordinates and see which location is about to be applied.
|
||||
- **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.
|
||||
- **Two runtime modes**
|
||||
- App Mode: runs the Go proxy on-device and covers only the current Wi-Fi network;
|
||||
- Third-party Proxy Mode: uses a supported proxy client and may cover Wi-Fi, 4G, or 5G depending on that client.
|
||||
|
||||
- **Environment checks**
|
||||
- App Mode checks the local proxy, CA trust, and request path;
|
||||
- Third-party Proxy Mode checks the WLOC configuration API and module response;
|
||||
- Failures route to the relevant setup or diagnostics screen.
|
||||
|
||||
- **Development diagnostics**
|
||||
- Runtime logs;
|
||||
- Log copy and cleanup;
|
||||
- Map coordinate-system change records;
|
||||
- Sanitized issue-report generation.
|
||||
|
||||
## How It Works
|
||||
|
||||
### App Mode
|
||||
|
||||
App Mode runs the local Go proxy inside the app. A manual HTTP proxy on the current Wi-Fi routes the selected requests
|
||||
through that on-device proxy.
|
||||
|
||||
```text
|
||||
iOS location request
|
||||
│
|
||||
│ Manual HTTP proxy on the current Wi-Fi
|
||||
▼
|
||||
On-device wloccore Go proxy
|
||||
│
|
||||
│ Handle selected Apple location-service requests
|
||||
▼
|
||||
Apple location-service response
|
||||
│
|
||||
│ Test coordinate response
|
||||
▼
|
||||
The system and apps read the location result
|
||||
```
|
||||
|
||||
App Mode:
|
||||
|
||||
- Does not create a Network Extension;
|
||||
- Does not display or occupy the system VPN slot;
|
||||
- Covers only the current Wi-Fi network;
|
||||
- Requires a manual HTTP proxy on that Wi-Fi network;
|
||||
- Requires installation and trust of the CA generated by the app;
|
||||
- Handles only the Apple location-service and environment-verification traffic defined by the project. It is not a
|
||||
general-purpose packet capture tool.
|
||||
|
||||
### Third-party Proxy Mode
|
||||
|
||||
Third-party Proxy Mode does not start the app's Go proxy and does not use the CA generated by the app.
|
||||
|
||||
```text
|
||||
Map selection
|
||||
│
|
||||
│ WGS-84 coordinates
|
||||
▼
|
||||
WLOC configuration API
|
||||
│
|
||||
▼
|
||||
Third-party proxy client stores the configuration
|
||||
│
|
||||
▼
|
||||
Third-party client processes location-service requests
|
||||
```
|
||||
|
||||
In this mode:
|
||||
|
||||
- The app owns map selection, favorites, coordinate synchronization, and coordinate clearing;
|
||||
- The third-party client owns proxy/VPN, MITM, certificates, and rule execution;
|
||||
- The third-party client owns coordinate persistence;
|
||||
- Wi-Fi, 4G, and 5G support depends on the client;
|
||||
- The configuration may remain active after Location Spoofer closes.
|
||||
|
||||
#### Configuration API and Client Integration
|
||||
|
||||
The app does not upload coordinates to a project server. It sends the following GET request, which the third-party
|
||||
client must intercept locally on the device:
|
||||
|
||||
```text
|
||||
https://gs-loc.apple.com/wloc-settings/save
|
||||
```
|
||||
|
||||
| Action | Query parameters | Purpose |
|
||||
|---|---|---|
|
||||
| Query | `action=query` | Verify module connectivity and read the stored coordinate |
|
||||
| Save | `lon=<WGS-84 longitude>&lat=<WGS-84 latitude>&acc=<accuracy>` | Store the selected coordinate |
|
||||
| Clear | `action=clear` | Remove the stored test coordinate |
|
||||
|
||||
The interception script must return HTTP 200 with JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"longitude": 113.0,
|
||||
"latitude": 22.0,
|
||||
"accuracy": 25
|
||||
}
|
||||
```
|
||||
|
||||
Failures use:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error description"
|
||||
}
|
||||
```
|
||||
|
||||
When no coordinate is stored, a query may return `{"success":false,"error":"无已保存的坐标"}`. The app interprets
|
||||
that response as “module connected, virtual location inactive.” A successful save response must echo the requested
|
||||
WGS-84 longitude and latitude.
|
||||
|
||||
To integrate another third-party client:
|
||||
|
||||
1. Add an HTTP request script for `gs-loc.apple.com/wloc-settings/save` that parses the parameters and returns the
|
||||
specified JSON;
|
||||
2. Store coordinates, accuracy, and optional state in the client's persistent storage;
|
||||
3. Enable HTTPS decryption for `gs-loc.apple.com` and `gs-loc-cn.apple.com`;
|
||||
4. Intercept `gs-loc(-cn).apple.com/clls/wloc` responses, read the same persistent data, and modify the WLOC response;
|
||||
5. Publish an importable module and verify query, save, clear, and location restoration on a real device.
|
||||
|
||||
The app validates only the configuration endpoint's HTTP status, JSON shape, and coordinate round trip. It does not
|
||||
manage the third-party client's certificate, MITM, VPN, or proxy state.
|
||||
|
||||
Do not enable App Mode interception and Third-party Proxy Mode interception at the same time.
|
||||
|
||||
## Runtime Modes
|
||||
|
||||
### App Mode
|
||||
|
||||
Suitable for:
|
||||
|
||||
- Wi-Fi-only device testing;
|
||||
- Local testing without a third-party proxy client;
|
||||
- Workflows that need in-app proxy, certificate, and environment guidance.
|
||||
|
||||
Requirements:
|
||||
|
||||
- An iOS device;
|
||||
- A Wi-Fi network that permits manual HTTP proxy configuration;
|
||||
- Installation and trust of the local CA;
|
||||
- Completion of the in-app proxy and environment checks.
|
||||
|
||||
### Third-party Proxy Mode
|
||||
|
||||
Suitable for:
|
||||
|
||||
- Tests that need Wi-Fi, 4G, or 5G coverage;
|
||||
- Existing supported proxy-client workflows;
|
||||
- Cases where the third-party client should keep the proxy configuration active.
|
||||
|
||||
Current client status:
|
||||
|
||||
| Client | Status | Community configuration |
|
||||
|---|---|---|
|
||||
| Shadowrocket | Currently used for on-device testing | Built-in App guide |
|
||||
| Surge | Configuration provided, not fully verified | Contributions wanted |
|
||||
| Quantumult X | Configuration provided, not fully verified | Contributions wanted |
|
||||
| Loon | Configuration provided, not fully verified | Contributions wanted |
|
||||
| Stash | Configuration provided, not fully verified | Contributions wanted |
|
||||
| Egern | Uses the Surge module, not fully verified | Contributions wanted |
|
||||
|
||||
Community configurations are reviewed by client. Accepted submissions are linked in this table with attribution unless
|
||||
the contributor requests anonymous inclusion.
|
||||
|
||||
Module snapshots and provenance:
|
||||
|
||||
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
The selected client owns its certificates, MITM configuration, and proxy switches. Review third-party modules and
|
||||
scripts before importing them.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install the App
|
||||
|
||||
- Download a build from [Releases](https://github.com/xweiba/location-spoofer/releases) and self-sign; or
|
||||
- Build from source on macOS with Xcode — see the [build guide](docs/BUILD.md).
|
||||
You can use:
|
||||
|
||||
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.
|
||||
- Your own Apple Developer signing environment;
|
||||
- A self-signing tool suitable for personal testing;
|
||||
- The unsigned IPA published in Releases;
|
||||
- A source build produced on macOS using the [build guide](docs/BUILD.md).
|
||||
|
||||
### 2. Choose a Runtime Mode
|
||||
Free self-signing environments may not provide Network Extension capabilities. App Mode therefore uses an on-device
|
||||
proxy plus a manual Wi-Fi HTTP proxy and does not depend on a VPN component.
|
||||
|
||||
#### App Mode
|
||||
#### Self-Signing Instructions
|
||||
|
||||
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.
|
||||
Release assets are unsigned IPA files and must be installed on an iPhone with a self-signing tool:
|
||||
|
||||
#### Third-party Proxy Mode
|
||||
1. **Enable sideloading support**: On iOS 16 or newer, open Settings → Privacy & Security → Developer Mode, enable it,
|
||||
then restart and confirm when prompted. iOS 15 does not have this switch, so skip this step.
|
||||
2. **Download the IPA**: Open this project's [Releases](https://github.com/xweiba/location-spoofer/releases) and download
|
||||
the latest `PaopaoLocationSpoofer-unsigned.ipa`.
|
||||
3. **Prepare signing software**: Download the appropriate Impactor build from
|
||||
[Impactor Releases](https://github.com/claration/Impactor/releases). Other tools that support self-signing and installing
|
||||
IPA files, such as Aisi Assistant, may also be used.
|
||||
4. **Connect and install**: Connect the iPhone to the computer with a USB cable, choose “Trust This Computer” on the
|
||||
phone, select the downloaded IPA in the signing software, and follow that tool's instructions to sign and install it.
|
||||
|
||||
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.
|
||||
Impactor supports Windows, macOS, and Linux. If Windows cannot detect the device, install the Apple device drivers supplied
|
||||
with iTunes first. Aisi Assistant is third-party software; obtain it from its official channel and evaluate its account,
|
||||
certificate, and privacy risks yourself.
|
||||
|
||||
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).
|
||||
After installation, if iOS blocks the app from opening, go to Settings → General → VPN & Device Management and trust the
|
||||
corresponding developer app. A free Apple ID signature normally expires after seven days and must then be renewed.
|
||||
|
||||
### 3. Local-mode CA Setup
|
||||
### 2. First Launch
|
||||
|
||||
Follow the first-setup wizard to download the profile, then:
|
||||
1. Choose App Mode or Third-party Proxy Mode;
|
||||
2. Complete the corresponding in-app setup;
|
||||
3. Run the environment check;
|
||||
4. Search, tap, or drag on the map to select a test location;
|
||||
5. Enable the test location and verify the result in the authorized test environment.
|
||||
|
||||
### 3. Restore the Real Location
|
||||
|
||||
App Mode:
|
||||
|
||||
1. Stop the test location;
|
||||
2. Disable the manual HTTP proxy on the current Wi-Fi network;
|
||||
3. Follow the in-app instructions to refresh the location environment.
|
||||
|
||||
Third-party Proxy Mode:
|
||||
|
||||
1. Clear the WLOC coordinates from the app;
|
||||
2. Disable the corresponding module or proxy in the third-party client;
|
||||
3. Restore HTTPS decryption and proxy settings according to the client documentation.
|
||||
|
||||
Location caches may take time to refresh. Restart the device if the system or target app continues to show an old
|
||||
location.
|
||||
|
||||
## Coordinate Handling
|
||||
|
||||
The project stores two coordinate representations:
|
||||
|
||||
- WGS-84: the international standard used for WLOC writes;
|
||||
- GCJ-02: the domestic map representation used where required.
|
||||
|
||||
MapKit does not expose a public API that reports whether its current runtime output uses GCJ-02 or WGS-84. The project
|
||||
uses a fixed-anchor query to resolve the active representation and performs controlled refreshes after blue-dot changes
|
||||
and explicit user actions.
|
||||
|
||||
Each write boundary stores a complete WGS-84/GCJ-02 pair. Rendering selects the field matching the confirmed map
|
||||
representation instead of repeatedly converting an already typed value.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
Settings → General → VPN & Device Management → install WLOC CA
|
||||
Settings → General → About → Certificate Trust Settings → enable full trust
|
||||
App/ SwiftUI interface, MapKit, location, and runtime flow
|
||||
Core/ Go proxy, certificate server, and location-response handling
|
||||
Shared/ Coordinates, favorites, logs, configuration, and shared models
|
||||
Resources/ Info.plist, Entitlements, and resources
|
||||
Config/ Build configuration
|
||||
Scripts/ Build, packaging, and validation scripts
|
||||
Tests/ XCTest and Shell contract tests
|
||||
docs/ Build, module, and release documentation
|
||||
```
|
||||
|
||||
Configure the current Wi-Fi proxy before installing the CA:
|
||||
## Building the Project
|
||||
|
||||
On the current Wi‑Fi's proxy settings, choose "Manual":
|
||||
Source builds require:
|
||||
|
||||
```text
|
||||
Server: 127.0.0.1
|
||||
Port: 8888
|
||||
Authentication: off
|
||||
```
|
||||
- macOS;
|
||||
- Xcode;
|
||||
- Xcode Command Line Tools;
|
||||
- XcodeGen;
|
||||
- Go 1.23 or newer.
|
||||
|
||||
### 4. Select a Location & Enable
|
||||
|
||||
1. Search, tap, or drag the map to pick a location; tap the real-time location button to jump to the MapKit blue dot.
|
||||
2. 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.
|
||||
|
||||
### 5. Restore Your Real Location
|
||||
|
||||
Stop spoofing, remove the manual proxy from the current Wi‑Fi, and follow the in‑app deactivation instructions to refresh the system location cache. If stale cache persists, restart your device.
|
||||
|
||||
## Why No VPN?
|
||||
|
||||
```text
|
||||
iPhone location request
|
||||
│ Wi‑Fi HTTP proxy: 127.0.0.1:8888
|
||||
▼
|
||||
Local wloccore Go proxy
|
||||
│ Handles only the targeted Apple location-service traffic
|
||||
▼
|
||||
Apple location service response
|
||||
│ The selected coordinate is written into the response
|
||||
▼
|
||||
The system and applications receive the modified result
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
| Item | Requirement |
|
||||
|---|---|
|
||||
| iOS | 15.0+ |
|
||||
| Build | macOS, Xcode, XcodeGen |
|
||||
| Swift | 5.9 |
|
||||
| Go | 1.23+ |
|
||||
| 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.
|
||||
|
||||
## Build & Project Structure
|
||||
Building the iOS app directly on Windows is not supported.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xweiba/location-spoofer.git
|
||||
cd location-spoofer
|
||||
|
||||
./build.sh
|
||||
```
|
||||
|
||||
The unsigned IPA is at:
|
||||
Build and run Simulator tests:
|
||||
|
||||
```bash
|
||||
./build.sh --test
|
||||
```
|
||||
|
||||
The build script generates an unsigned IPA:
|
||||
|
||||
```text
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
```text
|
||||
App/ SwiftUI, MapKit, location and setup flow
|
||||
Core/ Go local proxy and location response rewriting
|
||||
Shared/ Favorites, settings, logs, and shared models
|
||||
Resources/ Info.plist, Entitlements, and icons
|
||||
Scripts/ Build, signing, and verification scripts
|
||||
Tests/ XCTest and Bash contract tests
|
||||
docs/ Build, self-signing, and changelog documentation
|
||||
```
|
||||
Deploy it to a test device using your own signing and installation process.
|
||||
|
||||
## Documentation & Feedback
|
||||
## Privacy and Security Boundaries
|
||||
|
||||
- The project contains no telemetry or remote-control service;
|
||||
- The project does not automatically upload location data;
|
||||
- Runtime logs remain in the device App Group container and retain only the latest three days;
|
||||
- Issue reports are copied by the user before being submitted to GitHub;
|
||||
- App Mode accesses the local proxy and the environment-verification URL;
|
||||
- Third-party Proxy Mode may access the upstream module URL and the WLOC configuration endpoint;
|
||||
- The CA private key generated by the app is stored in the device Keychain;
|
||||
- Third-party MITM, certificates, and proxy behavior are owned by the selected client.
|
||||
|
||||
Do not post real locations, authentication information, CA private keys, or complete sensitive logs in public issues.
|
||||
|
||||
## Limitations
|
||||
|
||||
- iOS updates may change location-service behavior;
|
||||
- MapKit coordinate output can vary with the system, region, and location environment;
|
||||
- System location caches may delay visible changes;
|
||||
- Each third-party proxy client requires separate compatibility testing;
|
||||
- Not every app uses the same location API;
|
||||
- Not every app or service accepts test coordinates;
|
||||
- Behavior is not guaranteed across every network, device model, or iOS version.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome for:
|
||||
|
||||
- [Bug reports](https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml);
|
||||
- [Feature requests](https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE);
|
||||
- [Usage help and compatibility results](https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9);
|
||||
- [Third-party client configurations and sanitized screenshots](https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB);
|
||||
- Performance improvements;
|
||||
- Documentation improvements;
|
||||
- Additional tests.
|
||||
|
||||
For bugs, generate a report from **Settings → Support → Report Bug** in the App whenever possible. The report includes:
|
||||
|
||||
- iOS version;
|
||||
- App version;
|
||||
- Runtime mode;
|
||||
- Selected third-party client;
|
||||
- Whether the issue is reproducible;
|
||||
- Problem description;
|
||||
- Sanitized runtime logs;
|
||||
|
||||
Paste it into the **App-generated diagnostic report** field in the GitHub Issue Form.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Build guide](docs/BUILD.md)
|
||||
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Changelog](docs/CHANGELOG.md)
|
||||
- [中文文档](README.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
When reporting issues, please include reproduction steps, iOS version, device model, and sanitized runtime logs.
|
||||
## Feature Preview
|
||||
|
||||
## Links
|
||||
These screenshots show the main interface and selected on-device test scenarios. Actual results depend on the iOS
|
||||
version, network environment, system caches, and the target app's location strategy; they are not a compatibility
|
||||
guarantee for every app or release.
|
||||
|
||||
**LinuxDo** — [https://linux.do](https://linux.do/)
|
||||
<table>
|
||||
<tr>
|
||||
<th>Main interface</th>
|
||||
<th>Apple Maps test</th>
|
||||
<th>Amap test</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="Location Spoofer map selection interface" width="220"></td>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="Apple Maps location test scenario" width="220"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="Amap location test scenario" width="220"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>WeChat test</th>
|
||||
<th>DingTalk test</th>
|
||||
<th>Apple Watch scenario test</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/微信.jpg" alt="WeChat location test scenario" width="220"></td>
|
||||
<td><img src="images/钉钉.jpg" alt="DingTalk location test scenario" width="220"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch region-feature test scenario" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Acknowledgements and Links
|
||||
|
||||
The core location-response handling approach, Go implementation, and third-party modules are based on:
|
||||
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
- [ios-location-spoofer](https://github.com/mekos2772/ios-location-spoofer)
|
||||
|
||||
Thanks to the following LINUX DO users for their contributions:
|
||||
|
||||
- Bug fixes: [Chen Ze](https://linux.do/u/lixiaobaivv)
|
||||
- Ideas and suggestions: [Alex](https://linux.do/u/_alex), [ye4241](https://linux.do/u/ye4241)
|
||||
|
||||
Links:
|
||||
|
||||
- [LINUX DO](https://linux.do/)
|
||||
- [iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)
|
||||
|
||||
Thanks to the open-source contributors working on iOS location-service research, network proxies, and mobile testing
|
||||
tools.
|
||||
|
||||
@@ -2,227 +2,445 @@
|
||||
|
||||
# 📍 Location Spoofer
|
||||
|
||||
### iOS 虚拟定位 · 钉钉定位 · 微信定位 · Apple Watch 国区功能解锁 · Fake GPS
|
||||
### iOS Location Service Research & Testing Framework
|
||||
|
||||
**无需越狱;可使用 APP模式的本机 Wi‑Fi HTTP 代理,或第三方代理模式(支持 Wi‑Fi/4G/5G)改写 Apple 定位响应。**<br>
|
||||
可修改钉钉、微信及任意依赖系统定位的 App 的位置。地图选点、实时位置、环境检测、证书配置与运行日志集中在一个 App 中。
|
||||
一个用于 **iOS 定位服务研究、软件开发测试和 QA 验证** 的开源项目。
|
||||
|
||||
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
|
||||
不同地理位置和定位场景下的行为。
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](#为什么不需要-vpn)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[功能介绍](#核心功能) · [安装使用](#快速开始) · [English](README.en.md) · [更新日志](docs/CHANGELOG.md)
|
||||
|
||||
<img src="images/主界面.jpg" alt="Location Spoofer iOS 虚拟定位 Fake GPS 主界面" width="380">
|
||||
[功能概览](#功能概览) ·
|
||||
[工作原理](#工作原理) ·
|
||||
[快速开始](#快速开始) ·
|
||||
[构建项目](#构建项目) ·
|
||||
[English](README.en.md)
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本项目用于学习、安全研究与自有设备测试。APP模式需要安装自签 CA,并在当前 Wi‑Fi 上配置本机 HTTP 代理;第三方代理模式的证书、MITM 和代理/VPN 连接由所选客户端处理。请先阅读工作原理和风险说明,并遵守当地法律、网络管理规则及相关服务条款。
|
||||
> 本项目用于学习研究、自有设备测试、软件开发和 QA 验证。
|
||||
>
|
||||
> 请仅在你拥有或获得授权的设备、网络和软件环境中使用,并遵守当地法律法规、网络管理规定以及相关服务条
|
||||
> 款。
|
||||
>
|
||||
> 本项目不保证兼容所有 iOS 版本或第三方应用,也不承诺绕过第三方应用的安全策略、业务限制或服务规则。
|
||||
|
||||
## 致谢
|
||||
## 项目定位
|
||||
|
||||
核心定位响应改写思路与 Go 实现来源于 [Yu9191/wloc](https://github.com/Yu9191/wloc)。本项目在此基础上增加 SwiftUI 界面、MapKit 选点、证书与代理引导、环境验证、收藏和诊断能力。
|
||||
Location Spoofer 是一个面向 iOS 定位服务行为研究和开发测试的工具。
|
||||
|
||||
## 为什么选择 Location Spoofer?
|
||||
它提供:
|
||||
|
||||
很多 iOS 虚拟定位工具依赖电脑常驻、开发者调试、VPN 或越狱。本项目采用不同路线:在 iPhone 本机运行 Go 代理,仅对 Apple 定位服务目标请求进行处理。
|
||||
- 原生地图选点和位置场景切换;
|
||||
- Apple 定位服务响应的测试环境模拟;
|
||||
- 本地代理和第三方代理两种运行模式;
|
||||
- 坐标标准识别与 WGS-84 / GCJ-02 双坐标管理;
|
||||
- 环境检测、运行日志和问题诊断;
|
||||
- 收藏位置和上次地图状态恢复。
|
||||
|
||||
| 特性 | 说明 |
|
||||
|---|---|
|
||||
| 🔀 **双运行模式** | APP模式无需第三方客户端、支持 Wi‑Fi;第三方代理模式可覆盖 Wi‑Fi、4G 和 5G |
|
||||
| 📱 **无需越狱** | 支持自行签名安装,最低部署目标为 iOS 15 |
|
||||
| 🗺️ **原生地图体验** | 使用 Apple 地图同款蓝点,搜索、点击、拖动选点体验与 Apple 地图一致 |
|
||||
| 📍 **系统级虚拟定位** | 支持钉钉、微信、Apple 地图、高德等 App 的虚拟实时定位 |
|
||||
| 🔍 **可见缩放范围** | 左侧缩放控件显示当前可视范围,地点名称随级别自动适配 |
|
||||
| 🧪 **环境检测** | 检查本地代理、CA 证书信任与 Wi‑Fi 代理链路 |
|
||||
| 🧾 **诊断日志** | 每条日志独立可复制,方便整理和反馈问题 |
|
||||
项目不修改目标 App 的源代码,也不提供远程控制或数据采集服务。
|
||||
|
||||
## 效果预览
|
||||
## 功能概览
|
||||
|
||||
<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/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>
|
||||
- **原生地图交互**
|
||||
- 使用 MapKit 显示地图和系统蓝点;
|
||||
- 支持搜索、点击选点、拖动地图中心和缩放;
|
||||
- 支持收藏位置和恢复上次选点;
|
||||
- 当前选点同时显示国内坐标和国际坐标,可分别复制。
|
||||
|
||||
## 核心功能
|
||||
- **定位服务响应模拟**
|
||||
- 通过代理层处理指定的 Apple 定位服务请求;
|
||||
- 在测试环境中返回选定的坐标数据;
|
||||
- 不需要修改目标 App 代码。
|
||||
|
||||
- **iOS 虚拟定位 / Fake GPS**:将当前地图选点应用到本机定位响应改写代理,适配钉钉打卡、微信位置共享等场景。
|
||||
- **原生实时位置**:地图显示 MapKit 自带蓝点,不再由 App 额外绘制实时位置标记。
|
||||
- **并发安全选点**:拖动、点击、搜索、收藏和异步定位按用户最新意图处理,旧结果不会覆盖新选点。
|
||||
- **地点名称分级**:近距离显示 POI、门牌或道路;拉远后显示社区、区县、城市或省份。
|
||||
- **地图范围显示**:缩放控件中显示 `180 m`、`2.5 km`、`126 km` 等当前可视范围。
|
||||
- **收藏与快速切换**:保存常用坐标,并明确显示当前准备应用的位置。
|
||||
- **分流配置引导**:首次启动先选择模式;APP模式引导本机代理和 CA,第三方代理模式引导客户端、配置导入和接口检测。
|
||||
- **问题诊断**:内置验证流程和结构化运行日志。
|
||||
- **第三方代理模式(测试)**:内置各客户端模块配置,把收藏或当前选点的 WGS-84 坐标发送到第三方代理模块;代理客户端持久化坐标,关闭本 App 后仍可继续生效。
|
||||
- **双运行模式**
|
||||
- APP 模式:在设备内运行 Go 代理,仅支持当前 Wi-Fi 网络;
|
||||
- 第三方代理模式:通过支持的代理客户端覆盖 Wi-Fi、4G 或 5G,具体能力取决于客户端。
|
||||
|
||||
- **环境检测**
|
||||
- APP 模式检测本地代理、证书信任和请求链路;
|
||||
- 第三方代理模式检测 WLOC 配置接口和模块响应;
|
||||
- 失败时提供对应的配置或诊断入口。
|
||||
|
||||
- **开发调试**
|
||||
- 运行日志;
|
||||
- 日志复制和清理;
|
||||
- 坐标标准变化记录;
|
||||
- 脱敏问题报告生成。
|
||||
|
||||
## 工作原理
|
||||
|
||||
### APP 模式
|
||||
|
||||
APP 模式在设备内运行本地 Go 代理,并通过当前 Wi-Fi 的手动 HTTP 代理让指定请求经过本地代理。
|
||||
|
||||
```text
|
||||
iOS 定位请求
|
||||
│
|
||||
│ 当前 Wi-Fi 手动 HTTP 代理
|
||||
▼
|
||||
设备内 wloccore Go 代理
|
||||
│
|
||||
│ 处理指定 Apple 定位服务请求
|
||||
▼
|
||||
Apple 定位服务响应
|
||||
│
|
||||
│ 测试坐标响应
|
||||
▼
|
||||
系统和应用读取定位结果
|
||||
```
|
||||
|
||||
APP 模式:
|
||||
|
||||
- 不创建 Network Extension;
|
||||
- 不显示或占用系统 VPN;
|
||||
- 只覆盖当前 Wi-Fi 网络;
|
||||
- 需要配置当前 Wi-Fi 的手动 HTTP 代理;
|
||||
- 需要安装并信任 App 生成的本机 CA;
|
||||
- 代理只处理项目定义的 Apple 定位服务和环境验证请求,不是通用网络抓包工具。
|
||||
|
||||
### 第三方代理模式
|
||||
|
||||
第三方代理模式不启动 App 内置 Go 代理,也不使用 App 生成的 CA。
|
||||
|
||||
```text
|
||||
地图选点
|
||||
│
|
||||
│ WGS-84 坐标
|
||||
▼
|
||||
WLOC 配置接口
|
||||
│
|
||||
▼
|
||||
第三方代理客户端保存配置
|
||||
│
|
||||
▼
|
||||
第三方代理客户端处理定位服务请求
|
||||
```
|
||||
|
||||
在该模式下:
|
||||
|
||||
- App 负责地图选点、收藏、坐标同步和清除;
|
||||
- 第三方客户端负责代理/VPN、MITM、证书和规则执行;
|
||||
- 坐标持久化由第三方客户端负责;
|
||||
- 是否支持 Wi-Fi、4G 或 5G 取决于客户端;
|
||||
- App 关闭后,第三方客户端中的配置可能继续生效。
|
||||
|
||||
#### 配置接口与客户端适配
|
||||
|
||||
App 不会把坐标上传到项目服务器。它会发起以下 GET 请求,第三方客户端必须在设备本地拦截:
|
||||
|
||||
```text
|
||||
https://gs-loc.apple.com/wloc-settings/save
|
||||
```
|
||||
|
||||
| 操作 | 查询参数 | 用途 |
|
||||
|---|---|---|
|
||||
| 查询 | `action=query` | 检查模块是否连接,并读取当前保存的坐标 |
|
||||
| 保存 | `lon=<WGS-84 经度>&lat=<WGS-84 纬度>&acc=<精度>` | 保存当前选点 |
|
||||
| 清除 | `action=clear` | 删除已保存的测试坐标 |
|
||||
|
||||
拦截脚本必须返回 HTTP 200 和 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"longitude": 113.0,
|
||||
"latitude": 22.0,
|
||||
"accuracy": 25
|
||||
}
|
||||
```
|
||||
|
||||
失败时返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误说明"
|
||||
}
|
||||
```
|
||||
|
||||
查询时没有已保存坐标,可以返回 `{"success":false,"error":"无已保存的坐标"}`。App 会把它识别为
|
||||
“模块已连接,但虚拟定位未开启”。保存成功时,响应中的经纬度必须与请求中的 WGS-84 坐标一致。
|
||||
|
||||
要适配新的第三方客户端,需要:
|
||||
|
||||
1. 为 `gs-loc.apple.com/wloc-settings/save` 添加 HTTP 请求脚本,解析上述参数并返回约定 JSON;
|
||||
2. 使用客户端的持久化存储保存坐标、精度和可选状态;
|
||||
3. 为 `gs-loc.apple.com` 和 `gs-loc-cn.apple.com` 配置 HTTPS 解密;
|
||||
4. 拦截 `gs-loc(-cn).apple.com/clls/wloc` 响应,读取同一份持久化数据并修改 WLOC 响应;
|
||||
5. 提供可订阅的模块文件,并确认查询、保存、清除和定位恢复都能在真机完成。
|
||||
|
||||
App 只验证配置接口的 HTTP 状态、JSON 格式和坐标回读,不管理第三方客户端的证书、MITM、VPN 或代理状态。
|
||||
|
||||
不要同时启用 APP 模式代理和第三方代理模式,避免两个代理链路互相干扰。
|
||||
|
||||
## 运行模式
|
||||
|
||||
### APP 模式
|
||||
|
||||
适用于:
|
||||
|
||||
- 只使用 Wi-Fi 的设备测试;
|
||||
- 不依赖第三方代理客户端的本地验证;
|
||||
- 需要在 App 内完成代理、证书和环境检测的场景。
|
||||
|
||||
使用条件:
|
||||
|
||||
- iOS 设备;
|
||||
- 当前 Wi-Fi 支持手动 HTTP 代理;
|
||||
- 安装并信任本机 CA;
|
||||
- 在 App 内完成代理配置和环境检测。
|
||||
|
||||
### 第三方代理模式
|
||||
|
||||
适用于:
|
||||
|
||||
- 需要 Wi-Fi、4G 或 5G 网络覆盖的测试;
|
||||
- 已经使用支持模块或脚本的代理客户端;
|
||||
- 希望由第三方客户端继续保持代理配置的场景。
|
||||
|
||||
当前客户端状态:
|
||||
|
||||
| 客户端 | 状态 | 社区配置 |
|
||||
|---|---|---|
|
||||
| Shadowrocket | 当前用于真机测试 | App 内置教程 |
|
||||
| Surge | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Quantumult X | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Loon | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Stash | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Egern | 使用 Surge 模块,尚未完整验证 | 待征集 |
|
||||
|
||||
社区配置按客户端分区审核;采纳后会在上表链接教程和投稿者,投稿者也可以选择匿名收录。
|
||||
|
||||
相关模块快照和来源记录:
|
||||
|
||||
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
第三方客户端、证书、MITM 和代理开关由客户端自身负责。导入任何第三方模块前,请先审查其配置和脚本内容。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 App
|
||||
### 1. 安装应用
|
||||
|
||||
- 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 获取构建产物并自行签名;或
|
||||
- 在 macOS + Xcode 环境按[构建说明](docs/BUILD.md)编译。
|
||||
你可以使用:
|
||||
|
||||
#### 自签安装
|
||||
- 自己的 Apple Developer 签名环境;
|
||||
- 适合个人测试的自签工具;
|
||||
- 项目 Releases 中的未签名 IPA;
|
||||
- 在 macOS 上按[构建说明](docs/BUILD.md)自行构建。
|
||||
|
||||
免费 Apple ID 即可侧载,无需付费开发者账号。本项目不使用 VPN、Network Extension 或 Packet Tunnel Provider,但签名工具仍需保留 App 的能力与标识。
|
||||
免费自签环境可能无法使用 Network Extension,因此 APP 模式采用设备内本地代理和 Wi-Fi 手动代理,不依赖
|
||||
VPN 组件。
|
||||
|
||||
需要自行构建时执行:
|
||||
#### 自签安装说明
|
||||
|
||||
Release 附件是未签名 IPA,需要使用自签工具安装到 iPhone:
|
||||
|
||||
1. **开启自签支持**:iOS 16 及以上版本前往“设置 → 隐私与安全性 → 开发者模式”,开启后按系统提示重启并
|
||||
确认;iOS 15 没有此开关,可跳过本步。
|
||||
2. **下载 IPA**:前往本项目的 [Releases](https://github.com/xweiba/location-spoofer/releases),下载最新的
|
||||
`PaopaoLocationSpoofer-unsigned.ipa`。
|
||||
3. **准备自签软件**:前往 [Impactor Releases](https://github.com/claration/Impactor/releases) 下载对应系统
|
||||
版本的 Impactor;也可以使用爱思助手等支持 IPA 自签安装的软件。
|
||||
4. **连接并安装**:使用 USB 数据线连接 iPhone 与电脑,在手机上选择“信任此电脑”,然后在自签软件中选择
|
||||
刚下载的 IPA,根据软件提示完成签名与安装。
|
||||
|
||||
Impactor 支持 Windows、macOS 和 Linux;Windows 若无法识别设备,请先安装 iTunes 提供的 Apple 设备驱动。
|
||||
爱思助手属于第三方软件,请从其官方渠道获取,并自行评估账号、证书和隐私风险。
|
||||
|
||||
安装完成后,若 iOS 阻止打开 App,请前往“设置 → 通用 → VPN 与设备管理”信任对应的开发者 App。免费
|
||||
Apple ID 自签通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
### 2. 首次启动
|
||||
|
||||
首次启动时:
|
||||
|
||||
1. 选择 APP 模式或第三方代理模式;
|
||||
2. 按照 App 内引导完成对应配置;
|
||||
3. 执行环境检测;
|
||||
4. 在地图中搜索、点击或拖动选择测试位置;
|
||||
5. 启用测试位置并在目标测试环境中验证结果。
|
||||
|
||||
### 3. 恢复真实位置
|
||||
|
||||
APP 模式:
|
||||
|
||||
1. 停止测试位置;
|
||||
2. 关闭当前 Wi-Fi 的手动 HTTP 代理;
|
||||
3. 按 App 内提示刷新定位环境。
|
||||
|
||||
第三方代理模式:
|
||||
|
||||
1. 使用 App 清除 WLOC 坐标;
|
||||
2. 在第三方客户端中关闭对应模块或代理;
|
||||
3. 按客户端要求恢复 HTTPS 解密和代理设置。
|
||||
|
||||
如果系统或目标应用仍显示旧位置,可能需要等待定位缓存刷新,必要时重启设备后再次验证。
|
||||
|
||||
## 坐标处理
|
||||
|
||||
项目内部保存两种坐标表示:
|
||||
|
||||
- WGS-84:国际标准,WLOC 写入使用此坐标;
|
||||
- GCJ-02:国内地图标准,用于需要国内地图坐标的场景。
|
||||
|
||||
MapKit 不提供公开 API 直接返回当前是否使用 GCJ-02 或 WGS-84。项目通过固定锚点查询判断 MapKit 当前返回
|
||||
标准,并在运行期间根据蓝点变化和用户操作进行受控刷新。
|
||||
|
||||
坐标写入边界会保存完整的 WGS-84 / GCJ-02 坐标对,使用时根据当前地图标准选择对应字段,避免重复转换造成
|
||||
位置偏移。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
App/ SwiftUI 界面、MapKit、定位和运行流程
|
||||
Core/ Go 代理、证书服务和定位响应处理
|
||||
Shared/ 坐标、收藏、日志、配置和共享模型
|
||||
Resources/ Info.plist、Entitlements 和资源文件
|
||||
Config/ 构建配置
|
||||
Scripts/ 构建、打包和验证脚本
|
||||
Tests/ XCTest 和 Shell contract tests
|
||||
docs/ 构建、模块和版本文档
|
||||
```
|
||||
|
||||
## 构建项目
|
||||
|
||||
源码构建需要 macOS 环境:
|
||||
|
||||
- macOS;
|
||||
- Xcode;
|
||||
- Xcode Command Line Tools;
|
||||
- XcodeGen;
|
||||
- Go 1.23 或更高版本。
|
||||
|
||||
当前项目不支持在 Windows 上直接构建 iOS 应用。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xweiba/location-spoofer.git
|
||||
cd location-spoofer
|
||||
|
||||
./build.sh
|
||||
```
|
||||
|
||||
输出文件为 `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
|
||||
|
||||
按首次引导下载描述文件,然后完成:
|
||||
|
||||
```text
|
||||
设置 → 通用 → VPN 与设备管理 → 安装 WLOC CA
|
||||
设置 → 通用 → 关于本机 → 证书信任设置 → 完全信任
|
||||
```
|
||||
|
||||
#### 第三方代理模式
|
||||
|
||||
此模式由 App 负责地图选点、收藏和发送 WGS-84 坐标;WLOC 拦截、MITM 与坐标持久化由第三方代理客户端负责。它不会启动本机 Go 代理,不检查或使用 App 的 CA,也不要求配置 `127.0.0.1:8888`,可用于 Wi‑Fi、4G 和 5G。
|
||||
|
||||
首次引导或“设置 → 运行模式”切换后,选择客户端。App 提供官方订阅地址复制和客户端跳转按钮;在对应客户端的模块、重写或覆写订阅入口粘贴地址并导入。仓库和 App 包内仍保留以下模块配置快照,用于版本归档和离线核对:
|
||||
|
||||
| 客户端 | 状态 | 模块 |
|
||||
|---|---|---|
|
||||
| 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. APP 模式点击“开始虚拟定位”并等待环境检测;第三方代理模式点击“同步到第三方代理”。
|
||||
3. 按 App 内"生效说明"刷新飞行模式、Wi‑Fi 和定位服务状态。
|
||||
4. 打开 Apple 地图或目标 App 验证结果。
|
||||
|
||||
### 4. 恢复真实位置
|
||||
|
||||
停止虚拟定位,关闭当前 Wi‑Fi 的手动代理,并按 App 内"失效说明"刷新系统定位缓存。若系统仍保留旧缓存,请重启设备后再检查。
|
||||
|
||||
## 为什么不需要 VPN?
|
||||
|
||||
```text
|
||||
iPhone 定位请求
|
||||
│ 当前 Wi‑Fi HTTP 代理:127.0.0.1:8888
|
||||
▼
|
||||
本机 wloccore(Go)
|
||||
│ 仅处理目标 Apple 定位服务请求
|
||||
├──────────────► Apple 定位服务
|
||||
◄──────────────┘
|
||||
│ 改写目标响应中的坐标
|
||||
▼
|
||||
系统与应用读取定位结果
|
||||
```
|
||||
|
||||
APP 模式不使用 Network Extension 创建 VPN 隧道,因此不会显示 VPN 连接,也不会占用系统 VPN。**APP 模式仍需要为当前 Wi‑Fi 配置 HTTP 代理,并安装、信任本机生成的 CA。** 第三方代理模式则由所选代理客户端管理代理/VPN 和 MITM,可覆盖蜂窝网络;两种模式不得同时拦截 WLOC 请求。
|
||||
|
||||
## 兼容性
|
||||
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| iOS | 15.0+ |
|
||||
| 构建 | macOS、Xcode、XcodeGen |
|
||||
| Swift | 5.9 |
|
||||
| Go | 1.23+ |
|
||||
| 网络 | APP 模式:可手动配置 HTTP 代理的 Wi‑Fi;第三方代理模式(测试):取决于代理客户端,可覆盖 Wi‑Fi/4G/5G |
|
||||
| 安装 | 自行签名或使用 Releases 构建产物 |
|
||||
|
||||
效果会受到 iOS 版本、网络、系统定位缓存和目标 App 自身策略影响,不承诺兼容所有系统或第三方 App。
|
||||
|
||||
## 构建与项目结构
|
||||
运行构建并执行 Simulator 测试:
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
./build.sh --test
|
||||
```
|
||||
|
||||
构建产物默认位于:
|
||||
构建脚本会生成未签名 IPA:
|
||||
|
||||
```text
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
```text
|
||||
App/ SwiftUI、MapKit、定位和配置流程
|
||||
Core/ Go 本机代理与定位响应改写
|
||||
Shared/ 收藏、设置、日志和共享模型
|
||||
Resources/ Info.plist、Entitlements 与图标
|
||||
Scripts/ 构建、签名和检查脚本
|
||||
Tests/ XCTest 与 Bash 契约测试
|
||||
docs/ 构建和版本发布归档
|
||||
```
|
||||
之后需要使用你自己的签名和安装流程部署到测试设备。
|
||||
|
||||
## 文档与反馈
|
||||
## 隐私与安全边界
|
||||
|
||||
- 项目不包含遥测或远程控制服务;
|
||||
- 项目不会自动上传用户位置数据;
|
||||
- 运行日志保存在设备 App Group 容器中,并自动保留近三天;
|
||||
- 问题报告需要用户主动复制后提交到 GitHub;
|
||||
- APP 模式会访问本机代理和环境验证地址;
|
||||
- 第三方代理模式可能访问上游模块地址和 WLOC 配置接口;
|
||||
- App 生成的 CA 私钥保存在设备 Keychain 中;
|
||||
- 第三方客户端模块、MITM 和证书链路由用户选择的客户端负责。
|
||||
|
||||
请不要把真实位置、认证信息、证书私钥或完整敏感日志提交到公开 Issue。
|
||||
|
||||
## 限制
|
||||
|
||||
- iOS 系统版本变化可能影响定位服务行为;
|
||||
- MapKit 的坐标返回标准可能随系统、地区和定位环境变化;
|
||||
- 系统定位存在缓存,切换位置后不一定立即生效;
|
||||
- 第三方代理客户端的兼容性和规则行为需要分别验证;
|
||||
- 不保证所有应用都使用同一种定位 API;
|
||||
- 不保证所有应用或服务都接受测试坐标;
|
||||
- 不保证在所有网络环境、设备型号和 iOS 版本上表现一致。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交:
|
||||
|
||||
- [Bug Report](https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml);
|
||||
- [功能建议](https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE);
|
||||
- [使用帮助与兼容性测试](https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9);
|
||||
- [第三方客户端配置和脱敏原始截图](https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB);
|
||||
- 性能改进;
|
||||
- 文档改进;
|
||||
- 测试补充。
|
||||
|
||||
提交 Bug 时建议优先通过 App 的“设置 → 支持 → 报告 Bug”生成报告。报告包含:
|
||||
|
||||
- iOS 版本;
|
||||
- App 版本;
|
||||
- 使用的运行模式;
|
||||
- 当前第三方客户端;
|
||||
- 是否可以稳定复现;
|
||||
- 问题描述;
|
||||
- 脱敏后的运行日志;
|
||||
|
||||
GitHub Issue Form 中的“App 生成的诊断报告”字段与 App 复制内容一一对应。
|
||||
|
||||
## 文档
|
||||
|
||||
- [构建说明](docs/BUILD.md)
|
||||
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
|
||||
- [社区客户端教程与截图提交](docs/COMMUNITY_TUTORIALS.md)
|
||||
- [更新日志](docs/CHANGELOG.md)
|
||||
- [English README](README.en.md)
|
||||
- [英文文档](README.en.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
反馈问题时,请附上复现步骤、iOS 版本、设备型号及已脱敏的运行日志。
|
||||
## 功能预览
|
||||
|
||||
## 友链
|
||||
以下截图用于展示主界面和部分真机测试场景。实际结果会受到 iOS 版本、网络环境、系统缓存和目标应用定位策略
|
||||
影响,不代表对所有应用或版本作出兼容性保证。
|
||||
|
||||
**LinuxDo** — [https://linux.do](https://linux.do/)
|
||||
<table>
|
||||
<tr>
|
||||
<th>应用主界面</th>
|
||||
<th>Apple 地图测试</th>
|
||||
<th>高德地图测试</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/主界面.jpg" alt="Location Spoofer 地图选点主界面" width="220"></td>
|
||||
<td><img src="images/Apple%20Map.jpg" alt="Apple 地图定位测试场景" width="220"></td>
|
||||
<td><img src="images/高德地图.jpg" alt="高德地图定位测试场景" width="220"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>微信测试</th>
|
||||
<th>钉钉测试</th>
|
||||
<th>Apple Watch 场景测试</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="images/微信.jpg" alt="微信定位测试场景" width="220"></td>
|
||||
<td><img src="images/钉钉.jpg" alt="钉钉定位测试场景" width="220"></td>
|
||||
<td><img src="images/高血压.jpg" alt="Apple Watch 地区功能测试场景" width="220"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 致谢与友链
|
||||
|
||||
核心定位响应处理思路、Go 实现和第三方模块参考自:
|
||||
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
- [ios-location-spoofer](https://github.com/mekos2772/ios-location-spoofer)
|
||||
|
||||
感谢以下 LINUX DO 用户对项目的贡献:
|
||||
|
||||
- 功能修复:[陈泽](https://linux.do/u/lixiaobaivv)
|
||||
- 思路及建议:[Alex](https://linux.do/u/_alex)、[ye4241](https://linux.do/u/ye4241)
|
||||
|
||||
友链:
|
||||
|
||||
- [LINUX DO](https://linux.do/)
|
||||
- [iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)
|
||||
|
||||
感谢开源社区中参与 iOS 定位服务研究、网络代理和移动端测试工具建设的贡献者。
|
||||
|
||||
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-mode-certificate-install.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-mode-certificate-trust.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-mode-wifi-proxy.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadowrocket-config-details.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadowrocket-https-ca.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadowrocket-https-decryption.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadowrocket-module-import.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 60 KiB |
@@ -41,7 +41,7 @@ ${COMMITS}
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 Impact 完成签名安装,无需付费开发者账号。
|
||||
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID \`com.paopaolabs.location-spoofer\`、App Group \`group.com.paopaolabs.location-spoofer\` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
|
||||
@@ -116,3 +116,39 @@ struct VirtualLocationTipPreferences {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls the optional prompt asking users to share a verified third-party setup.
|
||||
struct ThirdPartyCommunityPromptPreferences {
|
||||
static let minimumCountForSuppression = 3
|
||||
|
||||
private enum Key {
|
||||
static let presentationCount = "thirdPartyCommunityPrompt.presentationCount"
|
||||
static let suppressed = "thirdPartyCommunityPrompt.suppressed"
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recordPresentation() -> Int {
|
||||
let next = defaults.integer(forKey: Key.presentationCount) + 1
|
||||
defaults.set(next, forKey: Key.presentationCount)
|
||||
return next
|
||||
}
|
||||
|
||||
func shouldPresent() -> Bool {
|
||||
!defaults.bool(forKey: Key.suppressed)
|
||||
}
|
||||
|
||||
func canSuppress() -> Bool {
|
||||
defaults.integer(forKey: Key.presentationCount) >= Self.minimumCountForSuppression
|
||||
}
|
||||
|
||||
func suppress() {
|
||||
guard canSuppress() else { return }
|
||||
defaults.set(true, forKey: Key.suppressed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import Foundation
|
||||
|
||||
struct AppUpdatePrompt: Identifiable, Equatable {
|
||||
enum Requirement: String {
|
||||
case recommended
|
||||
case required
|
||||
}
|
||||
|
||||
let currentVersion: String
|
||||
let latestVersion: String
|
||||
let minimumSupportedVersion: String
|
||||
let requirement: Requirement
|
||||
let releaseNotes: String?
|
||||
|
||||
var id: String {
|
||||
"\(requirement.rawValue)-\(latestVersion)-\(minimumSupportedVersion)"
|
||||
}
|
||||
}
|
||||
|
||||
struct AppRemoteConfiguration: Decodable, Equatable {
|
||||
let latestVersion: String
|
||||
let minimumSupportedVersion: String
|
||||
let communityPromptClients: [String]
|
||||
|
||||
static let fallback = AppRemoteConfiguration(
|
||||
latestVersion: "1.0.4",
|
||||
minimumSupportedVersion: "1.0.0",
|
||||
communityPromptClients: [
|
||||
ThirdPartyProxyClient.surge.rawValue,
|
||||
ThirdPartyProxyClient.quantumultX.rawValue,
|
||||
ThirdPartyProxyClient.loon.rawValue,
|
||||
ThirdPartyProxyClient.stash.rawValue,
|
||||
ThirdPartyProxyClient.egern.rawValue
|
||||
]
|
||||
)
|
||||
|
||||
init(
|
||||
latestVersion: String,
|
||||
minimumSupportedVersion: String,
|
||||
communityPromptClients: [String]
|
||||
) {
|
||||
self.latestVersion = latestVersion
|
||||
self.minimumSupportedVersion = minimumSupportedVersion
|
||||
self.communityPromptClients = communityPromptClients
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) throws -> AppRemoteConfiguration {
|
||||
let configuration = try JSONDecoder().decode(AppRemoteConfiguration.self, from: data)
|
||||
guard let latest = NumericVersion(configuration.latestVersion),
|
||||
let minimum = NumericVersion(configuration.minimumSupportedVersion),
|
||||
minimum <= latest else {
|
||||
throw AppRemoteConfigurationError.invalidVersion
|
||||
}
|
||||
guard configuration.communityPromptClients.allSatisfy({
|
||||
ThirdPartyProxyClient(rawValue: $0) != nil
|
||||
}) else {
|
||||
throw AppRemoteConfigurationError.invalidClient
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
|
||||
func updatePrompt(currentVersion: String, releaseNotes: String? = nil) -> AppUpdatePrompt? {
|
||||
guard let current = NumericVersion(currentVersion),
|
||||
let latest = NumericVersion(latestVersion),
|
||||
let minimum = NumericVersion(minimumSupportedVersion) else {
|
||||
return nil
|
||||
}
|
||||
if current < minimum {
|
||||
return AppUpdatePrompt(
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: latestVersion,
|
||||
minimumSupportedVersion: minimumSupportedVersion,
|
||||
requirement: .required,
|
||||
releaseNotes: releaseNotes
|
||||
)
|
||||
}
|
||||
if current < latest {
|
||||
return AppUpdatePrompt(
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: latestVersion,
|
||||
minimumSupportedVersion: minimumSupportedVersion,
|
||||
requirement: .recommended,
|
||||
releaseNotes: releaseNotes
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
|
||||
client != .shadowrocket && communityPromptClients.contains(client.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppRemoteConfigurationStore: ObservableObject {
|
||||
static let shared = AppRemoteConfigurationStore()
|
||||
|
||||
@Published private(set) var configuration = AppRemoteConfiguration.fallback
|
||||
|
||||
func apply(_ configuration: AppRemoteConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
|
||||
configuration.requestsCommunityPrompt(for: client)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppRemoteConfigurationService {
|
||||
static let configurationURL = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
||||
)!
|
||||
static let releasesURL = URL(
|
||||
string: "https://github.com/xweiba/location-spoofer/releases/latest"
|
||||
)!
|
||||
|
||||
static func fetch() async -> AppRemoteConfiguration? {
|
||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: configurationURL)
|
||||
request.timeoutInterval = 1.5
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置请求返回非 200,继续使用内置配置")
|
||||
return nil
|
||||
}
|
||||
let configuration = try AppRemoteConfiguration.decode(data)
|
||||
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
|
||||
"最新版本": configuration.latestVersion,
|
||||
"最低版本": configuration.minimumSupportedVersion,
|
||||
"社区征集客户端数": String(configuration.communityPromptClients.count)
|
||||
])
|
||||
return configuration
|
||||
} catch {
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置", details: [
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchReleaseNotes(version: String) async -> String? {
|
||||
guard let url = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 1.5
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明不存在,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
guard let markdown = String(data: data, encoding: .utf8) else {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明编码无效,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
return releaseNotesSummary(markdown)
|
||||
} catch {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
|
||||
"版本": version,
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func releaseNotesSummary(_ markdown: String) -> String? {
|
||||
var items: [String] = []
|
||||
var inMainUpdates = false
|
||||
for rawLine in markdown.components(separatedBy: .newlines) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line == "## 主要更新" {
|
||||
inMainUpdates = true
|
||||
continue
|
||||
}
|
||||
if inMainUpdates, line.hasPrefix("## ") {
|
||||
break
|
||||
}
|
||||
guard inMainUpdates, line.hasPrefix("- ") else { continue }
|
||||
items.append("• " + line.dropFirst(2))
|
||||
}
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
private enum AppRemoteConfigurationError: Error {
|
||||
case invalidVersion
|
||||
case invalidClient
|
||||
}
|
||||
|
||||
private struct NumericVersion: Comparable {
|
||||
let components: [Int]
|
||||
|
||||
init?(_ value: String) {
|
||||
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard !parts.isEmpty else { return nil }
|
||||
var parsed: [Int] = []
|
||||
for part in parts {
|
||||
guard !part.isEmpty, let number = Int(part), number >= 0 else { return nil }
|
||||
parsed.append(number)
|
||||
}
|
||||
components = parsed
|
||||
}
|
||||
|
||||
static func < (lhs: NumericVersion, rhs: NumericVersion) -> Bool {
|
||||
let count = max(lhs.components.count, rhs.components.count)
|
||||
for index in 0..<count {
|
||||
let left = index < lhs.components.count ? lhs.components[index] : 0
|
||||
let right = index < rhs.components.count ? rhs.components[index] : 0
|
||||
if left != right { return left < right }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,12 @@ final class CertificateAuthorityStore {
|
||||
try loadValidKeychainAuthority()
|
||||
}
|
||||
|
||||
func reset() throws {
|
||||
try removeLegacyFiles()
|
||||
try keychain.remove()
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "已删除设备 CA,等待重新生成")
|
||||
}
|
||||
|
||||
private func loadValidKeychainAuthority() throws -> CertificateAuthority? {
|
||||
guard let authority = try keychain.load() else { return nil }
|
||||
guard validator(authority) else {
|
||||
@@ -163,16 +169,20 @@ final class CertificateAuthorityStore {
|
||||
}
|
||||
|
||||
private func removeLegacyFilesBestEffort() {
|
||||
do {
|
||||
try removeLegacyFiles()
|
||||
} catch {
|
||||
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeLegacyFiles() throws {
|
||||
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: url)
|
||||
}
|
||||
if fileManager.fileExists(atPath: directory.path) {
|
||||
try fileManager.removeItem(at: directory)
|
||||
}
|
||||
try? fileManager.removeItem(at: directory)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,13 @@ enum CoordinateConverter {
|
||||
let current: MapCoordinateSystem
|
||||
}
|
||||
|
||||
enum RuntimeMapCoordinateSystemRefreshResult: Equatable {
|
||||
case unchanged(MapCoordinateSystem)
|
||||
case changed(MapCoordinateSystemChange)
|
||||
case unavailable(reason: String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
/// 当前 Apple 地图坐标标准。检测不可用时使用国内 GCJ-02 作为兜底。
|
||||
@MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02
|
||||
@MainActor private static var mapCoordinateSystemCheckPending = false
|
||||
@@ -110,7 +117,7 @@ enum CoordinateConverter {
|
||||
let nextType: MapCoordinateSystem
|
||||
switch await fixedAnchorCoordinateSystemProbe() {
|
||||
case let .response(name, count):
|
||||
nextType = name == "林士街" ? .gcj02 : .wgs84
|
||||
nextType = mapCoordinateSystem(forFixedAnchorFirstResultName: name)
|
||||
initialMapCoordinateSystemUsedFallback = false
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [
|
||||
"首条名称": name,
|
||||
@@ -155,6 +162,79 @@ enum CoordinateConverter {
|
||||
return nextType
|
||||
}
|
||||
|
||||
/// Re-runs the fixed-anchor MapKit behavior probe while the map is alive.
|
||||
/// Runtime failures preserve the last confirmed type: a potentially spoofed
|
||||
/// Core Location sample is not authoritative for MapKit's representation.
|
||||
@MainActor
|
||||
static func refreshRuntimeMapCoordinateSystem(reason: String) async -> RuntimeMapCoordinateSystemRefreshResult {
|
||||
guard !mapCoordinateSystemCheckPending else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测合并到进行中请求", details: [
|
||||
"触发原因": reason,
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return .unchanged(currentMapCoordinateSystem)
|
||||
}
|
||||
mapCoordinateSystemCheckPending = true
|
||||
defer { mapCoordinateSystemCheckPending = false }
|
||||
|
||||
let previous = currentMapCoordinateSystem
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测开始", details: [
|
||||
"触发原因": reason,
|
||||
"检测前标准": previous.rawValue,
|
||||
"锚点": "22.283819,114.158439",
|
||||
"缓存": "false"
|
||||
])
|
||||
|
||||
switch await fixedAnchorCoordinateSystemProbe() {
|
||||
case let .response(name, count):
|
||||
guard !Task.isCancelled else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测结果已过期,取消写入", details: [
|
||||
"触发原因": reason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .cancelled
|
||||
}
|
||||
let detected = mapCoordinateSystem(forFixedAnchorFirstResultName: name)
|
||||
initialMapCoordinateSystemUsedFallback = false
|
||||
guard detected != previous else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测完成,标准未变化", details: [
|
||||
"触发原因": reason,
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"确认标准": detected.rawValue
|
||||
])
|
||||
return .unchanged(detected)
|
||||
}
|
||||
let change = MapCoordinateSystemChange(previous: previous, current: detected)
|
||||
currentMapCoordinateSystem = detected
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测发现切换", details: [
|
||||
"触发原因": reason,
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"from": previous.rawValue,
|
||||
"to": detected.rawValue
|
||||
])
|
||||
return .changed(change)
|
||||
case .unavailable(let failureReason), .timedOut(let failureReason):
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测失败,保留当前标准", details: [
|
||||
"触发原因": reason,
|
||||
"原因": failureReason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .unavailable(reason: failureReason)
|
||||
case .cancelled:
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测已取消", details: [
|
||||
"触发原因": reason,
|
||||
"保留标准": previous.rawValue
|
||||
])
|
||||
return .cancelled
|
||||
}
|
||||
}
|
||||
|
||||
static func mapCoordinateSystem(forFixedAnchorFirstResultName name: String) -> MapCoordinateSystem {
|
||||
name == "林士街" ? .gcj02 : .wgs84
|
||||
}
|
||||
|
||||
/// A user-requested realtime sample is WGS-84 and can correct a provisional
|
||||
/// startup map coordinate system without altering persisted coordinate pairs.
|
||||
@MainActor
|
||||
|
||||
@@ -116,11 +116,24 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
|
||||
accuracy: Int
|
||||
) -> FavoriteLocation {
|
||||
let favorite = FavoriteLocation(
|
||||
name: name,
|
||||
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy
|
||||
save(
|
||||
FavoriteLocation(
|
||||
name: name,
|
||||
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Saves a coordinate pair whose source representation was already typed
|
||||
/// before an asynchronous map-coordinate-system refresh.
|
||||
@discardableResult
|
||||
func save(name: String, coordinatePair: CoordinatePair, accuracy: Int) -> FavoriteLocation {
|
||||
save(FavoriteLocation(name: name, coordinatePair: coordinatePair, accuracy: accuracy))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func save(_ favorite: FavoriteLocation) -> FavoriteLocation {
|
||||
favorites.removeAll {
|
||||
abs($0.coordinatePair.wgs84.latitude - favorite.coordinatePair.wgs84.latitude) < 0.000001
|
||||
&& abs($0.coordinatePair.wgs84.longitude - favorite.coordinatePair.wgs84.longitude) < 0.000001
|
||||
@@ -147,6 +160,18 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
defaults.set(id?.uuidString, forKey: Keys.selectedID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectMatching(coordinatePair: CoordinatePair) -> FavoriteLocation? {
|
||||
let matchingFavorite = favorites.first {
|
||||
$0.coordinatePair.matchesWGS84(
|
||||
latitude: coordinatePair.wgs84.latitude,
|
||||
longitude: coordinatePair.wgs84.longitude
|
||||
)
|
||||
}
|
||||
select(matchingFavorite?.id)
|
||||
return matchingFavorite
|
||||
}
|
||||
|
||||
func rename(_ id: UUID, to name: String) {
|
||||
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
|
||||
favorites[idx].name = name
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum GitHubSubmission {
|
||||
static let communityContributionURL = URL(
|
||||
string: "https://github.com/xweiba/location-spoofer/discussions/new?category=%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB"
|
||||
)!
|
||||
static let usageHelpURL = URL(
|
||||
string: "https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9"
|
||||
)!
|
||||
static let featureRequestURL = URL(
|
||||
string: "https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE"
|
||||
)!
|
||||
static let bugReportURL = URL(
|
||||
string: "https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml"
|
||||
)!
|
||||
|
||||
static func communityContributionTemplate(
|
||||
for client: ThirdPartyProxyClient,
|
||||
systemVersion: String
|
||||
) -> String {
|
||||
"""
|
||||
## 第三方客户端
|
||||
\(client.name)
|
||||
|
||||
## 第三方客户端版本
|
||||
请填写当前使用的第三方客户端版本。
|
||||
|
||||
## iOS 版本
|
||||
iOS \(systemVersion)
|
||||
|
||||
## 配置步骤
|
||||
请描述模块导入、证书或解密设置、代理连接和最终验证过程。
|
||||
|
||||
## 截图与补充说明
|
||||
请附上脱敏原图,并说明每张图片对应的步骤。
|
||||
|
||||
## README 收录署名
|
||||
- [ ] 匿名收录,不在 README 展示投稿账号
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,31 @@ final class ProxyRuntimeModeStore: ObservableObject {
|
||||
private enum Key {
|
||||
static let runtimeMode = "proxyRuntimeMode"
|
||||
static let hasSelectedRuntimeMode = "hasSelectedProxyRuntimeMode"
|
||||
static let localWiFiInitialized = "localWiFiRuntimeModeInitialized"
|
||||
static let thirdPartyInitialized = "thirdPartyRuntimeModeInitialized"
|
||||
static let initializationMigrationCompleted = "runtimeModeInitializationMigrationCompleted"
|
||||
static let legacySetupCompleted = "setupCompleted"
|
||||
}
|
||||
|
||||
@Published private(set) var mode: ProxyRuntimeMode
|
||||
@Published private(set) var hasSelectedMode: Bool
|
||||
@Published private(set) var localWiFiInitialized: Bool
|
||||
@Published private(set) var thirdPartyInitialized: Bool
|
||||
private let defaults: UserDefaults
|
||||
private let legacyDefaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
init(
|
||||
defaults: UserDefaults = AppGroup.defaults,
|
||||
legacyDefaults: UserDefaults = .standard
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.legacyDefaults = legacyDefaults
|
||||
self.mode = defaults.string(forKey: Key.runtimeMode)
|
||||
.flatMap(ProxyRuntimeMode.init(rawValue:)) ?? .localWiFi
|
||||
self.hasSelectedMode = defaults.bool(forKey: Key.hasSelectedRuntimeMode)
|
||||
self.localWiFiInitialized = defaults.bool(forKey: Key.localWiFiInitialized)
|
||||
self.thirdPartyInitialized = defaults.bool(forKey: Key.thirdPartyInitialized)
|
||||
migrateLegacyInitializationIfNeeded()
|
||||
}
|
||||
|
||||
func setMode(_ mode: ProxyRuntimeMode) {
|
||||
@@ -40,10 +54,51 @@ final class ProxyRuntimeModeStore: ObservableObject {
|
||||
hasSelectedMode = true
|
||||
defaults.set(mode.rawValue, forKey: Key.runtimeMode)
|
||||
defaults.set(true, forKey: Key.hasSelectedRuntimeMode)
|
||||
migrateLegacyInitializationIfNeeded()
|
||||
if changed {
|
||||
RuntimeLogger.info("APP", "Mode", "代理运行模式已切换", details: [
|
||||
"模式": mode.displayName
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func isInitialized(_ mode: ProxyRuntimeMode) -> Bool {
|
||||
switch mode {
|
||||
case .localWiFi: return localWiFiInitialized
|
||||
case .thirdParty: return thirdPartyInitialized
|
||||
}
|
||||
}
|
||||
|
||||
func markInitialized(_ mode: ProxyRuntimeMode) {
|
||||
setInitialized(true, for: mode)
|
||||
}
|
||||
|
||||
func resetInitialization(_ mode: ProxyRuntimeMode) {
|
||||
setInitialized(false, for: mode)
|
||||
}
|
||||
|
||||
private func setInitialized(_ initialized: Bool, for mode: ProxyRuntimeMode) {
|
||||
switch mode {
|
||||
case .localWiFi:
|
||||
localWiFiInitialized = initialized
|
||||
defaults.set(initialized, forKey: Key.localWiFiInitialized)
|
||||
case .thirdParty:
|
||||
thirdPartyInitialized = initialized
|
||||
defaults.set(initialized, forKey: Key.thirdPartyInitialized)
|
||||
}
|
||||
}
|
||||
|
||||
private func migrateLegacyInitializationIfNeeded() {
|
||||
guard hasSelectedMode,
|
||||
!defaults.bool(forKey: Key.initializationMigrationCompleted) else {
|
||||
return
|
||||
}
|
||||
if legacyDefaults.bool(forKey: Key.legacySetupCompleted) {
|
||||
setInitialized(true, for: mode)
|
||||
RuntimeLogger.info("APP", "Mode", "已迁移旧版模式初始化状态", details: [
|
||||
"模式": mode.displayName
|
||||
])
|
||||
}
|
||||
defaults.set(true, forKey: Key.initializationMigrationCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,12 @@ extension URLSession: ThirdPartyProxyRequesting {}
|
||||
final class ThirdPartyProxyManager: ObservableObject {
|
||||
static let shared = ThirdPartyProxyManager()
|
||||
static let interceptionHostname = "gs-loc.apple.com"
|
||||
static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
|
||||
|
||||
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
||||
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
|
||||
@Published private(set) var isRequesting = false
|
||||
private let requester: any ThirdPartyProxyRequesting
|
||||
private let endpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
|
||||
|
||||
init(requester: (any ThirdPartyProxyRequesting)? = nil) {
|
||||
if let requester {
|
||||
@@ -134,7 +134,7 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
isRequesting = true
|
||||
defer { isRequesting = false }
|
||||
|
||||
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
||||
var components = URLComponents(url: Self.configurationEndpoint, resolvingAgainstBaseURL: false)!
|
||||
switch action {
|
||||
case .query:
|
||||
components.queryItems = [URLQueryItem(name: "action", value: "query")]
|
||||
@@ -197,8 +197,8 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var verificationText: String {
|
||||
self == .shadowrocket ? "当前可测试" : "配置已提供,尚未验证"
|
||||
var verificationText: String? {
|
||||
self == .shadowrocket ? nil : "配置已提供,尚未验证"
|
||||
}
|
||||
|
||||
var moduleFileName: String {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 环境验证结果,UI 层根据此值弹出对应的引导面板。
|
||||
/// 环境验证结果,由 SetupCoordinator 统一路由到对应引导页面。
|
||||
enum VerificationResult: Equatable, Identifiable {
|
||||
case success
|
||||
case proxyNotRunning
|
||||
@@ -26,27 +26,4 @@ enum VerificationResult: Equatable, Identifiable {
|
||||
|
||||
var isSuccess: Bool { self == .success }
|
||||
|
||||
/// 对应的引导页类型
|
||||
var tipKind: TipKind? {
|
||||
switch self {
|
||||
case .success: return nil
|
||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
||||
case .certNotTrusted: return nil
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class AppRemoteConfigurationTests: XCTestCase {
|
||||
func testDecodesReadableJSONAndClientPromptSwitches() throws {
|
||||
let data = """
|
||||
{
|
||||
"latestVersion": "1.2.0",
|
||||
"minimumSupportedVersion": "1.1.0",
|
||||
"communityPromptClients": ["surge", "loon"]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let configuration = try AppRemoteConfiguration.decode(data)
|
||||
|
||||
XCTAssertEqual(configuration.latestVersion, "1.2.0")
|
||||
XCTAssertTrue(configuration.requestsCommunityPrompt(for: .surge))
|
||||
XCTAssertTrue(configuration.requestsCommunityPrompt(for: .loon))
|
||||
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .stash))
|
||||
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
|
||||
}
|
||||
|
||||
func testVersionPolicyDistinguishesRequiredRecommendedAndCurrent() throws {
|
||||
let configuration = AppRemoteConfiguration(
|
||||
latestVersion: "1.2.0",
|
||||
minimumSupportedVersion: "1.1.0",
|
||||
communityPromptClients: []
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
configuration.updatePrompt(currentVersion: "1.0.9")?.requirement,
|
||||
.required
|
||||
)
|
||||
XCTAssertEqual(
|
||||
configuration.updatePrompt(currentVersion: "1.1.0")?.requirement,
|
||||
.recommended
|
||||
)
|
||||
XCTAssertNil(configuration.updatePrompt(currentVersion: "1.2.0"))
|
||||
XCTAssertNil(configuration.updatePrompt(currentVersion: "1.2.0.0"))
|
||||
}
|
||||
|
||||
func testRejectsInvalidVersionsAndUnknownClients() {
|
||||
let invalidVersion = """
|
||||
{
|
||||
"latestVersion": "1.0.0",
|
||||
"minimumSupportedVersion": "2.0.0",
|
||||
"communityPromptClients": []
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertThrowsError(try AppRemoteConfiguration.decode(invalidVersion))
|
||||
|
||||
let invalidClient = """
|
||||
{
|
||||
"latestVersion": "2.0.0",
|
||||
"minimumSupportedVersion": "1.0.0",
|
||||
"communityPromptClients": ["unknown"]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertThrowsError(try AppRemoteConfiguration.decode(invalidClient))
|
||||
}
|
||||
|
||||
func testFallbackMatchesCurrentProjectPolicy() {
|
||||
let configuration = AppRemoteConfiguration.fallback
|
||||
|
||||
XCTAssertEqual(configuration.latestVersion, "1.0.4")
|
||||
XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0")
|
||||
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
|
||||
for client in ThirdPartyProxyClient.allCases where client != .shadowrocket {
|
||||
XCTAssertTrue(configuration.requestsCommunityPrompt(for: client))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,28 @@ final class CertificateAuthorityStoreTests: XCTestCase {
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
|
||||
}
|
||||
|
||||
func testResetRemovesKeychainAndLegacyAuthority() throws {
|
||||
let directory = try makeLegacyDirectory(authority: validAuthority)
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.stored = validAuthority
|
||||
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
|
||||
|
||||
try store.reset()
|
||||
|
||||
XCTAssertNil(keychain.stored)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path))
|
||||
}
|
||||
|
||||
func testResetFailurePreservesKeychainAuthority() {
|
||||
let keychain = InMemoryCertificateAuthorityKeychain()
|
||||
keychain.stored = validAuthority
|
||||
keychain.shouldFailRemove = true
|
||||
let store = makeStore(keychain: keychain) { self.validAuthority }
|
||||
|
||||
XCTAssertThrowsError(try store.reset())
|
||||
XCTAssertEqual(keychain.stored, validAuthority)
|
||||
}
|
||||
|
||||
private func makeStore(
|
||||
directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),
|
||||
keychain: InMemoryCertificateAuthorityKeychain,
|
||||
@@ -121,11 +143,13 @@ final class CertificateAuthorityStoreTests: XCTestCase {
|
||||
|
||||
private enum CertificateAuthorityStoreTestError: Error {
|
||||
case saveFailed
|
||||
case removeFailed
|
||||
}
|
||||
|
||||
private final class InMemoryCertificateAuthorityKeychain: CertificateAuthorityKeychain {
|
||||
var stored: CertificateAuthority?
|
||||
var shouldFailSave = false
|
||||
var shouldFailRemove = false
|
||||
|
||||
func load() throws -> CertificateAuthority? { stored }
|
||||
|
||||
@@ -135,6 +159,7 @@ private final class InMemoryCertificateAuthorityKeychain: CertificateAuthorityKe
|
||||
}
|
||||
|
||||
func remove() throws {
|
||||
guard !shouldFailRemove else { throw CertificateAuthorityStoreTestError.removeFailed }
|
||||
stored = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,39 @@ final class FavoriteLocationStoreTests: XCTestCase {
|
||||
XCTAssertEqual(FavoriteLocationStore(defaults: defaults).selectedFavorite?.name, "深圳湾")
|
||||
}
|
||||
|
||||
func testSelectingMatchingCoordinatePairRestoresFavoriteSelection() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = FavoriteLocationStore(defaults: defaults)
|
||||
let favorite = store.save(
|
||||
name: "深圳湾",
|
||||
coordinatePair: CoordinateConverter.coordinatePair(
|
||||
lat: 22.491_438,
|
||||
lon: 113.945_702,
|
||||
mapCoordinateSystem: .wgs84
|
||||
),
|
||||
accuracy: 20
|
||||
)
|
||||
store.select(nil)
|
||||
|
||||
let nearbyRealtimePair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.491_488,
|
||||
lon: 113.945_752,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
XCTAssertEqual(store.selectMatching(coordinatePair: nearbyRealtimePair)?.id, favorite.id)
|
||||
XCTAssertEqual(store.selectedFavoriteID, favorite.id)
|
||||
|
||||
let unrelatedPair = CoordinateConverter.coordinatePair(
|
||||
lat: 31.2304,
|
||||
lon: 121.4737,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
XCTAssertNil(store.selectMatching(coordinatePair: unrelatedPair))
|
||||
XCTAssertNil(store.selectedFavoriteID)
|
||||
}
|
||||
|
||||
func testFavoriteStoresBothFormsAndSelectsMatchingPairWithoutReadConversion() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
@@ -36,6 +69,25 @@ final class FavoriteLocationStoreTests: XCTestCase {
|
||||
XCTAssertNotEqual(favorite.coordinatePair.gcj02.longitude, wgs.longitude)
|
||||
}
|
||||
|
||||
func testSavingPrecomputedPairDoesNotReinterpretItAfterMapTypeRefresh() {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let pair = CoordinateConverter.coordinatePair(
|
||||
lat: 22.296_642,
|
||||
lon: 114.172_175,
|
||||
mapCoordinateSystem: .wgs84
|
||||
)
|
||||
|
||||
let favorite = FavoriteLocationStore(defaults: defaults).save(
|
||||
name: "香港天文台",
|
||||
coordinatePair: pair,
|
||||
accuracy: 25
|
||||
)
|
||||
|
||||
XCTAssertEqual(favorite.coordinatePair, pair)
|
||||
}
|
||||
|
||||
func testLegacyFavoriteIsUpgradedAsDomesticGCJAndRewritten() throws {
|
||||
let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
@@ -122,6 +174,17 @@ final class FavoriteLocationStoreTests: XCTestCase {
|
||||
XCTAssertNil(CoordinateConverter.diagnoseRepresentation(sample: unrelated, pair: pair).inferredSystem)
|
||||
}
|
||||
|
||||
func testFixedAnchorResultNameUsesOneSharedMapTypeRule() {
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "林士街"),
|
||||
.gcj02
|
||||
)
|
||||
XCTAssertEqual(
|
||||
CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "Connaught Road West"),
|
||||
.wgs84
|
||||
)
|
||||
}
|
||||
|
||||
func testMapConfigurationNeverRequestsRealUserLocation() {
|
||||
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
|
||||
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
|
||||
|
||||
@@ -173,6 +173,19 @@ final class MapLocationStateTests: XCTestCase {
|
||||
XCTAssertEqual(state.selection.source, .search)
|
||||
}
|
||||
|
||||
func testMapCoordinateSystemChangeClearsSupersededRealtimeSampleWithoutMovingSelection() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩")
|
||||
let selection = state.selection
|
||||
state.updateRealtimeLocation(CLLocation(latitude: 30.42, longitude: 114.25))
|
||||
|
||||
state.clearRealtimeLocationForMapCoordinateSystemChange()
|
||||
|
||||
XCTAssertNil(state.realtimeLocation)
|
||||
XCTAssertNil(state.realtimeCoordinate)
|
||||
XCTAssertEqual(state.selection, selection)
|
||||
}
|
||||
|
||||
func testRealtimeIntentCanImmediatelyAcceptNativeLocation() {
|
||||
let state = MapLocationState(initialCoordinate: initial)
|
||||
let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25)
|
||||
|
||||
@@ -5,16 +5,52 @@ import XCTest
|
||||
final class ProxyRuntimeModeTests: XCTestCase {
|
||||
func testDefaultsToLocalWiFiAndPersistsThirdPartyMode() {
|
||||
let suiteName = "ProxyRuntimeModeTests.\(UUID().uuidString)"
|
||||
let legacySuiteName = "ProxyRuntimeModeLegacyTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let legacyDefaults = UserDefaults(suiteName: legacySuiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
legacyDefaults.removePersistentDomain(forName: legacySuiteName)
|
||||
}
|
||||
|
||||
let initial = ProxyRuntimeModeStore(defaults: defaults)
|
||||
let initial = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
|
||||
XCTAssertEqual(initial.mode, .localWiFi)
|
||||
XCTAssertFalse(initial.hasSelectedMode)
|
||||
XCTAssertFalse(initial.isInitialized(.localWiFi))
|
||||
XCTAssertFalse(initial.isInitialized(.thirdParty))
|
||||
|
||||
initial.setMode(.thirdParty)
|
||||
let restored = ProxyRuntimeModeStore(defaults: defaults)
|
||||
initial.markInitialized(.thirdParty)
|
||||
let restored = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
|
||||
XCTAssertEqual(restored.mode, .thirdParty)
|
||||
XCTAssertTrue(restored.hasSelectedMode)
|
||||
XCTAssertFalse(restored.isInitialized(.localWiFi))
|
||||
XCTAssertTrue(restored.isInitialized(.thirdParty))
|
||||
|
||||
restored.resetInitialization(.thirdParty)
|
||||
XCTAssertFalse(restored.isInitialized(.thirdParty))
|
||||
}
|
||||
|
||||
func testMigratesOnlyCurrentLegacyCompletedMode() {
|
||||
let suiteName = "ProxyRuntimeModeMigrationTests.\(UUID().uuidString)"
|
||||
let legacySuiteName = "ProxyRuntimeModeMigrationLegacyTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
let legacyDefaults = UserDefaults(suiteName: legacySuiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
legacyDefaults.removePersistentDomain(forName: legacySuiteName)
|
||||
}
|
||||
|
||||
defaults.set(ProxyRuntimeMode.thirdParty.rawValue, forKey: "proxyRuntimeMode")
|
||||
defaults.set(true, forKey: "hasSelectedProxyRuntimeMode")
|
||||
legacyDefaults.set(true, forKey: "setupCompleted")
|
||||
|
||||
let migrated = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
|
||||
XCTAssertFalse(migrated.isInitialized(.localWiFi))
|
||||
XCTAssertTrue(migrated.isInitialized(.thirdParty))
|
||||
|
||||
migrated.setMode(.localWiFi)
|
||||
XCTAssertFalse(migrated.isInitialized(.localWiFi))
|
||||
XCTAssertTrue(migrated.isInitialized(.thirdParty))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,16 +33,50 @@ final class SetupCoordinatorTests: XCTestCase {
|
||||
XCTAssertEqual(coordinator.setupStep, .proxy)
|
||||
}
|
||||
|
||||
func testWiFiChangeMapsLocalProxyStartFailureToProxyReminder() {
|
||||
XCTAssertEqual(VerificationResult.proxyNotRunning.wifiChangeReminderTipKind, .proxySetup)
|
||||
func testExplicitCertificateRequestRoutesToCertificateStep() {
|
||||
let coordinator = SetupCoordinator()
|
||||
|
||||
coordinator.requestCertificateSetup()
|
||||
|
||||
XCTAssertTrue(coordinator.needsSetup)
|
||||
XCTAssertEqual(coordinator.setupStep, .cert)
|
||||
}
|
||||
|
||||
func testWiFiChangeDoesNotPresentFailureForConcurrentVerification() {
|
||||
XCTAssertNil(VerificationResult.verificationInProgress.wifiChangeReminderTipKind)
|
||||
func testThirdPartyFailureRequestPreservesErrorAndRoutesToImportGuide() {
|
||||
let coordinator = SetupCoordinator()
|
||||
|
||||
coordinator.requestThirdPartySetup(message: "模块未连接")
|
||||
|
||||
XCTAssertTrue(coordinator.needsSetup)
|
||||
XCTAssertEqual(coordinator.setupStep, .thirdPartyImport)
|
||||
XCTAssertEqual(coordinator.message, "模块未连接")
|
||||
}
|
||||
|
||||
func testWiFiChangeCertificateFailureDoesNotUseGenericReminder() {
|
||||
XCTAssertNil(VerificationResult.certNotTrusted.wifiChangeReminderTipKind)
|
||||
XCTAssertNil(VerificationResult.certNotTrusted.tipKind)
|
||||
func testThirdPartyOnboardingStartsWithClientSelection() {
|
||||
let coordinator = SetupCoordinator()
|
||||
|
||||
coordinator.requestThirdPartyOnboarding()
|
||||
|
||||
XCTAssertTrue(coordinator.needsSetup)
|
||||
XCTAssertEqual(coordinator.setupStep, .thirdPartyClient)
|
||||
XCTAssertTrue(coordinator.message.isEmpty)
|
||||
}
|
||||
|
||||
func testProxyFailurePreservesResultForPresentedGuide() {
|
||||
let coordinator = SetupCoordinator()
|
||||
|
||||
coordinator.applyVerificationResult(.proxyNotRunning)
|
||||
|
||||
XCTAssertEqual(coordinator.lastVerificationResult, .proxyNotRunning)
|
||||
XCTAssertTrue(coordinator.needsSetup)
|
||||
XCTAssertEqual(coordinator.setupStep, .proxy)
|
||||
}
|
||||
|
||||
func testConcurrentVerificationDoesNotOpenGuide() {
|
||||
let coordinator = SetupCoordinator()
|
||||
|
||||
coordinator.applyVerificationResult(.verificationInProgress)
|
||||
|
||||
XCTAssertFalse(coordinator.needsSetup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
@testable import PaopaoLocationSpoofer
|
||||
|
||||
final class ThirdPartyCommunityPromptPreferencesTests: XCTestCase {
|
||||
private var suites: [String] = []
|
||||
|
||||
override func tearDown() {
|
||||
for suite in suites {
|
||||
UserDefaults.standard.removePersistentDomain(forName: suite)
|
||||
}
|
||||
suites.removeAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSuppressionBecomesAvailableOnThirdPresentation() {
|
||||
let preferences = ThirdPartyCommunityPromptPreferences(defaults: makeDefaults())
|
||||
|
||||
XCTAssertEqual(preferences.recordPresentation(), 1)
|
||||
XCTAssertFalse(preferences.canSuppress())
|
||||
XCTAssertEqual(preferences.recordPresentation(), 2)
|
||||
XCTAssertFalse(preferences.canSuppress())
|
||||
XCTAssertEqual(preferences.recordPresentation(), 3)
|
||||
XCTAssertTrue(preferences.canSuppress())
|
||||
}
|
||||
|
||||
func testEarlySuppressionIsIgnoredAndThirdPresentationCanPersistIt() {
|
||||
let defaults = makeDefaults()
|
||||
let preferences = ThirdPartyCommunityPromptPreferences(defaults: defaults)
|
||||
|
||||
preferences.recordPresentation()
|
||||
preferences.suppress()
|
||||
XCTAssertTrue(preferences.shouldPresent())
|
||||
|
||||
preferences.recordPresentation()
|
||||
preferences.recordPresentation()
|
||||
preferences.suppress()
|
||||
XCTAssertFalse(preferences.shouldPresent())
|
||||
|
||||
let restored = ThirdPartyCommunityPromptPreferences(defaults: defaults)
|
||||
XCTAssertFalse(restored.shouldPresent())
|
||||
}
|
||||
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suite = "ThirdPartyCommunityPromptPreferencesTests.\(UUID().uuidString)"
|
||||
suites.append(suite)
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,8 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testClientLinksUseOfficialUpstreamModulesAndVerificationLabels() {
|
||||
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.verificationText, "当前可测试")
|
||||
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText.contains("尚未验证"))
|
||||
XCTAssertNil(ThirdPartyProxyClient.shadowrocket.verificationText)
|
||||
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText?.contains("尚未验证") == true)
|
||||
XCTAssertEqual(ThirdPartyProxyClient.egern.subscriptionURL, ThirdPartyProxyClient.surge.subscriptionURL)
|
||||
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.stoverride"))
|
||||
XCTAssertTrue(ThirdPartyProxyClient.shadowrocket.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.module"))
|
||||
|
||||
@@ -8,6 +8,34 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
test -f "$ROOT/App/ProxyManager.swift" || fail "ProxyManager must exist"
|
||||
test -f "$ROOT/Shared/RuntimeLog.swift" || fail "RuntimeLog must exist"
|
||||
test -f "$ROOT/App/RealtimeLocationManager.swift" || fail "RealtimeLocationManager must exist"
|
||||
grep -q 'Location Spoofer CA' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must use the stable app-branded CA name"
|
||||
! grep -q 'WLOC CA' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must not show the legacy dated CA name"
|
||||
grep -q 'localWiFiRuntimeModeInitialized' "$ROOT/Shared/ProxyRuntimeMode.swift" || fail "APP mode initialization must persist independently"
|
||||
grep -q 'thirdPartyRuntimeModeInitialized' "$ROOT/Shared/ProxyRuntimeMode.swift" || fail "third-party mode initialization must persist independently"
|
||||
! grep -q 'runVerificationTest' "$ROOT/App/ContentView.swift" || fail "normal app startup must not run the environment verification test"
|
||||
grep -q '重置证书' "$ROOT/App/SettingsView.swift" || fail "APP mode settings must expose certificate reset"
|
||||
grep -q 'certificateStore.reset()' "$ROOT/App/SettingsView.swift" || fail "certificate reset must remove the persisted app CA"
|
||||
grep -q 'requestCertificateSetup()' "$ROOT/App/SettingsView.swift" || fail "certificate reset must open the certificate setup flow"
|
||||
grep -q 'requestThirdPartySetup(message:' "$ROOT/App/SettingsView.swift" || fail "third-party settings failures must open the setup guide"
|
||||
grep -q 'SFSafariViewController' "$ROOT/App/SafariView.swift" || fail "certificate download must use an in-app Safari service"
|
||||
grep -q 'prepareCertificateDownloadURL' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must prepare an in-app download URL"
|
||||
! grep -q 'UIApplication.shared.open(url' "$ROOT/App/ProxyManager.swift" || fail "certificate download must not force an external browser"
|
||||
grep -q 'AppModeWiFiProxy' "$ROOT/App/FirstSetupView.swift" || fail "APP proxy setup must show the Wi-Fi screenshot"
|
||||
grep -q 'AppModeCertificateInstall' "$ROOT/App/FirstSetupView.swift" || fail "certificate install setup must show its screenshot"
|
||||
grep -q 'AppModeCertificateTrust' "$ROOT/App/FirstSetupView.swift" || fail "certificate trust setup must show its screenshot"
|
||||
grep -q 'UIImage(named: assetName)' "$ROOT/App/FirstSetupView.swift" || fail "missing screenshots must fall back to text without breaking setup"
|
||||
|
||||
for asset in AppModeWiFiProxy AppModeCertificateInstall AppModeCertificateTrust; do
|
||||
test -s "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|
||||
|| fail "missing APP onboarding image asset: $asset"
|
||||
grep -q '\.jpg' "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|
||||
|| fail "APP onboarding derivatives must use an Asset Catalog-supported JPEG: $asset"
|
||||
done
|
||||
test -s "$ROOT/docs/onboarding-screenshots/app-mode/app-mode-wifi-proxy.jpg" \
|
||||
|| fail "unannotated APP-mode source screenshots must be retained by mode"
|
||||
test -s "$ROOT/docs/app-icon-source.svg" || fail "the optimized app icon must retain an editable vector source"
|
||||
test -s "$ROOT/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png" \
|
||||
|| fail "the optimized 1024px app icon is missing"
|
||||
|
||||
# VPNManager and Tunnel must NOT exist
|
||||
test ! -f "$ROOT/App/VPNManager.swift" || fail "VPNManager must be removed"
|
||||
|
||||
@@ -4,6 +4,12 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
TIP_VIEWS="$ROOT/App/TipViews.swift"
|
||||
grep -Fq 'Label("还是无法生效?"' "$TIP_VIEWS" || fail "activation help must use the requested retry heading"
|
||||
grep -Fq 'Label("还是无法取消?"' "$TIP_VIEWS" || fail "deactivation help must use a mode-specific retry heading"
|
||||
grep -q 'frame(maxWidth: .infinity, alignment: .leading)' "$TIP_VIEWS" \
|
||||
|| fail "tip detail copy must align to the leading edge"
|
||||
|
||||
MAP_HOME="$ROOT/App/MapHomeView.swift"
|
||||
MAP_STATE="$ROOT/App/MapLocationState.swift"
|
||||
MAP_BRIDGE="$ROOT/App/MapViewRepresentable.swift"
|
||||
@@ -65,6 +71,22 @@ if grep -q 'logEvent("CONNECT " + host + " -> passthrough")' "$ROOT/Core/proxy.g
|
||||
fi
|
||||
grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing"
|
||||
grep -q 'await CoordinateConverter.resolveInitialMapCoordinateSystem()' "$CONTENT" || fail "map type must resolve before MapHomeView construction"
|
||||
grep -q 'refreshRuntimeMapCoordinateSystem(reason:' "$CONVERTER" || fail "fixed-anchor map type must support runtime refresh"
|
||||
grep -q 'scheduleBluePointMapCoordinateSystemRefresh()' "$MAP_HOME" || fail "native blue-point samples must trigger runtime map-type refresh while spoofing"
|
||||
! grep -A3 'private func scheduleBluePointMapCoordinateSystemRefresh' "$MAP_HOME" | grep -q 'spoofState == .active' || fail "blue-point map-type refresh must also detect the return to physical location"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "点击实时定位")' "$MAP_HOME" || fail "realtime button must await the coordinated map-type refresh"
|
||||
grep -q 'favorites.selectMatching(coordinatePair: pair)' "$MAP_HOME" || fail "realtime selection must restore a matching favorite selection"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "保存收藏")' "$MAP_HOME" || fail "favorite save must await the coordinated map-type refresh"
|
||||
grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "App回到前台")' "$MAP_HOME" || fail "foreground recovery must reuse the coordinated map-type refresh"
|
||||
grep -q '地图坐标标准运行期检测结果已过期,取消写入' "$CONVERTER" || fail "cancelled runtime probes must not mutate the global map type"
|
||||
! grep -q 'source == .coreLocation.*correctMapCoordinateSystemUsingRealtime' "$MAP_HOME" || fail "runtime Core Location samples must not infer MapKit type"
|
||||
grep -q 'clearRealtimeLocationForMapCoordinateSystemChange' "$MAP_HOME" || fail "map-type changes must discard superseded blue-point samples"
|
||||
grep -q '地图坐标类型已变化' "$MAP_HOME" || fail "map-type changes must emit an explicit searchable business log"
|
||||
grep -q '图钉已按新类型重设' "$MAP_HOME" || fail "map-type change log must report pin reprojection"
|
||||
grep -q 'coordinateRow(label: "GCJ-02(国内)", system: .gcj02)' "$MAP_HOME" || fail "current selection panel must label the domestic coordinate as GCJ-02"
|
||||
grep -q 'coordinateRow(label: "WGS-84(国际)", system: .wgs84)' "$MAP_HOME" || fail "current selection panel must label the international coordinate as WGS-84"
|
||||
grep -q 'fixedSize(horizontal: true, vertical: false)' "$MAP_HOME" || fail "coordinate labels must keep their natural single-line width"
|
||||
grep -q 'minimumScaleFactor(0.72)' "$MAP_HOME" || fail "coordinate values must shrink to remain on one line"
|
||||
grep -q 'phase = .map' "$CONTENT" || fail "ContentView must explicitly gate MapHomeView construction"
|
||||
! grep -q 'startTileProbe' "$MAP_HOME" || fail "MapHomeView must not start a second fixed-anchor coordinate-system probe"
|
||||
! grep -q 'initializeMap()' "$MAP_HOME" || fail "MapHomeView must not replay a second map initialization from onAppear"
|
||||
@@ -75,11 +97,15 @@ grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime p
|
||||
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 'wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi failures must not use a duplicate reminder mapping"
|
||||
grep -q 'setup.requestSetup(message:' "$MAP_HOME" || fail "missing Wi-Fi must enter the reusable proxy setup guide"
|
||||
test "$(grep -c 'setup.applyVerificationResult(result)' "$MAP_HOME")" -ge 2 \
|
||||
|| fail "activation and Wi-Fi-change verification failures must use the shared setup reducer"
|
||||
! grep -q 'activeTip = \.proxySetup' "$MAP_HOME" || fail "proxy failures must not use a duplicate tip sheet"
|
||||
! grep -q 'case certificate' "$ROOT/App/TipViews.swift" || fail "generic certificate tip must not coexist with certificate setup"
|
||||
! grep -q 'CertificateTipContent' "$ROOT/App/TipViews.swift" || fail "certificate failures must use the complete setup flow"
|
||||
! grep -q 'case proxySetup' "$ROOT/App/TipViews.swift" || fail "proxy failures must use the complete setup flow"
|
||||
! grep -q 'case rewriteFailed' "$ROOT/App/TipViews.swift" || fail "rewrite failures must use the complete setup flow"
|
||||
! grep -q 'onChange(of: net.isAirplaneMode)' "$MAP_HOME" || fail "airplane recovery must not race the Wi-Fi change verifier"
|
||||
grep -q 'hasReceivedInitialPath' "$NETWORK_MONITOR" || fail "initial network path must not be reported as a Wi-Fi switch"
|
||||
grep -q 'lastKnownSSID' "$NETWORK_MONITOR" || fail "SSID polling must preserve a baseline across temporary nil readings"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ZH="$ROOT/README.md"
|
||||
EN="$ROOT/README.en.md"
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
grep -q 'iOS Location Service Research & Testing Framework' "$ZH" || fail "Chinese README positioning is missing"
|
||||
grep -q 'iOS Location Service Research & Testing Framework' "$EN" || fail "English README positioning is missing"
|
||||
|
||||
grep -q '#### 自签安装说明' "$ZH" || fail "Chinese self-signing instructions are missing"
|
||||
grep -q '#### Self-Signing Instructions' "$EN" || fail "English self-signing instructions are missing"
|
||||
grep -q 'Impactor Releases' "$ZH" || fail "Chinese README must link the requested Impactor releases"
|
||||
grep -q 'Impactor Releases' "$EN" || fail "English README must link the requested Impactor releases"
|
||||
grep -q '开发者模式' "$ZH" || fail "Chinese README must explain iOS 16 Developer Mode"
|
||||
grep -q 'Developer Mode' "$EN" || fail "English README must explain iOS 16 Developer Mode"
|
||||
grep -q '7 天有效期' "$ZH" || fail "Chinese README must disclose free-signing expiry"
|
||||
grep -q 'seven days' "$EN" || fail "English README must disclose free-signing expiry"
|
||||
|
||||
grep -q '^## 功能预览$' "$ZH" || fail "Chinese feature preview is missing"
|
||||
grep -q '^## Feature Preview$' "$EN" || fail "English feature preview is missing"
|
||||
|
||||
images=(
|
||||
'主界面.jpg'
|
||||
'Apple%20Map.jpg'
|
||||
'高德地图.jpg'
|
||||
'微信.jpg'
|
||||
'钉钉.jpg'
|
||||
'高血压.jpg'
|
||||
)
|
||||
for image in "${images[@]}"; do
|
||||
grep -q "images/$image" "$ZH" || fail "Chinese README is missing image: $image"
|
||||
grep -q "images/$image" "$EN" || fail "English README is missing image: $image"
|
||||
done
|
||||
|
||||
image_files=(
|
||||
'主界面.jpg'
|
||||
'Apple Map.jpg'
|
||||
'高德地图.jpg'
|
||||
'微信.jpg'
|
||||
'钉钉.jpg'
|
||||
'高血压.jpg'
|
||||
)
|
||||
for image in "${image_files[@]}"; do
|
||||
test -f "$ROOT/images/$image" || fail "referenced preview image is missing: $image"
|
||||
done
|
||||
|
||||
test "$(grep -c '^## ' "$ZH")" -eq "$(grep -c '^## ' "$EN")" \
|
||||
|| fail "Chinese and English README section counts must stay aligned"
|
||||
|
||||
! grep -Eq '^## (许可证|License)$' "$ZH" "$EN" || fail "README must not claim a repository license"
|
||||
grep -q '当前项目不支持在 Windows 上直接构建 iOS 应用' "$ZH" || fail "Chinese README must reject Windows source builds"
|
||||
grep -q 'Building the iOS app directly on Windows is not supported' "$EN" || fail "English README must reject Windows source builds"
|
||||
grep -q 'docs/COMMUNITY_TUTORIALS.md' "$ZH" || fail "Chinese README must link the community tutorial submission guide"
|
||||
grep -q '^#### 配置接口与客户端适配$' "$ZH" \
|
||||
|| fail "Chinese README must document the third-party integration contract"
|
||||
grep -q '^#### Configuration API and Client Integration$' "$EN" \
|
||||
|| fail "English README must document the third-party integration contract"
|
||||
for contract in 'action=query' 'action=clear' 'lon=<WGS-84'; do
|
||||
grep -q "$contract" "$ZH" || fail "Chinese README is missing third-party contract: $contract"
|
||||
grep -q "$contract" "$EN" || fail "English README is missing third-party contract: $contract"
|
||||
done
|
||||
grep -q '除敏感信息遮挡外,不要自行添加箭头、编号、边框、说明文字或其他标注' \
|
||||
"$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|
||||
|| fail "tutorial submissions must keep source screenshots free of non-privacy annotations"
|
||||
grep -q '同一张截图可以对应多个步骤' "$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|
||||
|| fail "tutorial submissions must explain multi-step screenshot handling"
|
||||
grep -q '不得覆盖上述原图' "$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|
||||
|| fail "annotated app assets must not replace categorized source screenshots"
|
||||
|
||||
if grep -Rnw --include='*.md' --include='*.sh' \
|
||||
"$ROOT/build.sh" "$ROOT/README.md" "$ROOT/README.en.md" "$ROOT/docs" "$ROOT/Scripts" \
|
||||
-e 'Impact'; then
|
||||
fail "documentation and build output must use the correct Impactor name"
|
||||
fi
|
||||
|
||||
echo "PASS: README contract"
|
||||
@@ -18,13 +18,25 @@ grep -q 'guard runtimeMode.hasSelectedMode else' "$CONTENT" || fail "mode select
|
||||
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 'case thirdPartyTest' "$SETUP" || fail "third-party connection test must be part of the import page"
|
||||
! grep -q '生成并导入配置文件' "$SETUP" || fail "setup must not offer file generation/import"
|
||||
grep -q '复制订阅地址' "$SETUP" || fail "subscription URL copy action is missing"
|
||||
grep -q '复制模块订阅地址' "$SETUP" || fail "module subscription URL copy action is missing"
|
||||
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" || fail "setup must expose a client launch action"
|
||||
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" \
|
||||
|| fail "the import page must expose the selected client launch action"
|
||||
grep -Fq 'Label("打开 \(thirdPartyClient.selectedClient.name)"' "$SETTINGS" || fail "Settings must expose a client launch action"
|
||||
! grep -q '在浏览器打开模块文件' "$SETTINGS" || fail "Settings must not open the module URL as the primary client action"
|
||||
grep -q 'requestThirdPartySetup' "$SETTINGS" || fail "Settings must reopen third-party setup"
|
||||
grep -q 'static let configurationEndpoint' "$MANAGER" \
|
||||
|| fail "the third-party configuration endpoint must have one shared owner"
|
||||
grep -q 'DisclosureGroup("第三方客户端适配说明")' "$SETUP" \
|
||||
|| fail "client selection must expose the third-party integration contract"
|
||||
grep -Fq '查询:GET ?action=query' "$SETUP" \
|
||||
|| fail "client integration guidance must document the query action"
|
||||
grep -Fq '保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>' "$SETUP" \
|
||||
|| fail "client integration guidance must document WGS-84 coordinate saving"
|
||||
grep -Fq '清除:GET ?action=clear' "$SETUP" \
|
||||
|| fail "client integration guidance must document the clear action"
|
||||
|
||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
||||
@@ -39,11 +51,68 @@ done
|
||||
grep -q '复制解密域名' "$SETUP" || fail "Shadowrocket MITM hostname copy action is missing"
|
||||
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
|
||||
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
|
||||
! grep -q '当前可测试' "$SETUP" || fail "Shadowrocket must not show the obsolete current-test label"
|
||||
! grep -q '当前可测试' "$SETTINGS" || fail "Settings must not show the obsolete current-test label"
|
||||
grep -q 'thirdPartyTestFailure = ThirdPartyConnectionTestFailure' "$SETUP" \
|
||||
|| fail "third-party connection failures must render inline on the import page"
|
||||
grep -q 'testResultView(' "$SETUP" || fail "APP and third-party tests must share the same result component"
|
||||
grep -q 'showsVerificationResult = false' "$SETUP" \
|
||||
|| fail "switching setup pages must hide the shared APP verification result"
|
||||
grep -q 'showsThirdPartyFailureLog = false' "$SETUP" \
|
||||
|| fail "switching setup pages must hide the shared third-party test log"
|
||||
grep -q 'Label("查看诊断日志"' "$SETUP" \
|
||||
|| fail "third-party connection failure must expose the diagnostics action"
|
||||
! grep -q 'setupActionError = error.localizedDescription' "$SETUP" \
|
||||
|| fail "third-party connection failure must not use the generic failure alert"
|
||||
grep -q 'let response = try await thirdPartyProxy.query()' "$SETUP" \
|
||||
|| fail "the import page must query the third-party module"
|
||||
grep -A15 'let response = try await thirdPartyProxy.query()' "$SETUP" | grep -q 'onComplete()' \
|
||||
|| fail "a successful third-party connection test must close setup immediately"
|
||||
! grep -q 'thirdPartyTestResult?.success' "$SETUP" \
|
||||
|| fail "a successful third-party test must not leave a separate completion state"
|
||||
grep -q 'setupStep = \.thirdPartyImport' "$ROOT/App/SetupCoordinator.swift" \
|
||||
|| fail "third-party runtime failures must route directly to the import guide"
|
||||
grep -q 'setup.requestThirdPartySetup(message: error.localizedDescription)' "$ROOT/App/MapHomeView.swift" \
|
||||
|| fail "third-party coordinate sync failures must open the import guide"
|
||||
grep -q '检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测' "$SETUP" \
|
||||
|| fail "runtime repair must explain why the import guide opened"
|
||||
test "$(grep -c 'title: \"接口连接失败\"' "$SETUP")" -eq 1 \
|
||||
|| fail "third-party failure details must render in one shared result area"
|
||||
grep -Fq '当前客户端:\(client.name)' "$SETUP" \
|
||||
|| fail "third-party failure logs must identify the selected client"
|
||||
grep -q 'HStack(spacing: 12)' "$SETUP" || fail "setup footer actions must share one horizontal row"
|
||||
grep -q 'Spacer(minLength: 12)' "$SETUP" || fail "setup footer actions must stay at opposite edges"
|
||||
grep -q 'ScrollViewReader' "$SETUP" || fail "setup must keep failure results reachable after insertion"
|
||||
grep -q 'scrollProxy.scrollTo("thirdPartyFailureLog"' "$SETUP" \
|
||||
|| fail "third-party failure must scroll to the detailed log"
|
||||
grep -q '======== 第三方代理连接检测 ========' "$SETUP" \
|
||||
|| fail "third-party inline diagnostics must include a structured test log"
|
||||
grep -Fq 'Label("第 2 步:完成 \(client.name) 配置"' "$SETUP" \
|
||||
|| fail "unverified clients must use a two-step import and configuration guide"
|
||||
grep -Fq 'Text("请在 \(client.name) 中完成相应配置。")' "$SETUP" \
|
||||
|| fail "unverified client configuration guidance must avoid unverified menu details"
|
||||
! grep -q '进入模块、重写或覆写订阅入口' "$SETUP" \
|
||||
|| fail "unverified clients must not claim untested menu locations"
|
||||
grep -q '"当前客户端": client.name' "$SETUP" \
|
||||
|| fail "third-party runtime diagnostics must identify the selected client"
|
||||
for asset in ShadowrocketModuleImport ShadowrocketConfigDetails ShadowrocketHTTPSDecryption ShadowrocketHTTPSCA; do
|
||||
test -s "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|
||||
|| fail "missing Shadowrocket onboarding image asset: $asset"
|
||||
grep -q '\.jpg' "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|
||||
|| fail "Shadowrocket onboarding derivatives must use an Asset Catalog-supported JPEG: $asset"
|
||||
grep -q "$asset" "$SETUP" || fail "setup does not reference onboarding image asset: $asset"
|
||||
done
|
||||
config_line="$(grep -n 'assetName: "ShadowrocketConfigDetails"' "$SETUP" | head -n 1 | cut -d: -f1)"
|
||||
module_line="$(grep -n 'assetName: "ShadowrocketModuleImport"' "$SETUP" | head -n 1 | cut -d: -f1)"
|
||||
test "$config_line" -lt "$module_line" || fail "Shadowrocket setup must show the configuration page before the module page"
|
||||
! grep -q 'GeometryReader' "$SETUP" || fail "onboarding image markers must be baked into assets, not positioned at runtime"
|
||||
test -s "$ROOT/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-import.jpg" \
|
||||
|| fail "unannotated Shadowrocket source screenshots must be retained by client"
|
||||
! grep -q 'ToolbarItem(placement: .navigationBarLeading)' "$SETUP" || fail "setup must not show a top-left navigation action"
|
||||
grep -q 'presentSuccessfulOperationTip(.activation)' "$ROOT/App/MapHomeView.swift" || fail "third-party save must present the activation tip"
|
||||
grep -q 'presentSuccessfulOperationTip(.deactivation)' "$ROOT/App/MapHomeView.swift" || fail "third-party clear must present the deactivation tip"
|
||||
grep -q 'if spoofState == .active' "$ROOT/App/MapHomeView.swift" || fail "manual help must follow the shared spoof state"
|
||||
grep -q 'MARKETING_VERSION: "1.0.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"
|
||||
grep -q 'MARKETING_VERSION: "1.0.4"' "$ROOT/project.yml" || fail "marketing version must be 1.0.4"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "5"' "$ROOT/project.yml" || fail "build version must be 5"
|
||||
|
||||
echo "PASS: third-party proxy mode contract"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONFIG="$ROOT/Shared/AppRemoteConfiguration.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
MAP="$ROOT/App/MapHomeView.swift"
|
||||
SETUP="$ROOT/App/FirstSetupView.swift"
|
||||
SETTINGS="$ROOT/App/SettingsView.swift"
|
||||
BUG_REPORT="$ROOT/App/BugReportView.swift"
|
||||
GITHUB_SUBMISSION="$ROOT/Shared/GitHubSubmission.swift"
|
||||
DISCUSSION_FORM="$ROOT/.github/DISCUSSION_TEMPLATE/第三方配置分享.yml"
|
||||
ISSUE_FORM="$ROOT/.github/ISSUE_TEMPLATE/bug-report.yml"
|
||||
ISSUE_CONFIG="$ROOT/.github/ISSUE_TEMPLATE/config.yml"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
python3 - "$ROOT/version.txt" <<'PY' || exit 1
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
|
||||
assert config["latestVersion"] == "1.0.4"
|
||||
assert config["minimumSupportedVersion"] == "1.0.0"
|
||||
assert "shadowrocket" not in config["communityPromptClients"]
|
||||
assert set(config["communityPromptClients"]) == {
|
||||
"surge", "quantumultX", "loon", "stash", "egern"
|
||||
}
|
||||
PY
|
||||
|
||||
grep -q 'static let fallback = AppRemoteConfiguration' "$CONFIG" \
|
||||
|| fail "the app must ship a built-in remote-configuration fallback"
|
||||
grep -q 'timeoutIntervalForRequest = 1.5' "$CONFIG" \
|
||||
|| fail "remote configuration requests must use a short timeout"
|
||||
grep -q 'timeoutIntervalForResource = 2' "$CONFIG" \
|
||||
|| fail "remote configuration resource loading must use a short timeout"
|
||||
! grep -q 'data.count' "$CONFIG" \
|
||||
|| fail "the client must not impose a remote configuration file-size limit"
|
||||
grep -q 'docs/releases/v\\(version).md' "$CONFIG" \
|
||||
|| fail "update notes must come from the archived release document"
|
||||
grep -q '/releases/latest' "$CONFIG" \
|
||||
|| fail "missing release notes must fall back to the latest Release page"
|
||||
grep -q '.task { await checkForUpdates() }' "$CONTENT" \
|
||||
|| fail "update detection must run asynchronously outside bootstrap"
|
||||
! grep -A90 'private func bootstrap() async' "$CONTENT" | grep -q 'fetch()' \
|
||||
|| fail "remote configuration must not block the startup gate"
|
||||
|
||||
grep -q '社区分享成功配置?' "$MAP" \
|
||||
|| fail "non-Shadowrocket success must offer community contribution"
|
||||
grep -q 'Button("去提交")' "$MAP" \
|
||||
|| fail "community contribution prompt must expose the submit action"
|
||||
grep -q 'Button("复制模板")' "$MAP" \
|
||||
|| fail "community contribution prompt must expose an explicit copy action"
|
||||
grep -q 'Button("取消", role: .cancel)' "$MAP" \
|
||||
|| fail "the first community prompts must expose cancel"
|
||||
grep -q 'Button("不再提示", role: .cancel)' "$MAP" \
|
||||
|| fail "the third community prompt must allow permanent suppression"
|
||||
grep -q '匿名收录,不在 README 展示投稿账号' "$GITHUB_SUBMISSION" \
|
||||
|| fail "the contribution template must offer anonymous README attribution"
|
||||
grep -Fq 'https://github.com/xweiba/location-spoofer/discussions/new?category=%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB' "$GITHUB_SUBMISSION" \
|
||||
|| fail "community submission must use one fixed Discussions address"
|
||||
grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' "$MAP" \
|
||||
|| fail "the explicit copy action must copy the contribution template"
|
||||
grep -A8 'Button("去提交")' "$MAP" | grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' \
|
||||
|| fail "the submit action must copy the template before opening GitHub"
|
||||
grep -A8 'Button("去提交")' "$MAP" | grep -q 'openCommunityContributionPage()' \
|
||||
|| fail "the submit action must open the fixed Discussions page after copying"
|
||||
! grep -A12 'private func openCommunityContributionPage' "$MAP" | grep -q 'URLComponents' \
|
||||
|| fail "the fixed community address must not be rebuilt from dynamic query items"
|
||||
grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'SafariDestination' \
|
||||
|| fail "community submission must open with the shared in-App Safari destination"
|
||||
! grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'UIApplication.shared.open' \
|
||||
|| fail "community submission must not be intercepted by an external GitHub client"
|
||||
test -s "$DISCUSSION_FORM" \
|
||||
|| fail "the fixed Discussions category must provide a maintained submission form"
|
||||
grep -q 'label: 第三方客户端' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must classify submissions by client"
|
||||
grep -q '匿名收录,不在 README 展示投稿账号' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must retain anonymous README attribution"
|
||||
grep -q '请先移除账号、订阅、密码、设备标识、真实位置等敏感信息' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must remind users to remove sensitive information"
|
||||
grep -q '截图除敏感信息遮挡外不要添加箭头、编号或说明文字' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must explain the screenshot annotation rule"
|
||||
! grep -q 'label: 隐私确认' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must not require a separate privacy confirmation"
|
||||
for field in \
|
||||
'## 第三方客户端' \
|
||||
'## 第三方客户端版本' \
|
||||
'## iOS 版本' \
|
||||
'## 配置步骤' \
|
||||
'## 截图与补充说明' \
|
||||
'## README 收录署名'; do
|
||||
grep -Fq "$field" "$GITHUB_SUBMISSION" \
|
||||
|| fail "the App contribution template must match Discussions field: $field"
|
||||
done
|
||||
grep -Fq 'label: 第三方客户端版本' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must clearly request the third-party client version"
|
||||
|
||||
grep -Fq 'https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml' "$GITHUB_SUBMISSION" \
|
||||
|| fail "the App bug report must open the dedicated Issue form"
|
||||
grep -Fq 'GitHubSubmission.bugReportURL' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must use the shared GitHub destination"
|
||||
grep -Fq 'SafariView(url: destination.url)' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must open in the shared in-App Safari view"
|
||||
! grep -q 'UIApplication.shared.open' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must not be handed to an external GitHub client"
|
||||
grep -Fq 'App 生成的诊断报告' "$BUG_REPORT" \
|
||||
|| fail "the App must tell users where to paste the generated report"
|
||||
grep -Fq '第三方客户端:' "$BUG_REPORT" \
|
||||
|| fail "the generated report must identify the selected third-party client"
|
||||
grep -Fq 'Label("报告 Bug"' "$SETTINGS" \
|
||||
|| fail "Settings must identify the support action as a bug report"
|
||||
for action in '使用帮助' '功能建议' '分享第三方配置'; do
|
||||
grep -Fq "Label(\"$action\"" "$SETTINGS" \
|
||||
|| fail "Settings support must expose: $action"
|
||||
done
|
||||
grep -A8 'Label("分享第三方配置"' "$SETTINGS" >/dev/null \
|
||||
|| fail "Settings must retain the manual third-party sharing action"
|
||||
grep -q 'GitHubSubmission.communityContributionTemplate' "$SETTINGS" \
|
||||
|| fail "Settings manual sharing must copy the shared contribution template"
|
||||
grep -q 'SafariView(url: destination.url)' "$SETTINGS" \
|
||||
|| fail "Settings GitHub support actions must use the in-App Safari view"
|
||||
test -s "$ISSUE_FORM" \
|
||||
|| fail "the repository must provide a bug report Issue form"
|
||||
grep -Fq 'label: App 生成的诊断报告' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must expose the field named by the App"
|
||||
grep -Fq '设置 → 支持 → 报告 Bug' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must point users to the matching App workflow"
|
||||
grep -Fq 'label: 我已移除真实位置、认证信息、订阅地址、证书私钥和其他敏感信息' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must require privacy confirmation"
|
||||
grep -Fq 'blank_issues_enabled: false' "$ISSUE_CONFIG" \
|
||||
|| fail "blank Issues must be disabled so reports use the maintained form"
|
||||
for destination in '使用帮助' '功能建议' '第三方配置分享'; do
|
||||
grep -Fq "name: $destination" "$ISSUE_CONFIG" \
|
||||
|| fail "Issue chooser must route non-bug request to $destination"
|
||||
done
|
||||
|
||||
for obsolete in \
|
||||
'我已配置,开始检测' \
|
||||
'确认完成,重新检测' \
|
||||
'下一步:导入配置' \
|
||||
'我已导入,检测接口连接'; do
|
||||
! grep -q "$obsolete" "$SETUP" \
|
||||
|| fail "setup footer must not retain dynamic label: $obsolete"
|
||||
done
|
||||
test "$(grep -c 'actionLabel("完成")' "$SETUP")" -eq 4 \
|
||||
|| fail "all setup footer primary actions must use the fixed 完成 label"
|
||||
|
||||
application_line="$(grep -n 'Section("应用")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
|
||||
certificate_line="$(grep -n 'Section("证书")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
|
||||
test "$application_line" -lt "$certificate_line" \
|
||||
|| fail "the APP certificate reset section must appear below the application section"
|
||||
|
||||
echo "PASS: update and community contribution contract"
|
||||
@@ -73,4 +73,4 @@ if [ "$run_tests" -eq 1 ]; then
|
||||
run_simulator_tests
|
||||
fi
|
||||
|
||||
echo "Next: sign with Impact (https://github.com/claration/Impactor) and install on device."
|
||||
echo "Next: sign with Impactor (https://github.com/claration/Impactor) and install on device."
|
||||
|
||||
@@ -32,12 +32,12 @@ Output:
|
||||
dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
```
|
||||
|
||||
IPA 始终保持未签名。用 [Impact](https://github.com/claration/Impactor) 签名安装即可。
|
||||
IPA 始终保持未签名。用 [Impactor](https://github.com/claration/Impactor) 签名安装即可。
|
||||
|
||||
## 发布验收
|
||||
|
||||
1. `./build.sh` 通过并输出未签名 IPA
|
||||
2. 用 Impact 签名后安装到设备
|
||||
2. 用 Impactor 签名后安装到设备
|
||||
3. 真机安装后,先配置 WiFi HTTP 代理 `127.0.0.1:8888`,再按检测结果完成 CA 下载、安装和信任
|
||||
4. 环境检测通过后,选点开启虚拟定位
|
||||
5. 打开 Apple 地图验证定位是否变为虚拟位置
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
## 已发布
|
||||
|
||||
- [v1.0.4](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.4) — 2026-08-09
|
||||
- [v1.0.3](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.3) — 2026-08-09
|
||||
- [v1.0.2](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.2) — 2026-08-07
|
||||
- [v1.0.1](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.1) — 2026-08-06
|
||||
- [v1.0.0](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.0) — 2026-08-05
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 社区客户端教程与截图提交
|
||||
|
||||
我们公开征集 Shadowrocket、Surge、Quantumult X、Loon、Stash 和 Egern 的配置教程与真机截图。
|
||||
提交内容会经过复核、统一裁剪和标注后再进入 App 或项目文档。
|
||||
|
||||
## 客户端分区
|
||||
|
||||
投稿统一进入 GitHub Discussions 的“第三方配置分享”固定入口。表单通过“第三方客户端”下拉框分类:
|
||||
|
||||
| 客户端 | 表单选项 |
|
||||
|---|---|
|
||||
| Shadowrocket | `Shadowrocket` |
|
||||
| Surge | `Surge` |
|
||||
| Quantumult X | `Quantumult X` |
|
||||
| Loon | `Loon` |
|
||||
| Stash | `Stash` |
|
||||
| Egern | `Egern` |
|
||||
|
||||
App 始终打开同一个投稿地址,仓库中的 Discussion 表单负责维护客户端、版本、步骤、截图和署名字段。
|
||||
“去提交”会先复制带当前客户端名称的投稿模板,再通过 App 内 Safari 打开固定页面,避免被不支持
|
||||
Discussions 的 GitHub 客户端拦截。用户也可以从 Safari 视图的分享菜单改用其他浏览器。“复制模板”按钮仍可
|
||||
单独使用,设置页的“支持 → 分享第三方配置”也提供相同入口。采纳后的配置在 README 客户端状态表中按客户端
|
||||
收录,并链接到教程或原始投稿。投稿者可以选择匿名收录;未选择匿名时可在 README 中显示 GitHub 账号。
|
||||
|
||||
## 提交内容
|
||||
|
||||
请提供:
|
||||
|
||||
1. 客户端名称和版本;
|
||||
2. iOS 版本;
|
||||
3. 从导入模块到启用代理的完整操作步骤;
|
||||
4. 每一步对应的原始截图文件名;
|
||||
5. 需要点击、开启或填写的位置说明;
|
||||
6. 最终是否验证通过,以及失败时看到的错误。
|
||||
|
||||
推荐按下面的格式描述步骤:
|
||||
|
||||
```text
|
||||
客户端:Shadowrocket
|
||||
版本:
|
||||
iOS 版本:
|
||||
|
||||
步骤 1:
|
||||
- 截图:01-module-list.jpg
|
||||
- 操作:点击右上角“+”
|
||||
|
||||
步骤 2:
|
||||
- 截图:02-https-decryption.jpg
|
||||
- 操作:开启 HTTPS 解密,并填写 gs-loc.apple.com
|
||||
|
||||
验证结果:
|
||||
```
|
||||
|
||||
## 截图要求
|
||||
|
||||
- 提交原始、清晰、未压缩过度的截图。
|
||||
- 除敏感信息遮挡外,不要自行添加箭头、编号、边框、说明文字或其他标注。
|
||||
- 不要为了突出某一步自行裁剪。请在步骤说明中写清需要点击或填写的位置,由维护者统一裁剪和编号。
|
||||
- 同一张截图可以对应多个步骤,请分别说明每个步骤的位置和顺序。
|
||||
- 可以遮挡账号、订阅地址、节点、密码、设备标识、开发者账号、真实位置和通知内容。
|
||||
- 必须完整遮挡证书私钥、认证 Token、Cookie、完整订阅链接以及其他可用于访问账户或服务的信息。
|
||||
- 遮挡后请再次检查状态栏、列表副标题、输入框、二维码和弹窗,避免遗漏。
|
||||
- 建议使用稳定、描述性的英文文件名,例如 `shadowrocket-https-decryption.jpg`。
|
||||
|
||||
维护者会保留未标注的脱敏原图,并在 App 中使用统一的编号圆点和步骤文字叠加标注。这样同一张图可以复用
|
||||
多个步骤,客户端界面变化时也能单独替换截图而不重做整套教程。
|
||||
|
||||
原图按客户端或运行模式分类保存:
|
||||
|
||||
```text
|
||||
docs/onboarding-screenshots/
|
||||
├── app-mode/
|
||||
└── shadowrocket/
|
||||
```
|
||||
|
||||
App 使用的裁剪、压缩和编号版本保存在 `Resources/Assets.xcassets/`,不得覆盖上述原图。新增客户端时创建
|
||||
独立的小写英文目录,例如 `surge/` 或 `quantumult-x/`。
|
||||
|
||||
## 审核边界
|
||||
|
||||
- 教程必须来自实际客户端操作,不能把未验证配置描述为已支持。
|
||||
- 截图和步骤不得包含付费节点、共享账号、破解内容或绕过服务限制的方法。
|
||||
- 项目只审核教程是否清晰、是否脱敏以及是否能复现,不为第三方客户端、订阅服务或网络环境提供担保。
|
||||
- 后续客户端版本变化导致界面或步骤失效时,教程可能被标记为待复核或替换。
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<rect width="1024" height="1024" fill="#087FF5"/>
|
||||
|
||||
<path
|
||||
d="M168 736 C292 736 284 590 420 590 C520 590 514 458 606 458"
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="42"
|
||||
stroke-linecap="round"
|
||||
opacity="0.42"
|
||||
/>
|
||||
<circle cx="168" cy="736" r="50" fill="#35D6C1" stroke="#FFFFFF" stroke-width="24"/>
|
||||
|
||||
<path
|
||||
d="M656 156 C488 156 370 276 370 434 C370 638 656 878 656 878 C656 878 942 638 942 434 C942 276 824 156 656 156 Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
<circle cx="656" cy="420" r="126" fill="#FF453A"/>
|
||||
<circle cx="656" cy="420" r="48" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 681 B |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 154 KiB |
@@ -16,13 +16,13 @@
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| iOS | 15.0+ |
|
||||
| 安装 | 自行签名(推荐 [Impact](https://github.com/claration/Impactor)) |
|
||||
| 安装 | 自行签名(推荐 [Impactor](https://github.com/claration/Impactor)) |
|
||||
| 网络 | 可手动配置 HTTP 代理的 WiFi |
|
||||
|
||||
### 自签安装
|
||||
|
||||
1. 从 [Releases](https://github.com/xweiba/location-spoofer/releases) 下载 `PaopaoLocationSpoofer-unsigned.ipa`
|
||||
2. 使用 [Impact](https://github.com/claration/Impactor) 签名安装
|
||||
2. 使用 [Impactor](https://github.com/claration/Impactor) 签名安装
|
||||
3. 签名时保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements
|
||||
|
||||
### 致谢
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 Impact 完成签名安装,无需付费开发者账号。
|
||||
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# v1.0.2
|
||||
|
||||
发布日期:2026-08-07
|
||||
|
||||
## 主要更新
|
||||
|
||||
- 修复虚拟定位跨越中国大陆与境外后,MapKit 坐标标准变化可能导致的地图选点、蓝点和收藏位置偏移。
|
||||
- 增加运行期固定锚点刷新与状态清理,坐标标准切换后会按已保存的 WGS-84 / GCJ-02 坐标对重新显示图钉。
|
||||
- 点击实时定位时,如果当前位置与已保存收藏匹配,会恢复对应收藏的选中状态。
|
||||
- 当前选点面板同时显示 `GCJ-02(国内)` 与 `WGS-84(国际)` 坐标,两行可独立复制,并在窄屏自动调整宽度。
|
||||
|
||||
## 文档与安装
|
||||
|
||||
- 重写中英文 README,统一项目定位、双运行模式、构建要求、隐私边界和限制说明。
|
||||
- 补充 iOS 开发者模式、Impactor/第三方自签工具、USB 安装、设备驱动和 7 天免费签名有效期说明。
|
||||
- 增加完整功能预览,包含主界面和现有真机测试截图。
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
- APP 模式仍使用设备内 Go 代理和当前 Wi-Fi 的手动 HTTP 代理,不创建 Network Extension 或系统 VPN。
|
||||
- 第三方代理模式仍由所选客户端负责代理/VPN、MITM、证书和坐标持久化。
|
||||
- MapKit 和系统定位存在运行时差异与缓存,本版本改进了坐标一致性,但不保证兼容所有 iOS 版本或第三方应用。
|
||||
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 [Impactor](https://github.com/claration/Impactor/releases) 完成签名安装,无需付费开发者账号。
|
||||
- iOS 16 及以上版本需要在“设置 → 隐私与安全性 → 开发者模式”中开启开发者模式;iOS 15 可跳过。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
- 第三方自签软件请从官方渠道获取,并自行评估账号、证书和隐私风险。
|
||||
|
||||
<!-- commit-range: v1.0.1..v1.0.2 -->
|
||||
@@ -0,0 +1,32 @@
|
||||
# v1.0.3
|
||||
|
||||
发布日期:2026-08-09
|
||||
|
||||
## 主要更新
|
||||
|
||||
- 增加 APP 模式与第三方代理模式的独立初始化状态和切换引导。
|
||||
- 调整环境检测时机,启动时不再阻塞首屏,开启虚拟定位时再执行检测。
|
||||
- 完善证书重置、下载、安装、信任和异常修复流程。
|
||||
- 完善第三方代理导入、连接检测、诊断日志和客户端跳转。
|
||||
|
||||
## 第三方代理与社区
|
||||
|
||||
- 增加 Shadowrocket 图文引导并修正配置步骤顺序。
|
||||
- 增加社区配置分享、Bug 报告和 GitHub Discussions 工作流。
|
||||
- 补充第三方 WLOC 配置接口、JSON 格式及客户端适配说明。
|
||||
- 支持通过远程配置控制社区投稿提示。
|
||||
|
||||
## 更新与兼容性
|
||||
|
||||
- 增加异步版本检测,支持强制更新和推荐更新策略。
|
||||
- App 内置远程配置兜底,不因 GitHub 访问超时阻塞首屏。
|
||||
- 新生成的根证书固定显示为 `Location Spoofer CA`;已有有效证书继续复用。
|
||||
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
<!-- commit-range: v1.0.2..v1.0.3 -->
|
||||
@@ -0,0 +1,29 @@
|
||||
# v1.0.4
|
||||
|
||||
发布日期:2026-08-09
|
||||
|
||||
## 主要更新
|
||||
|
||||
- 增加结构化 ARPC 响应解析,修改定位数据后会正确更新 32 位 payload 长度。
|
||||
- 完善 marker、synthetic 和 bare protobuf 响应兼容,保留原始封装前后缀及未知字段。
|
||||
- 补充 CellTower 字段 22/24、ARPC 大 payload 和多响应格式回归测试。
|
||||
|
||||
## 文档与致谢
|
||||
|
||||
- 整理项目参考、社区贡献和友链结构。
|
||||
- 补充相关定位服务研究项目致谢。
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
- WiFi 和 CellTower 坐标替换逻辑保持不变。
|
||||
- 无法识别的新响应封装会回退原有检测路径,不会截断响应。
|
||||
- 本版本未写入语义尚未确认的运动状态字段。
|
||||
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
<!-- commit-range: v1.0.3..v1.0.4 -->
|
||||
@@ -8,8 +8,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.1"
|
||||
CURRENT_PROJECT_VERSION: "2"
|
||||
MARKETING_VERSION: "1.0.4"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"latestVersion": "1.0.4",
|
||||
"minimumSupportedVersion": "1.0.0",
|
||||
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
|
||||
}
|
||||