diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift index 8118ac7d..ff262db3 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+Commands.swift @@ -69,10 +69,11 @@ extension ProxyRuntimeController { } else { let cleanupOutcome = await cascadeSystemProxyOffForAgentOff() await stopAgent() - if let cleanupOutcome, cleanupOutcome.hasOperationFailure { - systemProxyObserver.systemProxyState = cleanupOutcome + if let cleanupOutcome { + systemProxyObserver.presentCleanupOutcome(cleanupOutcome) + } else { + systemProxyObserver.updateSystemProxyActions() } - systemProxyObserver.updateSystemProxyActions() } } @@ -104,47 +105,24 @@ extension ProxyRuntimeController { guard persistSettings(next) else { return } settings = next if enabled { - systemProxyObserver.systemProxyInitialApplyPending = true - systemProxyObserver.systemProxyWasUnavailable = false - await systemProxyObserver.convergeSystemProxy() - systemProxyObserver.startEnabledSystemProxyObservation() + await systemProxyObserver.enableSystemProxyIntent() } else { - systemProxyObserver.systemProxyApprovalRequired = false - systemProxyObserver.systemProxyInitialApplyPending = false - systemProxyObserver.systemProxyReadGeneration += 1 - systemProxyObserver.systemProxyState = - await systemProxyObserver.clearAndStopSystemProxyObservation() - systemProxyObserver.updateSystemProxyActions() + await systemProxyObserver.disableSystemProxyIntent() } } /// GUI startup restores the Agent. Healthy proxy intent only inspects; unhealthy /// proxy intent suspends; disabled intent never writes SystemConfiguration. func resyncOnLaunch() async { - systemProxyObserver.systemProxyStartupInProgress = true + systemProxyObserver.beginStartupResync() guard settings.agentEnabled else { await stopAgent() - systemProxyObserver.systemProxyStartupInProgress = false - if settings.systemProxyEnabled { - await systemProxyObserver.suspendSystemProxy() - systemProxyObserver.startEnabledSystemProxyObservation() - } + await systemProxyObserver.settleLaunchWithAgentOff() return } let catalog = catalogSnapshotReader.catalogSnapshot await convergeToReexpanded(catalog) - systemProxyObserver.systemProxyStartupInProgress = false - if settings.systemProxyEnabled { - systemProxyObserver.lastDesiredSystemProxyConfiguration = desiredSystemProxyConfiguration - if systemProxyExitAvailable { - await systemProxyObserver.recheckSystemProxy() - } else { - await systemProxyObserver.suspendSystemProxy() - } - systemProxyObserver.startEnabledSystemProxyObservation() - } else { - systemProxyObserver.systemProxyState = .idle - } + await systemProxyObserver.settleLaunchAfterAgentConverged() } // MARK: - 动作执行 @@ -159,23 +137,12 @@ extension ProxyRuntimeController { /// 系统代理状态按意图事实收尾——级联后意图已关闭为空闲;残留的「意图开 /// 启 + agent 关闭」组合保持待应用(不静默清理,也无需网络观察)。 func stopAgent() async { - systemProxyNetworkChangeMonitor.stop() - systemProxyObserver.systemProxyHealthTask?.cancel() - systemProxyObserver.systemProxyHealthTask = nil - systemProxyObserver.systemProxyObservationMode = .stopped + systemProxyObserver.agentDidStop() _ = await execute(.stop, document: nil) state = .off lastDocument = nil skippedServers = [] lastActivationFailure = nil - if settings.systemProxyEnabled { - systemProxyObserver.systemProxyState = .pending - } else { - if !systemProxyState.hasOperationFailure { - systemProxyObserver.systemProxyState = .idle - } - systemProxyObserver.updateSystemProxyActions() - } } /// 无活动目标时 agent 仍提供本地监听(issue #60):空服务器列表文档, diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+Mode.swift index c4e5710a..33e8337d 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 { systemProxyObserver.systemProxyState = .pending } + systemProxyObserver.modeTransitionAwaitingRuntime() return } diff --git a/ShadowsocksX-NG2/App/SystemProxyObserver.swift b/ShadowsocksX-NG2/App/SystemProxyObserver.swift index adb61662..1e801b0f 100644 --- a/ShadowsocksX-NG2/App/SystemProxyObserver.swift +++ b/ShadowsocksX-NG2/App/SystemProxyObserver.swift @@ -14,23 +14,30 @@ final class SystemProxyObserver: ObservableObject { } /// Current configuration or operation result, independent of persisted intent. - @Published var systemProxyState: SystemProxyApplicationFacts = .idle + @Published private(set) var systemProxyState: SystemProxyApplicationFacts = .idle /// Approval remains actionable even after an off-intent clear failure. - @Published var systemProxyApprovalRequired = false - @Published var systemProxyInspection = SystemProxyInspectionFacts() + @Published private(set) var systemProxyApprovalRequired = false + @Published private(set) var systemProxyInspection = SystemProxyInspectionFacts() - var lastDesiredSystemProxyConfiguration: SystemProxyConfiguration? - var knownSystemProxyServices: Set = [] - var systemProxyOperationInProgress = false - var systemProxyStartupInProgress = false - var systemProxyInitialApplyPending = false - var systemProxyWasUnavailable = false + // 写面唯一入口是下方「意图命令」;private(set) 的读面仅开给健康循环体 + // 与观察机测试,其余实现细节一律 private。 + private var lastDesiredSystemProxyConfiguration: SystemProxyConfiguration? + private var knownSystemProxyServices: Set = [] + private var systemProxyOperationInProgress = false + /// 读面:健康循环体在 launch 重同步抑制期跳过巡检。 + private(set) var systemProxyStartupInProgress = false + /// 读面:健康循环的收敛条件。 + private(set) var systemProxyInitialApplyPending = false + /// 读面:健康循环的收敛条件。 + private(set) var systemProxyWasUnavailable = false + /// 过渡:任务柄创建仍在控制器侧,柄归属收回观察机后转 private。 var systemProxyHealthTask: Task? - var systemProxyReadGeneration = 0 - var systemProxyObservationMode = SystemProxyObservationMode.stopped - var systemProxyCleanupRescanRequested = false - var systemProxyCleanupTask: Task? - var systemProxyInspectionScheduled = false + private var systemProxyReadGeneration = 0 + /// 读面:观察机测试。 + private(set) var systemProxyObservationMode = SystemProxyObservationMode.stopped + private var systemProxyCleanupRescanRequested = false + private var systemProxyCleanupTask: Task? + private var systemProxyInspectionScheduled = false let systemProxy: SystemProxyControlling let systemProxyHelper: SystemProxyHelperServicing @@ -216,28 +223,6 @@ final class SystemProxyObserver: ObservableObject { } } - 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 { @@ -296,6 +281,125 @@ final class SystemProxyObserver: ObservableObject { } } +// MARK: - 呈现动作与批准刷新 + +extension SystemProxyObserver { + 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 + } + } +} + +// MARK: - 意图命令(控制器写面唯一入口) + +extension SystemProxyObserver { + /// 系统代理意图开启(setSystemProxyEnabled on):本轮必须真实应用一次—— + /// 清理期可能已把系统设置清空,不能凭 lastDesired 相等跳过;收敛后转入 + /// enabled 观察与网络变化巡检。 + func enableSystemProxyIntent() async { + systemProxyInitialApplyPending = true + systemProxyWasUnavailable = false + await convergeSystemProxy() + startEnabledSystemProxyObservation() + } + + /// 系统代理意图关闭(setSystemProxyEnabled off):撤销待批准/待应用标记, + /// 读取代际推进作废在途读,无条件清理并停观察,结果原样呈现。 + func disableSystemProxyIntent() async { + systemProxyApprovalRequired = false + systemProxyInitialApplyPending = false + systemProxyReadGeneration += 1 + systemProxyState = await clearAndStopSystemProxyObservation() + updateSystemProxyActions() + } + + /// agent 停机的观察收尾(stopAgent):停网络监视与健康循环,观察模式归 + /// 零。呈现按代理意图分流——意图仍在则保持待应用(等 agent 恢复后收敛, + /// issue #71),意图已关则空闲。 + func agentDidStop() { + systemProxyNetworkChangeMonitor.stop() + systemProxyHealthTask?.cancel() + systemProxyHealthTask = nil + systemProxyObservationMode = .stopped + if isIntentEnabled() { + systemProxyState = .pending + } else { + if !systemProxyState.hasOperationFailure { + systemProxyState = .idle + } + updateSystemProxyActions() + } + } + + /// agent 关闭级联的清理结果呈现(setAgentEnabled off):typed 失败原样 + /// 上屏(清理不重试),成功不覆盖既有呈现。 + func presentCleanupOutcome(_ outcome: SystemProxyApplicationFacts) { + if outcome.hasOperationFailure { + systemProxyState = outcome + } + updateSystemProxyActions() + } + + /// mode 切换发现无可收敛运行时(agent 关或无活动文档):代理意图仍开时 + /// 保持待应用,等运行时恢复后由健康循环收敛(+Mode 提前出口)。 + func modeTransitionAwaitingRuntime() { + if isIntentEnabled() { + systemProxyState = .pending + } + } + + /// launch 重同步开始(resyncOnLaunch 入口):抑制期 converge/suspend 拒绝 + /// 执行、健康循环跳过巡检;须由 settle 命令解除。 + func beginStartupResync() { + systemProxyStartupInProgress = true + } + + /// launch 重同步收尾(agent 意图关闭分支):解除抑制(抑制期 suspend 的 + /// 守卫会拒绝执行);代理意图仍开则挂起残留配置并转入观察。 + func settleLaunchWithAgentOff() async { + systemProxyStartupInProgress = false + if isIntentEnabled() { + await suspendSystemProxy() + startEnabledSystemProxyObservation() + } + } + + /// launch 重同步收尾(agent 已收敛分支):解除抑制;代理意图开则播种期望 + /// 配置(健康 GUI 启动只巡检不强推应用),按出口可用性巡检或挂起,转入 + /// 观察;意图关呈现空闲。 + func settleLaunchAfterAgentConverged() async { + systemProxyStartupInProgress = false + if isIntentEnabled() { + lastDesiredSystemProxyConfiguration = desiredConfiguration() + if isExitAvailable() { + await recheckSystemProxy() + } else { + await suspendSystemProxy() + } + startEnabledSystemProxyObservation() + } else { + systemProxyState = .idle + } + } +} + // MARK: - helper 门禁(issue #71) extension SystemProxyObserver { diff --git a/ShadowsocksX-NG2/Tests/SystemProxyObserverTests.swift b/ShadowsocksX-NG2/Tests/SystemProxyObserverTests.swift index bd7adcde..b1612767 100644 --- a/ShadowsocksX-NG2/Tests/SystemProxyObserverTests.swift +++ b/ShadowsocksX-NG2/Tests/SystemProxyObserverTests.swift @@ -34,7 +34,6 @@ final class SystemProxyObserverTests: XCTestCase { func testConvergeWaitsForInFlightOperationInsteadOfSkipping() async throws { let proxy = ProxyRuntimeFixture.FakeSystemProxy() let observer = makeObserver(proxy: proxy, cleanupSettleNanoseconds: 1_000_000) - observer.systemProxyInitialApplyPending = true // 拉长首次读服务的窗口,制造进行中的操作。 proxy.beforeRead = { _ = try? await Task.sleep(nanoseconds: 30_000_000) } @@ -46,11 +45,101 @@ final class SystemProxyObserverTests: XCTestCase { XCTAssertEqual(proxy.applied.count, 2, "并发收敛必须等操作收尾后执行而非被跳过") } + func testEnableIntentAppliesFreshAndStartsObservation() async throws { + let proxy = ProxyRuntimeFixture.FakeSystemProxy() + let monitor = ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor() + let observer = makeObserver(proxy: proxy, monitor: monitor, cleanupSettleNanoseconds: 1_000_000) + + await observer.enableSystemProxyIntent() + + XCTAssertEqual(proxy.applied.count, 1, "开启意图必须真实应用一次(清理期可能已清空系统设置)") + XCTAssertEqual(observer.systemProxyState, .applied) + XCTAssertEqual(observer.systemProxyObservationMode, .enabled) + XCTAssertEqual(monitor.startCount, 1) + } + + func testDisableIntentClearsStopsObservationAndPublishesOutcome() async throws { + let proxy = ProxyRuntimeFixture.FakeSystemProxy() + let monitor = ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor() + let observer = makeObserver(proxy: proxy, monitor: monitor, cleanupSettleNanoseconds: 1_000_000) + await observer.enableSystemProxyIntent() + + await observer.disableSystemProxyIntent() + + XCTAssertEqual(proxy.clearCount, 1) + XCTAssertEqual(observer.systemProxyState, .idle) + XCTAssertEqual(observer.systemProxyObservationMode, .stopped) + XCTAssertEqual(monitor.stopCount, 1) + XCTAssertFalse(observer.systemProxyApprovalRequired) + } + + func testAgentStopPresentationFollowsProxyIntent() async throws { + let proxy = ProxyRuntimeFixture.FakeSystemProxy() + let monitor = ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor() + var intentEnabled = true + let observer = makeObserver( + proxy: proxy, monitor: monitor, cleanupSettleNanoseconds: 1_000_000, + isIntentEnabled: { intentEnabled }) + + observer.agentDidStop() + XCTAssertEqual(observer.systemProxyState, .pending, "代理意图仍在开时,agent 停机保持待应用(issue #71)") + XCTAssertEqual(observer.systemProxyObservationMode, .stopped) + XCTAssertEqual(monitor.stopCount, 1) + + intentEnabled = false + observer.agentDidStop() + XCTAssertEqual(observer.systemProxyState, .idle, "代理意图已关时,agent 停机呈现空闲") + } + + func testModeTransitionAwaitingRuntimeStampsPendingOnlyUnderIntent() async throws { + var intentEnabled = false + let observer = makeObserver( + proxy: ProxyRuntimeFixture.FakeSystemProxy(), cleanupSettleNanoseconds: 1_000_000, + isIntentEnabled: { intentEnabled }) + + observer.modeTransitionAwaitingRuntime() + XCTAssertEqual(observer.systemProxyState, .idle, "代理意图关闭时不打待应用图章") + + intentEnabled = true + observer.modeTransitionAwaitingRuntime() + XCTAssertEqual(observer.systemProxyState, .pending) + } + + func testSettleLaunchAfterConvergedInspectsWithoutReapplying() async throws { + let proxy = ProxyRuntimeFixture.FakeSystemProxy() + let observer = makeObserver(proxy: proxy, cleanupSettleNanoseconds: 1_000_000) + + await observer.settleLaunchAfterAgentConverged() + + XCTAssertEqual(proxy.applied.count, 0, "健康的 GUI 启动只巡检,不强推应用") + XCTAssertGreaterThanOrEqual(proxy.readCount, 1) + XCTAssertEqual(observer.systemProxyObservationMode, .enabled) + XCTAssertFalse(observer.systemProxyStartupInProgress) + // 播种 lastDesired 后,后续收敛(如网络变化回调)不得因相等而重新应用。 + await observer.convergeSystemProxy() + XCTAssertEqual(proxy.applied.count, 0, "播种 lastDesired 后收敛不得重新应用") + } + + func testStartupSuppressionBlocksConvergeUntilSettled() async throws { + let proxy = ProxyRuntimeFixture.FakeSystemProxy() + let observer = makeObserver(proxy: proxy, cleanupSettleNanoseconds: 1_000_000) + observer.beginStartupResync() + + await observer.convergeSystemProxy(forceApply: true) + XCTAssertEqual(proxy.applied.count, 0, "launch 重同步抑制期 converge 必须被拒绝") + + await observer.settleLaunchWithAgentOff() + XCTAssertFalse(observer.systemProxyStartupInProgress) + XCTAssertEqual(observer.systemProxyState, .paused, "抑制解除后按意图挂起残留配置") + XCTAssertEqual(observer.systemProxyObservationMode, .enabled) + } + private func makeObserver( proxy: ProxyRuntimeFixture.FakeSystemProxy, monitor: SystemProxyNetworkChangeMonitoring = ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor(), - cleanupSettleNanoseconds: UInt64 + cleanupSettleNanoseconds: UInt64, + isIntentEnabled: @escaping @MainActor () -> Bool = { true } ) -> SystemProxyObserver { SystemProxyObserver( systemProxy: proxy, @@ -59,7 +148,7 @@ final class SystemProxyObserverTests: XCTestCase { appBundle: AppArtifact.bundle, helperRefreshDelayNanoseconds: 1_000_000, cleanupSettleNanoseconds: cleanupSettleNanoseconds, - isIntentEnabled: { true }, + isIntentEnabled: isIntentEnabled, isExitAvailable: { true }, desiredConfiguration: { try? ProxyMode.direct.systemProxyConfiguration(