import Foundation /// 代理运行时的只读诊断事实缝:runtime 只以固定状态 /// 类别、活动目标存在性、监听事实与已脱敏契约摘要进入诊断 workflow;生产 /// 实现是 `ProxyRuntimeController` 的诊断只读面,测试注入替身。诊断 module /// 不直接操作 runtime controller。 @MainActor protocol ProxyRuntimeDiagnosticFacts: AnyObject { /// 固定代理状态类别(不透传任意错误 detail)。 var proxyState: DiagnosticProxyState { get } var hasActiveTarget: Bool { get } /// 当前监听设置;监听地址在报告中只以回环/非回环两态呈现。 var listen: RuntimeListenFacts { get } /// 运行时契约的脱敏摘要(数量与模式);契约缺失或无效为 nil。 var contractSummary: String? { get } /// 自定义规则安全摘要:数量 + 内容版本,无原始域名。 var customRuleSummary: CustomRuleSummary? { get } } /// 报告事件白名单(ADR-0006):封闭的 RuntimeLogEvent /// 不自动获得导出资格——诊断 module 以只读派生属性显式决定每个事件的报告 /// 呈现,自由错误 detail 与对外公布的主机地址一律清洗掉;数量、端口与领域 /// 点名原因可以进入。新增事件必须先在此补充决策(漏 case 无法编译), /// 隐私承诺不因新增字段自动扩大。 extension RuntimeLogEvent { /// 事件在安全报告中的呈现文本;nil = 不进报告。 var diagnosticReportLine: String? { switch self { case .connectivityMeasurement: return nil case .contractWritten(let serverCount): return "contract written (servers=\(serverCount))" case .contractUnchanged: return "contract unchanged, write skipped" case .systemProxyUnchanged: return "system proxy values unchanged, write skipped" case .systemProxyHelperRegisterFailed: return "system proxy helper register failed" case .systemProxyWriteFailed: return "system proxy write failed" case .runtimeFilesDeleted: return "runtime files deleted" case .runtimePersistFailed: return "runtime metadata persist failed" case .agentRegistered: return "launch agent registered" case .agentRegisterFailed: return "launch agent register failed" case .agentUnregistered: return "launch agent unregistered" case .agentUnregisterFailed: return "launch agent unregister failed" case .sslocalSpawned(let pid): return "sslocal spawned (pid=\(pid))" case .sslocalSpawnFailed: return "sslocal spawn failed" case .sslocalExitedUnexpectedly(let status): return "sslocal exited unexpectedly (status=\(status))" case .sslocalStopRequested: return "sslocal stop requested" case .listenNotEstablished: return "listen not established within deadline" case .contractMissing: return "contract missing" case .contractInvalid: return "supervision invalid, preserved" case .reloadForwarded: return "reload forwarded to sslocal" case .reloadDeferred: return "reload deferred until listeners are ready" case .reloadRestarted: return "reload: listen change, restarting sslocal" // 本机监听端点事实只保留端口:主机地址态的对外公布地址不进报告。 case .endpointProbeFailed(_, let port, _): return "local endpoint not ready (port=\(port))" // 激活原因是领域的点名文案(短节点片段 + 固定文本,无秘密),与代理 // 状态类别同源;任意运行时错误 detail 不在此列。 case .activationFailed(let reason): return "activation failed: \(reason)" case .diagnosticsExported: return "diagnostics report exported (redacted)" case .settingsUnreadable: return "settings unreadable, falling back to factory defaults" // 前台应用 bundle id 等用户环境事实只进应用内日志查看器,不进共享报告。 case .launchActivationRetry, .windowAnchorAttached: return nil } } } /// 诊断侧栏与详情共用的安全摘要:同一份最新 projection。 struct DiagnosticSummary: Equatable, Sendable { var proxyState: DiagnosticProxyState var hasActiveTarget: Bool } /// 日志视图 projection(ADR-0006):raw GUI 事件行与 wrapper /// `agent.log` 尾部行经日志视图 intake 归一为同一种行,只经显式查看/复制 /// 呈现,永不进入诊断报告。 struct DiagnosticLogView: Equatable, Sendable { var entries: [DiagnosticLogEntry] = [] var rows: [LogLine] { entries.map(\.line) } } /// 日志行身份来自事实源,独立于排序、过滤与重复内容。 struct DiagnosticLogEntry: Equatable, Identifiable, Sendable { enum RowID: Hashable, Sendable { case gui(Int) // 原文也参与身份,避免文件原地截断重写后复用旧位置的选择。 case agent(file: UInt64, offset: UInt64, text: String) } let id: RowID let line: LogLine } /// 报告准备的结构化产物:UI 只负责把 data 写入用户选择的文件。 struct DiagnosticReportDraft: Equatable { let data: Data let suggestedFileName: String } enum DiagnosticReportFailure: Equatable, Error { /// 报告文本无法编码为 UTF-8(module 不产生本地化 UI 字符串)。 case encodingFailed } /// 报告准备的 typed 结果:ready 携带可写入的草稿;safe projection 无法构造 /// 时返回 typed failure,不产生报告。 enum DiagnosticReportOutcome: Equatable { case ready(DiagnosticReportDraft) case failed(DiagnosticReportFailure) } /// 诊断工作流 module(Candidate 03):诊断区的唯一 UI-facing /// seam。内部组合 runtime 事实、目录聚合事实、GUI 事件源、agent.log 尾部、 /// 文件元数据、受管插件事实、时钟与纯报告 builder;内部事实源不暴露给 UI。 /// 向 UI 提供:侧栏/详情共用的安全摘要、raw 日志视图 projection、读取生命 /// 周期(代际守卫 + 取消)、报告准备(typed 结果)与导出完成登记。 /// /// 诊断收集是只读的:不启停代理、不写系统代理设置、不改目录与凭据。文件 /// 写入、保存面板、剪贴板与 alert 属于 UI;只有用户选择的报告文件实际写入 /// 成功后,UI 才调用 `noteExportCompleted` 登记既有的导出完成事件。 @MainActor final class DiagnosticsWorkflow: ObservableObject { /// 侧栏摘要与详情共用的最新安全 projection。 @Published private(set) var summary: DiagnosticSummary /// 日志视图 projection:读取期间持续更新。 @Published private(set) var logView = DiagnosticLogView() private let runtimeFacts: any ProxyRuntimeDiagnosticFacts private let events: RuntimeEventStore private let catalogFacts: @MainActor () -> DiagnosticCatalogFacts? private let agentLogTail: () -> DiagnosticLogTail? private let fileFacts: @MainActor () -> [DiagnosticFileFacts] private let managedPlugins: @MainActor () -> [DiagnosticPluginFacts] private let homePath: () -> String? private let clock: () -> Date private let reportRendering: (DiagnosticSnapshot) -> String? /// 轮询间隔(生产 1 秒;测试注入更细粒度)。 private let pollInterval: Duration /// 读取代际:新一轮读取使旧一轮的在途结果作废。 private var readGeneration = 0 init( runtimeFacts: any ProxyRuntimeDiagnosticFacts, events: RuntimeEventStore = .shared, catalogFacts: @escaping @MainActor () -> DiagnosticCatalogFacts?, agentLogTail: @escaping () -> DiagnosticLogTail? = { DiagnosticLogIntake.readTailSnapshot(of: ApplicationSupportPaths.current.agentLogURL) }, fileFacts: @escaping @MainActor () -> [DiagnosticFileFacts] = DiagnosticsWorkflow.productionFileFacts, managedPlugins: @escaping @MainActor () -> [DiagnosticPluginFacts] = DiagnosticsWorkflow.productionManagedPlugins, homePath: @escaping () -> String? = { NSHomeDirectory() }, clock: @escaping () -> Date = { Date() }, reportRendering: @escaping (DiagnosticSnapshot) -> String? = { DiagnosticReportBuilder.markdown(from: $0) }, pollInterval: Duration = .seconds(1) ) { self.runtimeFacts = runtimeFacts self.events = events self.catalogFacts = catalogFacts self.agentLogTail = agentLogTail self.fileFacts = fileFacts self.managedPlugins = managedPlugins self.homePath = homePath self.clock = clock self.reportRendering = reportRendering self.pollInterval = pollInterval summary = DiagnosticSummary( proxyState: runtimeFacts.proxyState, hasActiveTarget: runtimeFacts.hasActiveTarget) } /// 诊断读取生命周期(UI 在诊断区出现时驱动):循环轮询直到任务被取消; /// 每次进入递增代际,旧一轮的在途结果不再发布; /// 共享实例保证侧栏与详情不重复轮询。 func readWhileActive() async { readGeneration += 1 let generation = readGeneration while !Task.isCancelled, generation == readGeneration { refresh(generation: generation) try? await Task.sleep(for: pollInterval) } } /// 保存面板的建议文件名(UI 在打开面板前取用;时间戳来自注入时钟)。 func suggestedReportFileName() -> String { "ShadowsocksX-NG2-诊断-\(fileStamp()).txt" } /// 准备脱敏诊断报告:只读采样各事实源(best-effort,单一来源缺失不阻止 /// 其他事实),经纯 builder 渲染并编码为 UTF-8。safe projection 无法构造 /// 时返回 typed failure,不生成报告。 func prepareReport() -> DiagnosticReportOutcome { let text = reportRendering(makeSnapshot()) guard let data = text?.data(using: .utf8) else { return .failed(.encodingFailed) } return .ready(DiagnosticReportDraft(data: data, suggestedFileName: suggestedReportFileName())) } /// 导出完成登记:只有用户选择的报告文件实际写入成功后由 /// UI 调用;打开导出面板或准备报告文本不算导出。 func noteExportCompleted() { RuntimeLog.emit(.diagnosticsExported) } // MARK: - 事实采样与渲染(implementation,UI 不可见) private func refresh(generation: Int) { summary = DiagnosticSummary( proxyState: runtimeFacts.proxyState, hasActiveTarget: runtimeFacts.hasActiveTarget) let entries = DiagnosticLogIntake.entries(events: events.snapshot, tail: agentLogTail()) guard generation == readGeneration, !Task.isCancelled else { return } logView = DiagnosticLogView(entries: entries) } private func makeSnapshot() -> DiagnosticSnapshot { var snapshot = DiagnosticSnapshot() snapshot.generatedAt = clock() snapshot.appVersion = Self.appVersion() snapshot.systemSummary = Self.systemSummary() snapshot.proxyState = runtimeFacts.proxyState snapshot.hasActiveTarget = runtimeFacts.hasActiveTarget snapshot.listen = runtimeFacts.listen snapshot.runtimeDocumentSummary = runtimeFacts.contractSummary snapshot.customRuleSummary = runtimeFacts.customRuleSummary snapshot.catalogFacts = catalogFacts() snapshot.fileFacts = fileFacts() snapshot.managedPlugins = managedPlugins() snapshot.eventLines = Self.reportEventLines(from: events.snapshot) snapshot.homePathForRedaction = homePath() return snapshot } /// 报告侧事件行:白名单清洗后的文本复用原始行的时间戳格式(原始 detail /// 只留在日志视图 projection,ADR-0006)。 static func reportEventLines(from entries: [RuntimeEventStore.Entry]) -> [String] { entries.suffix(reportEventLimit).compactMap { entry in entry.event.diagnosticReportLine.map { entry.renderedLine(eventText: $0) } } } private static let reportEventLimit = 200 /// 受管插件清单:静态事实表 + 可执行文件存在性(生成配置时的同一检查; /// 参数与路径不入导出)。 private static func productionManagedPlugins() -> [DiagnosticPluginFacts] { let provider = BundleManagedPluginProvider() return ManagedPluginCatalog.plugins.map { info in DiagnosticPluginFacts( program: info.program, version: info.dependency?.version ?? "", present: provider.resolve(program: info.program) != nil) } } private static func productionFileFacts() -> [DiagnosticFileFacts] { [ DiagnosticFileCollector.collect( label: "运行时目录", url: ApplicationSupportPaths.current.runtime.directoryURL), DiagnosticFileCollector.collect( label: "catalog.age", url: CatalogFileStore.defaultFileURL()), DiagnosticFileCollector.collect( label: "activation.json", url: ActivationStateFileStore.defaultFileURL()), DiagnosticFileCollector.collect( label: "sslocal-active.json", url: ApplicationSupportPaths.current.runtime.fileURL), DiagnosticFileCollector.collect( label: "agent.pid", url: ApplicationSupportPaths.current.runtime.pidFileURL), DiagnosticFileCollector.collect( label: "agent.log", url: ApplicationSupportPaths.current.agentLogURL), ] } private static func appVersion() -> String? { guard let info = Bundle.main.infoDictionary else { return nil } let parts = [ (info["CFBundleShortVersionString"] as? String).map { "版本 \($0)" }, (info["CFBundleVersion"] as? String).map { "构建 \($0)" }, ].compactMap { $0 } return parts.isEmpty ? nil : parts.joined(separator: ",") } private static func systemSummary() -> String { let arch: String #if arch(arm64) arch = "arm64" #elseif arch(x86_64) arch = "x86_64" #else arch = "未知架构" #endif return "macOS \(ProcessInfo.processInfo.operatingSystemVersionString),\(arch)" } private func fileStamp() -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyyMMdd-HHmmss" return formatter.string(from: clock()) } } // MARK: - 生产 runtime 事实适配 extension ProxyRuntimeController: ProxyRuntimeDiagnosticFacts { /// 控制器状态 → 诊断安全呈现:不透传任意错误 detail;监听地址只以 /// 回环/非回环两态进入导出,原始错误文本一律丢弃)。系统代理写入失败与 /// 激活拒绝优先于运行状态呈现(两个状态面分开后仍归入同一组 /// 固定诊断类别)。 var proxyState: DiagnosticProxyState { if systemProxyState.hasOperationFailure { return .systemProxyFailed } if let failure = lastActivationFailure { return .activationFailed(reason: AppPresentation.message(for: failure)) } switch state { case .off: return .off case .starting: return .starting case .running: return .running case .firewallBlocked: return .firewallBlocked case .launchFailed: return .launchFailed case .requiresApproval: return .requiresApproval case .serviceFailed: return .serviceFailed } } var hasActiveTarget: Bool { isActiveTargetPresent } var listen: RuntimeListenFacts { RuntimeListenFacts(listen: listenSettings) } var contractSummary: String? { runtimeDocumentSummary() } var customRuleSummary: CustomRuleSummary? { readCustomRuleSummary() } }