Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6688b4a49 | ||
|
|
893f674004 | ||
|
|
8cc65172b2 | ||
|
|
b07fa61195 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ struct MapHomeView: View {
|
||||
@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
|
||||
@@ -69,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>?
|
||||
@@ -261,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 = "" } }
|
||||
@@ -269,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 {
|
||||
@@ -305,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
|
||||
@@ -588,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
|
||||
@@ -640,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
|
||||
@@ -667,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
|
||||
}
|
||||
@@ -700,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) {
|
||||
@@ -990,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
|
||||
}
|
||||
@@ -1039,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
|
||||
}
|
||||
|
||||
@@ -1066,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", "环境检测连续被占用,本次不重复弹窗")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ responses in a controlled test environment.
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[Features](#feature-overview) ·
|
||||
[How It Works](#how-it-works) ·
|
||||
@@ -134,6 +134,57 @@ In this mode:
|
||||
- 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
|
||||
@@ -163,14 +214,17 @@ Suitable for:
|
||||
|
||||
Current client status:
|
||||
|
||||
| Client | Status |
|
||||
|---|---|
|
||||
| Shadowrocket | Currently used for on-device testing |
|
||||
| Surge | Configuration provided, not fully verified |
|
||||
| Quantumult X | Configuration provided, not fully verified |
|
||||
| Loon | Configuration provided, not fully verified |
|
||||
| Stash | Configuration provided, not fully verified |
|
||||
| Egern | Uses the Surge module, not fully verified |
|
||||
| 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:
|
||||
|
||||
@@ -327,21 +381,25 @@ Do not post real locations, authentication information, CA private keys, or comp
|
||||
|
||||
Contributions are welcome for:
|
||||
|
||||
- Bug reports;
|
||||
- Feature requests;
|
||||
- Compatibility results;
|
||||
- [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.
|
||||
|
||||
When filing an issue, include:
|
||||
For bugs, generate a report from **Settings → Support → Report Bug** in the App whenever possible. The report includes:
|
||||
|
||||
- iOS version;
|
||||
- Device model;
|
||||
- App version;
|
||||
- Runtime mode;
|
||||
- Reproduction steps;
|
||||
- Selected third-party client;
|
||||
- Whether the issue is reproducible;
|
||||
- Problem description;
|
||||
- Sanitized runtime logs;
|
||||
- Whether a third-party proxy client was used.
|
||||
|
||||
Paste it into the **App-generated diagnostic report** field in the GitHub Issue Form.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[功能概览](#功能概览) ·
|
||||
[工作原理](#工作原理) ·
|
||||
@@ -130,6 +130,53 @@ WLOC 配置接口
|
||||
- 是否支持 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 模式代理和第三方代理模式,避免两个代理链路互相干扰。
|
||||
|
||||
## 运行模式
|
||||
@@ -159,14 +206,16 @@ WLOC 配置接口
|
||||
|
||||
当前客户端状态:
|
||||
|
||||
| 客户端 | 状态 |
|
||||
|---|---|
|
||||
| Shadowrocket | 当前用于真机测试 |
|
||||
| Surge | 已提供配置,尚未完整验证 |
|
||||
| Quantumult X | 已提供配置,尚未完整验证 |
|
||||
| Loon | 已提供配置,尚未完整验证 |
|
||||
| Stash | 已提供配置,尚未完整验证 |
|
||||
| Egern | 使用 Surge 模块,尚未完整验证 |
|
||||
| 客户端 | 状态 | 社区配置 |
|
||||
|---|---|---|
|
||||
| Shadowrocket | 当前用于真机测试 | App 内置教程 |
|
||||
| Surge | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Quantumult X | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Loon | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Stash | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Egern | 使用 Surge 模块,尚未完整验证 | 待征集 |
|
||||
|
||||
社区配置按客户端分区审核;采纳后会在上表链接教程和投稿者,投稿者也可以选择匿名收录。
|
||||
|
||||
相关模块快照和来源记录:
|
||||
|
||||
@@ -320,26 +369,31 @@ dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
|
||||
欢迎提交:
|
||||
|
||||
- Bug Report;
|
||||
- Feature Request;
|
||||
- 兼容性测试结果;
|
||||
- [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);
|
||||
- 性能改进;
|
||||
- 文档改进;
|
||||
- 测试补充。
|
||||
|
||||
提交 Issue 时建议包含:
|
||||
提交 Bug 时建议优先通过 App 的“设置 → 支持 → 报告 Bug”生成报告。报告包含:
|
||||
|
||||
- iOS 版本;
|
||||
- 设备型号;
|
||||
- App 版本;
|
||||
- 使用的运行模式;
|
||||
- 复现步骤;
|
||||
- 当前第三方客户端;
|
||||
- 是否可以稳定复现;
|
||||
- 问题描述;
|
||||
- 脱敏后的运行日志;
|
||||
- 是否使用第三方代理客户端。
|
||||
|
||||
GitHub Issue Form 中的“App 生成的诊断报告”字段与 App 复制内容一一对应。
|
||||
|
||||
## 文档
|
||||
|
||||
- [构建说明](docs/BUILD.md)
|
||||
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
|
||||
- [社区客户端教程与截图提交](docs/COMMUNITY_TUTORIALS.md)
|
||||
- [更新日志](docs/CHANGELOG.md)
|
||||
- [英文文档](README.en.md)
|
||||
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
|
||||
|
||||
|
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 |
@@ -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.3",
|
||||
minimumSupportedVersion: "1.0.0",
|
||||
communityPromptClients: [
|
||||
ThirdPartyProxyClient.surge.rawValue,
|
||||
ThirdPartyProxyClient.quantumultX.rawValue,
|
||||
ThirdPartyProxyClient.loon.rawValue,
|
||||
ThirdPartyProxyClient.stash.rawValue,
|
||||
ThirdPartyProxyClient.egern.rawValue
|
||||
]
|
||||
)
|
||||
|
||||
init(
|
||||
latestVersion: String,
|
||||
minimumSupportedVersion: String,
|
||||
communityPromptClients: [String]
|
||||
) {
|
||||
self.latestVersion = latestVersion
|
||||
self.minimumSupportedVersion = minimumSupportedVersion
|
||||
self.communityPromptClients = communityPromptClients
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) throws -> AppRemoteConfiguration {
|
||||
let configuration = try JSONDecoder().decode(AppRemoteConfiguration.self, from: data)
|
||||
guard let latest = NumericVersion(configuration.latestVersion),
|
||||
let minimum = NumericVersion(configuration.minimumSupportedVersion),
|
||||
minimum <= latest else {
|
||||
throw AppRemoteConfigurationError.invalidVersion
|
||||
}
|
||||
guard configuration.communityPromptClients.allSatisfy({
|
||||
ThirdPartyProxyClient(rawValue: $0) != nil
|
||||
}) else {
|
||||
throw AppRemoteConfigurationError.invalidClient
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
|
||||
func updatePrompt(currentVersion: String, releaseNotes: String? = nil) -> AppUpdatePrompt? {
|
||||
guard let current = NumericVersion(currentVersion),
|
||||
let latest = NumericVersion(latestVersion),
|
||||
let minimum = NumericVersion(minimumSupportedVersion) else {
|
||||
return nil
|
||||
}
|
||||
if current < minimum {
|
||||
return AppUpdatePrompt(
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: latestVersion,
|
||||
minimumSupportedVersion: minimumSupportedVersion,
|
||||
requirement: .required,
|
||||
releaseNotes: releaseNotes
|
||||
)
|
||||
}
|
||||
if current < latest {
|
||||
return AppUpdatePrompt(
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: latestVersion,
|
||||
minimumSupportedVersion: minimumSupportedVersion,
|
||||
requirement: .recommended,
|
||||
releaseNotes: releaseNotes
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
|
||||
client != .shadowrocket && communityPromptClients.contains(client.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppRemoteConfigurationStore: ObservableObject {
|
||||
static let shared = AppRemoteConfigurationStore()
|
||||
|
||||
@Published private(set) var configuration = AppRemoteConfiguration.fallback
|
||||
|
||||
func apply(_ configuration: AppRemoteConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
|
||||
configuration.requestsCommunityPrompt(for: client)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppRemoteConfigurationService {
|
||||
static let configurationURL = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
||||
)!
|
||||
static let releasesURL = URL(
|
||||
string: "https://github.com/xweiba/location-spoofer/releases/latest"
|
||||
)!
|
||||
|
||||
static func fetch() async -> AppRemoteConfiguration? {
|
||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: configurationURL)
|
||||
request.timeoutInterval = 1.5
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置请求返回非 200,继续使用内置配置")
|
||||
return nil
|
||||
}
|
||||
let configuration = try AppRemoteConfiguration.decode(data)
|
||||
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
|
||||
"最新版本": configuration.latestVersion,
|
||||
"最低版本": configuration.minimumSupportedVersion,
|
||||
"社区征集客户端数": String(configuration.communityPromptClients.count)
|
||||
])
|
||||
return configuration
|
||||
} catch {
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置", details: [
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchReleaseNotes(version: String) async -> String? {
|
||||
guard let url = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 1.5
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明不存在,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
guard let markdown = String(data: data, encoding: .utf8) else {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明编码无效,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
return releaseNotesSummary(markdown)
|
||||
} catch {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
|
||||
"版本": version,
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func releaseNotesSummary(_ markdown: String) -> String? {
|
||||
var items: [String] = []
|
||||
var inMainUpdates = false
|
||||
for rawLine in markdown.components(separatedBy: .newlines) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line == "## 主要更新" {
|
||||
inMainUpdates = true
|
||||
continue
|
||||
}
|
||||
if inMainUpdates, line.hasPrefix("## ") {
|
||||
break
|
||||
}
|
||||
guard inMainUpdates, line.hasPrefix("- ") else { continue }
|
||||
items.append("• " + line.dropFirst(2))
|
||||
}
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
private enum AppRemoteConfigurationError: Error {
|
||||
case invalidVersion
|
||||
case invalidClient
|
||||
}
|
||||
|
||||
private struct NumericVersion: Comparable {
|
||||
let components: [Int]
|
||||
|
||||
init?(_ value: String) {
|
||||
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard !parts.isEmpty else { return nil }
|
||||
var parsed: [Int] = []
|
||||
for part in parts {
|
||||
guard !part.isEmpty, let number = Int(part), number >= 0 else { return nil }
|
||||
parsed.append(number)
|
||||
}
|
||||
components = parsed
|
||||
}
|
||||
|
||||
static func < (lhs: NumericVersion, rhs: NumericVersion) -> Bool {
|
||||
let count = max(lhs.components.count, rhs.components.count)
|
||||
for index in 0..<count {
|
||||
let left = index < lhs.components.count ? lhs.components[index] : 0
|
||||
let right = index < rhs.components.count ? rhs.components[index] : 0
|
||||
if left != right { return left < right }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.3")
|
||||
XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0")
|
||||
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
|
||||
for client in ThirdPartyProxyClient.allCases where client != .shadowrocket {
|
||||
XCTAssertTrue(configuration.requestsCommunityPrompt(for: client))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -91,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"
|
||||
|
||||
@@ -52,6 +52,22 @@ test "$(grep -c '^## ' "$ZH")" -eq "$(grep -c '^## ' "$EN")" \
|
||||
! 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" \
|
||||
|
||||
@@ -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.2"' "$ROOT/project.yml" || fail "marketing version must be 1.0.2"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "3"' "$ROOT/project.yml" || fail "build version must be 3"
|
||||
grep -q 'MARKETING_VERSION: "1.0.3"' "$ROOT/project.yml" || fail "marketing version must be 1.0.3"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "4"' "$ROOT/project.yml" || fail "build version must be 4"
|
||||
|
||||
echo "PASS: third-party proxy mode contract"
|
||||
|
||||
@@ -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.3"
|
||||
assert config["minimumSupportedVersion"] == "1.0.0"
|
||||
assert "shadowrocket" not in config["communityPromptClients"]
|
||||
assert set(config["communityPromptClients"]) == {
|
||||
"surge", "quantumultX", "loon", "stash", "egern"
|
||||
}
|
||||
PY
|
||||
|
||||
grep -q 'static let fallback = AppRemoteConfiguration' "$CONFIG" \
|
||||
|| fail "the app must ship a built-in remote-configuration fallback"
|
||||
grep -q 'timeoutIntervalForRequest = 1.5' "$CONFIG" \
|
||||
|| fail "remote configuration requests must use a short timeout"
|
||||
grep -q 'timeoutIntervalForResource = 2' "$CONFIG" \
|
||||
|| fail "remote configuration resource loading must use a short timeout"
|
||||
! grep -q 'data.count' "$CONFIG" \
|
||||
|| fail "the client must not impose a remote configuration file-size limit"
|
||||
grep -q 'docs/releases/v\\(version).md' "$CONFIG" \
|
||||
|| fail "update notes must come from the archived release document"
|
||||
grep -q '/releases/latest' "$CONFIG" \
|
||||
|| fail "missing release notes must fall back to the latest Release page"
|
||||
grep -q '.task { await checkForUpdates() }' "$CONTENT" \
|
||||
|| fail "update detection must run asynchronously outside bootstrap"
|
||||
! grep -A90 'private func bootstrap() async' "$CONTENT" | grep -q 'fetch()' \
|
||||
|| fail "remote configuration must not block the startup gate"
|
||||
|
||||
grep -q '社区分享成功配置?' "$MAP" \
|
||||
|| fail "non-Shadowrocket success must offer community contribution"
|
||||
grep -q 'Button("去提交")' "$MAP" \
|
||||
|| fail "community contribution prompt must expose the submit action"
|
||||
grep -q 'Button("复制模板")' "$MAP" \
|
||||
|| fail "community contribution prompt must expose an explicit copy action"
|
||||
grep -q 'Button("取消", role: .cancel)' "$MAP" \
|
||||
|| fail "the first community prompts must expose cancel"
|
||||
grep -q 'Button("不再提示", role: .cancel)' "$MAP" \
|
||||
|| fail "the third community prompt must allow permanent suppression"
|
||||
grep -q '匿名收录,不在 README 展示投稿账号' "$GITHUB_SUBMISSION" \
|
||||
|| fail "the contribution template must offer anonymous README attribution"
|
||||
grep -Fq 'https://github.com/xweiba/location-spoofer/discussions/new?category=%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB' "$GITHUB_SUBMISSION" \
|
||||
|| fail "community submission must use one fixed Discussions address"
|
||||
grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' "$MAP" \
|
||||
|| fail "the explicit copy action must copy the contribution template"
|
||||
grep -A8 'Button("去提交")' "$MAP" | grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' \
|
||||
|| fail "the submit action must copy the template before opening GitHub"
|
||||
grep -A8 'Button("去提交")' "$MAP" | grep -q 'openCommunityContributionPage()' \
|
||||
|| fail "the submit action must open the fixed Discussions page after copying"
|
||||
! grep -A12 'private func openCommunityContributionPage' "$MAP" | grep -q 'URLComponents' \
|
||||
|| fail "the fixed community address must not be rebuilt from dynamic query items"
|
||||
grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'SafariDestination' \
|
||||
|| fail "community submission must open with the shared in-App Safari destination"
|
||||
! grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'UIApplication.shared.open' \
|
||||
|| fail "community submission must not be intercepted by an external GitHub client"
|
||||
test -s "$DISCUSSION_FORM" \
|
||||
|| fail "the fixed Discussions category must provide a maintained submission form"
|
||||
grep -q 'label: 第三方客户端' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must classify submissions by client"
|
||||
grep -q '匿名收录,不在 README 展示投稿账号' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must retain anonymous README attribution"
|
||||
grep -q '请先移除账号、订阅、密码、设备标识、真实位置等敏感信息' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must remind users to remove sensitive information"
|
||||
grep -q '截图除敏感信息遮挡外不要添加箭头、编号或说明文字' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must explain the screenshot annotation rule"
|
||||
! grep -q 'label: 隐私确认' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must not require a separate privacy confirmation"
|
||||
for field in \
|
||||
'## 第三方客户端' \
|
||||
'## 第三方客户端版本' \
|
||||
'## iOS 版本' \
|
||||
'## 配置步骤' \
|
||||
'## 截图与补充说明' \
|
||||
'## README 收录署名'; do
|
||||
grep -Fq "$field" "$GITHUB_SUBMISSION" \
|
||||
|| fail "the App contribution template must match Discussions field: $field"
|
||||
done
|
||||
grep -Fq 'label: 第三方客户端版本' "$DISCUSSION_FORM" \
|
||||
|| fail "the Discussions form must clearly request the third-party client version"
|
||||
|
||||
grep -Fq 'https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml' "$GITHUB_SUBMISSION" \
|
||||
|| fail "the App bug report must open the dedicated Issue form"
|
||||
grep -Fq 'GitHubSubmission.bugReportURL' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must use the shared GitHub destination"
|
||||
grep -Fq 'SafariView(url: destination.url)' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must open in the shared in-App Safari view"
|
||||
! grep -q 'UIApplication.shared.open' "$BUG_REPORT" \
|
||||
|| fail "the App bug report must not be handed to an external GitHub client"
|
||||
grep -Fq 'App 生成的诊断报告' "$BUG_REPORT" \
|
||||
|| fail "the App must tell users where to paste the generated report"
|
||||
grep -Fq '第三方客户端:' "$BUG_REPORT" \
|
||||
|| fail "the generated report must identify the selected third-party client"
|
||||
grep -Fq 'Label("报告 Bug"' "$SETTINGS" \
|
||||
|| fail "Settings must identify the support action as a bug report"
|
||||
for action in '使用帮助' '功能建议' '分享第三方配置'; do
|
||||
grep -Fq "Label(\"$action\"" "$SETTINGS" \
|
||||
|| fail "Settings support must expose: $action"
|
||||
done
|
||||
grep -A8 'Label("分享第三方配置"' "$SETTINGS" >/dev/null \
|
||||
|| fail "Settings must retain the manual third-party sharing action"
|
||||
grep -q 'GitHubSubmission.communityContributionTemplate' "$SETTINGS" \
|
||||
|| fail "Settings manual sharing must copy the shared contribution template"
|
||||
grep -q 'SafariView(url: destination.url)' "$SETTINGS" \
|
||||
|| fail "Settings GitHub support actions must use the in-App Safari view"
|
||||
test -s "$ISSUE_FORM" \
|
||||
|| fail "the repository must provide a bug report Issue form"
|
||||
grep -Fq 'label: App 生成的诊断报告' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must expose the field named by the App"
|
||||
grep -Fq '设置 → 支持 → 报告 Bug' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must point users to the matching App workflow"
|
||||
grep -Fq 'label: 我已移除真实位置、认证信息、订阅地址、证书私钥和其他敏感信息' "$ISSUE_FORM" \
|
||||
|| fail "the Issue form must require privacy confirmation"
|
||||
grep -Fq 'blank_issues_enabled: false' "$ISSUE_CONFIG" \
|
||||
|| fail "blank Issues must be disabled so reports use the maintained form"
|
||||
for destination in '使用帮助' '功能建议' '第三方配置分享'; do
|
||||
grep -Fq "name: $destination" "$ISSUE_CONFIG" \
|
||||
|| fail "Issue chooser must route non-bug request to $destination"
|
||||
done
|
||||
|
||||
for obsolete in \
|
||||
'我已配置,开始检测' \
|
||||
'确认完成,重新检测' \
|
||||
'下一步:导入配置' \
|
||||
'我已导入,检测接口连接'; do
|
||||
! grep -q "$obsolete" "$SETUP" \
|
||||
|| fail "setup footer must not retain dynamic label: $obsolete"
|
||||
done
|
||||
test "$(grep -c 'actionLabel("完成")' "$SETUP")" -eq 4 \
|
||||
|| fail "all setup footer primary actions must use the fixed 完成 label"
|
||||
|
||||
application_line="$(grep -n 'Section("应用")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
|
||||
certificate_line="$(grep -n 'Section("证书")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
|
||||
test "$application_line" -lt "$certificate_line" \
|
||||
|| fail "the APP certificate reset section must appear below the application section"
|
||||
|
||||
echo "PASS: update and community contribution contract"
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 已发布
|
||||
|
||||
- [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 |
@@ -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 -->
|
||||
@@ -8,8 +8,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.2"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
MARKETING_VERSION: "1.0.3"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"latestVersion": "1.0.3",
|
||||
"minimumSupportedVersion": "1.0.0",
|
||||
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
|
||||
}
|
||||