feat: add third-party proxy mode and improve onboarding

This commit is contained in:
xweiba
2026-08-06 19:20:25 +08:00
parent e86909cc2f
commit b52d72bef2
36 changed files with 1946 additions and 218 deletions
+352 -6
View File
@@ -1,13 +1,21 @@
import SwiftUI
enum SetupStep: Int, CaseIterable {
case mode
case proxy
case cert
case thirdPartyClient
case thirdPartyImport
case thirdPartyTest
var title: String {
switch self {
case .mode: return "选择模式"
case .proxy: return "配置 Wi-Fi 代理"
case .cert: return "初始化 CA 证书"
case .thirdPartyClient: return "选择客户端"
case .thirdPartyImport: return "导入配置"
case .thirdPartyTest: return "连接检测"
}
}
}
@@ -22,9 +30,16 @@ struct FirstSetupView: View {
@State private var trustedDone = false
@State private var result: VerificationResult?
@State private var isVerifying = false
@State private var isPreparingMode = false
@State private var manualHint = ""
@State private var setupActionError = ""
@State private var showDiagnostics = false
@StateObject private var diagnosticActions = LocationActionCoordinator()
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
@State private var copiedSubscriptionURL = false
@State private var copiedMITMHostname = false
init(setup: SetupCoordinator, onComplete: @escaping () -> Void) {
self.setup = setup
@@ -44,15 +59,25 @@ struct FirstSetupView: View {
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
}
if let result { resultView(result) }
}
.padding(20)
}
Divider()
primaryAction
VStack(spacing: 10) {
primaryAction
if step != .mode {
Button("上一步") { returnToPreviousStep() }
.disabled(isVerifying || thirdPartyProxy.isRequesting)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
@@ -73,19 +98,28 @@ struct FirstSetupView: View {
)) {
Button("知道了", role: .cancel) {}
} message: { Text(manualHint) }
.alert("操作失败", isPresented: Binding(
get: { !setupActionError.isEmpty },
set: { if !$0 { setupActionError = "" } }
)) {
Button("查看诊断日志") { showDiagnostics = true }
Button("知道了", role: .cancel) {}
} message: {
Text(setupActionError)
}
}
}
private var progress: some View {
HStack(spacing: 8) {
ForEach(SetupStep.allCases, id: \.rawValue) { value in
ForEach(visibleSteps, id: \.rawValue) { value in
HStack(spacing: 6) {
Circle()
.fill(value.rawValue <= step.rawValue ? Color.blue : Color.gray.opacity(0.3))
.frame(width: 10, height: 10)
Text(value.title).font(.caption).foregroundStyle(.secondary)
}
if value != SetupStep.allCases.last {
if value != visibleSteps.last {
Rectangle().fill(Color.gray.opacity(0.3)).frame(width: 28, height: 2)
}
}
@@ -93,6 +127,94 @@ struct FirstSetupView: View {
.padding(.vertical, 16)
}
private var visibleSteps: [SetupStep] {
switch step {
case .mode:
return [.mode]
case .proxy, .cert:
return [.mode, .proxy, .cert]
case .thirdPartyClient, .thirdPartyImport, .thirdPartyTest:
return [.mode, .thirdPartyClient, .thirdPartyImport, .thirdPartyTest]
}
}
private var modeStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("选择运行模式")
.font(.title2.bold())
Text("后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。")
.font(.footnote)
.foregroundStyle(.secondary)
modeCard(
title: "APP模式",
icon: "iphone.and.arrow.forward",
badges: ["仅 Wi-Fi", "无外部依赖"],
description: "App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。",
tint: .blue
) {
selectMode(.localWiFi)
}
.disabled(isPreparingMode)
modeCard(
title: "第三方代理模式",
icon: "network.badge.shield.half.filled",
badges: ["Wi-Fi + 4G/5G", "测试模式"],
description: "App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。",
tint: .orange
) {
selectMode(.thirdParty)
}
.disabled(isPreparingMode)
if isPreparingMode {
HStack(spacing: 8) {
ProgressView()
Text("正在准备 APP模式本地服务…")
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
}
private func modeCard(
title: String,
icon: String,
badges: [String],
description: String,
tint: Color,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 12) {
Label(title, systemImage: icon)
.font(.headline)
.foregroundStyle(tint)
HStack(spacing: 6) {
ForEach(badges, id: \.self) { badge in
Text(badge)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(tint.opacity(0.12), in: Capsule())
}
}
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(tint.opacity(0.25)))
}
.buttonStyle(.plain)
}
private var proxyStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("先配置 Wi-Fi 系统代理", systemImage: "wifi")) {
@@ -123,7 +245,14 @@ struct FirstSetupView: View {
actionTitle: "去下载",
actionIcon: "arrow.down.circle.fill",
complete: downloadedDone,
action: { Task { await setup.proxy.openCertificateDownload() } },
action: {
Task {
let opened = await setup.proxy.openCertificateDownload()
if !opened {
setupActionError = setup.proxy.error ?? "无法打开证书下载页面,请查看诊断日志"
}
}
},
markComplete: { downloadedDone = true }
)
certificateCard(
@@ -149,6 +278,171 @@ struct FirstSetupView: View {
}
}
private var thirdPartyClientStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("选择第三方代理客户端", systemImage: "app.badge.checkmark")) {
VStack(spacing: 0) {
ForEach(ThirdPartyProxyClient.allCases) { client in
Button {
thirdPartyClient.select(client)
} label: {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(client.name).foregroundStyle(.primary)
Text(client.verificationText)
.font(.caption2)
.foregroundStyle(client == .shadowrocket ? .green : .orange)
}
Spacer()
Image(systemName: thirdPartyClient.selectedClient == client ? "checkmark.circle.fill" : "circle")
.foregroundStyle(thirdPartyClient.selectedClient == client ? .blue : .secondary)
}
.padding(.vertical, 10)
}
.buttonStyle(.plain)
if client != ThirdPartyProxyClient.allCases.last { Divider() }
}
}
}
Text("Egern 直接使用 Surge 模块。Stash 直接订阅 .stoverride,不需要 Script Hub 转换。除 Shadowrocket 外,当前仅提供配置,尚未完成真机验证。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var thirdPartyImportStep: some View {
let client = thirdPartyClient.selectedClient
return VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("第 1 步:导入 \(client.name) 模块", systemImage: "square.and.arrow.down")) {
VStack(alignment: .leading, spacing: 12) {
Text(importInstructions(for: client))
.font(.caption)
.foregroundStyle(.secondary)
Button {
UIPasteboard.general.string = client.subscriptionURL.absoluteString
copiedSubscriptionURL = true
} label: {
Label(copiedSubscriptionURL ? "已复制订阅地址" : "复制订阅地址", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
openThirdPartyClient(client)
} label: {
Label("打开 \(client.name)", systemImage: "arrow.up.forward.app")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
if client == .shadowrocket {
shadowrocketHTTPSDecryptionGuide
} else {
Text("请复制订阅地址,在客户端的模块、重写或覆写订阅入口中添加。证书、MITM、VPN 和代理连接请按第三方客户端自己的流程配置。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
private var shadowrocketHTTPSDecryptionGuide: some View {
GroupBox(label: Label("第 2 步:配置 HTTPS 解密", systemImage: "lock.open")) {
VStack(alignment: .leading, spacing: 12) {
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
Button {
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostname
copiedMITMHostname = true
} label: {
Label(copiedMITMHostname ? "已复制 gs-loc.apple.com" : "复制解密域名", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
instructionRow(5, "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。")
Button {
openThirdPartyClient(.shadowrocket)
} label: {
Label("打开 Shadowrocket 继续配置", systemImage: "arrow.up.forward.app")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Text("App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private func instructionRow(_ number: Int, _ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("\(number)")
.font(.caption2.bold())
.foregroundStyle(.white)
.frame(width: 20, height: 20)
.background(Color.blue, in: Circle())
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func importInstructions(for client: ThirdPartyProxyClient) -> String {
if client == .shadowrocket {
return "先复制订阅地址。然后打开 Shadowrocket,进入“配置 → 模块”,点击右上角“+”,粘贴订阅地址并完成导入。导入完成后,模块配置由 Shadowrocket 保存,不依赖本 App 持续运行。"
}
return "先复制订阅地址,然后打开 \(client.name),在模块、重写或覆写订阅入口中粘贴并导入。配置由 \(client.name) 保存,不依赖本 App 持续运行。"
}
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
guard let url = client.launchURL else { return }
UIApplication.shared.open(url, options: [:]) { opened in
guard !opened else { return }
Task { @MainActor in
manualHint = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
}
}
}
private var thirdPartyTestStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("检测配置接口", systemImage: "network")) {
Text("请先在第三方客户端中启用刚导入的配置,并按客户端要求完成 MITM、证书和代理/VPN 连接。App 只调用 WLOC 查询接口确认模块能否正常响应,不检查或管理第三方证书。")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
}
private func returnToPreviousStep() {
result = nil
setupActionError = ""
switch step {
case .mode:
break
case .proxy, .thirdPartyClient:
step = .mode
case .cert:
step = .proxy
case .thirdPartyImport:
step = .thirdPartyClient
case .thirdPartyTest:
step = .thirdPartyImport
}
}
private func certificateCard(
title: String,
icon: String,
@@ -212,7 +506,9 @@ struct FirstSetupView: View {
@ViewBuilder
private var primaryAction: some View {
if step == .proxy {
if step == .mode {
EmptyView()
} else if step == .proxy {
Button {
verifyAfterProxyConfirmation()
} label: {
@@ -220,7 +516,7 @@ struct FirstSetupView: View {
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying)
} else {
} else if step == .cert {
Button {
verifyAfterCertificateConfirmation()
} label: {
@@ -228,6 +524,56 @@ struct FirstSetupView: View {
}
.buttonStyle(.borderedProminent)
.disabled(!certificateStepsComplete || isVerifying)
} else if step == .thirdPartyClient {
Button("下一步:导入配置") { step = .thirdPartyImport }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
} else if step == .thirdPartyImport {
Button("我已导入,下一步") { step = .thirdPartyTest }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
} else {
Button {
verifyThirdPartyConnection()
} label: {
actionLabel("检测接口连接")
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying || thirdPartyProxy.isRequesting)
}
}
private func selectMode(_ mode: ProxyRuntimeMode) {
guard !isPreparingMode else { return }
runtimeMode.setMode(mode)
result = nil
switch mode {
case .localWiFi:
isPreparingMode = true
Task { @MainActor in
await setup.prepareLocalServices()
isPreparingMode = false
step = .proxy
}
case .thirdParty:
setup.proxy.stop()
BackgroundKeepAlive.shared.stop()
step = .thirdPartyClient
}
}
private func verifyThirdPartyConnection() {
guard !isVerifying else { return }
isVerifying = true
result = nil
Task { @MainActor in
defer { isVerifying = false }
do {
_ = try await thirdPartyProxy.query()
onComplete()
} catch {
setupActionError = error.localizedDescription
}
}
}