From eac30c4abd5b2e733fca721238dee108bc8c2a71 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sat, 3 Oct 2026 12:44:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(sysproxy):=20=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E7=AD=96=E7=95=A5=E6=9C=BA=E6=8A=BD=E4=B8=BA?= =?UTF-8?q?=20SystemProxyObserver=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0022 的观察/修复/挂起策略此前溶解在控制器 15 个可变字段里、由 Gate/Inspection/Commands/Health/Mode 五个文件交叉读写。现在策略机独立为 SystemProxyObserver:意图开关、出口可用性、期望配置与健康循环调度以 @MainActor 闭包注入,模块不反向依赖控制器;底层继续只依赖 SystemProxyControlling / helper 注册缝 / 网络变化监视三条既有缝。 - 3 个 @Published 呈现事实随迁,控制器留同名只读门面(测试与诊断面零改动); adapter 再发布链 merge 观察机 objectWillChange,主队列 hop 语义不变 - 健康循环留在控制器(循环体含运行时健康呈现),任务柄归观察机; cleanup-until-quiet、网络变化 inspection 调度与操作互斥全部内聚 - 方法体逐字搬运(settings.systemProxyEnabled→isIntentEnabled() 等机械替换), 语义零改动;既有 777 测试全绿 --- .../ProxyControlAdapters.swift | 32 +- .../App/ProxyRuntimeController+Commands.swift | 67 +-- .../ProxyRuntimeController+Convergence.swift | 10 +- .../App/ProxyRuntimeController+Facts.swift | 16 + .../App/ProxyRuntimeController+Health.swift | 33 +- .../App/ProxyRuntimeController+Mode.swift | 2 +- ...oxyRuntimeController+SystemProxyGate.swift | 198 +------ ...timeController+SystemProxyInspection.swift | 225 -------- .../App/ProxyRuntimeController.swift | 45 +- .../App/SystemProxyObserver.swift | 492 ++++++++++++++++++ ...eControllerTests+AgentAndSystemProxy.swift | 6 +- 11 files changed, 616 insertions(+), 510 deletions(-) delete mode 100644 ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyInspection.swift create mode 100644 ShadowsocksX-NG2/App/SystemProxyObserver.swift diff --git a/ShadowsocksX-NG2/App/ProxyControlWorkflow/ProxyControlAdapters.swift b/ShadowsocksX-NG2/App/ProxyControlWorkflow/ProxyControlAdapters.swift index 72168728..1fe27255 100644 --- a/ShadowsocksX-NG2/App/ProxyControlWorkflow/ProxyControlAdapters.swift +++ b/ShadowsocksX-NG2/App/ProxyControlWorkflow/ProxyControlAdapters.swift @@ -1,9 +1,10 @@ import Combine import Foundation -/// 生产运行时 adapter(issue #47):现有 `ProxyRuntimeController` 的唯一 -/// 包装,由应用组合根接线。只把控制器既有能力与观察投影到 workflow 缝上, -/// 不复制状态机、generation、健康门禁与系统代理生命周期语义。 +/// 生产运行时 adapter(issue #47):现有 `ProxyRuntimeController` 与其系统代理 +/// 观察机(`SystemProxyObserver`,ADR-0022 策略机)的唯一包装,由应用组合根 +/// 接线。只把既有能力与观察投影到 workflow 缝上,不复制状态机、generation、 +/// 健康门禁与系统代理生命周期语义。 @MainActor final class ControllerProxyRuntimeAdapter: ProxyRuntimeAdapting { private let controller: ProxyRuntimeController @@ -44,13 +45,16 @@ final class ControllerProxyRuntimeAdapter: ProxyRuntimeAdapting { RuntimeListenFacts(listen: controller.listenSettings) } - /// 控制器的 `objectWillChange` 是 willChange 语义;主队列 hop 落地时被 - /// 发布的新值已可读,workflow 重观察不会读到半程状态。 + /// 控制器与系统代理观察机的 `objectWillChange` 都是 willChange 语义;主队列 + /// hop 落地时被发布的新值已可读,workflow 重观察不会读到半程状态。系统代理 + /// 呈现事实的存储与发布在观察机上,其发布并入同一再发布链。 var changes: AnyPublisher { - controller.objectWillChange - .map { _ in () } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() + Publishers.Merge( + controller.objectWillChange.map { _ in () }, + controller.systemProxyObserver.objectWillChange.map { _ in () } + ) + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() } func resyncOnLaunch() async { @@ -66,12 +70,14 @@ final class ControllerProxyRuntimeAdapter: ProxyRuntimeAdapting { } func openSystemProxyHelperApproval() async { - await controller.openSystemProxyHelperApproval() + await controller.systemProxyObserver.openSystemProxyHelperApproval() } - func repairSystemProxy() async { await controller.repairSystemProxy() } - func retrySystemProxyClear() async { await controller.retrySystemProxyClear() } - func recheckSystemProxy() async { await controller.recheckSystemProxy() } + func repairSystemProxy() async { await controller.systemProxyObserver.repairSystemProxy() } + func retrySystemProxyClear() async { + await controller.systemProxyObserver.retrySystemProxyClear() + } + func recheckSystemProxy() async { await controller.systemProxyObserver.recheckSystemProxy() } func setProxyMode(_ mode: ProxyMode) async { await controller.setProxyMode(mode) diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift index b2d05711..847edf73 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift @@ -61,14 +61,14 @@ extension ProxyRuntimeController { settings = next if enabled { await convergeAgent() - if settings.systemProxyEnabled { startEnabledSystemProxyObservation() } + if settings.systemProxyEnabled { systemProxyObserver.startEnabledSystemProxyObservation() } } else { let cleanupOutcome = await cascadeSystemProxyOffForAgentOff() await stopAgent() if let cleanupOutcome, cleanupOutcome.hasOperationFailure { - systemProxyState = cleanupOutcome + systemProxyObserver.systemProxyState = cleanupOutcome } - updateSystemProxyActions() + systemProxyObserver.updateSystemProxyActions() } } @@ -87,7 +87,7 @@ extension ProxyRuntimeController { } settings = cascade // 持久化的 on→off 迁移才触发清理;先清后停本地监听。 - return await clearAndStopSystemProxyObservation() + return await systemProxyObserver.clearAndStopSystemProxyObservation() } /// 系统代理开关(issue #71):先持久化意图。开启时经 helper 注册门禁收敛 @@ -100,41 +100,46 @@ extension ProxyRuntimeController { guard persistSettings(next) else { return } settings = next if enabled { - systemProxyInitialApplyPending = true - systemProxyWasUnavailable = false - await convergeSystemProxy() - startEnabledSystemProxyObservation() + systemProxyObserver.systemProxyInitialApplyPending = true + systemProxyObserver.systemProxyWasUnavailable = false + await systemProxyObserver.convergeSystemProxy() + systemProxyObserver.startEnabledSystemProxyObservation() } else { - systemProxyApprovalRequired = false - systemProxyInitialApplyPending = false - systemProxyReadGeneration += 1 - systemProxyState = await clearAndStopSystemProxyObservation() - updateSystemProxyActions() + systemProxyObserver.systemProxyApprovalRequired = false + systemProxyObserver.systemProxyInitialApplyPending = false + systemProxyObserver.systemProxyReadGeneration += 1 + systemProxyObserver.systemProxyState = + await systemProxyObserver.clearAndStopSystemProxyObservation() + systemProxyObserver.updateSystemProxyActions() } } /// GUI startup restores the Agent. Healthy proxy intent only inspects; unhealthy /// proxy intent suspends; disabled intent never writes SystemConfiguration. func resyncOnLaunch() async { - systemProxyStartupInProgress = true + systemProxyObserver.systemProxyStartupInProgress = true guard settings.agentEnabled else { await stopAgent() - systemProxyStartupInProgress = false + systemProxyObserver.systemProxyStartupInProgress = false if settings.systemProxyEnabled { - await suspendSystemProxy() - startEnabledSystemProxyObservation() + await systemProxyObserver.suspendSystemProxy() + systemProxyObserver.startEnabledSystemProxyObservation() } return } let catalog = catalogSnapshotReader.catalogSnapshot await convergeToReexpanded(catalog) - systemProxyStartupInProgress = false + systemProxyObserver.systemProxyStartupInProgress = false if settings.systemProxyEnabled { - lastDesiredSystemProxyConfiguration = desiredSystemProxyConfiguration - if systemProxyExitAvailable { await recheckSystemProxy() } else { await suspendSystemProxy() } - startEnabledSystemProxyObservation() + systemProxyObserver.lastDesiredSystemProxyConfiguration = desiredSystemProxyConfiguration + if systemProxyExitAvailable { + await systemProxyObserver.recheckSystemProxy() + } else { + await systemProxyObserver.suspendSystemProxy() + } + systemProxyObserver.startEnabledSystemProxyObservation() } else { - systemProxyState = .idle + systemProxyObserver.systemProxyState = .idle } } @@ -151,19 +156,21 @@ extension ProxyRuntimeController { /// 启 + agent 关闭」组合保持待应用(不静默清理,也无需网络观察)。 func stopAgent() async { systemProxyNetworkChangeMonitor.stop() - systemProxyHealthTask?.cancel() - systemProxyHealthTask = nil - systemProxyObservationMode = .stopped + systemProxyObserver.systemProxyHealthTask?.cancel() + systemProxyObserver.systemProxyHealthTask = nil + systemProxyObserver.systemProxyObservationMode = .stopped _ = await execute(.stop, document: nil) state = .off lastDocument = nil skippedServers = [] lastActivationFailure = nil if settings.systemProxyEnabled { - systemProxyState = .pending + systemProxyObserver.systemProxyState = .pending } else { - if !systemProxyState.hasOperationFailure { systemProxyState = .idle } - updateSystemProxyActions() + if !systemProxyState.hasOperationFailure { + systemProxyObserver.systemProxyState = .idle + } + systemProxyObserver.updateSystemProxyActions() } } @@ -221,7 +228,7 @@ extension ProxyRuntimeController { lastDocument = nil skippedServers = [] state = .launchFailed(.unreadableSettings) - await holdSystemProxyIntent() + await systemProxyObserver.holdSystemProxyIntent() } /// 活动目标失效(issue #60):清除并持久化 nil,点名原因独立呈现;agent @@ -237,7 +244,7 @@ extension ProxyRuntimeController { } lastActivationFailure = failure skippedServers = [] - await holdSystemProxyIntent() + await systemProxyObserver.holdSystemProxyIntent() if settings.agentEnabled { await deployListeningWithoutTarget() } else { diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift index a29a4be7..1c77a07a 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Convergence.swift @@ -174,8 +174,8 @@ extension ProxyRuntimeController { guard healthy else { return .failed } lastDocument = document switch proxyTail { - case .forceApply: await convergeSystemProxy(forceApply: true) - case .converge: await convergeSystemProxy() + case .forceApply: await systemProxyObserver.convergeSystemProxy(forceApply: true) + case .converge: await systemProxyObserver.convergeSystemProxy() case .none: break } return .applied @@ -230,7 +230,7 @@ extension ProxyRuntimeController { ticket.flow = execution.flow guard convergenceIsCurrent(ticket, checking: checking) else { return report(nil) } guard execution.succeeded else { - await holdSystemProxyIntent() + await systemProxyObserver.holdSystemProxyIntent() return report(false) } let healthy = await presentLaunchHealth( @@ -241,12 +241,12 @@ extension ProxyRuntimeController { if plan.convergeProxyOnIntentChange, settings.systemProxyEnabled != plan.systemProxyIntentAtCapture { - await convergeSystemProxy(forceApply: true) + await systemProxyObserver.convergeSystemProxy(forceApply: true) } else { state = plan.state } } else { - await holdSystemProxyIntent() + await systemProxyObserver.holdSystemProxyIntent() } return report(healthy) } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Facts.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Facts.swift index 8fd8f36e..af8cd571 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Facts.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Facts.swift @@ -11,6 +11,22 @@ extension ProxyRuntimeController { /// 系统代理开关意图(持久化事实;开关 UI 的绑定来源,issue #60)。 var systemProxyIntentEnabled: Bool { settings.systemProxyEnabled } + /// 系统代理观察机的呈现事实门面:存储与发布在 `SystemProxyObserver`, + /// 此处只读投影供既有测试与诊断面沿用原名。 + var systemProxyState: SystemProxyApplicationFacts { systemProxyObserver.systemProxyState } + var systemProxyApprovalRequired: Bool { systemProxyObserver.systemProxyApprovalRequired } + var systemProxyInspection: SystemProxyInspectionFacts { + systemProxyObserver.systemProxyInspection + } + + /// Desired system proxy configuration:当前保存设置要求的代理值(ADR-0022)。 + /// 观察机经 init 闭包读取同一事实源。 + var desiredSystemProxyConfiguration: SystemProxyConfiguration? { + guard let document = lastDocument else { return nil } + return try? proxyMode.systemProxyConfiguration( + for: document, exceptions: settings.proxyExceptionList) + } + /// Stable app-facing projection for status-menu presentation. The menu does /// not depend on this controller's nested state representation. var runtimeFacts: ProxyRuntimeFacts { diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift index dceaa16f..6f0807f2 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.swift @@ -122,7 +122,7 @@ extension ProxyRuntimeController { if let receipt, !receiptIsCurrentAndLive(receipt) { return await reportAgentLost(preserveProxyOnFailure: preserveProxyOnFailure) } - if convergeProxyOnSuccess { await convergeSystemProxy() } + if convergeProxyOnSuccess { await systemProxyObserver.convergeSystemProxy() } return true } @@ -136,14 +136,14 @@ extension ProxyRuntimeController { private func reportAgentLost(preserveProxyOnFailure: Bool) async -> Bool { guard !preserveProxyOnFailure else { return false } state = .serviceFailed(.agent) - await holdSystemProxyIntent() + await systemProxyObserver.holdSystemProxyIntent() return false } private func reportRuntimeFileFailure(preserveProxyOnFailure: Bool) async -> Bool { guard !preserveProxyOnFailure else { return false } state = .serviceFailed(.runtimeFile) - await holdSystemProxyIntent() + await systemProxyObserver.holdSystemProxyIntent() return false } @@ -163,7 +163,7 @@ extension ProxyRuntimeController { state = .launchFailed( .localEndpoint(endpoint: "SOCKS", host: "127.0.0.1", port: 0, cause: .unknown)) } - await holdSystemProxyIntent() + await systemProxyObserver.holdSystemProxyIntent() return false } @@ -214,19 +214,22 @@ extension ProxyRuntimeController { extension ProxyRuntimeController { /// Uses the existing local endpoint/receipt health seam while the GUI is alive. /// Network path availability is deliberately absent from this decision. + /// 任务柄持有在观察机上(cleanup 与停止路径都要取消它);循环体留在本体, + /// 因为它同时承担运行时健康呈现,系统代理反应转发给观察机。 func startSystemProxyHealthObservation() { - guard systemProxyHealthTask == nil else { return } + guard systemProxyObserver.systemProxyHealthTask == nil else { return } let interval = systemProxyHealthPollIntervalNanoseconds - systemProxyHealthTask = Task { @MainActor [weak self] in + systemProxyObserver.systemProxyHealthTask = Task { @MainActor [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: interval) guard !Task.isCancelled, let self, settings.systemProxyEnabled || systemProxyState.hasOperationFailure else { return } - refreshSystemProxyApproval() - updateSystemProxyActions() + systemProxyObserver.refreshSystemProxyApproval() + systemProxyObserver.updateSystemProxyActions() guard settings.systemProxyEnabled, state != .starting, - !systemProxyStartupInProgress, settings.agentEnabled, let document = lastDocument + !systemProxyObserver.systemProxyStartupInProgress, settings.agentEnabled, + let document = lastDocument else { continue } await inspectSystemProxyRuntimeHealth(document) } @@ -242,22 +245,22 @@ extension ProxyRuntimeController { } switch outcome { case .ready: - let wasUnavailable = systemProxyWasUnavailable + let wasUnavailable = systemProxyObserver.systemProxyWasUnavailable switch state { case .running, .firewallBlocked: break default: await presentFirewallStatus(for: document) } if !systemProxyExitAvailable { - await suspendSystemProxy() - } else if wasUnavailable || systemProxyInitialApplyPending { - await convergeSystemProxy() + await systemProxyObserver.suspendSystemProxy() + } else if wasUnavailable || systemProxyObserver.systemProxyInitialApplyPending { + await systemProxyObserver.convergeSystemProxy() } case .agentLost: state = .serviceFailed(.agent) - await suspendSystemProxy() + await systemProxyObserver.suspendSystemProxy() case .retry(let failure): if let failure { presentEndpointFailure(failure) } else { state = .serviceFailed(.agent) } - await suspendSystemProxy() + await systemProxyObserver.suspendSystemProxy() } } } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift index 9bfb602c..13e7592d 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift @@ -50,7 +50,7 @@ extension ProxyRuntimeController { guard settings.agentEnabled, state != .off, let currentDocument = lastDocument ?? runtimeFileStore.loadDocument() else { - if settings.systemProxyEnabled { systemProxyState = .pending } + if settings.systemProxyEnabled { systemProxyObserver.systemProxyState = .pending } return } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift index c4bc8c77..4160d3ae 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift @@ -1,6 +1,6 @@ import Foundation -// MARK: - 防火墙呈现、wrapper 观测与系统代理门禁 +// MARK: - 防火墙呈现、wrapper 观测与系统代理出口事实 extension ProxyRuntimeController { func presentFirewallStatus(for document: SslocalRuntimeDocument) async { @@ -115,78 +115,10 @@ extension ProxyRuntimeController { String(describing: error) } - // MARK: - 系统代理门禁(issue #60/#71) - - /// helper 可用性门禁(issue #71):已批准即通过——仅当 LaunchDaemon 清单 - /// 与上次注册时的指纹漂移(app 更新改过清单)才注销重注刷新 launchd 的 - /// job 定义;未注册则尝试注册(注册本身不弹授权框,成功即记指纹);待批准 - /// 或注册失败时置位批准路径并保持待应用。 - func ensureHelperAvailableForApply() async -> Bool { - switch systemProxyHelper.status { - case .approved: - if helperRegistrationDrifted() { - do { - try systemProxyHelper.unregister() - // launchd 对节流中(spawn scheduled)job 的移除是异步的:立即重注 - // 会命中尚未移除的旧定义(实测注销重注后定义不变)。等过 minimum - // runtime(10s)节流窗再重注;间隔内收敛以旧定义失败呈超时。 - try? await Task.sleep(nanoseconds: helperRefreshDelayNanoseconds) - try systemProxyHelper.register() - storeHelperRegistrationStamp() - } catch { - // 刷新失败不记指纹,下次启动重试;状态退回后由下方分支接手。 - RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: describe(error))) - } - } - if systemProxyHelper.status == .approved { - systemProxyApprovalRequired = false - return true - } - case .notRegistered: - do { - try systemProxyHelper.register() - storeHelperRegistrationStamp() - } catch { - RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: describe(error))) - } - if systemProxyHelper.status == .approved { - systemProxyApprovalRequired = false - return true - } - case .requiresApproval: - break - } - systemProxyApprovalRequired = true - return false - } - - private func helperRegistrationDrifted(defaults: UserDefaults = .standard) -> Bool { - SystemProxyHelperRegistrationStamp.drifted( - plistData: SystemProxyHelperIdentity.launchDaemonPlistData(in: appBundle), - storedStamp: defaults.string(forKey: SystemProxyHelperRegistrationStamp.defaultsKey)) - } - - private func storeHelperRegistrationStamp(defaults: UserDefaults = .standard) { - guard let plistData = SystemProxyHelperIdentity.launchDaemonPlistData(in: appBundle) else { - return - } - defaults.set( - SystemProxyHelperRegistrationStamp.stamp(plistData), - forKey: SystemProxyHelperRegistrationStamp.defaultsKey) - } - - /// Approval continues only an unattempted initial request; failed writes need a user retry. - func openSystemProxyHelperApproval() async { - systemProxyHelper.openApprovalPath() - refreshSystemProxyApproval() - if systemProxyInitialApplyPending && !systemProxyState.hasOperationFailure { - await convergeSystemProxy() - } - updateSystemProxyActions() - } + // MARK: - 系统代理出口事实(issue #60/#71) /// 出口可用 = agent 入站健康(回环入口可用,含防火墙仅阻主机态的情形) - /// 且模式有出口;直连不依赖活动服务器目标。 + /// 且模式有出口;直连不依赖活动服务器目标。观察机以闭包读取同一事实源。 var systemProxyExitAvailable: Bool { switch state { case .running, .firewallBlocked: @@ -195,128 +127,4 @@ extension ProxyRuntimeController { return false } } - - /// Health/exit loss clears unavailable settings while preserving enabled intent. - func holdSystemProxyIntent() async { - if settings.systemProxyEnabled { - await suspendSystemProxy() - } else if !systemProxyState.hasOperationFailure { - systemProxyState = .idle - } - } - - /// 无条件清除全部系统代理配置(issue #71):typed 失败原样呈现,不重试。 - func clearSystemProxyOutcome() async -> SystemProxyControlState { - do { - try await systemProxy.clear() - return .idle - } catch { - refreshSystemProxyApproval() - return .clearFailed(systemProxyFacts(for: error)) - } - } - - func startEnabledSystemProxyObservation() { - guard systemProxyObservationMode != .enabled else { return } - systemProxyObservationMode = .enabled - startSystemProxyHealthObservation() - systemProxyNetworkChangeMonitor.start { [weak self] change in - self?.handleSystemProxyNetworkChange(change) - } - } - - func clearAndStopSystemProxyObservation() async -> SystemProxyControlState { - if let systemProxyCleanupTask { return await systemProxyCleanupTask.value } - let task = Task { @MainActor [weak self] in - guard let self else { return SystemProxyControlState.idle } - return await performSystemProxyCleanupUntilQuiet() - } - systemProxyCleanupTask = task - let result = await task.value - systemProxyCleanupTask = nil - return result - } - - private func performSystemProxyCleanupUntilQuiet() async -> SystemProxyControlState { - if systemProxyObservationMode != .enabled { - systemProxyObservationMode = .cleanup - systemProxyNetworkChangeMonitor.start { [weak self] change in - self?.handleSystemProxyNetworkChange(change) - } - } else { - systemProxyObservationMode = .cleanup - } - - guard await waitForSystemProxyOperation() else { return systemProxyState } - beginSystemProxyOperation() - var outcome: SystemProxyControlState = .idle - repeat { - systemProxyCleanupRescanRequested = false - outcome = await clearSystemProxyOutcome() - // Let queued SystemConfiguration notifications reach the main actor. Any - // location/service/proxy change during this cleanup starts another full scan. - try? await Task.sleep(nanoseconds: 100_000_000) - await Task.yield() - } while systemProxyCleanupRescanRequested && !outcome.hasOperationFailure - - systemProxyNetworkChangeMonitor.stop() - systemProxyHealthTask?.cancel() - systemProxyHealthTask = nil - systemProxyObservationMode = .stopped - systemProxyInspection = SystemProxyInspectionFacts() - endSystemProxyOperation() - return outcome - } - - private func handleSystemProxyNetworkChange(_ change: SystemProxyNetworkChange) { - switch systemProxyObservationMode { - case .stopped: - return - case .cleanup: - if !change.isDisjoint(with: [.networkConfiguration, .proxyConfiguration]) { - systemProxyCleanupRescanRequested = true - } - case .enabled: - guard settings.systemProxyEnabled else { return } - guard !systemProxyInspectionScheduled else { return } - systemProxyInspectionScheduled = true - Task { @MainActor [weak self] in - await Task.yield() - guard let self else { return } - self.systemProxyInspectionScheduled = false - await self.recheckSystemProxy() - } - } - } - - func systemProxyFacts(for error: Error) -> SystemProxyFailureFacts { - if let error = error as? SystemProxyError { - return Self.systemProxyFacts(for: error) - } - if let error = error as? ProxyModeError { - return .mode(error) - } - return .unknown - } - - // 显式映射保持与错误族一一对应;错误带关联值,无法用字典键穷举。 - // swiftlint:disable:next cyclomatic_complexity - private static func systemProxyFacts( - for error: SystemProxyError - ) -> SystemProxyFailureFacts { - switch error { - case .preferencesUnavailable: return .operation(.preferencesUnavailable) - case .preferencesBusy: return .operation(.preferencesBusy) - case .noCurrentNetworkSet: return .operation(.noCurrentNetworkSet) - case .noProxyServices: return .operation(.noProxyServices) - case .unreadableService: return .operation(.unreadableService) - case .invalidStoredConfiguration: return .operation(.invalidStoredConfiguration) - case .cannotWriteService: return .operation(.cannotWriteService) - case .commitFailed: return .operation(.commitFailed) - case .applyFailed: return .operation(.applyFailed) - case .noNetworkLocations: return .operation(.noNetworkLocations) - case .invalidRequest: return .operation(.invalidRequest) - case .helperUnavailable: return .operation(.helperUnavailable) - } - } } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyInspection.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyInspection.swift deleted file mode 100644 index ffeaad11..00000000 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyInspection.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation - -// Current configuration observation and explicit operations (issue #73). -extension ProxyRuntimeController { - /// Explicit initial application, saved-value changes, or real health recovery. - /// A healthy GUI startup and passive events only inspect current settings. - func convergeSystemProxy(forceApply: Bool = false) async { - guard await waitForSystemProxyOperation() else { return } - guard settings.systemProxyEnabled, !systemProxyStartupInProgress else { return } - guard systemProxyExitAvailable else { - await suspendSystemProxy() - return - } - guard let configuration = desiredSystemProxyConfiguration else { return } - let needsApplication = - forceApply || systemProxyInitialApplyPending || systemProxyWasUnavailable - || lastDesiredSystemProxyConfiguration != configuration - if needsApplication { - await applySystemProxy(configuration, repairing: false) - } else { - if systemProxyHelper.status == .approved { _ = await ensureHelperAvailableForApply() } - await recheckSystemProxy() - } - } - - var desiredSystemProxyConfiguration: SystemProxyConfiguration? { - guard let document = lastDocument else { return nil } - return try? proxyMode.systemProxyConfiguration( - for: document, exceptions: settings.proxyExceptionList) - } - - private func applySystemProxy( - _ configuration: SystemProxyConfiguration, repairing: Bool - ) async { - guard !systemProxyOperationInProgress else { return } - beginSystemProxyOperation() - defer { endSystemProxyOperation() } - guard await ensureHelperAvailableForApply() else { - if !systemProxyState.hasOperationFailure { systemProxyState = .pending } - updateSystemProxyActions() - return - } - guard settings.systemProxyEnabled, systemProxyExitAvailable else { return } - if repairing { - guard await prepareSystemProxyRepair(configuration) else { return } - } - systemProxyState = repairing ? .repairing : .applying - updateSystemProxyActions() - // Mark the attempt before awaiting: a failed operation is never implicitly retried. - systemProxyInitialApplyPending = false - systemProxyWasUnavailable = false - systemProxyInspection.backgroundUnavailable = false - lastDesiredSystemProxyConfiguration = configuration - do { - if repairing { - try await systemProxy.repair(configuration) - } else { - let outcome = try await systemProxy.apply(configuration) - if outcome == .unchanged { RuntimeLog.emit(.systemProxyUnchanged) } - } - await inspectSystemProxy(configuration: configuration, afterRepair: repairing) - } catch { - RuntimeLog.emit(.systemProxyWriteFailed(detail: describe(error))) - systemProxyState = - repairing - ? .repairFailed(systemProxyFacts(for: error)) : .failed(systemProxyFacts(for: error)) - await inspectSystemProxy(configuration: configuration, preserveResult: true) - } - } - - private func prepareSystemProxyRepair(_ configuration: SystemProxyConfiguration) async -> Bool { - guard - await inspectSystemProxy( - configuration: configuration, - preserveResult: systemProxyState.hasOperationFailure) - else { return false } - guard settings.systemProxyEnabled, systemProxyExitAvailable, - desiredSystemProxyConfiguration == configuration, systemProxyHelper.status == .approved - else { return false } - guard !systemProxyInspection.differences.isEmpty else { - systemProxyState = .applied - return false - } - return true - } - - func repairSystemProxy() async { - updateSystemProxyActions() - guard systemProxyInspection.canRepair, let configuration = desiredSystemProxyConfiguration - else { return } - await applySystemProxy(configuration, repairing: true) - } - - func recheckSystemProxy() async { - refreshSystemProxyApproval() - guard settings.systemProxyEnabled, let configuration = desiredSystemProxyConfiguration else { - updateSystemProxyActions() - return - } - guard !systemProxyOperationInProgress else { return } - await inspectSystemProxy( - configuration: configuration, - preserveResult: systemProxyState.hasOperationFailure || !systemProxyExitAvailable) - updateSystemProxyActions() - } - - @discardableResult - private func inspectSystemProxy( - configuration: SystemProxyConfiguration, afterRepair: Bool = false, - preserveResult: Bool = false - ) async -> Bool { - systemProxyReadGeneration += 1 - let generation = systemProxyReadGeneration - do { - let services = try await systemProxy.readServices() - guard !services.isEmpty else { throw SystemProxyError.noProxyServices } - guard generation == systemProxyReadGeneration, settings.systemProxyEnabled, - desiredSystemProxyConfiguration == configuration - else { return false } - let writes = try SystemProxyPlanner.makeApplyPlan( - services: services, configuration: configuration - ).writes - let differing = Set(writes.map(\.identifier)) - systemProxyInspection.differences = services.compactMap { service in - guard differing.contains(service.identifier) else { return nil } - return SystemProxyDifference( - identifier: service.identifier, name: service.name, - kind: afterRepair - ? .remaining - : (knownSystemProxyServices.contains(service.identifier) ? .changed : .notApplied)) - } - // Only observed consistency establishes a known service; an unsuccessful write - // response cannot establish that this app's configuration was ever present. - knownSystemProxyServices.formUnion( - services.filter { - !differing.contains($0.identifier) - }.map(\.identifier)) - systemProxyInspection.readFailure = nil - if !preserveResult && !systemProxyState.isClearFailure && !systemProxyWasUnavailable { - systemProxyState = differing.isEmpty ? .applied : .changed - } - return true - } catch { - guard generation == systemProxyReadGeneration else { return false } - systemProxyInspection.differences = [] - systemProxyInspection.readFailure = systemProxyFacts(for: error) - if !preserveResult && !systemProxyState.isClearFailure && !systemProxyWasUnavailable { - systemProxyState = .unreadable(systemProxyFacts(for: error)) - } - return false - } - } - - func refreshSystemProxyApproval() { - systemProxyApprovalRequired = - systemProxyHelper.status != .approved - && (settings.systemProxyEnabled || systemProxyState.hasOperationFailure) - } - - func updateSystemProxyActions() { - refreshSystemProxyApproval() - let available = !systemProxyOperationInProgress && !systemProxyApprovalRequired - systemProxyInspection.canRepair = - available && settings.systemProxyEnabled - && systemProxyExitAvailable && systemProxyInspection.readFailure == nil - && (!systemProxyInspection.differences.isEmpty || systemProxyState.isApplyFailure) - systemProxyInspection.canRetryClear = available && systemProxyState.isClearFailure - if systemProxyState.hasOperationFailure { - startSystemProxyHealthObservation() - } else if !settings.systemProxyEnabled { - systemProxyHealthTask?.cancel() - systemProxyHealthTask = nil - } - } - - func suspendSystemProxy() async { - guard settings.systemProxyEnabled, !systemProxyStartupInProgress else { return } - guard !systemProxyWasUnavailable else { - updateSystemProxyActions() - return - } - guard await waitForSystemProxyOperation() else { return } - guard settings.systemProxyEnabled, !systemProxyWasUnavailable else { return } - systemProxyWasUnavailable = true - systemProxyInspection.backgroundUnavailable = true - systemProxyInitialApplyPending = false - beginSystemProxyOperation() - defer { endSystemProxyOperation() } - let outcome = await clearSystemProxyOutcome() - systemProxyState = outcome == .idle ? .paused : outcome - if outcome == .idle { - systemProxyInspection.differences = [] - systemProxyInspection.readFailure = nil - } - } - - func retrySystemProxyClear() async { - updateSystemProxyActions() - guard systemProxyInspection.canRetryClear else { return } - beginSystemProxyOperation() - defer { endSystemProxyOperation() } - let outcome = await clearSystemProxyOutcome() - systemProxyState = outcome == .idle && settings.systemProxyEnabled ? .paused : outcome - } - - func beginSystemProxyOperation() { - systemProxyOperationInProgress = true - systemProxyInspection.isBusy = true - systemProxyReadGeneration += 1 - updateSystemProxyActions() - } - - func endSystemProxyOperation() { - systemProxyOperationInProgress = false - systemProxyInspection.isBusy = false - updateSystemProxyActions() - } - - func waitForSystemProxyOperation() async -> Bool { - while systemProxyOperationInProgress { - do { try await Task.sleep(nanoseconds: 10_000_000) } catch { return false } - } - return !Task.isCancelled - } -} diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index 13a475f8..ffe52880 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -4,7 +4,9 @@ import Foundation /// persist independent intentions. System proxy writes use the privileged helper; /// current configuration is observed read-only and differs from write outcomes. /// Health/exit loss suspends system proxy settings, real recovery reapplies, and -/// passive network events only inspect (ADR-0022 / issue #73). GUI exit ends these +/// passive network events only inspect (ADR-0022 / issue #73)——该观察/修复策略机 +/// 独立在 `SystemProxyObserver`:控制器注入意图/出口/期望配置三个事实闭包并 +/// 转发命令,健康循环保留在本体(循环体含运行时健康呈现)。GUI exit ends these /// observations without stopping the LaunchAgent or adding helper responsibilities. @MainActor final class ProxyRuntimeController: ObservableObject { @@ -27,25 +29,7 @@ final class ProxyRuntimeController: ObservableObject { /// Current configuration or operation result, independent of persisted intent. typealias SystemProxyControlState = SystemProxyApplicationFacts - enum SystemProxyObservationMode: Equatable { - case stopped - case enabled - case cleanup - } - @Published var state: AgentRunState = .off - @Published var systemProxyState: SystemProxyControlState = .idle - /// Approval remains actionable even after an off-intent clear failure. - @Published var systemProxyApprovalRequired = false - @Published var systemProxyInspection = SystemProxyInspectionFacts() - var lastDesiredSystemProxyConfiguration: SystemProxyConfiguration? - var knownSystemProxyServices: Set = [] - var systemProxyOperationInProgress = false - var systemProxyStartupInProgress = false - var systemProxyInitialApplyPending = false - var systemProxyWasUnavailable = false - var systemProxyHealthTask: Task? - var systemProxyReadGeneration = 0 @Published var settings: ProxySettings /// 当前活动目标(菜单栏状态摘要与级联只读呈现用,issue #31)。machine 是 /// 非发布值的普通结构体,代理关闭路径的激活动作不会触碰 state,菜单的 @@ -80,6 +64,25 @@ final class ProxyRuntimeController: ObservableObject { let systemProxy: SystemProxyControlling let systemProxyHelper: SystemProxyHelperServicing let systemProxyNetworkChangeMonitor: SystemProxyNetworkChangeMonitoring + /// 系统代理观察/修复策略机(ADR-0022):呈现事实的存储与发布点,详情见 + /// 模块文档。惰性构造:事实闭包需捕获控制器,而闭包成形必须晚于自身 + /// 全部存储属性的初始化(首次访问发生在 init 之后)。 + lazy var systemProxyObserver: SystemProxyObserver = SystemProxyObserver( + systemProxy: systemProxy, + systemProxyHelper: systemProxyHelper, + systemProxyNetworkChangeMonitor: systemProxyNetworkChangeMonitor, + appBundle: appBundle, + helperRefreshDelayNanoseconds: helperRefreshDelayNanoseconds, + isIntentEnabled: { [weak self] in self?.settings.systemProxyEnabled ?? false }, + isExitAvailable: { [weak self] in self?.systemProxyExitAvailable ?? false }, + desiredConfiguration: { [weak self] in + guard let self else { return nil } + return self.desiredSystemProxyConfiguration + }, + startSystemProxyHealthObservation: { [weak self] in + guard let self else { return } + self.startSystemProxyHealthObservation() + }) let firewallChecker: FirewallStatusChecking let firewallExecutableURLs: [URL] /// 宿主 app bundle 缝(生产为 Bundle.main):内置规则快照与 LaunchDaemon @@ -99,10 +102,6 @@ final class ProxyRuntimeController: ObservableObject { var modeChangeGeneration = 0 var lastDocument: SslocalRuntimeDocument? var firewallObservationTask: Task? - var systemProxyObservationMode = SystemProxyObservationMode.stopped - var systemProxyCleanupRescanRequested = false - var systemProxyCleanupTask: Task? - var systemProxyInspectionScheduled = false @Published var proxyMode: ProxyMode diff --git a/ShadowsocksX-NG2/App/SystemProxyObserver.swift b/ShadowsocksX-NG2/App/SystemProxyObserver.swift new file mode 100644 index 00000000..a27be3d3 --- /dev/null +++ b/ShadowsocksX-NG2/App/SystemProxyObserver.swift @@ -0,0 +1,492 @@ +import Foundation + +/// 系统代理观察/修复策略机(ADR-0022 / issue #73):收敛、挂起、意图保持、 +/// 修复、清理重试、helper 门禁与 cleanup-until-quiet 观察循环的唯一属主。 +/// 意图开关、出口可用性与期望配置由组合方以闭包注入,模块不反向依赖控制器; +/// 呈现事实经自身 `objectWillChange` 由运行时 adapter 合入 workflow 再发布链。 +/// 底层只依赖 `SystemProxyControlling`、helper 注册缝与网络变化监视。 +@MainActor +final class SystemProxyObserver: ObservableObject { + enum SystemProxyObservationMode: Equatable { + case stopped + case enabled + case cleanup + } + + /// Current configuration or operation result, independent of persisted intent. + @Published var systemProxyState: SystemProxyApplicationFacts = .idle + /// Approval remains actionable even after an off-intent clear failure. + @Published var systemProxyApprovalRequired = false + @Published var systemProxyInspection = SystemProxyInspectionFacts() + + var lastDesiredSystemProxyConfiguration: SystemProxyConfiguration? + var knownSystemProxyServices: Set = [] + var systemProxyOperationInProgress = false + var systemProxyStartupInProgress = false + var systemProxyInitialApplyPending = false + var systemProxyWasUnavailable = false + var systemProxyHealthTask: Task? + var systemProxyReadGeneration = 0 + var systemProxyObservationMode = SystemProxyObservationMode.stopped + var systemProxyCleanupRescanRequested = false + var systemProxyCleanupTask: Task? + var systemProxyInspectionScheduled = false + + let systemProxy: SystemProxyControlling + let systemProxyHelper: SystemProxyHelperServicing + let systemProxyNetworkChangeMonitor: SystemProxyNetworkChangeMonitoring + /// 宿主 app bundle 缝(生产为 Bundle.main):LaunchDaemon 清单指纹的定位基准。 + let appBundle: Bundle + /// 注册清单漂移重注时,注销与重注的间隔(launchd 对节流中 job 的移除异步)。 + let helperRefreshDelayNanoseconds: UInt64 + private let isIntentEnabled: @MainActor () -> Bool + private let isExitAvailable: @MainActor () -> Bool + private let desiredConfiguration: @MainActor () -> SystemProxyConfiguration? + /// 健康观察循环属控制器(循环体含运行时健康呈现),策略机只请求调度。 + private let startSystemProxyHealthObservation: @MainActor () -> Void + + init( + systemProxy: SystemProxyControlling, + systemProxyHelper: SystemProxyHelperServicing, + systemProxyNetworkChangeMonitor: SystemProxyNetworkChangeMonitoring, + appBundle: Bundle, + helperRefreshDelayNanoseconds: UInt64, + isIntentEnabled: @escaping @MainActor () -> Bool, + isExitAvailable: @escaping @MainActor () -> Bool, + desiredConfiguration: @escaping @MainActor () -> SystemProxyConfiguration?, + startSystemProxyHealthObservation: @escaping @MainActor () -> Void + ) { + self.systemProxy = systemProxy + self.systemProxyHelper = systemProxyHelper + self.systemProxyNetworkChangeMonitor = systemProxyNetworkChangeMonitor + self.appBundle = appBundle + self.helperRefreshDelayNanoseconds = helperRefreshDelayNanoseconds + self.isIntentEnabled = isIntentEnabled + self.isExitAvailable = isExitAvailable + self.desiredConfiguration = desiredConfiguration + self.startSystemProxyHealthObservation = startSystemProxyHealthObservation + } + + // MARK: - 收敛与应用 + + /// Explicit initial application, saved-value changes, or real health recovery. + /// A healthy GUI startup and passive events only inspect current settings. + func convergeSystemProxy(forceApply: Bool = false) async { + guard await waitForSystemProxyOperation() else { return } + guard isIntentEnabled(), !systemProxyStartupInProgress else { return } + guard isExitAvailable() else { + await suspendSystemProxy() + return + } + guard let configuration = desiredConfiguration() else { return } + let needsApplication = + forceApply || systemProxyInitialApplyPending || systemProxyWasUnavailable + || lastDesiredSystemProxyConfiguration != configuration + if needsApplication { + await applySystemProxy(configuration, repairing: false) + } else { + if systemProxyHelper.status == .approved { _ = await ensureHelperAvailableForApply() } + await recheckSystemProxy() + } + } + + private func applySystemProxy( + _ configuration: SystemProxyConfiguration, repairing: Bool + ) async { + guard !systemProxyOperationInProgress else { return } + beginSystemProxyOperation() + defer { endSystemProxyOperation() } + guard await ensureHelperAvailableForApply() else { + if !systemProxyState.hasOperationFailure { systemProxyState = .pending } + updateSystemProxyActions() + return + } + guard isIntentEnabled(), isExitAvailable() else { return } + if repairing { + guard await prepareSystemProxyRepair(configuration) else { return } + } + systemProxyState = repairing ? .repairing : .applying + updateSystemProxyActions() + // Mark the attempt before awaiting: a failed operation is never implicitly retried. + systemProxyInitialApplyPending = false + systemProxyWasUnavailable = false + systemProxyInspection.backgroundUnavailable = false + lastDesiredSystemProxyConfiguration = configuration + do { + if repairing { + try await systemProxy.repair(configuration) + } else { + let outcome = try await systemProxy.apply(configuration) + if outcome == .unchanged { RuntimeLog.emit(.systemProxyUnchanged) } + } + await inspectSystemProxy(configuration: configuration, afterRepair: repairing) + } catch { + RuntimeLog.emit(.systemProxyWriteFailed(detail: String(describing: error))) + systemProxyState = + repairing + ? .repairFailed(systemProxyFacts(for: error)) : .failed(systemProxyFacts(for: error)) + await inspectSystemProxy(configuration: configuration, preserveResult: true) + } + } + + private func prepareSystemProxyRepair(_ configuration: SystemProxyConfiguration) async -> Bool { + guard + await inspectSystemProxy( + configuration: configuration, + preserveResult: systemProxyState.hasOperationFailure) + else { return false } + guard isIntentEnabled(), isExitAvailable(), + desiredConfiguration() == configuration, systemProxyHelper.status == .approved + else { return false } + guard !systemProxyInspection.differences.isEmpty else { + systemProxyState = .applied + return false + } + return true + } + + func repairSystemProxy() async { + updateSystemProxyActions() + guard systemProxyInspection.canRepair, let configuration = desiredConfiguration() + else { return } + await applySystemProxy(configuration, repairing: true) + } + + func recheckSystemProxy() async { + refreshSystemProxyApproval() + guard isIntentEnabled(), let configuration = desiredConfiguration() else { + updateSystemProxyActions() + return + } + guard !systemProxyOperationInProgress else { return } + await inspectSystemProxy( + configuration: configuration, + preserveResult: systemProxyState.hasOperationFailure || !isExitAvailable()) + updateSystemProxyActions() + } + + @discardableResult + private func inspectSystemProxy( + configuration: SystemProxyConfiguration, afterRepair: Bool = false, + preserveResult: Bool = false + ) async -> Bool { + systemProxyReadGeneration += 1 + let generation = systemProxyReadGeneration + do { + let services = try await systemProxy.readServices() + guard !services.isEmpty else { throw SystemProxyError.noProxyServices } + guard generation == systemProxyReadGeneration, isIntentEnabled(), + desiredConfiguration() == configuration + else { return false } + let writes = try SystemProxyPlanner.makeApplyPlan( + services: services, configuration: configuration + ).writes + let differing = Set(writes.map(\.identifier)) + systemProxyInspection.differences = services.compactMap { service in + guard differing.contains(service.identifier) else { return nil } + return SystemProxyDifference( + identifier: service.identifier, name: service.name, + kind: afterRepair + ? .remaining + : (knownSystemProxyServices.contains(service.identifier) ? .changed : .notApplied)) + } + // Only observed consistency establishes a known service; an unsuccessful write + // response cannot establish that this app's configuration was ever present. + knownSystemProxyServices.formUnion( + services.filter { + !differing.contains($0.identifier) + }.map(\.identifier)) + systemProxyInspection.readFailure = nil + if !preserveResult && !systemProxyState.isClearFailure && !systemProxyWasUnavailable { + systemProxyState = differing.isEmpty ? .applied : .changed + } + return true + } catch { + guard generation == systemProxyReadGeneration else { return false } + systemProxyInspection.differences = [] + systemProxyInspection.readFailure = systemProxyFacts(for: error) + if !preserveResult && !systemProxyState.isClearFailure && !systemProxyWasUnavailable { + systemProxyState = .unreadable(systemProxyFacts(for: error)) + } + return false + } + } + + func refreshSystemProxyApproval() { + systemProxyApprovalRequired = + systemProxyHelper.status != .approved + && (isIntentEnabled() || systemProxyState.hasOperationFailure) + } + + func updateSystemProxyActions() { + refreshSystemProxyApproval() + let available = !systemProxyOperationInProgress && !systemProxyApprovalRequired + systemProxyInspection.canRepair = + available && isIntentEnabled() + && isExitAvailable() && systemProxyInspection.readFailure == nil + && (!systemProxyInspection.differences.isEmpty || systemProxyState.isApplyFailure) + systemProxyInspection.canRetryClear = available && systemProxyState.isClearFailure + if systemProxyState.hasOperationFailure { + startSystemProxyHealthObservation() + } else if !isIntentEnabled() { + systemProxyHealthTask?.cancel() + systemProxyHealthTask = nil + } + } + + func suspendSystemProxy() async { + guard isIntentEnabled(), !systemProxyStartupInProgress else { return } + guard !systemProxyWasUnavailable else { + updateSystemProxyActions() + return + } + guard await waitForSystemProxyOperation() else { return } + guard isIntentEnabled(), !systemProxyWasUnavailable else { return } + systemProxyWasUnavailable = true + systemProxyInspection.backgroundUnavailable = true + systemProxyInitialApplyPending = false + beginSystemProxyOperation() + defer { endSystemProxyOperation() } + let outcome = await clearSystemProxyOutcome() + systemProxyState = outcome == .idle ? .paused : outcome + if outcome == .idle { + systemProxyInspection.differences = [] + systemProxyInspection.readFailure = nil + } + } + + func retrySystemProxyClear() async { + updateSystemProxyActions() + guard systemProxyInspection.canRetryClear else { return } + beginSystemProxyOperation() + defer { endSystemProxyOperation() } + let outcome = await clearSystemProxyOutcome() + systemProxyState = outcome == .idle && isIntentEnabled() ? .paused : outcome + } + + private func beginSystemProxyOperation() { + systemProxyOperationInProgress = true + systemProxyInspection.isBusy = true + systemProxyReadGeneration += 1 + updateSystemProxyActions() + } + + private func endSystemProxyOperation() { + systemProxyOperationInProgress = false + systemProxyInspection.isBusy = false + updateSystemProxyActions() + } + + private func waitForSystemProxyOperation() async -> Bool { + while systemProxyOperationInProgress { + do { try await Task.sleep(nanoseconds: 10_000_000) } catch { return false } + } + return !Task.isCancelled + } +} + +// MARK: - helper 门禁(issue #71) + +extension SystemProxyObserver { + /// helper 可用性门禁(issue #71):已批准即通过——仅当 LaunchDaemon 清单 + /// 与上次注册时的指纹漂移(app 更新改过清单)才注销重注刷新 launchd 的 + /// job 定义;未注册则尝试注册(注册本身不弹授权框,成功即记指纹);待批准 + /// 或注册失败时置位批准路径并保持待应用。 + private func ensureHelperAvailableForApply() async -> Bool { + switch systemProxyHelper.status { + case .approved: + if helperRegistrationDrifted() { + do { + try systemProxyHelper.unregister() + // launchd 对节流中(spawn scheduled)job 的移除是异步的:立即重注 + // 会命中尚未移除的旧定义(实测注销重注后定义不变)。等过 minimum + // runtime(10s)节流窗再重注;间隔内收敛以旧定义失败呈超时。 + try? await Task.sleep(nanoseconds: helperRefreshDelayNanoseconds) + try systemProxyHelper.register() + storeHelperRegistrationStamp() + } catch { + // 刷新失败不记指纹,下次启动重试;状态退回后由下方分支接手。 + RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: String(describing: error))) + } + } + if systemProxyHelper.status == .approved { + systemProxyApprovalRequired = false + return true + } + case .notRegistered: + do { + try systemProxyHelper.register() + storeHelperRegistrationStamp() + } catch { + RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: String(describing: error))) + } + if systemProxyHelper.status == .approved { + systemProxyApprovalRequired = false + return true + } + case .requiresApproval: + break + } + systemProxyApprovalRequired = true + return false + } + + private func helperRegistrationDrifted(defaults: UserDefaults = .standard) -> Bool { + SystemProxyHelperRegistrationStamp.drifted( + plistData: SystemProxyHelperIdentity.launchDaemonPlistData(in: appBundle), + storedStamp: defaults.string(forKey: SystemProxyHelperRegistrationStamp.defaultsKey)) + } + + private func storeHelperRegistrationStamp(defaults: UserDefaults = .standard) { + guard let plistData = SystemProxyHelperIdentity.launchDaemonPlistData(in: appBundle) else { + return + } + defaults.set( + SystemProxyHelperRegistrationStamp.stamp(plistData), + forKey: SystemProxyHelperRegistrationStamp.defaultsKey) + } + + /// Approval continues only an unattempted initial request; failed writes need a user retry. + func openSystemProxyHelperApproval() async { + systemProxyHelper.openApprovalPath() + refreshSystemProxyApproval() + if systemProxyInitialApplyPending && !systemProxyState.hasOperationFailure { + await convergeSystemProxy() + } + updateSystemProxyActions() + } + +} + +// MARK: - 意图保持与清理 + +extension SystemProxyObserver { + /// Health/exit loss clears unavailable settings while preserving enabled intent. + func holdSystemProxyIntent() async { + if isIntentEnabled() { + await suspendSystemProxy() + } else if !systemProxyState.hasOperationFailure { + systemProxyState = .idle + } + } + + /// 无条件清除全部系统代理配置(issue #71):typed 失败原样呈现,不重试。 + private func clearSystemProxyOutcome() async -> SystemProxyApplicationFacts { + do { + try await systemProxy.clear() + return .idle + } catch { + refreshSystemProxyApproval() + return .clearFailed(systemProxyFacts(for: error)) + } + } + + func startEnabledSystemProxyObservation() { + guard systemProxyObservationMode != .enabled else { return } + systemProxyObservationMode = .enabled + startSystemProxyHealthObservation() + systemProxyNetworkChangeMonitor.start { [weak self] change in + self?.handleSystemProxyNetworkChange(change) + } + } + + func clearAndStopSystemProxyObservation() async -> SystemProxyApplicationFacts { + if let systemProxyCleanupTask { return await systemProxyCleanupTask.value } + let task = Task { @MainActor [weak self] in + guard let self else { return SystemProxyApplicationFacts.idle } + return await self.performSystemProxyCleanupUntilQuiet() + } + systemProxyCleanupTask = task + let result = await task.value + systemProxyCleanupTask = nil + return result + } + + private func performSystemProxyCleanupUntilQuiet() async -> SystemProxyApplicationFacts { + if systemProxyObservationMode != .enabled { + systemProxyObservationMode = .cleanup + systemProxyNetworkChangeMonitor.start { [weak self] change in + self?.handleSystemProxyNetworkChange(change) + } + } else { + systemProxyObservationMode = .cleanup + } + + guard await waitForSystemProxyOperation() else { return systemProxyState } + beginSystemProxyOperation() + var outcome: SystemProxyApplicationFacts = .idle + repeat { + systemProxyCleanupRescanRequested = false + outcome = await clearSystemProxyOutcome() + // Let queued SystemConfiguration notifications reach the main actor. Any + // location/service/proxy change during this cleanup starts another full scan. + try? await Task.sleep(nanoseconds: 100_000_000) + await Task.yield() + } while systemProxyCleanupRescanRequested && !outcome.hasOperationFailure + + systemProxyNetworkChangeMonitor.stop() + systemProxyHealthTask?.cancel() + systemProxyHealthTask = nil + systemProxyObservationMode = .stopped + systemProxyInspection = SystemProxyInspectionFacts() + endSystemProxyOperation() + return outcome + } + + private func handleSystemProxyNetworkChange(_ change: SystemProxyNetworkChange) { + switch systemProxyObservationMode { + case .stopped: + return + case .cleanup: + if !change.isDisjoint(with: [.networkConfiguration, .proxyConfiguration]) { + systemProxyCleanupRescanRequested = true + } + case .enabled: + guard isIntentEnabled() else { return } + guard !systemProxyInspectionScheduled else { return } + systemProxyInspectionScheduled = true + Task { @MainActor [weak self] in + await Task.yield() + guard let self else { return } + self.systemProxyInspectionScheduled = false + await self.recheckSystemProxy() + } + } + } + +} + +// MARK: - 失败事实映射 + +extension SystemProxyObserver { + private func systemProxyFacts(for error: Error) -> SystemProxyFailureFacts { + if let error = error as? SystemProxyError { + return Self.systemProxyFacts(for: error) + } + if let error = error as? ProxyModeError { + return .mode(error) + } + return .unknown + } + + // 显式映射保持与错误族一一对应;错误带关联值,无法用字典键穷举。 + // swiftlint:disable:next cyclomatic_complexity + private static func systemProxyFacts( + for error: SystemProxyError + ) -> SystemProxyFailureFacts { + switch error { + case .preferencesUnavailable: return .operation(.preferencesUnavailable) + case .preferencesBusy: return .operation(.preferencesBusy) + case .noCurrentNetworkSet: return .operation(.noCurrentNetworkSet) + case .noProxyServices: return .operation(.noProxyServices) + case .unreadableService: return .operation(.unreadableService) + case .invalidStoredConfiguration: return .operation(.invalidStoredConfiguration) + case .cannotWriteService: return .operation(.cannotWriteService) + case .commitFailed: return .operation(.commitFailed) + case .applyFailed: return .operation(.applyFailed) + case .noNetworkLocations: return .operation(.noNetworkLocations) + case .invalidRequest: return .operation(.invalidRequest) + case .helperUnavailable: return .operation(.helperUnavailable) + } + } +} diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift index 5b40e435..4c473b53 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift @@ -295,7 +295,7 @@ extension ProxyRuntimeControllerTests { // 批准路径:打开登录项设置并重试收敛;批准达成后 applied。 systemProxyHelper.setStatus(.approved) - await controller.openSystemProxyHelperApproval() + await controller.systemProxyObserver.openSystemProxyHelperApproval() XCTAssertEqual(controller.systemProxyState, .applied) XCTAssertFalse(controller.systemProxyApprovalRequired) @@ -517,14 +517,14 @@ extension ProxyRuntimeControllerTests { let unregisterCountAfterEnable = systemProxyHelper.unregisterCount // 指纹一致:后续收敛不注销重注。 - await controller.convergeSystemProxy() + await controller.systemProxyObserver.convergeSystemProxy() XCTAssertEqual(systemProxyHelper.registerCount, registerCountAfterEnable, "指纹一致不重注") XCTAssertEqual(systemProxyHelper.unregisterCount, unregisterCountAfterEnable) XCTAssertEqual(controller.systemProxyState, .applied) // 指纹漂移(模拟 app 更新改了清单):注销重注一次并更新指纹。 defaults.set("drifted-stamp", forKey: stampKey) - await controller.convergeSystemProxy() + await controller.systemProxyObserver.convergeSystemProxy() XCTAssertEqual(systemProxyHelper.unregisterCount, unregisterCountAfterEnable + 1, "漂移触发重注") XCTAssertEqual(systemProxyHelper.registerCount, registerCountAfterEnable + 1) XCTAssertEqual(defaults.string(forKey: stampKey), stampAfterRegister, "重注后记录当前清单指纹")