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
+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)
}