refactor(sysproxy): 系统代理策略机抽为 SystemProxyObserver 模块

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 测试全绿
This commit is contained in:
Qiu Yuzhou
2026-10-03 12:44:20 +08:00
parent 21259dde2a
commit eac30c4abd
11 changed files with 616 additions and 510 deletions
@@ -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<Void, Never> {
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)
@@ -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 {
@@ -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)
}
@@ -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 {
@@ -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()
}
}
}
@@ -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
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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<SystemProxyServiceIdentifier> = []
var systemProxyOperationInProgress = false
var systemProxyStartupInProgress = false
var systemProxyInitialApplyPending = false
var systemProxyWasUnavailable = false
var systemProxyHealthTask: Task<Void, Never>?
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<Void, Never>?
var systemProxyObservationMode = SystemProxyObservationMode.stopped
var systemProxyCleanupRescanRequested = false
var systemProxyCleanupTask: Task<SystemProxyControlState, Never>?
var systemProxyInspectionScheduled = false
@Published var proxyMode: ProxyMode
@@ -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<SystemProxyServiceIdentifier> = []
var systemProxyOperationInProgress = false
var systemProxyStartupInProgress = false
var systemProxyInitialApplyPending = false
var systemProxyWasUnavailable = false
var systemProxyHealthTask: Task<Void, Never>?
var systemProxyReadGeneration = 0
var systemProxyObservationMode = SystemProxyObservationMode.stopped
var systemProxyCleanupRescanRequested = false
var systemProxyCleanupTask: Task<SystemProxyApplicationFacts, Never>?
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)
}
}
}
@@ -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, "重注后记录当前清单指纹")