mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
369 lines
15 KiB
Swift
369 lines
15 KiB
Swift
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() }
|
||
}
|