diff --git a/App/ContentView.swift b/App/ContentView.swift index 0605ef3..1e4ad87 100644 --- a/App/ContentView.swift +++ b/App/ContentView.swift @@ -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 + } + } } diff --git a/App/FirstSetupView.swift b/App/FirstSetupView.swift index e02a6ae..eb7fb80 100644 --- a/App/FirstSetupView.swift +++ b/App/FirstSetupView.swift @@ -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) } diff --git a/App/MapHomeView.swift b/App/MapHomeView.swift index befbd43..176fa86 100644 --- a/App/MapHomeView.swift +++ b/App/MapHomeView.swift @@ -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? @@ -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", "环境检测连续被占用,本次不重复弹窗") } diff --git a/App/ProxyManager.swift b/App/ProxyManager.swift index 9a40a3b..e304eef 100644 --- a/App/ProxyManager.swift +++ b/App/ProxyManager.swift @@ -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 } } diff --git a/App/SafariView.swift b/App/SafariView.swift new file mode 100644 index 0000000..bc9550f --- /dev/null +++ b/App/SafariView.swift @@ -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) {} +} diff --git a/App/SettingsView.swift b/App/SettingsView.swift index d0616fd..0abd0ef 100644 --- a/App/SettingsView.swift +++ b/App/SettingsView.swift @@ -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 } } diff --git a/App/SetupCoordinator.swift b/App/SetupCoordinator.swift index 655a12a..db6e13f 100644 --- a/App/SetupCoordinator.swift +++ b/App/SetupCoordinator.swift @@ -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 } diff --git a/App/TipViews.swift b/App/TipViews.swift index deaed10..4286d17 100644 --- a/App/TipViews.swift +++ b/App/TipViews.swift @@ -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) - } - } -} diff --git a/Core/ca.go b/Core/ca.go index ecf1636..b98d77c 100644 --- a/Core/ca.go +++ b/Core/ca.go @@ -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), diff --git a/Core/ca_test.go b/Core/ca_test.go index 032a9fa..b6a9d81 100644 --- a/Core/ca_test.go +++ b/Core/ca_test.go @@ -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 } diff --git a/README.en.md b/README.en.md index 99401cd..b6552c3 100644 --- a/README.en.md +++ b/README.en.md @@ -163,14 +163,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: diff --git a/README.md b/README.md index 8da381c..98077b6 100644 --- a/README.md +++ b/README.md @@ -159,14 +159,16 @@ WLOC 配置接口 当前客户端状态: -| 客户端 | 状态 | -|---|---| -| Shadowrocket | 当前用于真机测试 | -| Surge | 已提供配置,尚未完整验证 | -| Quantumult X | 已提供配置,尚未完整验证 | -| Loon | 已提供配置,尚未完整验证 | -| Stash | 已提供配置,尚未完整验证 | -| Egern | 使用 Surge 模块,尚未完整验证 | +| 客户端 | 状态 | 社区配置 | +|---|---|---| +| Shadowrocket | 当前用于真机测试 | App 内置教程 | +| Surge | 已提供配置,尚未完整验证 | 待征集 | +| Quantumult X | 已提供配置,尚未完整验证 | 待征集 | +| Loon | 已提供配置,尚未完整验证 | 待征集 | +| Stash | 已提供配置,尚未完整验证 | 待征集 | +| Egern | 使用 Surge 模块,尚未完整验证 | 待征集 | + +社区配置按客户端分区审核;采纳后会在上表链接教程和投稿者,投稿者也可以选择匿名收录。 相关模块快照和来源记录: @@ -323,6 +325,7 @@ dist/PaopaoLocationSpoofer-unsigned.ipa - Bug Report; - Feature Request; - 兼容性测试结果; +- [第三方客户端教程和脱敏原始截图](docs/COMMUNITY_TUTORIALS.md); - 性能改进; - 文档改进; - 测试补充。 @@ -340,6 +343,7 @@ dist/PaopaoLocationSpoofer-unsigned.ipa - [构建说明](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) diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png index 27d2544..b61f63d 100644 Binary files a/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png and b/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Resources/Assets.xcassets/AppModeCertificateInstall.imageset/Contents.json b/Resources/Assets.xcassets/AppModeCertificateInstall.imageset/Contents.json new file mode 100644 index 0000000..487b839 --- /dev/null +++ b/Resources/Assets.xcassets/AppModeCertificateInstall.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "app-mode-certificate-install.jpg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/AppModeCertificateInstall.imageset/app-mode-certificate-install.jpg b/Resources/Assets.xcassets/AppModeCertificateInstall.imageset/app-mode-certificate-install.jpg new file mode 100644 index 0000000..66340a3 Binary files /dev/null and b/Resources/Assets.xcassets/AppModeCertificateInstall.imageset/app-mode-certificate-install.jpg differ diff --git a/Resources/Assets.xcassets/AppModeCertificateTrust.imageset/Contents.json b/Resources/Assets.xcassets/AppModeCertificateTrust.imageset/Contents.json new file mode 100644 index 0000000..0ab4681 --- /dev/null +++ b/Resources/Assets.xcassets/AppModeCertificateTrust.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "app-mode-certificate-trust.jpg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/AppModeCertificateTrust.imageset/app-mode-certificate-trust.jpg b/Resources/Assets.xcassets/AppModeCertificateTrust.imageset/app-mode-certificate-trust.jpg new file mode 100644 index 0000000..1948dcd Binary files /dev/null and b/Resources/Assets.xcassets/AppModeCertificateTrust.imageset/app-mode-certificate-trust.jpg differ diff --git a/Resources/Assets.xcassets/AppModeWiFiProxy.imageset/Contents.json b/Resources/Assets.xcassets/AppModeWiFiProxy.imageset/Contents.json new file mode 100644 index 0000000..df9c467 --- /dev/null +++ b/Resources/Assets.xcassets/AppModeWiFiProxy.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "app-mode-wifi-proxy.jpg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/AppModeWiFiProxy.imageset/app-mode-wifi-proxy.jpg b/Resources/Assets.xcassets/AppModeWiFiProxy.imageset/app-mode-wifi-proxy.jpg new file mode 100644 index 0000000..658344a Binary files /dev/null and b/Resources/Assets.xcassets/AppModeWiFiProxy.imageset/app-mode-wifi-proxy.jpg differ diff --git a/Resources/Assets.xcassets/ShadowrocketConfigDetails.imageset/Contents.json b/Resources/Assets.xcassets/ShadowrocketConfigDetails.imageset/Contents.json new file mode 100644 index 0000000..f1b1d19 --- /dev/null +++ b/Resources/Assets.xcassets/ShadowrocketConfigDetails.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "shadowrocket-config-details.jpg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/ShadowrocketConfigDetails.imageset/shadowrocket-config-details.jpg b/Resources/Assets.xcassets/ShadowrocketConfigDetails.imageset/shadowrocket-config-details.jpg new file mode 100644 index 0000000..4518f21 Binary files /dev/null and b/Resources/Assets.xcassets/ShadowrocketConfigDetails.imageset/shadowrocket-config-details.jpg differ diff --git a/Resources/Assets.xcassets/ShadowrocketHTTPSCA.imageset/Contents.json b/Resources/Assets.xcassets/ShadowrocketHTTPSCA.imageset/Contents.json new file mode 100644 index 0000000..4cf8ae4 --- /dev/null +++ b/Resources/Assets.xcassets/ShadowrocketHTTPSCA.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "shadowrocket-https-ca.jpg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/ShadowrocketHTTPSCA.imageset/shadowrocket-https-ca.jpg b/Resources/Assets.xcassets/ShadowrocketHTTPSCA.imageset/shadowrocket-https-ca.jpg new file mode 100644 index 0000000..42d8ef7 Binary files /dev/null and b/Resources/Assets.xcassets/ShadowrocketHTTPSCA.imageset/shadowrocket-https-ca.jpg differ diff --git a/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/Contents.json b/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/Contents.json new file mode 100644 index 0000000..0f9de44 --- /dev/null +++ b/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "shadowrocket-https-decryption.jpg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/shadowrocket-https-decryption.jpg b/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/shadowrocket-https-decryption.jpg new file mode 100644 index 0000000..4cdebf5 Binary files /dev/null and b/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/shadowrocket-https-decryption.jpg differ diff --git a/Resources/Assets.xcassets/ShadowrocketModuleImport.imageset/Contents.json b/Resources/Assets.xcassets/ShadowrocketModuleImport.imageset/Contents.json new file mode 100644 index 0000000..322e6bd --- /dev/null +++ b/Resources/Assets.xcassets/ShadowrocketModuleImport.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "shadowrocket-module-import.jpg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/ShadowrocketModuleImport.imageset/shadowrocket-module-import.jpg b/Resources/Assets.xcassets/ShadowrocketModuleImport.imageset/shadowrocket-module-import.jpg new file mode 100644 index 0000000..18646cb Binary files /dev/null and b/Resources/Assets.xcassets/ShadowrocketModuleImport.imageset/shadowrocket-module-import.jpg differ diff --git a/Shared/AppGroup.swift b/Shared/AppGroup.swift index 010e7bf..bfe574b 100644 --- a/Shared/AppGroup.swift +++ b/Shared/AppGroup.swift @@ -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) + } +} diff --git a/Shared/AppRemoteConfiguration.swift b/Shared/AppRemoteConfiguration.swift new file mode 100644 index 0000000..209b185 --- /dev/null +++ b/Shared/AppRemoteConfiguration.swift @@ -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.2", + 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.. 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) } } diff --git a/Shared/ProxyRuntimeMode.swift b/Shared/ProxyRuntimeMode.swift index 147b211..2f1f7f8 100644 --- a/Shared/ProxyRuntimeMode.swift +++ b/Shared/ProxyRuntimeMode.swift @@ -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) + } } diff --git a/Shared/ThirdPartyProxyManager.swift b/Shared/ThirdPartyProxyManager.swift index 17fc0bb..67b7444 100644 --- a/Shared/ThirdPartyProxyManager.swift +++ b/Shared/ThirdPartyProxyManager.swift @@ -197,8 +197,8 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable { } } - var verificationText: String { - self == .shadowrocket ? "当前可测试" : "配置已提供,尚未验证" + var verificationText: String? { + self == .shadowrocket ? nil : "配置已提供,尚未验证" } var moduleFileName: String { diff --git a/Shared/VerificationResult.swift b/Shared/VerificationResult.swift index 6afe605..fe4fa80 100644 --- a/Shared/VerificationResult.swift +++ b/Shared/VerificationResult.swift @@ -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 - } - } } diff --git a/Tests/PaopaoLocationSpooferTests/AppRemoteConfigurationTests.swift b/Tests/PaopaoLocationSpooferTests/AppRemoteConfigurationTests.swift new file mode 100644 index 0000000..48d8c3d --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/AppRemoteConfigurationTests.swift @@ -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.2") + XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0") + XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket)) + for client in ThirdPartyProxyClient.allCases where client != .shadowrocket { + XCTAssertTrue(configuration.requestsCommunityPrompt(for: client)) + } + } +} diff --git a/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift b/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift index 2aaf0b8..2df84fa 100644 --- a/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift +++ b/Tests/PaopaoLocationSpooferTests/CertificateAuthorityStoreTests.swift @@ -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 } } diff --git a/Tests/PaopaoLocationSpooferTests/ProxyRuntimeModeTests.swift b/Tests/PaopaoLocationSpooferTests/ProxyRuntimeModeTests.swift index c5b141f..be3f9cd 100644 --- a/Tests/PaopaoLocationSpooferTests/ProxyRuntimeModeTests.swift +++ b/Tests/PaopaoLocationSpooferTests/ProxyRuntimeModeTests.swift @@ -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)) } } diff --git a/Tests/PaopaoLocationSpooferTests/SetupCoordinatorTests.swift b/Tests/PaopaoLocationSpooferTests/SetupCoordinatorTests.swift index 12f0ee1..43a1c2c 100644 --- a/Tests/PaopaoLocationSpooferTests/SetupCoordinatorTests.swift +++ b/Tests/PaopaoLocationSpooferTests/SetupCoordinatorTests.swift @@ -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) } } diff --git a/Tests/PaopaoLocationSpooferTests/ThirdPartyCommunityPromptPreferencesTests.swift b/Tests/PaopaoLocationSpooferTests/ThirdPartyCommunityPromptPreferencesTests.swift new file mode 100644 index 0000000..a4de6f2 --- /dev/null +++ b/Tests/PaopaoLocationSpooferTests/ThirdPartyCommunityPromptPreferencesTests.swift @@ -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 + } +} diff --git a/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift b/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift index 5b0589d..c1bfe13 100644 --- a/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift +++ b/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift @@ -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")) diff --git a/Tests/ios_compilation_contract_test.sh b/Tests/ios_compilation_contract_test.sh index 5846b90..522f1bf 100755 --- a/Tests/ios_compilation_contract_test.sh +++ b/Tests/ios_compilation_contract_test.sh @@ -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" diff --git a/Tests/map_refactor_contract_test.sh b/Tests/map_refactor_contract_test.sh index 4ccdff5..4e7fd0f 100755 --- a/Tests/map_refactor_contract_test.sh +++ b/Tests/map_refactor_contract_test.sh @@ -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" diff --git a/Tests/readme_contract_test.sh b/Tests/readme_contract_test.sh index 845d79f..8dfcafe 100755 --- a/Tests/readme_contract_test.sh +++ b/Tests/readme_contract_test.sh @@ -52,6 +52,14 @@ 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 '除敏感信息遮挡外,不要自行添加箭头、编号、边框、说明文字或其他标注' \ + "$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" \ diff --git a/Tests/third_party_mode_contract_test.sh b/Tests/third_party_mode_contract_test.sh index 8523543..53b7696 100755 --- a/Tests/third_party_mode_contract_test.sh +++ b/Tests/third_party_mode_contract_test.sh @@ -18,10 +18,12 @@ 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" @@ -39,6 +41,63 @@ 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" diff --git a/Tests/update_and_contribution_contract_test.sh b/Tests/update_and_contribution_contract_test.sh new file mode 100644 index 0000000..22c67b5 --- /dev/null +++ b/Tests/update_and_contribution_contract_test.sh @@ -0,0 +1,83 @@ +#!/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" + +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.2" +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 展示投稿账号' "$MAP" \ + || fail "the contribution template must offer anonymous README attribution" +grep -q 'community-config,client-\\(client.rawValue)' "$MAP" \ + || fail "community submissions must be categorized by client labels" +grep -q 'UIPasteboard.general.string = communityContributionIssueBody' "$MAP" \ + || fail "the explicit copy action must copy the issue template" +! grep -A25 'private func openCommunityContributionIssue' "$MAP" | grep -q 'UIPasteboard.general.string' \ + || fail "opening GitHub must not copy the template automatically" + +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" diff --git a/docs/COMMUNITY_TUTORIALS.md b/docs/COMMUNITY_TUTORIALS.md new file mode 100644 index 0000000..ffc086a --- /dev/null +++ b/docs/COMMUNITY_TUTORIALS.md @@ -0,0 +1,81 @@ +# 社区客户端教程与截图提交 + +我们公开征集 Shadowrocket、Surge、Quantumult X、Loon、Stash 和 Egern 的配置教程与真机截图。 +提交内容会经过复核、统一裁剪和标注后再进入 App 或项目文档。 + +## 客户端分区 + +投稿 Issue 使用 `community-config` 总标签,并按客户端增加以下分区标签: + +| 客户端 | 分区标签 | +|---|---| +| Shadowrocket | `client-shadowrocket` | +| Surge | `client-surge` | +| Quantumult X | `client-quantumultX` | +| Loon | `client-loon` | +| Stash | `client-stash` | +| Egern | `client-egern` | + +App 打开的投稿链接会预填对应标签、客户端名称和模板。采纳后的配置在 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/`。 + +## 审核边界 + +- 教程必须来自实际客户端操作,不能把未验证配置描述为已支持。 +- 截图和步骤不得包含付费节点、共享账号、破解内容或绕过服务限制的方法。 +- 项目只审核教程是否清晰、是否脱敏以及是否能复现,不为第三方客户端、订阅服务或网络环境提供担保。 +- 后续客户端版本变化导致界面或步骤失效时,教程可能被标记为待复核或替换。 diff --git a/docs/app-icon-source.svg b/docs/app-icon-source.svg new file mode 100644 index 0000000..7fdb717 --- /dev/null +++ b/docs/app-icon-source.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/docs/onboarding-screenshots/app-mode/app-mode-certificate-install.jpg b/docs/onboarding-screenshots/app-mode/app-mode-certificate-install.jpg new file mode 100644 index 0000000..1c268e5 Binary files /dev/null and b/docs/onboarding-screenshots/app-mode/app-mode-certificate-install.jpg differ diff --git a/docs/onboarding-screenshots/app-mode/app-mode-certificate-trust.jpg b/docs/onboarding-screenshots/app-mode/app-mode-certificate-trust.jpg new file mode 100644 index 0000000..ef78a8a Binary files /dev/null and b/docs/onboarding-screenshots/app-mode/app-mode-certificate-trust.jpg differ diff --git a/docs/onboarding-screenshots/app-mode/app-mode-wifi-proxy.jpg b/docs/onboarding-screenshots/app-mode/app-mode-wifi-proxy.jpg new file mode 100644 index 0000000..40c1cec Binary files /dev/null and b/docs/onboarding-screenshots/app-mode/app-mode-wifi-proxy.jpg differ diff --git a/docs/onboarding-screenshots/shadowrocket/shadowrocket-config-details.jpg b/docs/onboarding-screenshots/shadowrocket/shadowrocket-config-details.jpg new file mode 100644 index 0000000..4b698fc Binary files /dev/null and b/docs/onboarding-screenshots/shadowrocket/shadowrocket-config-details.jpg differ diff --git a/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-ca.jpg b/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-ca.jpg new file mode 100644 index 0000000..f6cbe7f Binary files /dev/null and b/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-ca.jpg differ diff --git a/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-decryption.jpg b/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-decryption.jpg new file mode 100644 index 0000000..b52646f Binary files /dev/null and b/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-decryption.jpg differ diff --git a/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-import.jpg b/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-import.jpg new file mode 100644 index 0000000..ff0086f Binary files /dev/null and b/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-import.jpg differ diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..57047d7 --- /dev/null +++ b/version.txt @@ -0,0 +1,5 @@ +{ + "latestVersion": "1.0.2", + "minimumSupportedVersion": "1.0.0", + "communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"] +}