diff --git a/ShadowsocksX-NG2/App/HomeView.swift b/ShadowsocksX-NG2/App/HomeView.swift index d66e1a22..d08fa259 100644 --- a/ShadowsocksX-NG2/App/HomeView.swift +++ b/ShadowsocksX-NG2/App/HomeView.swift @@ -76,7 +76,7 @@ private struct RuntimeControlCard: View { Toggle("设置系统代理", isOn: systemProxyBinding) .toggleStyle(.switch) Spacer(minLength: 0) - Text(systemProxyShortStatus(summary)) + Text(summary.systemProxyStateLabel) .font(.callout.weight(.medium)) .foregroundStyle(.secondary) .lineLimit(1) @@ -109,10 +109,6 @@ private struct RuntimeControlCard: View { set: { enabled in Task { await control.setSystemProxyEnabled(enabled) } }) } - private func systemProxyShortStatus(_ summary: StatusMenuModel.Summary) -> String { - summary.systemProxyStatus.replacingOccurrences(of: "系统代理:", with: "") - } - private func systemProxyHint(_ summary: StatusMenuModel.Summary) -> String { switch control.snapshot.systemProxyApplication { case .idle: diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index 28ae5b53..8ca6a16a 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -13,9 +13,10 @@ struct SystemEndpointProbe: EndpointProbing { } /// 代理运行时控制器(spec #21 D2/D5/D7/D9,issue #27/#28/#60):把激活状态机 -/// 的产出接到「GUI → LaunchAgent → wrapper → sslocal」链路。决策全部在纯域 -/// `ProxyRuntimePlan`,本类按序执行动作并负责健康呈现;GUI 退出不影响任何 -/// 一侧(agent 由 launchd 持有,构造上成立)。 +/// 的产出接到「GUI → LaunchAgent → wrapper → sslocal」链路。launchd/契约动作 +/// 序列在纯域 `ProxyRuntimePlan`,本类按序执行、负责健康呈现与系统代理门禁 +/// 收敛(issue #60:意图持久化先行,门禁与待应用策略是应用层决策);GUI 退出 +/// 不影响任何一侧(agent 由 launchd 持有,构造上成立)。 /// /// 两个用户意图相互独立(issue #60):agent 意图(`settings.agentEnabled`, /// 默认开启)驱动 LaunchAgent 注册与本地监听;系统代理意图 @@ -230,9 +231,20 @@ final class ProxyRuntimeController: ObservableObject { } /// Agent 开关(issue #60):先持久化意图(显式关闭在 GUI 重启后仍生效), - /// 再收敛运行时。持久化失败保留现状并点名,不静默偏离持久化事实。 + /// 再收敛运行时。持久化失败保留现状并点名,不静默偏离持久化事实。意图已 + /// 开启时的开启命令仅在未达健康态时重收敛——它是启动失败/重置后的显式 + /// 重试入口;健康运行中不做无谓的注销重拉。 func setAgentEnabled(_ enabled: Bool) async { - guard enabled != settings.agentEnabled else { return } + if enabled == settings.agentEnabled { + guard enabled else { return } + switch state { + case .off, .launchFailed, .serviceFailed: + await convergeAgent() + case .starting, .running, .firewallBlocked, .requiresApproval: + break + } + return + } var next = settings next.agentEnabled = enabled do { diff --git a/ShadowsocksX-NG2/App/StatusMenuModel.swift b/ShadowsocksX-NG2/App/StatusMenuModel.swift index 210bf286..87d2b97e 100644 --- a/ShadowsocksX-NG2/App/StatusMenuModel.swift +++ b/ShadowsocksX-NG2/App/StatusMenuModel.swift @@ -20,6 +20,8 @@ enum StatusMenuModel { let systemProxyIntentEnabled: Bool /// 系统代理实际应用状态文本(与 agent 运行状态分开呈现)。 let systemProxyStatus: String + /// 同上但不带「系统代理:」前缀(首页等已有上下文的呈现面用)。 + let systemProxyStateLabel: String /// 系统代理写入/恢复失败的点名原因;非失败态为 nil。 let systemProxyDetail: String? let modeLabel: String @@ -50,20 +52,20 @@ enum StatusMenuModel { let agentDetail = snapshot.runtime.failure.map { AppPresentation.message(for: $0) } ?? snapshot.activationFailure.map { AppPresentation.message(for: $0) } - let systemProxyStatus: String + let stateLabel: String let systemProxyDetail: String? switch snapshot.systemProxyApplication { case .idle: - systemProxyStatus = "系统代理:未接管" + stateLabel = "未接管" systemProxyDetail = nil case .pending: - systemProxyStatus = "系统代理:待应用" + stateLabel = "待应用" systemProxyDetail = nil case .applied: - systemProxyStatus = "系统代理:已应用" + stateLabel = "已应用" systemProxyDetail = nil case .failed(let facts): - systemProxyStatus = "系统代理:应用失败" + stateLabel = "应用失败" systemProxyDetail = AppPresentation.message(for: RuntimeFailureFacts.systemProxy(facts)) } return Summary( @@ -72,7 +74,8 @@ enum StatusMenuModel { status: status, detail: agentDetail, systemProxyIntentEnabled: snapshot.systemProxyIntentEnabled, - systemProxyStatus: systemProxyStatus, + systemProxyStatus: "系统代理:\(stateLabel)", + systemProxyStateLabel: stateLabel, systemProxyDetail: systemProxyDetail, modeLabel: snapshot.proxyMode.label, targetPath: snapshot.activeTarget?.pathSummary) diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift index ed523b69..6054e59f 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift @@ -692,3 +692,25 @@ extension ProxyRuntimeControllerTests { XCTAssertTrue(systemProxy.applied.isEmpty, "系统代理意图关闭,不写系统设置") } } + +extension ProxyRuntimeControllerTests { + /// 意图已开启时的开启命令是失败态的显式重试入口(issue #60):无需先关再开。 + func testAgentEnableCommandRetriesConvergenceWhileIntentAlreadyOn() async throws { + let seeded = try makeSeededCatalog() + let probe = ProxyRuntimeFixture.FakeProbe.refusing() + let controller = makeController(probe: probe) + + // 激活即按默认意图部署:端点不可达 → 启动失败(走满 15 秒健康窗)。 + try await controller.activate(seeded.server) + if case .launchFailed = controller.state { + } else { + XCTFail("端点不可达应呈现启动失败,实际 \(controller.state)") + } + + probe.setOutcomes([.reachable]) + await controller.setAgentEnabled(true) + + XCTAssertEqual(controller.state, .running, "意图已开启时开启命令重走收敛") + XCTAssertEqual(controller.systemProxyState, .idle) + } +}