feat: improve runtime setup and guidance

This commit is contained in:
xweiba
2026-08-08 15:18:40 +08:00
parent 3499f080d7
commit b07fa61195
54 changed files with 1794 additions and 325 deletions
+93 -22
View File
@@ -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
}
}
}
+337 -74
View File
@@ -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,7 +427,7 @@ struct FirstSetupView: View {
}
}
Text("Egern 直接使用 Surge 模块。Stash 直接订阅 .stoverride,不需要 Script Hub 转换。除 Shadowrocket 外,当前仅提供配置,尚未完成真机验证。")
Text("除 Shadowrocket 外,当前客户端配置尚未完成真机验证,页面只提供模块导入入口和通用配置提醒。")
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -314,21 +436,31 @@ struct FirstSetupView: View {
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 +468,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 +518,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 +534,11 @@ struct FirstSetupView: View {
.buttonStyle(.borderedProminent)
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
setupScreenshot(
assetName: "ShadowrocketHTTPSCA",
title: "授权 Shadowrocket 证书",
caption: "1 打开 Shadowrocket 证书项并按提示安装、授权。"
)
instructionRow(5, "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。")
Button {
@@ -397,11 +570,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 +620,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 +631,8 @@ struct FirstSetupView: View {
case .cert:
step = .proxy
case .thirdPartyImport:
thirdPartyTestFailure = nil
step = .thirdPartyClient
case .thirdPartyTest:
step = .thirdPartyImport
}
}
@@ -483,12 +676,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 +713,7 @@ struct FirstSetupView: View {
Button {
verifyAfterProxyConfirmation()
} label: {
actionLabel("我已配置,开始检测")
actionLabel("完成")
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying)
@@ -520,23 +721,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 +764,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 +871,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)
}
+144 -21
View File
@@ -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,10 @@ 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 editingFavorite: FavoriteLocation?
@State private var editName = ""
@State private var reverseGeocodeTask: Task<Void, Never>?
@@ -261,6 +267,36 @@ struct MapHomeView: View {
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind, runtimeMode: runtimeMode.mode)
}
.alert("社区分享成功配置?", isPresented: Binding(
get: { communityContributionClient != nil },
set: { if !$0 { communityContributionClient = nil } }
)) {
Button("去提交") {
guard let client = communityContributionClient else { return }
openCommunityContributionIssue(for: client)
}
Button("复制模板") {
guard let client = communityContributionClient else { return }
UIPasteboard.general.string = communityContributionIssueBody(for: client)
showCommunityTemplateCopied = true
}
if communityPromptPreferences.canSuppress() {
Button("不再提示", role: .cancel) {
communityPromptPreferences.suppress()
}
} else {
Button("取消", role: .cancel) {}
}
} message: {
Text(
"你正在使用 \(communityContributionClient?.name ?? "第三方客户端")。欢迎分享成功配置,采纳后将收录到 README,可选择是否匿名署名。提交前请移除账号、订阅和其他敏感信息。"
)
}
.alert("已复制投稿模板", isPresented: $showCommunityTemplateCopied) {
Button("知道了", role: .cancel) {}
} message: {
Text("如果 GitHub 登录或浏览器跳转后模板没有自动填充,可以直接粘贴。")
}
.alert("无法直接跳转", isPresented: Binding(
get: { !manualHint.isEmpty },
set: { if !$0 { manualHint = "" } }
@@ -269,8 +305,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 +339,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 +629,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 +695,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 +723,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 +768,59 @@ 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 openCommunityContributionIssue(for client: ThirdPartyProxyClient) {
let issueBody = communityContributionIssueBody(for: client)
var components = URLComponents(
string: "https://github.com/xweiba/location-spoofer/issues/new"
)
components?.queryItems = [
URLQueryItem(
name: "title",
value: "[第三方配置分享] \(client.name) 成功配置"
),
URLQueryItem(
name: "body",
value: issueBody
),
URLQueryItem(
name: "labels",
value: "community-config,client-\(client.rawValue)"
)
]
guard let url = components?.url else { return }
UIApplication.shared.open(url)
}
private func communityContributionIssueBody(for client: ThirdPartyProxyClient) -> String {
"""
## 客户端
\(client.name)
## 配置说明
请描述模块导入、MITM、证书和代理连接步骤,并附上必要截图。
## README 收录署名
- [ ] 匿名收录,不在 README 展示投稿账号
## 隐私确认
- [ ] 已移除账号、订阅地址、密码、设备标识、真实位置等敏感信息
- [ ] 原始截图除敏感信息遮挡外未添加其他标注
"""
}
private func favoriteChip(_ f: FavoriteLocation) -> some View {
HStack(spacing: 0) {
@@ -990,14 +1111,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 +1167,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 +1195,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", "环境检测连续被占用,本次不重复弹窗")
}
+5 -17
View File
@@ -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
}
}
+14
View File
@@ -0,0 +1,14 @@
import SafariServices
import SwiftUI
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) {}
}
+133 -41
View File
@@ -13,6 +13,7 @@ struct SettingsView: View {
@State private var proxyOperationAlertTitle = "代理操作失败"
@State private var modeOperationRunning = false
@State private var copiedClient: ThirdPartyProxyClient?
@State private var showCertificateResetConfirmation = false
var body: some View {
Form {
@@ -25,11 +26,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 +65,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,6 +101,21 @@ 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)
@@ -152,6 +164,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 +229,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 +253,7 @@ struct SettingsView: View {
}
Button {
setup.requestThirdPartySetup()
setup.requestThirdPartyOnboarding()
dismiss()
} label: {
Label("重新打开配置引导", systemImage: "arrow.clockwise.circle")
@@ -241,7 +267,7 @@ struct SettingsView: View {
.font(.footnote).foregroundStyle(.secondary)
}
Text("复制链接后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
.font(.footnote).foregroundStyle(.secondary)
}
}
@@ -294,9 +320,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 +342,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
}
}
+29 -3
View File
@@ -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
}
+13 -66
View File
@@ -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)
}
}
}