diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift index cd0f3b7a..ed9eb307 100644 --- a/ShadowsocksX-NG2/App/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -153,6 +153,8 @@ extension AppPresentation { switch failure { case .store(let error): return settingsStore(error) case .mode(let error): return proxyMode(error) + case .runtime(let runtimeFailure): + return runtimeFailure.map { message(for: $0) } ?? unknownError case .unknown: return unknownError } } @@ -321,6 +323,7 @@ extension AppPresentation { case .ioFailure: return "偏好文件读写失败" case .missingCredential: return "偏好中的敏感 URL 无法从钥匙串读取" case .credentialFailure: return "偏好中的敏感 URL 无法写入钥匙串" + case .rollbackFailed: return "偏好保存失败,旧设置或凭据未能完整恢复" case .legacyListenSettings(let error): return "旧版监听设置无法读取:\(listenStore(error))" } } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index 876f8901..b1dc7502 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -145,6 +145,20 @@ final class ProxyRuntimeController: ObservableObject { /// 两态呈现(D7)。 var listenSettings: SslocalListenSettings { settings.listen } + /// 当前已部署 runtime 的完整监听身份。已停止或启动失败时没有有效的 + /// runtime 例外,不能拿最后一次设置快照冒充仍在监听。 + var effectiveRuntimeListenFacts: RuntimeListenFacts? { + let isListening: Bool + switch state { + case .running, .firewallBlocked, .systemProxyFailed: + isListening = true + case .off, .starting, .launchFailed, .activationFailed, .requiresApproval, .serviceFailed: + isListening = false + } + guard isListening, let lastDocument else { return nil } + return RuntimeListenFacts(document: lastDocument) + } + /// 运行时契约的脱敏摘要(数量与协议元数据,D5);契约缺失或无效返回 nil。 /// 诊断导出不读契约内容,只携带此摘要。 func runtimeDocumentSummary() -> String? { @@ -475,16 +489,40 @@ extension ProxyRuntimeController { } } + /// SettingsWorkflow 只需要知道 runtime 是否独立收敛,以及失败的安全 typed + /// fact;它不消费控制器内部的 state machine。 + private func settingsRuntimeOutcome() -> SettingsRuntimeOutcome { + switch state { + case .off: + return .notRunning + case .running: + return .converged + case .firewallBlocked(let facts): + return .failed(.firewallBlocked(facts)) + case .launchFailed(let facts): + return .failed(.launch(facts)) + case .activationFailed(let failure): + return .failed(.activation(failure)) + case .requiresApproval: + return .failed(.requiresApproval) + case .serviceFailed(let facts): + return .failed(.service(facts)) + case .systemProxyFailed(let facts): + return .failed(.systemProxy(facts)) + case .starting: + return .failed(nil) + } + } + /// Persists a fully validated settings snapshot and, when the proxy is /// active, re-derives the same runtime path with the new snapshot. - func updateSettings(_ proposed: ProxySettings) async throws { + func updateSettings(_ proposed: ProxySettings) async throws -> SettingsRuntimeOutcome { let catalog = catalogSnapshotReader.catalogSnapshot try settingsStore.save(proposed) settings = proposed listenSettingsUnreadable = false settingsUnreadable = false - - guard state != .off else { return } + guard state != .off else { return .notRunning } switch reexpand(in: catalog) { case .deployed(let configuration): await deploy(configuration.document) @@ -493,11 +531,12 @@ extension ProxyRuntimeController { case nil: break } + return settingsRuntimeOutcome() } /// Restores factory defaults, removes the persisted snapshot and stops any /// active runtime before the next user action can use the defaults. - func resetPreferences() async throws { + func resetPreferences() async throws -> SettingsRuntimeOutcome { try settingsStore.reset() settings = ProxySettings() listenSettingsUnreadable = false @@ -505,7 +544,9 @@ extension ProxyRuntimeController { proxyMode = .pac if state != .off { await setProxyEnabled(false) + return state == .off ? .stopped : settingsRuntimeOutcome() } + return .notRunning } /// Applies the post-import 2.0 runtime boundary without touching diff --git a/ShadowsocksX-NG2/App/SettingsView.swift b/ShadowsocksX-NG2/App/SettingsView.swift index 4ae03805..1ab8cf2a 100644 --- a/ShadowsocksX-NG2/App/SettingsView.swift +++ b/ShadowsocksX-NG2/App/SettingsView.swift @@ -20,7 +20,7 @@ struct SettingsView: View { .formStyle(.grouped) .padding() .onAppear { - workflow.reloadFromCommitted() + Task { _ = await workflow.reloadFromCommitted() } } .alert( workflow.pendingConfirmation.map { confirmationPresentation(for: $0).title } ?? "", @@ -137,11 +137,11 @@ struct SettingsView: View { HStack { Spacer() Button("重置偏好") { - workflow.reset() + Task { _ = await workflow.reset() } } .disabled(workflow.isCommitting) Button(workflow.isCommitting ? "保存中…" : "保存") { - workflow.save() + Task { _ = await workflow.save() } } .keyboardShortcut(.defaultAction) .disabled(!workflow.canSave) @@ -178,7 +178,7 @@ extension SettingsView { } if state.canSuggestFreePort { Button("建议空闲端口") { - workflow.suggestFreePort(for: id) + Task { _ = await workflow.suggestFreePort(for: id) } } .font(.caption) } @@ -250,15 +250,15 @@ extension SettingsView { title: "PAC 地址将失效", confirmTitle: "继续保存", confirmRole: nil, - confirm: { workflow.confirmPACNotice() }, - cancel: { workflow.cancelPACNotice() }) + confirm: { Task { _ = await workflow.confirmPACNotice() } }, + cancel: { Task { _ = await workflow.cancelPACNotice() } }) case .resetPreferences: ConfirmationPresentation( title: "重置所有偏好?", confirmTitle: "重置", confirmRole: .destructive, - confirm: { workflow.confirmReset() }, - cancel: { workflow.cancelReset() }) + confirm: { Task { _ = await workflow.confirmReset() } }, + cancel: { Task { _ = await workflow.cancelReset() } }) } } diff --git a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsDraftAdapter.swift b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsDraftAdapter.swift index de23cde0..7e8e6899 100644 --- a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsDraftAdapter.swift +++ b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsDraftAdapter.swift @@ -1,20 +1,20 @@ import Foundation -/// 写入侧窄 seam(issue #44):设置工作流只依赖已提交快照、代理是否在跑与 -/// 两个提交入口。生产实现是 `ProxyRuntimeController` 的薄扩展;测试注入 -/// fake。Domain 快照类型只出现在本缝与 `SettingsDraftAdapter`。读取侧是 +/// 写入侧窄 seam(issue #48):设置工作流只依赖已提交快照、当前 runtime 的 +/// typed listener facts 与两个提交入口。生产实现是 `ProxyRuntimeController` +/// 的薄扩展;测试注入 fake。Domain 快照类型只出现在本缝与 adapter。读取侧是 /// 运行时控制器的主 actor 状态,本缝随之主 actor 绑定。 @MainActor protocol SettingsCommitting: AnyObject { var committedSettings: ProxySettings { get } - var isProxyRunning: Bool { get } - func updateSettings(_ proposed: ProxySettings) async throws - func resetPreferences() async throws + var runtimeListenFacts: RuntimeListenFacts? { get } + func updateSettings(_ proposed: ProxySettings) async throws -> SettingsRuntimeOutcome + func resetPreferences() async throws -> SettingsRuntimeOutcome } extension ProxyRuntimeController: SettingsCommitting { var committedSettings: ProxySettings { settings } - var isProxyRunning: Bool { state.isOn } + var runtimeListenFacts: RuntimeListenFacts? { effectiveRuntimeListenFacts } } /// 平坦草稿与 Domain 快照的单一 adapter(story 45):字段清单变化只改此处。 @@ -71,6 +71,20 @@ enum SettingsDraftAdapter { preferredMode: base.preferredMode) } + /// 平坦草稿对应的完整有效监听身份。占用探测和 runtime 例外判断共用此 + /// adapter,避免在 workflow 内复制 scope/address/endpoint 字段清单。 + static func listenFacts(from draft: SettingsDraft) -> RuntimeListenFacts { + var listen = SslocalListenSettings() + listen.scope = + draft.isHostScope ? .host(advertisedAddress: draft.advertisedAddress) : .loopback + listen.socksPort = draft.socksPort + listen.httpProxyEnabled = draft.httpProxyEnabled + listen.httpPort = draft.httpPort + listen.pacPort = draft.pacPort + listen.udpRelayEnabled = draft.udpRelayEnabled + return RuntimeListenFacts(listen: listen) + } + /// UI 形状端口标识 → 端点类型(module 内部复用 Domain 端口语义)。 static func endpoint(for id: SettingsPortID) -> ProxyEndpointKind { switch id { diff --git a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift index 7aa26dd0..c7cf6fdc 100644 --- a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift +++ b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift @@ -1,32 +1,34 @@ import Foundation -/// 设置工作流 module(issue #44):设置窗口的唯一 UI-facing seam。以平坦的 +/// 设置工作流 module(issue #48):设置窗口的唯一 UI-facing seam。以平坦的 /// UI 形状草稿为编辑态唯一 source of truth,向 UI 只提供字段归位的校验问题、 -/// 端口 field state、保存门禁/脏态/提交中/typed failure projection、统一确认事实与具名 -/// typed command。视图不再拆装 Domain 枚举、翻译占用事实、推导门禁或自备 +/// 端口 field state、保存门禁/脏态/提交中/typed outcome、统一确认事实与具名 +/// async command。视图不再拆装 Domain 枚举、翻译占用事实、推导门禁或自备 /// 确认文案;alert、sheet 与窗口状态仍由 UI 持有。登录启动项是独立偏好域, /// 不经本 module。 /// /// 内部沿用既有深 module:`ProxySettings` 的点名校验、`ProxyPortSemantics` 的 -/// 端口互异/建议算法、`PortChangeNotice` 的 PAC 失效判定与 `PortOccupancyProbing` -/// 探测缝(编辑期尽力而为的即时提示,激活成败仍以 runtime 健康门禁为准)。 -/// 写入侧只依赖窄 seam `SettingsCommitting`。module 不依赖 SwiftUI。 +/// 端口互异/建议算法、`PortChangeNotice` 的 PAC 失效判定与 typed occupancy +/// request。写入侧只依赖窄 seam `SettingsCommitting`;module 不依赖 SwiftUI。 @MainActor final class SettingsWorkflow: ObservableObject { /// 编辑中的 UI 形状草稿:视图按字段绑定;监听设置变化时自动重探占用。 @Published var draft: SettingsDraft { didSet { - if listenFingerprint(of: draft) != listenFingerprint(of: oldValue) { + if listenFacts(of: draft) != listenFacts(of: oldValue) { refreshOccupancy() } } } + /// 各端口的尽力而为占用判定(非权威,只影响编辑期提示与保存门禁)。 @Published private var occupancyByPort: [SettingsPortID: SettingsPortOccupancy] = [:] /// 提交/重置进行中(按钮进入进行中状态且不可重复触发)。 @Published private(set) var isCommitting = false /// 最近一次提交/重置失败的 typed fact(nil = 无)。 @Published private(set) var lastFailure: SettingsWorkflowFailure? + /// 最近一次离散 command 的结构化结果,供 UI 观察而无需解析字符串。 + @Published private(set) var lastOutcome: SettingsCommandOutcome? /// 等待用户裁定的确认事实(PAC 失效、重置偏好);视图只持有 alert 呈现状态。 @Published private(set) var pendingConfirmation: SettingsConfirmation? @@ -35,27 +37,6 @@ final class SettingsWorkflow: ObservableObject { /// 占用探测代际:草稿快速连续变化时只有最新一轮结果生效。 private var occupancyGeneration = 0 - /// 触发占用重探的监听字段指纹(范围、公布地址、端口与 HTTP/UDP 开关)。 - private struct ListenFingerprint: Equatable { - let isHostScope: Bool - let advertisedAddress: String - let socksPort: Int - let httpProxyEnabled: Bool - let httpPort: Int - let pacPort: Int - let udpRelayEnabled: Bool - - init(_ draft: SettingsDraft) { - isHostScope = draft.isHostScope - advertisedAddress = draft.advertisedAddress - socksPort = draft.socksPort - httpProxyEnabled = draft.httpProxyEnabled - httpPort = draft.httpPort - pacPort = draft.pacPort - udpRelayEnabled = draft.udpRelayEnabled - } - } - init( committing: SettingsCommitting, occupancyProbe: PortOccupancyProbing = SystemPortOccupancyProbe() @@ -100,10 +81,9 @@ final class SettingsWorkflow: ObservableObject { canSuggestFreePort: canSuggest) } - /// 是否存在阻塞保存的端口占用(事实查询,呈现由视图决定)。HTTP 入站关闭 - /// 时 HTTP 端点不参与门禁。 - var hasBlockingPortOccupancy: Bool { - SettingsPortID.allCases.contains { id in + /// 明确阻塞保存的端口。HTTP 入站关闭时 HTTP 端点不参与门禁。 + var blockingPortIDs: [SettingsPortID] { + SettingsPortID.allCases.filter { id in guard id != .http || draft.httpProxyEnabled else { return false } guard !isRuntimePortException(id) else { return false } if case .occupied = occupancyByPort[id] { return true } @@ -111,11 +91,17 @@ final class SettingsWorkflow: ObservableObject { } } + /// 是否存在阻塞保存的端口占用(事实查询,呈现由视图决定)。 + var hasBlockingPortOccupancy: Bool { !blockingPortIDs.isEmpty } + // MARK: - 操作区只读投影 - /// 保存门禁:校验问题清零、无阻塞性占用、且不在提交中。 + /// 保存门禁:校验问题清零、无阻塞性占用、不在提交中且没有待裁定确认。 var canSave: Bool { - fieldIssues.isEmpty && !hasBlockingPortOccupancy && !isCommitting + fieldIssues.isEmpty + && blockingPortIDs.isEmpty + && !isCommitting + && pendingConfirmation == nil } /// 脏态:草稿若提交会不会改变已提交快照(草稿经唯一 adapter 归一后比较, @@ -124,136 +110,192 @@ final class SettingsWorkflow: ObservableObject { makeSettings(from: draft) != committing.committedSettings } - // MARK: - 具名 typed command + // MARK: - 具名 typed async commands + + /// 保存:先返回 typed gate rejection;PAC 端口变化则返回确认事实,否则等待 + /// persistence seam 完成,并把 runtime convergence 独立放进结果。 + @discardableResult + func save() async -> SettingsCommandOutcome { + guard !isCommitting else { return record(.rejected(.inProgress)) } + guard pendingConfirmation == nil else { + return record(.rejected(.confirmationPending)) + } + guard fieldIssues.isEmpty else { + return record(.rejected(.validation(fieldIssues))) + } + guard blockingPortIDs.isEmpty else { + return record(.rejected(.occupied(blockingPortIDs))) + } - /// 保存:门禁通过后,若 PAC 端口将变化则挂起失效确认,否则提交。 - func save() { - guard canSave else { return } let proposed = makeSettings(from: draft) if PortChangeNotice.pacPortChanged( from: committing.committedSettings.listen, to: proposed.listen) { - pendingConfirmation = .pacInvalidation( + let confirmation = SettingsConfirmation.pacInvalidation( previousPort: committing.committedSettings.listen.pacPort, nextPort: proposed.listen.pacPort) - return + pendingConfirmation = confirmation + return record(.confirmationRequired(confirmation)) } - commit(proposed) + return await commit(proposed) } /// 用户裁定继续保存(确认 PAC 失效提示)后提交。 - func confirmPACNotice() { - guard case .pacInvalidation = pendingConfirmation else { return } + @discardableResult + func confirmPACNotice() async -> SettingsCommandOutcome { + guard case .pacInvalidation = pendingConfirmation else { + return record(.rejected(.noPendingConfirmation)) + } pendingConfirmation = nil - guard canSave else { return } - commit(makeSettings(from: draft)) + guard !isCommitting else { return record(.rejected(.inProgress)) } + guard fieldIssues.isEmpty else { + return record(.rejected(.validation(fieldIssues))) + } + guard blockingPortIDs.isEmpty else { + return record(.rejected(.occupied(blockingPortIDs))) + } + return await commit(makeSettings(from: draft)) } /// 取消挂起的 PAC 失效提示:草稿保留且不提交。 - func cancelPACNotice() { - guard case .pacInvalidation = pendingConfirmation else { return } + @discardableResult + func cancelPACNotice() async -> SettingsCommandOutcome { + guard case .pacInvalidation = pendingConfirmation else { + return record(.rejected(.noPendingConfirmation)) + } pendingConfirmation = nil + return record(.confirmationCancelled) } /// 重置偏好:seam 裁定恒需确认,挂起重置确认事实(摘要范围与重置事务 /// 一致)。登录启动项与快捷键意图是独立偏好域,不在此事务内。 - func reset() { - guard !isCommitting else { return } + @discardableResult + func reset() async -> SettingsCommandOutcome { + guard !isCommitting else { return record(.rejected(.inProgress)) } + guard pendingConfirmation == nil else { + return record(.rejected(.confirmationPending)) + } pendingConfirmation = .resetPreferences + return record(.confirmationRequired(.resetPreferences)) } /// 用户裁定重置后走重置提交入口:恢复出厂值并停止运行中的代理。 - func confirmReset() { - guard case .resetPreferences = pendingConfirmation else { return } + @discardableResult + func confirmReset() async -> SettingsCommandOutcome { + guard case .resetPreferences = pendingConfirmation else { + return record(.rejected(.noPendingConfirmation)) + } pendingConfirmation = nil - guard !isCommitting else { return } + guard !isCommitting else { return record(.rejected(.inProgress)) } isCommitting = true lastFailure = nil - Task { @MainActor in - do { - try await committing.resetPreferences() - adoptCommittedSettings() - } catch { - lastFailure = Self.failure(for: error) - } + do { + let runtime = try await committing.resetPreferences() + adoptCommittedSettings() isCommitting = false + return record(persistedOutcome(for: runtime)) + } catch { + isCommitting = false + let failure = Self.workflowFailure(for: error) + lastFailure = failure + return record(.persistenceFailed(Self.persistenceFailure(for: error))) } } /// 取消重置:不发生任何提交。 - func cancelReset() { - guard case .resetPreferences = pendingConfirmation else { return } + @discardableResult + func cancelReset() async -> SettingsCommandOutcome { + guard case .resetPreferences = pendingConfirmation else { + return record(.rejected(.noPendingConfirmation)) + } pendingConfirmation = nil + return record(.confirmationCancelled) } /// 为端口建议一个空闲端口:只把候选写进草稿对应字段,绝不替用户保存。 - func suggestFreePort(for id: SettingsPortID) { + @discardableResult + func suggestFreePort(for id: SettingsPortID) async -> SettingsCommandOutcome { + guard !isCommitting else { return record(.rejected(.inProgress)) } let listen = makeSettings(from: draft).listen + let facts = RuntimeListenFacts(listen: listen) let endpoint = SettingsDraftAdapter.endpoint(for: id) + let generation = occupancyGeneration let probe = occupancyProbe - Task { @MainActor in - let candidate = await Task.detached(priority: .utility) { - listen.suggestedPort(for: endpoint) { port in - if case .free = probe.occupancy(port: port, bindAddress: listen.bindAddress) { - return true - } - return false - } - }.value - guard let candidate else { return } - draft.setPortValue(candidate, for: id) + let candidate = await Task.detached(priority: .utility) { + listen.suggestedPort(for: endpoint) { port in + let request = PortOccupancyProbeRequest( + endpoint: endpoint, listen: facts.replacingPort(port, for: endpoint), port: port) + if case .free = probe.occupancy(for: request) { return true } + return false + } + }.value + guard generation == occupancyGeneration else { + return record(.rejected(.superseded)) } + guard let candidate else { + return record(.rejected(.noFreePort(id))) + } + draft.setPortValue(candidate, for: id) + return record(.draftUpdated(port: id, value: candidate)) } /// 回到已提交快照:放弃未保存修改;监听设置未变时仍刷新占用,避免陈旧提示。 - func reloadFromCommitted() { + @discardableResult + func reloadFromCommitted() async -> SettingsCommandOutcome { + guard !isCommitting else { return record(.rejected(.inProgress)) } adoptCommittedSettings() + return record(.reloaded) } // MARK: - 提交与占用探测(implementation,UI 不可见) - private var committedListen: SslocalListenSettings { - committing.committedSettings.listen - } - private func makeSettings(from draft: SettingsDraft) -> ProxySettings { SettingsDraftAdapter.settings(from: draft, preservingModeOf: committing.committedSettings) } - /// 端口是否正是当前运行中的代理在用的端口(保存其他设置不会触发冲突)。 - /// 例外只影响保存门禁与提示,不改持久化;HTTP 端点还需已提交侧启用入站。 + /// 例外只在当前 runtime 的完整有效监听身份与草稿身份相同时成立。 + /// 例外只影响保存门禁与提示,不改持久化。 private func isRuntimePortException(_ id: SettingsPortID) -> Bool { - guard committing.isProxyRunning else { return false } - let endpoint = SettingsDraftAdapter.endpoint(for: id) - guard committedListen.configuredPort(for: endpoint) == draft.portValue(for: id) else { - return false - } - return id != .http || committedListen.httpProxyEnabled + guard let runtime = committing.runtimeListenFacts else { return false } + guard runtime == listenFacts(of: draft) else { return false } + return id != .http || runtime.httpProxyEnabled } - private func listenFingerprint(of draft: SettingsDraft) -> ListenFingerprint { - ListenFingerprint(draft) + private func listenFacts(of draft: SettingsDraft) -> RuntimeListenFacts { + SettingsDraftAdapter.listenFacts(from: draft) } - private func commit(_ proposed: ProxySettings) { + private func commit(_ proposed: ProxySettings) async -> SettingsCommandOutcome { + guard !isCommitting else { return record(.rejected(.inProgress)) } isCommitting = true lastFailure = nil - Task { @MainActor in - do { - try await committing.updateSettings(proposed) - adoptCommittedSettings() - } catch { - lastFailure = Self.failure(for: error) - } + do { + let runtime = try await committing.updateSettings(proposed) + adoptCommittedSettings() isCommitting = false + return record(persistedOutcome(for: runtime)) + } catch { + isCommitting = false + let failure = Self.workflowFailure(for: error) + lastFailure = failure + return record(.persistenceFailed(Self.persistenceFailure(for: error))) } } + private func persistedOutcome(for runtime: SettingsRuntimeOutcome) -> SettingsCommandOutcome { + if case .failed(let failure) = runtime { + lastFailure = .runtime(failure) + } else { + lastFailure = nil + } + return .persisted(runtime: runtime) + } + /// 提交成功或回到已提交快照:草稿回到已提交快照;监听设置未变时 didSet /// 不会重探,需显式刷新。 private func adoptCommittedSettings() { let next = SettingsDraftAdapter.draft(from: committing.committedSettings) - let listenUnchanged = listenFingerprint(of: draft) == listenFingerprint(of: next) + let listenUnchanged = listenFacts(of: draft) == listenFacts(of: next) draft = next if listenUnchanged { refreshOccupancy() @@ -263,19 +305,15 @@ final class SettingsWorkflow: ObservableObject { private func refreshOccupancy() { occupancyGeneration += 1 let generation = occupancyGeneration - let listen = makeSettings(from: draft).listen + let listen = listenFacts(of: draft) let probe = occupancyProbe Task { @MainActor in let result = await Task.detached(priority: .utility) { Dictionary( uniqueKeysWithValues: SettingsPortID.allCases.map { id in - ( - id, - SettingsPortOccupancy( - probe.occupancy( - port: listen.configuredPort(for: SettingsDraftAdapter.endpoint(for: id)), - bindAddress: listen.bindAddress)) - ) + let endpoint = SettingsDraftAdapter.endpoint(for: id) + let request = PortOccupancyProbeRequest(endpoint: endpoint, listen: listen) + return (id, SettingsPortOccupancy(probe.occupancy(for: request))) }) }.value guard generation == occupancyGeneration else { return } @@ -283,7 +321,19 @@ final class SettingsWorkflow: ObservableObject { } } - private static func failure(for error: Error) -> SettingsWorkflowFailure { + private func record(_ outcome: SettingsCommandOutcome) -> SettingsCommandOutcome { + lastOutcome = outcome + return outcome + } + + private static func persistenceFailure(for error: Error) -> SettingsPersistenceFailure { + if let error = error as? ProxySettingsStoreError { + return .store(error) + } + return .unknown + } + + private static func workflowFailure(for error: Error) -> SettingsWorkflowFailure { if let error = error as? ProxySettingsStoreError { return .store(error) } diff --git a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift index 568536af..fd8e8f7a 100644 --- a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift @@ -116,9 +116,50 @@ enum SettingsConfirmation: Equatable, Sendable { case resetPreferences } +/// Typed reasons why a command cannot proceed. The workflow exposes facts, +/// not localized strings or a Boolean that makes validation and occupancy +/// failures indistinguishable. +enum SettingsCommandRejection: Equatable, Sendable { + case inProgress + case confirmationPending + case superseded + case validation([SettingsFieldIssue]) + case occupied([SettingsPortID]) + case noPendingConfirmation + case noFreePort(SettingsPortID) +} + +/// Runtime convergence is deliberately separate from settings persistence. +/// A persisted snapshot can therefore be retained even when the runtime has +/// an independent, safely projected failure. +enum SettingsRuntimeOutcome: Equatable, Sendable { + case notRunning + case converged + case stopped + case failed(RuntimeFailureFacts?) +} + +enum SettingsPersistenceFailure: Error, Equatable, Sendable { + case store(ProxySettingsStoreError) + case unknown +} + +/// Result returned by every discrete SettingsWorkflow command. Confirmation, +/// draft-only changes, persistence and runtime facts remain distinguishable. +enum SettingsCommandOutcome: Equatable, Sendable { + case rejected(SettingsCommandRejection) + case confirmationRequired(SettingsConfirmation) + case confirmationCancelled + case draftUpdated(port: SettingsPortID, value: Int) + case reloaded + case persisted(runtime: SettingsRuntimeOutcome) + case persistenceFailed(SettingsPersistenceFailure) +} + /// Settings workflow 的最后失败仍是 typed;workflow 不提前渲染句子。 enum SettingsWorkflowFailure: Error, Equatable, Sendable { case store(ProxySettingsStoreError) case mode(ProxyModeError) + case runtime(RuntimeFailureFacts?) case unknown } diff --git a/ShadowsocksX-NG2/Domain/CredentialWriteJournal.swift b/ShadowsocksX-NG2/Domain/CredentialWriteJournal.swift index 6fbcf794..011ff8d4 100644 --- a/ShadowsocksX-NG2/Domain/CredentialWriteJournal.swift +++ b/ShadowsocksX-NG2/Domain/CredentialWriteJournal.swift @@ -40,10 +40,16 @@ struct CredentialWriteJournal { /// 尽力删除(幂等);回滚时恢复改动前的原值或「不存在」。 mutating func delete(_ reference: CredentialReference) { + try? deleteOrThrow(reference) + } + + /// 事务调用方需要知道删除是否成功;保留 `delete` 的既有尽力而为语义 + /// 给目录工作流使用。 + mutating func deleteOrThrow(_ reference: CredentialReference) throws { if originals[reference] == nil { - originals[reference] = OriginalValue(secret: (try? credentials.secret(for: reference)) ?? nil) + originals[reference] = OriginalValue(secret: try credentials.secret(for: reference)) } - try? credentials.delete(reference) + try credentials.delete(reference) } /// 恢复全部触碰过的引用到改动前的值(尽力),返回逐引用结果。 diff --git a/ShadowsocksX-NG2/Domain/PortOccupancyProbe.swift b/ShadowsocksX-NG2/Domain/PortOccupancyProbe.swift index 13c8df87..b383f97a 100644 --- a/ShadowsocksX-NG2/Domain/PortOccupancyProbe.swift +++ b/ShadowsocksX-NG2/Domain/PortOccupancyProbe.swift @@ -10,16 +10,82 @@ enum PortOccupancy: Equatable, Sendable { case unknown(detail: String) } +extension RuntimeListenFacts { + func port(for endpoint: ProxyEndpointKind) -> Int { + switch endpoint { + case .socks: socksPort + case .http: httpPort + case .pac: pacPort + } + } + + func replacingPort(_ port: Int, for endpoint: ProxyEndpointKind) -> RuntimeListenFacts { + switch endpoint { + case .socks: + return RuntimeListenFacts( + scope: scope, + socksPort: port, + httpProxyEnabled: httpProxyEnabled, + httpPort: httpPort, + pacPort: pacPort, + udpRelayEnabled: udpRelayEnabled) + case .http: + return RuntimeListenFacts( + scope: scope, + socksPort: socksPort, + httpProxyEnabled: httpProxyEnabled, + httpPort: port, + pacPort: pacPort, + udpRelayEnabled: udpRelayEnabled) + case .pac: + return RuntimeListenFacts( + scope: scope, + socksPort: socksPort, + httpProxyEnabled: httpProxyEnabled, + httpPort: httpPort, + pacPort: port, + udpRelayEnabled: udpRelayEnabled) + } + } +} + +/// A port probe request carries the complete effective listener identity. The +/// requested port may differ from `listen.port(for:)` only for a suggested +/// candidate; the rest of the listener facts remain part of the request. +struct PortOccupancyProbeRequest: Equatable, Sendable { + let endpoint: ProxyEndpointKind + let listen: RuntimeListenFacts + let port: Int + + init( + endpoint: ProxyEndpointKind, + listen: RuntimeListenFacts, + port: Int? = nil + ) { + self.endpoint = endpoint + self.listen = listen + self.port = port ?? listen.port(for: endpoint) + } + + var bindAddress: String { listen.bindAddress } +} + /// 端口占用探测缝(issue #30):设置编辑期的即时占用校验。激活是否成功 /// 仍以 runtime 实际绑定为准(#29 健康门禁),本探测不构成权威判定。 protocol PortOccupancyProbing: Sendable { - func occupancy(port: Int, bindAddress: String) -> PortOccupancy + func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy } /// 系统实现:对 `bindAddress:port` 做一次不带复用选项的 TCP bind——能绑定 /// 即空闲,`EADDRINUSE` 即占用;占用时尽力以 `lsof` 解析监听进程名。探测 /// 不设置 SO_REUSEADDR/SO_REUSEPORT,避免 Legacy 式复用残留的假空闲。 struct SystemPortOccupancyProbe: PortOccupancyProbing { + func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy { + occupancy(port: request.port, bindAddress: request.bindAddress) + } + + /// Compatibility helper for the lower-level probe tests. SettingsWorkflow + /// always uses `occupancy(for:)` so production requests retain full facts. func occupancy(port: Int, bindAddress: String) -> PortOccupancy { var address = sockaddr_in() address.sin_len = UInt8(MemoryLayout.size) diff --git a/ShadowsocksX-NG2/Domain/ProxySettings.swift b/ShadowsocksX-NG2/Domain/ProxySettings.swift index e88548b8..3aed5161 100644 --- a/ShadowsocksX-NG2/Domain/ProxySettings.swift +++ b/ShadowsocksX-NG2/Domain/ProxySettings.swift @@ -111,6 +111,7 @@ enum ProxySettingsStoreError: Error, Equatable { case ioFailure(detail: String) case missingCredential(CredentialReference) case credentialFailure(detail: String) + case rollbackFailed case legacyListenSettings(ListenSettingsStoreError) } @@ -175,34 +176,53 @@ struct ProxySettingsFileStore: ProxySettingsStoring { } func save(_ settings: ProxySettings) throws { - try validated(settings) - do { - try saveOrDelete(settings.gfwListURL, reference: Self.gfwListReference) - } catch let error as ProxySettingsStoreError { - throw error - } catch { - throw ProxySettingsStoreError.credentialFailure(detail: String(describing: error)) - } - + _ = try validated(settings) + var journal = CredentialWriteJournal(credentials: credentials) do { + if settings.gfwListURL.isEmpty { + try journal.deleteOrThrow(Self.gfwListReference) + } else { + try journal.save(settings.gfwListURL, for: Self.gfwListReference) + } let data = try Self.jsonEncoder.encode(record(from: settings)) try AtomicFileWriter.write(data, to: fileURL) + } catch let error as ProxySettingsStoreError { + throw transactionalFailure(error, journal: journal) } catch let error as AtomicFileWriter.WriteError { - throw ProxySettingsStoreError.ioFailure(detail: String(describing: error)) + throw transactionalFailure( + .ioFailure(detail: String(describing: error)), journal: journal) + } catch let error as CredentialStoreError { + throw transactionalFailure( + .credentialFailure(detail: String(describing: error)), journal: journal) } catch { - throw ProxySettingsStoreError.ioFailure(detail: String(describing: error)) + throw transactionalFailure( + .ioFailure(detail: String(describing: error)), journal: journal) } } func reset() throws { + let settingsSnapshot = try fileSnapshot(at: fileURL) + let legacySnapshot = try fileSnapshot(at: legacyListenFileURL) + var journal = CredentialWriteJournal(credentials: credentials) do { - try credentials.delete(Self.gfwListReference) - for url in [fileURL, legacyListenFileURL] - where FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.removeItem(at: url) - } + try journal.deleteOrThrow(Self.gfwListReference) + try removeIfPresent(fileURL) + try removeIfPresent(legacyListenFileURL) } catch { - throw ProxySettingsStoreError.ioFailure(detail: String(describing: error)) + let filesRestored = + restore(settingsSnapshot, at: fileURL) + && restore(legacySnapshot, at: legacyListenFileURL) + let credentialsRestored: Bool + switch journal.rollback() { + case .partial: + credentialsRestored = false + case .nothingToRestore, .restored: + credentialsRestored = true + } + guard filesRestored && credentialsRestored else { + throw ProxySettingsStoreError.rollbackFailed + } + throw asStoreError(error) } } @@ -227,18 +247,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring { return settings } - private func saveOrDelete(_ secret: String, reference: CredentialReference) throws { - do { - if secret.isEmpty { - try credentials.delete(reference) - } else { - try credentials.save(secret, for: reference) - } - } catch { - throw ProxySettingsStoreError.credentialFailure(detail: String(describing: error)) - } - } - private func requiredSecret(for reference: CredentialReference) throws -> String { do { guard let secret = try credentials.secret(for: reference) else { @@ -314,6 +322,60 @@ struct ProxySettingsFileStore: ProxySettingsStoring { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return encoder }() + + private func transactionalFailure( + _ error: ProxySettingsStoreError, + journal: CredentialWriteJournal + ) -> ProxySettingsStoreError { + if case .partial = journal.rollback() { + return .rollbackFailed + } + return error + } + + private func asStoreError(_ error: Error) -> ProxySettingsStoreError { + if let error = error as? ProxySettingsStoreError { return error } + if let error = error as? CredentialStoreError { + return .credentialFailure(detail: String(describing: error)) + } + return .ioFailure(detail: String(describing: error)) + } + + private func fileSnapshot(at url: URL) throws -> Data? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + do { + return try Data(contentsOf: url) + } catch { + throw ProxySettingsStoreError.ioFailure(detail: String(describing: error)) + } + } + + private func removeIfPresent(_ url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + do { + try FileManager.default.removeItem(at: url) + } catch { + throw ProxySettingsStoreError.ioFailure(detail: String(describing: error)) + } + } + + private func restore(_ snapshot: Data?, at url: URL) -> Bool { + guard let snapshot else { + guard FileManager.default.fileExists(atPath: url.path) else { return true } + do { + try FileManager.default.removeItem(at: url) + return true + } catch { + return false + } + } + do { + try AtomicFileWriter.write(snapshot, to: url) + return true + } catch { + return false + } + } } private struct ProxySettingsRecord: Codable, Equatable, Sendable { diff --git a/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift b/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift index a6668cc6..e2154ddf 100644 --- a/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift +++ b/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift @@ -272,6 +272,67 @@ struct SslocalListenSettings: Equatable, Sendable { } } +/// Typed effective listener facts exposed by the runtime boundary. This is the +/// complete identity of the listeners that are actually intended to be bound: +/// scope carries both bind and advertised addresses, while the endpoint flags +/// and ports prevent a same-port comparison from masquerading as a match. +struct RuntimeListenFacts: Equatable, Sendable { + let scope: ListenScope + let socksPort: Int + let httpProxyEnabled: Bool + let httpPort: Int + let pacPort: Int + let udpRelayEnabled: Bool + + init(listen: SslocalListenSettings) { + self.init( + scope: listen.scope, + socksPort: listen.socksPort, + httpProxyEnabled: listen.httpProxyEnabled, + httpPort: listen.httpPort, + pacPort: listen.pacPort, + udpRelayEnabled: listen.udpRelayEnabled) + } + + init( + scope: ListenScope, + socksPort: Int, + httpProxyEnabled: Bool, + httpPort: Int, + pacPort: Int, + udpRelayEnabled: Bool + ) { + self.scope = scope + self.socksPort = socksPort + self.httpProxyEnabled = httpProxyEnabled + self.httpPort = httpPort + self.pacPort = pacPort + self.udpRelayEnabled = udpRelayEnabled + } + + init(document: SslocalRuntimeDocument) { + let scope: ListenScope + switch document.pac.listenScope { + case .loopback: + scope = .loopback + case .host: + scope = .host(advertisedAddress: document.pac.advertisedAddress) + } + let socks = document.locals.first { $0.inboundProtocol == "socks" } + let http = document.locals.first { $0.inboundProtocol == "http" } + self.init( + scope: scope, + socksPort: socks?.localPort ?? document.pac.socksPort, + httpProxyEnabled: http != nil, + httpPort: http?.localPort ?? SslocalListenSettings.defaultHTTPPort, + pacPort: document.pac.port, + udpRelayEnabled: socks?.mode == "tcp_and_udp") + } + + var bindAddress: String { scope.bindAddress } + var advertisedAddress: String { scope.advertisedAddress } +} + /// 监听指纹(spec #21 D5/D7):服务器列表变化可热重载;任一本地入站或 PAC /// endpoint 变化都必须由 wrapper 走优雅重启。 struct SslocalListenFingerprint: Equatable, Sendable { diff --git a/ShadowsocksX-NG2/Tests/ProxySettingsFileStoreTests.swift b/ShadowsocksX-NG2/Tests/ProxySettingsFileStoreTests.swift index 7814d1c9..d47aee22 100644 --- a/ShadowsocksX-NG2/Tests/ProxySettingsFileStoreTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxySettingsFileStoreTests.swift @@ -31,6 +31,18 @@ final class ProxySettingsFileStoreTests: XCTestCase { try Data(text.utf8).write(to: store.fileURL) } + private func makeWriteFailingStore(credentials: CredentialStoring) throws + -> ProxySettingsFileStore + { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let blockedParent = directory.appendingPathComponent("blocked") + try Data("not a directory".utf8).write(to: blockedParent) + return ProxySettingsFileStore( + fileURL: blockedParent.appendingPathComponent("settings.json"), + legacyListenFileURL: directory.appendingPathComponent("listen-settings.json"), + credentials: credentials) + } + func testRoundTripKeepsPreferencesButDoesNotWriteRemoteURLsToSettingsFile() throws { var settings = ProxySettings() settings.listen.scope = .host(advertisedAddress: "192.168.2.89") @@ -74,6 +86,15 @@ final class ProxySettingsFileStoreTests: XCTestCase { XCTAssertEqual(try store.load(), ProxySettings()) } + func testUnknownEnabledModesIsIgnoredAndNeverWrittenBack() throws { + try writeRaw("{\"enabledModes\":[\"global\"],\"preferredMode\":\"pac\"}") + + XCTAssertEqual(try store.load().preferredMode, .pac) + try store.save(ProxySettings()) + let raw = try String(contentsOf: store.fileURL, encoding: .utf8) + XCTAssertFalse(raw.contains("enabledModes")) + } + func testInvalidSaveDoesNotTouchExistingSettings() throws { try store.save(ProxySettings()) var invalid = ProxySettings() @@ -87,6 +108,34 @@ final class ProxySettingsFileStoreTests: XCTestCase { XCTAssertEqual(try store.load(), ProxySettings()) } + func testSaveFailureRollsBackTheCredentialWhenTheSettingsDocumentCannotBeWritten() throws { + store = try makeWriteFailingStore(credentials: credentials) + var next = ProxySettings() + next.gfwListURL = "https://lists.example.test/new.txt" + + XCTAssertThrowsError(try store.save(next)) { error in + guard case .ioFailure = error as? ProxySettingsStoreError else { + return XCTFail("文件写入失败应保留为 typed io failure,实际为 \(error)") + } + } + XCTAssertNil(try credentials.secret(for: ProxySettingsFileStore.gfwListReference)) + } + + func testPartialCredentialRollbackIsTypedAndNeverLooksLikeSuccess() throws { + let failingCredentials = SelectiveCredentialStore() + store = try makeWriteFailingStore(credentials: failingCredentials) + failingCredentials.failDelete = true + var next = ProxySettings() + next.gfwListURL = "https://lists.example.test/new.txt" + + XCTAssertThrowsError(try store.save(next)) { error in + XCTAssertEqual(error as? ProxySettingsStoreError, .rollbackFailed) + } + XCTAssertEqual( + try failingCredentials.secret(for: ProxySettingsFileStore.gfwListReference), + next.gfwListURL) + } + func testMissingNewFileMigratesLegacyListenSettingsAndUsesNewDefaults() throws { var legacy = SslocalListenSettings() legacy.socksPort = 2086 @@ -111,3 +160,23 @@ final class ProxySettingsFileStoreTests: XCTestCase { XCTAssertEqual(try store.load(), ProxySettings()) } } + +private final class SelectiveCredentialStore: CredentialStoring { + private var values: [CredentialReference: String] = [:] + var failDelete = false + + func save(_ secret: String, for reference: CredentialReference) throws { + values[reference] = secret + } + + func secret(for reference: CredentialReference) throws -> String? { + values[reference] + } + + func delete(_ reference: CredentialReference) throws { + if failDelete { + throw CredentialStoreError.keychainStatus(-25300) + } + values.removeValue(forKey: reference) + } +} diff --git a/ShadowsocksX-NG2/Tests/SettingsWorkflowInterfaceTests.swift b/ShadowsocksX-NG2/Tests/SettingsWorkflowInterfaceTests.swift index 942d5f50..87d7af0d 100644 --- a/ShadowsocksX-NG2/Tests/SettingsWorkflowInterfaceTests.swift +++ b/ShadowsocksX-NG2/Tests/SettingsWorkflowInterfaceTests.swift @@ -88,7 +88,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { func testPortFieldStateReportsFreeOccupiedAndUnknownFacts() async throws { probe = FakeOccupancyProbe(occupiedPorts: [11087], unknownPorts: [11089]) let workflow = makeWorkflow() - workflow.reloadFromCommitted() + _ = await workflow.reloadFromCommitted() await waitUntil(workflow.portFieldState(for: .pac).occupancy != nil) XCTAssertEqual(workflow.portFieldState(for: .socks).occupancy, .free) @@ -97,10 +97,28 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { XCTAssertEqual(workflow.portFieldState(for: .socks).draftValue, 11086) } + func testOccupancyProbeReceivesTheCompleteEffectiveListenIdentity() async throws { + let workflow = makeWorkflow() + workflow.draft.isHostScope = true + workflow.draft.advertisedAddress = "192.168.2.89" + let expected = RuntimeListenFacts( + scope: .host(advertisedAddress: "192.168.2.89"), + socksPort: 11086, + httpProxyEnabled: true, + httpPort: 11087, + pacPort: 11089, + udpRelayEnabled: false) + + await waitUntil(probe.requests.contains { $0.listen == expected }) + + XCTAssertEqual(probe.requests.first(where: { $0.listen == expected })?.endpoint, .socks) + XCTAssertEqual(probe.requests.first(where: { $0.listen == expected })?.bindAddress, "0.0.0.0") + } + func testOccupiedPortCarriesOccupierFact() async throws { probe = FakeOccupancyProbe(occupiedPorts: [11086]) let workflow = makeWorkflow() - workflow.reloadFromCommitted() + _ = await workflow.reloadFromCommitted() await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) XCTAssertEqual(workflow.portFieldState(for: .socks).occupancy, .occupied(occupier: "other-app")) @@ -124,7 +142,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { probe = FakeOccupancyProbe(occupiedPorts: [11086]) committing.isProxyRunning = true let workflow = makeWorkflow() - workflow.reloadFromCommitted() + _ = await workflow.reloadFromCommitted() await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) XCTAssertTrue(workflow.portFieldState(for: .socks).isRuntimePortException) @@ -136,7 +154,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { probe = FakeOccupancyProbe(occupiedPorts: [11086]) committing.isProxyRunning = false let workflow = makeWorkflow() - workflow.reloadFromCommitted() + _ = await workflow.reloadFromCommitted() await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) XCTAssertFalse(workflow.portFieldState(for: .socks).isRuntimePortException) @@ -163,18 +181,45 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { committing.committedSettings = committed let workflow = makeWorkflow() - workflow.reloadFromCommitted() + _ = await workflow.reloadFromCommitted() await waitUntil(workflow.portFieldState(for: .http).occupancy != nil) XCTAssertFalse(workflow.portFieldState(for: .http).isRuntimePortException) } + func testRuntimePortExceptionRequiresTheCompleteListenIdentity() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [11086]) + committing.isProxyRunning = true + let workflow = makeWorkflow() + _ = await workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) + XCTAssertTrue(workflow.portFieldState(for: .socks).isRuntimePortException) + + committing.runtimeListenFacts = RuntimeListenFacts( + scope: .host(advertisedAddress: "192.168.2.89"), + socksPort: 11086, + httpProxyEnabled: true, + httpPort: 11087, + pacPort: 11089, + udpRelayEnabled: false) + _ = await workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) + + XCTAssertFalse( + workflow.portFieldState(for: .socks).isRuntimePortException, + "同端口但 bind/public 地址不同不能作为当前 runtime 例外") + XCTAssertTrue(workflow.hasBlockingPortOccupancy) + } +} + +extension SettingsWorkflowInterfaceTests { + // MARK: - 建议空闲端口 func testSuggestFreePortOnlyWritesThatPortFieldIntoDraft() async throws { let workflow = makeWorkflow() - workflow.suggestFreePort(for: .socks) + _ = await workflow.suggestFreePort(for: .socks) await waitUntil(workflow.draft.socksPort != 11086) XCTAssertEqual(workflow.draft.socksPort, 32768) @@ -192,7 +237,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { XCTAssertFalse(workflow.canSave) - workflow.save() + _ = await workflow.save() await waitUntil(!workflow.isCommitting && workflow.pendingConfirmation == nil) XCTAssertTrue(committing.updateCalls.isEmpty) } @@ -206,7 +251,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { XCTAssertTrue(workflow.hasBlockingPortOccupancy) XCTAssertFalse(workflow.canSave) - workflow.save() + _ = await workflow.save() XCTAssertTrue(committing.updateCalls.isEmpty) } @@ -215,12 +260,55 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { workflow.draft.timeoutSeconds = 120 XCTAssertTrue(workflow.canSave) - workflow.save() - await waitUntil(!committing.updateCalls.isEmpty) + let outcome = await workflow.save() + XCTAssertEqual(outcome, .persisted(runtime: .notRunning)) XCTAssertEqual(committing.updateCalls.first?.timeoutSeconds, 120) } + func testSaveReturnsAValidationRejectionWithoutStartingACommit() async throws { + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 0 + + let outcome = await workflow.save() + + XCTAssertEqual( + outcome, + .rejected(.validation([.timeoutSeconds(error: .invalidTimeout(0))]))) + XCTAssertFalse(workflow.isCommitting) + XCTAssertTrue(committing.updateCalls.isEmpty) + } + + func testSaveReportsPersistedSettingsAndIndependentRuntimeFailure() async throws { + let runtimeFailure = RuntimeFailureFacts.service(.persistence) + committing.updateOutcome = .failed(runtimeFailure) + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 120 + + let outcome = await workflow.save() + + XCTAssertEqual(outcome, .persisted(runtime: .failed(runtimeFailure))) + XCTAssertEqual(workflow.lastFailure, .runtime(runtimeFailure)) + XCTAssertEqual(committing.committedSettings.timeoutSeconds, 120) + } + + func testRepeatedSaveReturnsAnInProgressRejection() async throws { + committing.updateGate = AsyncGate() + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 120 + + let first = Task { await workflow.save() } + await Task.yield() + await waitUntil(workflow.isCommitting) + let second = await workflow.save() + committing.updateGate?.release() + let firstOutcome = await first.value + + XCTAssertEqual(second, .rejected(.inProgress)) + XCTAssertEqual(firstOutcome, .persisted(runtime: .notRunning)) + XCTAssertEqual(committing.updateCalls.count, 1) + } + // MARK: - 脏态 func testDirtyStateTracksDraftAgainstCommittedSnapshot() async throws { @@ -230,11 +318,11 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { workflow.draft.timeoutSeconds = 120 XCTAssertTrue(workflow.isDirty) - workflow.reloadFromCommitted() + _ = await workflow.reloadFromCommitted() XCTAssertFalse(workflow.isDirty) workflow.draft.timeoutSeconds = 120 - workflow.save() + _ = await workflow.save() await waitUntil(!workflow.isCommitting) XCTAssertFalse(workflow.isDirty, "提交成功后草稿回到已提交快照") XCTAssertEqual(workflow.draft.timeoutSeconds, 120) @@ -259,7 +347,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { let workflow = makeWorkflow() workflow.draft.timeoutSeconds = 120 - workflow.save() + _ = await workflow.save() await waitUntil(!workflow.isCommitting) XCTAssertEqual(committing.updateCalls.first?.preferredMode, .global, "草稿不编辑当前模式") @@ -271,7 +359,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { let workflow = makeWorkflow() workflow.draft.pacPort = 13089 - workflow.save() + _ = await workflow.save() guard case .pacInvalidation(_, let nextPort)? = workflow.pendingConfirmation else { return XCTFail("应挂起 PAC 失效确认,实际 \(String(describing: workflow.pendingConfirmation))") @@ -282,7 +370,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { .contains("13089")) XCTAssertTrue(committing.updateCalls.isEmpty, "未确认失效提示不得提交") - workflow.confirmPACNotice() + _ = await workflow.confirmPACNotice() await waitUntil(!workflow.isCommitting) XCTAssertNil(workflow.pendingConfirmation) XCTAssertEqual(committing.updateCalls.first?.listen.pacPort, 13089) @@ -292,8 +380,8 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { let workflow = makeWorkflow() workflow.draft.pacPort = 13089 - workflow.save() - workflow.cancelPACNotice() + _ = await workflow.save() + _ = await workflow.cancelPACNotice() XCTAssertNil(workflow.pendingConfirmation) XCTAssertTrue(committing.updateCalls.isEmpty) @@ -304,10 +392,10 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { // MARK: - 重置确认 - func testResetArmsConfirmationWithScopeSummaryFromSeam() { + func testResetArmsConfirmationWithScopeSummaryFromSeam() async throws { let workflow = makeWorkflow() - workflow.reset() + _ = await workflow.reset() guard case .resetPreferences? = workflow.pendingConfirmation else { return XCTFail("应挂起重置确认,实际 \(String(describing: workflow.pendingConfirmation))") @@ -326,8 +414,8 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { let workflow = makeWorkflow() workflow.draft.timeoutSeconds = 120 - workflow.reset() - workflow.confirmReset() + _ = await workflow.reset() + _ = await workflow.confirmReset() await waitUntil(!workflow.isCommitting) XCTAssertEqual(committing.resetCallCount, 1) @@ -336,11 +424,24 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { XCTAssertEqual(workflow.draft, SettingsDraftAdapter.draft(from: ProxySettings())) } - func testCancelingResetDoesNotCommit() { + func testResetReturnsPersistedDefaultsAndIndependentRuntimeFailure() async throws { + let runtimeFailure = RuntimeFailureFacts.systemProxy(.ownershipConflict) + committing.resetOutcome = .failed(runtimeFailure) let workflow = makeWorkflow() - workflow.reset() - workflow.cancelReset() + _ = await workflow.reset() + let outcome = await workflow.confirmReset() + + XCTAssertEqual(outcome, .persisted(runtime: .failed(runtimeFailure))) + XCTAssertEqual(workflow.lastFailure, .runtime(runtimeFailure)) + XCTAssertEqual(workflow.draft, SettingsDraftAdapter.draft(from: ProxySettings())) + } + + func testCancelingResetDoesNotCommit() async throws { + let workflow = makeWorkflow() + + _ = await workflow.reset() + _ = await workflow.cancelReset() XCTAssertNil(workflow.pendingConfirmation) XCTAssertEqual(committing.resetCallCount, 0) @@ -354,7 +455,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { let workflow = makeWorkflow() workflow.draft.timeoutSeconds = 120 - workflow.save() + _ = await workflow.save() await waitUntil(workflow.lastFailure != nil) XCTAssertEqual(workflow.lastFailure, .unknown) @@ -370,7 +471,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { workflow.draft.timeoutSeconds = 120 committing.updateError = ProxySettingsStoreError.ioFailure(detail: "disk full") - workflow.save() + _ = await workflow.save() await waitUntil(!workflow.isCommitting) XCTAssertEqual( workflow.lastFailure, @@ -379,7 +480,7 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { committing.updateError = ProxySettingsStoreError.missingCredential( ProxySettingsFileStore.gfwListReference) - workflow.save() + _ = await workflow.save() await waitUntil( workflow.lastFailure == .store( @@ -390,15 +491,20 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { } func testCommittingStateBlocksRepeatedSave() async throws { + committing.updateGate = AsyncGate() let workflow = makeWorkflow() workflow.draft.timeoutSeconds = 120 - workflow.save() + let first = Task { await workflow.save() } + await Task.yield() + await waitUntil(workflow.isCommitting) XCTAssertTrue(workflow.isCommitting) XCTAssertFalse(workflow.canSave, "提交中保存门禁关闭") - workflow.save() - await waitUntil(!workflow.isCommitting) + let second = await workflow.save() + committing.updateGate?.release() + _ = await first.value + XCTAssertEqual(second, .rejected(.inProgress)) XCTAssertEqual(committing.updateCalls.count, 1, "提交中不得重复触发") } @@ -406,8 +512,8 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { committing.resetError = FakeCommitError.io let workflow = makeWorkflow() - workflow.reset() - workflow.confirmReset() + _ = await workflow.reset() + _ = await workflow.confirmReset() await waitUntil(workflow.lastFailure != nil) XCTAssertEqual(workflow.lastFailure, .unknown) @@ -435,11 +541,11 @@ final class SettingsWorkflowInterfaceTests: XCTestCase { func testReloadWithUnlistenChangesStillRefreshesOccupancy() async throws { probe = FakeOccupancyProbe() let workflow = makeWorkflow() - workflow.reloadFromCommitted() + _ = await workflow.reloadFromCommitted() await waitUntil(workflow.portFieldState(for: .socks).occupancy == .free) probe.setAnswer(.occupied(occupier: "later")) - workflow.reloadFromCommitted() + _ = await workflow.reloadFromCommitted() await waitUntil(workflow.portFieldState(for: .socks).occupancy != .free) XCTAssertEqual( diff --git a/ShadowsocksX-NG2/Tests/SettingsWorkflowTestSupport.swift b/ShadowsocksX-NG2/Tests/SettingsWorkflowTestSupport.swift index 1786d320..020ff5b0 100644 --- a/ShadowsocksX-NG2/Tests/SettingsWorkflowTestSupport.swift +++ b/ShadowsocksX-NG2/Tests/SettingsWorkflowTestSupport.swift @@ -22,22 +22,64 @@ func waitUntil( @MainActor final class FakeSettingsCommitter: SettingsCommitting { var committedSettings: ProxySettings = ProxySettings() - var isProxyRunning = false + var runtimeListenFacts: RuntimeListenFacts? var updateError: Error? var resetError: Error? + var updateOutcome: SettingsRuntimeOutcome = .notRunning + var resetOutcome: SettingsRuntimeOutcome = .notRunning + var updateGate: AsyncGate? private(set) var updateCalls: [ProxySettings] = [] private(set) var resetCallCount = 0 - func updateSettings(_ proposed: ProxySettings) async throws { - if let updateError { throw updateError } - updateCalls.append(proposed) - committedSettings = proposed + var isProxyRunning: Bool { + get { runtimeListenFacts != nil } + set { + runtimeListenFacts = newValue ? RuntimeListenFacts(listen: committedSettings.listen) : nil + } } - func resetPreferences() async throws { + func updateSettings(_ proposed: ProxySettings) async throws -> SettingsRuntimeOutcome { + if let updateError { throw updateError } + if let updateGate { await updateGate.wait() } + updateCalls.append(proposed) + committedSettings = proposed + return updateOutcome + } + + func resetPreferences() async throws -> SettingsRuntimeOutcome { if let resetError { throw resetError } resetCallCount += 1 committedSettings = ProxySettings() + return resetOutcome + } +} + +/// 可重复使用的异步门闩,用于证明 workflow 在提交等待期间拒绝第二次命令。 +final class AsyncGate: @unchecked Sendable { + private let lock = NSLock() + private var released = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + lock.lock() + if released { + lock.unlock() + continuation.resume() + } else { + waiters.append(continuation) + lock.unlock() + } + } + } + + func release() { + lock.lock() + released = true + let waiters = self.waiters + self.waiters.removeAll() + lock.unlock() + waiters.forEach { $0.resume() } } } @@ -47,6 +89,7 @@ final class FakeOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { private let occupiedPorts: Set private let unknownPorts: Set private var requestedPorts: [Int] = [] + private var requestedRequests: [PortOccupancyProbeRequest] = [] private var answerOverride: PortOccupancy? init(occupiedPorts: Set = [], unknownPorts: Set = []) { @@ -66,6 +109,12 @@ final class FakeOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { return requestedPorts.count } + var requests: [PortOccupancyProbeRequest] { + lock.lock() + defer { lock.unlock() } + return requestedRequests + } + /// 覆盖全部端口的应答(占用代际测试用)。 func setAnswer(_ answer: PortOccupancy?) { lock.lock() @@ -73,15 +122,16 @@ final class FakeOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { answerOverride = answer } - func occupancy(port: Int, bindAddress: String) -> PortOccupancy { + func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy { lock.lock() defer { lock.unlock() } - requestedPorts.append(port) + requestedPorts.append(request.port) + requestedRequests.append(request) if let answerOverride { return answerOverride } - if unknownPorts.contains(port) { + if unknownPorts.contains(request.port) { return .unknown(detail: "无法判定") } - return occupiedPorts.contains(port) ? .occupied(occupier: "other-app") : .free + return occupiedPorts.contains(request.port) ? .occupied(occupier: "other-app") : .free } } @@ -128,7 +178,7 @@ final class GatedOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { } } - func occupancy(port: Int, bindAddress: String) -> PortOccupancy { + func occupancy(for request: PortOccupancyProbeRequest) -> PortOccupancy { lock.lock() enteredCount += 1 let gated = isGated diff --git a/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift b/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift index 914290c9..f41bdd8c 100644 --- a/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift @@ -37,8 +37,7 @@ final class SettingsWorkflowTests: XCTestCase { let pair = makePair() pair.workflow.draft.timeoutSeconds = 120 - pair.workflow.save() - await waitUntil(!pair.workflow.isCommitting) + _ = await pair.workflow.save() XCTAssertEqual(settingsStore.saved?.timeoutSeconds, 120) XCTAssertEqual(pair.controller.settings.timeoutSeconds, 120) @@ -51,7 +50,7 @@ final class SettingsWorkflowTests: XCTestCase { probe = FakeOccupancyProbe(occupiedPorts: [11086]) let pair = try await makeRunningPair() - pair.workflow.reloadFromCommitted() + _ = await pair.workflow.reloadFromCommitted() await waitUntil(pair.workflow.portFieldState(for: .socks).occupancy != nil) XCTAssertEqual( @@ -63,8 +62,7 @@ final class SettingsWorkflowTests: XCTestCase { XCTAssertTrue(pair.workflow.canSave) pair.workflow.draft.timeoutSeconds = 120 - pair.workflow.save() - await waitUntil(settingsStore.saved != nil) + _ = await pair.workflow.save() XCTAssertEqual(settingsStore.saved?.timeoutSeconds, 120) } @@ -72,12 +70,11 @@ final class SettingsWorkflowTests: XCTestCase { let pair = makePair() pair.workflow.draft.pacPort = 13089 - pair.workflow.save() + _ = await pair.workflow.save() XCTAssertNotNil(pair.workflow.pendingConfirmation) XCTAssertNil(settingsStore.saved, "未确认失效提示不得提交") - pair.workflow.confirmPACNotice() - await waitUntil(settingsStore.saved != nil) + _ = await pair.workflow.confirmPACNotice() XCTAssertNil(pair.workflow.pendingConfirmation) XCTAssertEqual(pair.controller.settings.listen.pacPort, 13089) } @@ -87,8 +84,7 @@ final class SettingsWorkflowTests: XCTestCase { let pair = makePair() pair.workflow.draft.timeoutSeconds = 120 - pair.workflow.save() - await waitUntil(pair.workflow.lastFailure != nil) + _ = await pair.workflow.save() XCTAssertEqual(pair.workflow.lastFailure, .unknown) XCTAssertEqual(pair.workflow.lastFailure?.presentableMessage, AppPresentation.unknownError) @@ -101,13 +97,12 @@ final class SettingsWorkflowTests: XCTestCase { let pair = makePair() var custom = pair.controller.settings custom.timeoutSeconds = 120 - try await pair.controller.updateSettings(custom) + _ = try await pair.controller.updateSettings(custom) XCTAssertEqual(pair.controller.settings.timeoutSeconds, 120) - pair.workflow.reset() + _ = await pair.workflow.reset() XCTAssertNotNil(pair.workflow.pendingConfirmation, "重置恒先经 seam 裁定的确认") - pair.workflow.confirmReset() - await waitUntil(pair.controller.settings == ProxySettings()) + _ = await pair.workflow.confirmReset() XCTAssertEqual(pair.workflow.draft, SettingsDraftAdapter.draft(from: ProxySettings())) XCTAssertNil(settingsStore.saved)