diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 7e1cbe16..4fc20fc9 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -32,8 +32,9 @@ struct ShadowsocksXNG2App: App { }, activator: controller) _catalogWorkflow = StateObject(wrappedValue: catalogWorkflow) - // 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次。 - _settingsWorkflow = StateObject(wrappedValue: SettingsWorkflow(controller: controller)) + // 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次; + // 写入侧经窄缝 SettingsCommitting(issue #44),运行时控制器薄扩展即生产实现。 + _settingsWorkflow = StateObject(wrappedValue: SettingsWorkflow(committing: controller)) // 诊断工作流 module(issue #43):诊断区唯一 seam,组合根接线一次;共享 // 实例供诊断侧栏与详情共同使用。 _diagnosticsWorkflow = StateObject( diff --git a/ShadowsocksX-NG2/App/SettingsView.swift b/ShadowsocksX-NG2/App/SettingsView.swift index 5e667030..6c3975ec 100644 --- a/ShadowsocksX-NG2/App/SettingsView.swift +++ b/ShadowsocksX-NG2/App/SettingsView.swift @@ -1,16 +1,16 @@ import SwiftUI -/// Native macOS settings for issue #33. Pure presentation over the settings -/// workflow module: field bindings edit the module's draft, actions go through -/// typed commands, and the login-item toggle binds its own independent -/// controller. Domain decisions (validation, occupancy, save gating, the -/// PAC-invalidation notice, reset) live behind the module's seam. +/// Native macOS settings for issue #33, bound to the settings workflow seam +/// (issue #44): field bindings edit the flat UI-shaped draft, issues render +/// beside their fields from the seam's field-scoped projection, port rows read +/// typed field state, and every discrete action is a named typed command. +/// Whether a confirmation is required and its summary come from the seam's +/// unified fact; this view only owns alert presentation. The login-item toggle +/// binds its own independent controller (separate preference domain). struct SettingsView: View { @ObservedObject var workflow: SettingsWorkflow @ObservedObject var loginController: LaunchAtLoginController - @State private var showResetConfirmation = false - var body: some View { Form { generalSection @@ -22,25 +22,17 @@ struct SettingsView: View { .onAppear { workflow.reloadFromCommitted() } - .alert("PAC 地址将失效", isPresented: pacNoticeBinding) { - Button("继续保存") { - workflow.confirmPACNotice() - } - Button("取消", role: .cancel) { - workflow.cancelPACNotice() - } - } message: { - Text(workflow.pendingPACNotice ?? "") - } .alert( - "重置所有偏好?", isPresented: $showResetConfirmation - ) { - Button("重置", role: .destructive) { - workflow.reset() - } - Button("取消", role: .cancel) {} - } message: { - Text("端口、监听范围和 PAC 设置都会恢复为出厂值。") + workflow.pendingConfirmation.map { confirmationPresentation(for: $0).title } ?? "", + isPresented: confirmationBinding, + presenting: workflow.pendingConfirmation + ) { confirmation in + let presentation = confirmationPresentation(for: confirmation) + Button( + presentation.confirmTitle, role: presentation.confirmRole, action: presentation.confirm) + Button("取消", role: .cancel, action: presentation.cancel) + } message: { confirmation in + Text(confirmation.summary) } } @@ -62,23 +54,30 @@ struct SettingsView: View { .foregroundStyle(.red) } - Toggle("启用 UDP 中继", isOn: $workflow.draft.listen.udpRelayEnabled) + Toggle("启用 UDP 中继", isOn: $workflow.draft.udpRelayEnabled) Stepper(value: $workflow.draft.timeoutSeconds, in: 1...86_400) { Text("超时:" + String(workflow.draft.timeoutSeconds) + " 秒") } + fieldIssues(.timeoutSeconds) Toggle("详细日志(verbose)", isOn: $workflow.draft.verboseLogging) } } private var advancedSection: some View { Section("高级") { - Picker("监听范围", selection: scopeBinding) { + Picker("监听范围", selection: $workflow.draft.isHostScope) { Text("仅本机(127.0.0.1)").tag(false) Text("局域网(主机地址)").tag(true) } - if case .host(let address) = workflow.draft.listen.scope { - TextField("对外公布的 IPv4 地址", text: hostAddressBinding) + if workflow.draft.isHostScope { + TextField("对外公布的 IPv4 地址", text: $workflow.draft.advertisedAddress) + fieldIssues(.advertisedAddress) + if workflow.draft.advertisedAddress.isEmpty { + Text("请输入本机可路由的局域网 IPv4 地址。") + .font(.caption) + .foregroundStyle(.red) + } Label( "局域网模式会把无鉴权的代理端口开放给局域网;请确认防火墙允许所需程序,并只在可信网络使用。", systemImage: "exclamationmark.triangle" @@ -90,22 +89,19 @@ struct SettingsView: View { ) .font(.caption) .foregroundStyle(.secondary) - if address.isEmpty { - Text("请输入本机可路由的局域网 IPv4 地址。") - .font(.caption) - .foregroundStyle(.red) - } } - portRow(.socks, value: $workflow.draft.listen.socksPort) - Toggle("启用 HTTP 代理", isOn: $workflow.draft.listen.httpProxyEnabled) - portRow(.http, value: $workflow.draft.listen.httpPort) - .disabled(!workflow.draft.listen.httpProxyEnabled) - portRow(.pac, value: $workflow.draft.listen.pacPort) + portRow(.socks) + Toggle("启用 HTTP 代理", isOn: $workflow.draft.httpProxyEnabled) + portRow(.http) + .disabled(!workflow.draft.httpProxyEnabled) + portRow(.pac) TextField("绕过列表(逗号或空格分隔)", text: $workflow.draft.proxyExceptions) TextField("外部 PAC URL(可选)", text: $workflow.draft.externalPACURL) + fieldIssues(.externalPACURL) TextField("GFW List URL", text: $workflow.draft.gfwListURL) + fieldIssues(.gfwListURL) Text("外部内容只保存 URL;本设置页不负责远程内容校验或自动更新。") .font(.caption) .foregroundStyle(.secondary) @@ -125,113 +121,102 @@ struct SettingsView: View { private var actionSection: some View { Section { - if let errorMessage = workflow.errorMessage { - Text(errorMessage) + if let failure = workflow.lastFailureMessage { + Text(failure) .foregroundStyle(.red) } - if !workflow.validationErrors.isEmpty { - Text( - workflow.validationErrors.map(\.presentedReason).joined(separator: "\n") - ) - .font(.caption) - .foregroundStyle(.red) - } - if workflow.hasOccupiedPort { + if workflow.hasBlockingPortOccupancy { Text("检测到端口已被占用;请先使用对应的“建议空闲端口”,再保存设置。") .font(.caption) .foregroundStyle(.orange) } + if workflow.isDirty { + Text("存在未保存的修改。") + .font(.caption) + .foregroundStyle(.secondary) + } HStack { Spacer() Button("重置偏好") { - showResetConfirmation = true + workflow.reset() } - Button(workflow.isSaving ? "保存中…" : "保存") { + .disabled(workflow.isCommitting) + Button(workflow.isCommitting ? "保存中…" : "保存") { workflow.save() } .keyboardShortcut(.defaultAction) - .disabled(workflow.isSaving || !workflow.canSave) + .disabled(!workflow.canSave) } } } - - private var pacNoticeBinding: Binding { - Binding( - get: { workflow.pendingPACNotice != nil }, - set: { presented in - if !presented, workflow.pendingPACNotice != nil { - workflow.cancelPACNotice() - } - }) - } } extension SettingsView { - private func portRow(_ endpoint: ProxyEndpointKind, value: Binding) -> some View { - VStack(alignment: .leading, spacing: 4) { + private func portRow(_ id: SettingsPortID) -> some View { + let state = workflow.portFieldState(for: id) + return VStack(alignment: .leading, spacing: 4) { HStack { - Text(endpoint.displayName + " 端口") + Text(title(for: id) + " 端口") Spacer() TextField( - endpoint.displayName + " 端口", - value: value, + title(for: id) + " 端口", + value: portBinding(for: id), format: .number ) .frame(width: 90) .multilineTextAlignment(.trailing) .labelsHidden() } - if let occupancy = workflow.occupancy[endpoint] { - Text(occupancyText(occupancy, endpoint: endpoint)) + ForEach(state.issues, id: \.self) { issue in + Text(issue) + .font(.caption) + .foregroundStyle(.red) + } + if let occupancy = state.occupancy { + Text(occupancyText(occupancy, state: state)) .font(.caption) .foregroundStyle(occupancyColor(occupancy)) } - if case .occupied = workflow.occupancy[endpoint], !workflow.isCurrentRuntimePort(endpoint) { + if state.canSuggestFreePort { Button("建议空闲端口") { - workflow.suggestPort(for: endpoint) + workflow.suggestFreePort(for: id) } .font(.caption) } } } - private var scopeBinding: Binding { + private func portBinding(for id: SettingsPortID) -> Binding { Binding( - get: { - if case .host = workflow.draft.listen.scope { return true } - return false - }, - set: { isHost in - guard isHost else { - workflow.draft.listen.scope = .loopback - return - } - let address: String - if case .host(let current) = workflow.draft.listen.scope { - address = current - } else { - address = "" - } - workflow.draft.listen.scope = .host(advertisedAddress: address) - }) + get: { workflow.draft.portValue(for: id) }, + set: { workflow.draft.setPortValue($0, for: id) }) } - private var hostAddressBinding: Binding { - Binding( - get: { - if case .host(let address) = workflow.draft.listen.scope { return address } - return "" - }, - set: { workflow.draft.listen.scope = .host(advertisedAddress: $0) }) + private func fieldIssues(_ field: SettingsFieldID) -> some View { + ForEach(workflow.issues(for: field), id: \.self) { issue in + Text(issue) + .font(.caption) + .foregroundStyle(.red) + } } - private func occupancyText(_ occupancy: PortOccupancy, endpoint: ProxyEndpointKind) -> String { + private func title(for id: SettingsPortID) -> String { + switch id { + case .socks: "SOCKS5" + case .http: "HTTP" + case .pac: "PAC" + } + } + + private func occupancyText( + _ occupancy: SettingsPortOccupancy, state: SettingsPortFieldState + ) -> String { switch occupancy { case .free: return "当前端口可用" case .occupied(let occupier): - if workflow.isCurrentRuntimePort(endpoint) { + if state.isRuntimePortException { return "当前代理正在使用此端口,保存其他设置不会触发冲突" } return "当前端口已占用" + (occupier.map { "(" + $0 + ")" } ?? "") @@ -240,11 +225,52 @@ extension SettingsView { } } - private func occupancyColor(_ occupancy: PortOccupancy) -> Color { + private func occupancyColor(_ occupancy: SettingsPortOccupancy) -> Color { switch occupancy { case .free: .secondary case .occupied: .orange case .unknown: .secondary } } + + // MARK: - 确认 alert 呈现(视图只持有呈现状态,事实来自 seam) + + private struct ConfirmationPresentation { + let title: String + let confirmTitle: String + let confirmRole: ButtonRole? + let confirm: () -> Void + let cancel: () -> Void + } + + private func confirmationPresentation( + for confirmation: SettingsConfirmation + ) -> ConfirmationPresentation { + switch confirmation { + case .pacInvalidation: + ConfirmationPresentation( + title: "PAC 地址将失效", + confirmTitle: "继续保存", + confirmRole: nil, + confirm: { workflow.confirmPACNotice() }, + cancel: { workflow.cancelPACNotice() }) + case .resetPreferences: + ConfirmationPresentation( + title: "重置所有偏好?", + confirmTitle: "重置", + confirmRole: .destructive, + confirm: { workflow.confirmReset() }, + cancel: { workflow.cancelReset() }) + } + } + + private var confirmationBinding: Binding { + Binding( + get: { workflow.pendingConfirmation != nil }, + set: { presented in + if !presented, let confirmation = workflow.pendingConfirmation { + confirmationPresentation(for: confirmation).cancel() + } + }) + } } diff --git a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsDraftAdapter.swift b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsDraftAdapter.swift new file mode 100644 index 00000000..d6231939 --- /dev/null +++ b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsDraftAdapter.swift @@ -0,0 +1,131 @@ +import Foundation + +/// 写入侧窄 seam(issue #44):设置工作流只依赖已提交快照、代理是否在跑与 +/// 两个提交入口。生产实现是 `ProxyRuntimeController` 的薄扩展;测试注入 +/// fake。Domain 快照类型只出现在本缝与 `SettingsDraftAdapter`。读取侧是 +/// 运行时控制器的主 actor 状态,本缝随之主 actor 绑定。 +@MainActor +protocol SettingsCommitting: AnyObject { + var committedSettings: ProxySettings { get } + var isProxyRunning: Bool { get } + func updateSettings(_ proposed: ProxySettings) async throws + func resetPreferences() async throws +} + +extension ProxyRuntimeController: SettingsCommitting { + var committedSettings: ProxySettings { settings } + var isProxyRunning: Bool { state.isOn } +} + +/// 平坦草稿与 Domain 快照的单一 adapter(story 45):字段清单变化只改此处。 +/// 校验错误到字段问题、UI 端口标识到端点类型的映射同属字段清单,一并收在 +/// 本文件;Domain 快照类型不出现在 UI-facing interface。 +enum SettingsDraftAdapter { + /// 已提交快照 → 平坦草稿。主机地址态的公布地址只在局域网模式有意义, + /// 仅本机模式回到空文本。 + static func draft(from settings: ProxySettings) -> SettingsDraft { + let isHostScope: Bool + let advertisedAddress: String + switch settings.listen.scope { + case .loopback: + isHostScope = false + advertisedAddress = "" + case .host(let address): + isHostScope = true + advertisedAddress = address + } + return SettingsDraft( + isHostScope: isHostScope, + advertisedAddress: advertisedAddress, + socksPort: settings.listen.socksPort, + httpProxyEnabled: settings.listen.httpProxyEnabled, + httpPort: settings.listen.httpPort, + pacPort: settings.listen.pacPort, + udpRelayEnabled: settings.listen.udpRelayEnabled, + timeoutSeconds: settings.timeoutSeconds, + verboseLogging: settings.verboseLogging, + proxyExceptions: settings.proxyExceptions, + externalPACURL: settings.externalPACURL, + gfwListURL: settings.gfwListURL, + pacUserRules: settings.pacUserRules) + } + + /// 平坦草稿 → Domain 快照。持久化的当前模式不在草稿中,从 `base` 保留。 + static func settings( + from draft: SettingsDraft, preservingModeOf base: ProxySettings + ) -> ProxySettings { + 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 ProxySettings( + listen: listen, + timeoutSeconds: draft.timeoutSeconds, + verboseLogging: draft.verboseLogging, + proxyExceptions: draft.proxyExceptions, + externalPACURL: draft.externalPACURL, + gfwListURL: draft.gfwListURL, + pacUserRules: draft.pacUserRules, + preferredMode: base.preferredMode) + } + + /// UI 形状端口标识 → 端点类型(module 内部复用 Domain 端口语义)。 + static func endpoint(for id: SettingsPortID) -> ProxyEndpointKind { + switch id { + case .socks: .socks + case .http: .http + case .pac: .pac + } + } + + /// Domain 点名校验错误 → 按字段归位的问题;点名文案沿用既有错误属性。 + /// 端口互异涉及两个端点,两侧端口各归位一条,用户只改有错的那个输入。 + static func fieldIssues(from errors: [ProxySettingsValidationError]) -> [SettingsFieldIssue] { + errors.flatMap { error -> [SettingsFieldIssue] in + switch error { + case .portOutOfRange(let endpoint, _): + return [.port(portID(for: endpoint), message: error.presentedReason)] + case .duplicatePort(let endpoint, let otherEndpoint, _): + let message = error.presentedReason + return [ + .port(portID(for: endpoint), message: message), + .port(portID(for: otherEndpoint), message: message), + ] + case .invalidTimeout: + return [.timeoutSeconds(message: error.presentedReason)] + case .invalidHostAddress: + return [.advertisedAddress(message: error.presentedReason)] + case .invalidExternalPACURL: + return [.externalPACURL(message: error.presentedReason)] + case .invalidGFWListURL: + return [.gfwListURL(message: error.presentedReason)] + } + } + } + + private static func portID(for endpoint: ProxyEndpointKind) -> SettingsPortID { + switch endpoint { + case .socks: .socks + case .http: .http + case .pac: .pac + } + } +} + +extension SettingsPortOccupancy { + /// 探测层判定 → UI 形状事实。 + init(_ occupancy: PortOccupancy) { + switch occupancy { + case .free: + self = .free + case .occupied(let occupier): + self = .occupied(occupier: occupier) + case .unknown(let detail): + self = .unknown(detail: detail) + } + } +} diff --git a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift index 26764fde..e2d14749 100644 --- a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift +++ b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift @@ -1,89 +1,193 @@ import Foundation -/// 设置工作流 module(Candidate 02):设置窗口的唯一 UI-facing seam。持有编辑 -/// 草稿并隐藏端口占用探测的异步编排、保存门禁、「当前运行端口」例外、PAC 失效 -/// 确认与重置事务;提交一律经 `ProxyRuntimeController.updateSettings` / -/// `resetPreferences`,与其他用户动作走相同的校验与重展开路径。登录启动项是 -/// 独立偏好域,视图直连其控制器,不经本模块。确认 alert 的呈现等窗口状态由 -/// UI 持有;本类不生成用户可见文案,文案来自 Domain 错误的 presentedReason。 +/// 设置工作流 module(issue #44):设置窗口的唯一 UI-facing seam。以平坦的 +/// UI 形状草稿为编辑态唯一 source of truth,向 UI 只提供字段归位的校验问题、 +/// 端口 field state、保存门禁/脏态/提交中/失败文案投影、统一确认事实与具名 +/// typed command。视图不再拆装 Domain 枚举、翻译占用事实、推导门禁或自备 +/// 确认文案;alert、sheet 与窗口状态仍由 UI 持有。登录启动项是独立偏好域, +/// 不经本 module。 /// /// 内部沿用既有深 module:`ProxySettings` 的点名校验、`ProxyPortSemantics` 的 /// 端口互异/建议算法、`PortChangeNotice` 的 PAC 失效判定与 `PortOccupancyProbing` /// 探测缝(编辑期尽力而为的即时提示,激活成败仍以 runtime 健康门禁为准)。 +/// 写入侧只依赖窄 seam `SettingsCommitting`。module 不依赖 SwiftUI。 @MainActor final class SettingsWorkflow: ObservableObject { - /// 编辑中的设置快照:视图经 binding 逐字段编辑;监听设置变化时自动重探占用。 - @Published var draft: ProxySettings { + /// 编辑中的 UI 形状草稿:视图按字段绑定;监听设置变化时自动重探占用。 + @Published var draft: SettingsDraft { didSet { - if oldValue.listen != draft.listen { + if listenFingerprint(of: draft) != listenFingerprint(of: oldValue) { refreshOccupancy() } } } - /// 各端点的尽力而为占用判定(非权威,只影响编辑期提示与保存门禁)。 - @Published private(set) var occupancy: [ProxyEndpointKind: PortOccupancy] = [:] - @Published private(set) var isSaving = false + /// 各端口的尽力而为占用判定(非权威,只影响编辑期提示与保存门禁)。 + @Published private var occupancyByPort: [SettingsPortID: SettingsPortOccupancy] = [:] + /// 提交/重置进行中(按钮进入进行中状态且不可重复触发)。 + @Published private(set) var isCommitting = false /// 最近一次提交/重置失败的点名原因(nil = 无)。 - @Published private(set) var errorMessage: String? - /// 等待用户确认的 PAC 失效提示;确认后才提交草稿。 - @Published private(set) var pendingPACNotice: String? + @Published private(set) var lastFailureMessage: String? + /// 等待用户裁定的确认事实(PAC 失效、重置偏好);视图只持有 alert 呈现状态。 + @Published private(set) var pendingConfirmation: SettingsConfirmation? - private let controller: ProxyRuntimeController + private let committing: SettingsCommitting private let occupancyProbe: PortOccupancyProbing /// 占用探测代际:草稿快速连续变化时只有最新一轮结果生效。 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( - controller: ProxyRuntimeController, + committing: SettingsCommitting, occupancyProbe: PortOccupancyProbing = SystemPortOccupancyProbe() ) { - self.controller = controller + self.committing = committing self.occupancyProbe = occupancyProbe - draft = controller.settings + draft = SettingsDraftAdapter.draft(from: committing.committedSettings) + refreshOccupancy() } - /// 草稿的点名校验错误(Domain 派生,随 draft 变化)。 - var validationErrors: [ProxySettingsValidationError] { - draft.validationErrors + // MARK: - 字段归位的校验问题 + + /// 草稿点名校验问题(随 draft 变化;点名文案来自既有 Domain 错误)。 + var fieldIssues: [SettingsFieldIssue] { + SettingsDraftAdapter.fieldIssues(from: makeSettings(from: draft).validationErrors) } - /// 保存门禁:无校验错误,且没有(当前运行端口之外的)占用。 - var canSave: Bool { - validationErrors.isEmpty && !hasOccupiedPort + /// 按字段归位的问题点名文案。 + func issues(for field: SettingsFieldID) -> [String] { + fieldIssues.filter { $0.field == field }.map(\.message) } - /// 是否存在阻塞保存的端口占用(事实查询,呈现由视图决定)。 - var hasOccupiedPort: Bool { - occupancy.contains { endpoint, occupancy in - guard endpoint != .http || draft.listen.httpProxyEnabled else { return false } - guard !isCurrentRuntimePort(endpoint) else { return false } - if case .occupied = occupancy { return true } + // MARK: - 端口 field state + + /// 端口行按统一端口标识取 field state。 + func portFieldState(for id: SettingsPortID) -> SettingsPortFieldState { + let exception = isRuntimePortException(id) + let occupancy = occupancyByPort[id] + let canSuggest: Bool + switch occupancy { + case .occupied: + canSuggest = !exception + case .free, .unknown, nil: + canSuggest = false + } + return SettingsPortFieldState( + id: id, + draftValue: draft.portValue(for: id), + occupancy: occupancy, + issues: issues(for: .port(id)), + isRuntimePortException: exception, + canSuggestFreePort: canSuggest) + } + + /// 是否存在阻塞保存的端口占用(事实查询,呈现由视图决定)。HTTP 入站关闭 + /// 时 HTTP 端点不参与门禁。 + var hasBlockingPortOccupancy: Bool { + SettingsPortID.allCases.contains { id in + guard id != .http || draft.httpProxyEnabled else { return false } + guard !isRuntimePortException(id) else { return false } + if case .occupied = occupancyByPort[id] { return true } return false } } - /// 端口是否正是当前运行中的代理在监听的端口(保存其他设置不会触发冲突)。 - func isCurrentRuntimePort(_ endpoint: ProxyEndpointKind) -> Bool { - guard controller.state.isOn else { return false } - let committed = controller.settings.listen - guard committed.configuredPort(for: endpoint) == draft.listen.configuredPort(for: endpoint) - else { return false } - return endpoint != .http || committed.httpProxyEnabled + // MARK: - 操作区只读投影 + + /// 保存门禁:校验问题清零、无阻塞性占用、且不在提交中。 + var canSave: Bool { + fieldIssues.isEmpty && !hasBlockingPortOccupancy && !isCommitting } - /// 放弃未保存的修改回到已提交快照(设置窗口重新出现时调用)。 - func reloadFromCommitted() { - let committed = controller.settings - let listenUnchanged = draft.listen == committed.listen - draft = committed - if listenUnchanged { - refreshOccupancy() + /// 脏态:草稿若提交会不会改变已提交快照(草稿经唯一 adapter 归一后比较, + /// 隐藏字段里的残留文本不误报未保存修改)。 + var isDirty: Bool { + makeSettings(from: draft) != committing.committedSettings + } + + // MARK: - 具名 typed command + + /// 保存:门禁通过后,若 PAC 端口将变化则挂起失效确认,否则提交。 + func save() { + guard canSave else { return } + let proposed = makeSettings(from: draft) + if let notice = PortChangeNotice.pacInvalidation( + from: committing.committedSettings.listen, to: proposed.listen) + { + pendingConfirmation = .pacInvalidation(summary: notice) + return + } + commit(proposed) + } + + /// 用户裁定继续保存(确认 PAC 失效提示)后提交。 + func confirmPACNotice() { + guard case .pacInvalidation = pendingConfirmation else { return } + pendingConfirmation = nil + guard canSave else { return } + commit(makeSettings(from: draft)) + } + + /// 取消挂起的 PAC 失效提示:草稿保留且不提交。 + func cancelPACNotice() { + guard case .pacInvalidation = pendingConfirmation else { return } + pendingConfirmation = nil + } + + /// 重置偏好:seam 裁定恒需确认,挂起重置确认事实(摘要范围与重置事务 + /// 一致)。登录启动项与快捷键意图是独立偏好域,不在此事务内。 + func reset() { + guard !isCommitting else { return } + pendingConfirmation = .resetPreferences(summary: Self.resetSummary) + } + + /// 用户裁定重置后走重置提交入口:恢复出厂值并停止运行中的代理。 + func confirmReset() { + guard case .resetPreferences = pendingConfirmation else { return } + pendingConfirmation = nil + guard !isCommitting else { return } + isCommitting = true + lastFailureMessage = nil + Task { @MainActor in + do { + try await committing.resetPreferences() + adoptCommittedSettings() + } catch { + lastFailureMessage = Self.presentedReason(for: error) + } + isCommitting = false } } - /// 为端点建议一个空闲端口:只把候选写进草稿,绝不替用户保存。 - func suggestPort(for endpoint: ProxyEndpointKind) { - let listen = draft.listen + /// 取消重置:不发生任何提交。 + func cancelReset() { + guard case .resetPreferences = pendingConfirmation else { return } + pendingConfirmation = nil + } + + /// 为端口建议一个空闲端口:只把候选写进草稿对应字段,绝不替用户保存。 + func suggestFreePort(for id: SettingsPortID) { + let listen = makeSettings(from: draft).listen + let endpoint = SettingsDraftAdapter.endpoint(for: id) let probe = occupancyProbe Task { @MainActor in let candidate = await Task.detached(priority: .utility) { @@ -95,77 +199,65 @@ final class SettingsWorkflow: ObservableObject { } }.value guard let candidate else { return } - var next = draft.listen - switch endpoint { - case .socks: next.socksPort = candidate - case .http: next.httpPort = candidate - case .pac: next.pacPort = candidate - } - draft.listen = next + draft.setPortValue(candidate, for: id) } } - /// 保存:门禁通过后,若 PAC 端口将变化则挂起等待用户确认失效提示,否则提交。 - func save() { - guard canSave else { return } - if let notice = PortChangeNotice.pacInvalidation( - from: controller.settings.listen, to: draft.listen) - { - pendingPACNotice = notice - return - } - commit(draft) - } - - /// 用户确认 PAC 失效提示后提交。 - func confirmPACNotice() { - pendingPACNotice = nil - guard canSave else { return } - commit(draft) - } - - /// 取消挂起的保存。 - func cancelPACNotice() { - pendingPACNotice = nil - } - - /// 重置偏好:恢复出厂值并停止运行中的代理;持久化失败保留旧值并点名。 - /// 登录启动项与快捷键意图是独立偏好域,不在此事务内。 - func reset() { - isSaving = true - errorMessage = nil - Task { @MainActor in - do { - try await controller.resetPreferences() - adoptCommittedSettings() - } catch { - errorMessage = presentedReason(for: error) - } - isSaving = false - } + /// 回到已提交快照:放弃未保存修改;监听设置未变时仍刷新占用,避免陈旧提示。 + func reloadFromCommitted() { + adoptCommittedSettings() } // MARK: - 提交与占用探测(implementation,UI 不可见) - private func commit(_ settings: ProxySettings) { - isSaving = true - errorMessage = nil + /// 重置事务的范围摘要(story 25):与 `resetPreferences` 的实际事务一致—— + /// 全部偏好(含端口、监听范围与 PAC 设置)回出厂值,运行中的代理停止。 + private static let resetSummary = + "端口、监听范围和 PAC 设置等全部偏好都会恢复为出厂值,运行中的代理会停止。" + + private var committedListen: SslocalListenSettings { + committing.committedSettings.listen + } + + private func makeSettings(from draft: SettingsDraft) -> ProxySettings { + SettingsDraftAdapter.settings(from: draft, preservingModeOf: committing.committedSettings) + } + + /// 端口是否正是当前运行中的代理在用的端口(保存其他设置不会触发冲突)。 + /// 例外只影响保存门禁与提示,不改持久化;HTTP 端点还需已提交侧启用入站。 + 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 + } + + private func listenFingerprint(of draft: SettingsDraft) -> ListenFingerprint { + ListenFingerprint(draft) + } + + private func commit(_ proposed: ProxySettings) { + isCommitting = true + lastFailureMessage = nil Task { @MainActor in do { - try await controller.updateSettings(settings) + try await committing.updateSettings(proposed) adoptCommittedSettings() } catch { - errorMessage = presentedReason(for: error) + lastFailureMessage = Self.presentedReason(for: error) } - isSaving = false + isCommitting = false } } - /// 提交成功后回到已提交快照;监听设置未变时 didSet 不会重探,需显式刷新。 + /// 提交成功或回到已提交快照:草稿回到已提交快照;监听设置未变时 didSet + /// 不会重探,需显式刷新。 private func adoptCommittedSettings() { - let committed = controller.settings - let listenUnchanged = draft.listen == committed.listen - draft = committed + let next = SettingsDraftAdapter.draft(from: committing.committedSettings) + let listenUnchanged = listenFingerprint(of: draft) == listenFingerprint(of: next) + draft = next if listenUnchanged { refreshOccupancy() } @@ -174,25 +266,27 @@ final class SettingsWorkflow: ObservableObject { private func refreshOccupancy() { occupancyGeneration += 1 let generation = occupancyGeneration - let listen = draft.listen + let listen = makeSettings(from: draft).listen let probe = occupancyProbe Task { @MainActor in let result = await Task.detached(priority: .utility) { Dictionary( - uniqueKeysWithValues: ProxyEndpointKind.allCases.map { endpoint in + uniqueKeysWithValues: SettingsPortID.allCases.map { id in ( - endpoint, - probe.occupancy( - port: listen.configuredPort(for: endpoint), bindAddress: listen.bindAddress) + id, + SettingsPortOccupancy( + probe.occupancy( + port: listen.configuredPort(for: SettingsDraftAdapter.endpoint(for: id)), + bindAddress: listen.bindAddress)) ) }) }.value guard generation == occupancyGeneration else { return } - occupancy = result + occupancyByPort = result } } - private func presentedReason(for error: Error) -> String { + private static func presentedReason(for error: Error) -> String { if let error = error as? ProxySettingsStoreError { return error.presentedReason } diff --git a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift new file mode 100644 index 00000000..12ba37fb --- /dev/null +++ b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift @@ -0,0 +1,128 @@ +import Foundation + +// MARK: - 平坦草稿(UI 形状,设置编辑态的唯一 source of truth) + +/// UI 形状端口标识:设置页三个端口字段的稳定标识。端口行与建议空闲端口命令 +/// 都用本标识;视图不导入探测层端点类型。 +enum SettingsPortID: CaseIterable, Hashable, Sendable { + case socks + case http + case pac +} + +/// 平坦的 UI 形状编辑草稿:字段全部是 UI 原语。监听范围拆成布尔开关与公布 +/// 地址文本两个字段,端口与超时保持整数;持久化的当前模式不在草稿中。 +/// 与 Domain 快照的互转只在 `SettingsDraftAdapter`。 +struct SettingsDraft: Equatable, Sendable { + var isHostScope: Bool + var advertisedAddress: String + var socksPort: Int + var httpProxyEnabled: Bool + var httpPort: Int + var pacPort: Int + var udpRelayEnabled: Bool + var timeoutSeconds: Int + var verboseLogging: Bool + var proxyExceptions: String + var externalPACURL: String + var gfwListURL: String + var pacUserRules: String + + func portValue(for id: SettingsPortID) -> Int { + switch id { + case .socks: socksPort + case .http: httpPort + case .pac: pacPort + } + } + + mutating func setPortValue(_ value: Int, for id: SettingsPortID) { + switch id { + case .socks: socksPort = value + case .http: httpPort = value + case .pac: pacPort = value + } + } +} + +// MARK: - 按字段归位的校验问题 + +/// 字段标识(按字段归位查询);端口问题用端口标识变体。 +enum SettingsFieldID: Hashable, Sendable { + case advertisedAddress + case port(SettingsPortID) + case timeoutSeconds + case externalPACURL + case gfwListURL +} + +/// 按字段归位的校验问题:每条载带点名文案(来自既有 Domain 错误的 +/// presentedReason;本次不收编文案归属)。 +enum SettingsFieldIssue: Equatable, Sendable { + case port(SettingsPortID, message: String) + case advertisedAddress(message: String) + case timeoutSeconds(message: String) + case externalPACURL(message: String) + case gfwListURL(message: String) + + var field: SettingsFieldID { + switch self { + case .port(let id, _): .port(id) + case .advertisedAddress: .advertisedAddress + case .timeoutSeconds: .timeoutSeconds + case .externalPACURL: .externalPACURL + case .gfwListURL: .gfwListURL + } + } + + var message: String { + switch self { + case .port(_, let message), .advertisedAddress(let message), .timeoutSeconds(let message), + .externalPACURL(let message), .gfwListURL(let message): + message + } + } +} + +// MARK: - 端口 field state + +/// 端口占用的类型化事实(UI 形状):文案与颜色由视图决定。 +enum SettingsPortOccupancy: Equatable, Sendable { + case free + /// 端口不可绑定;占用进程名尽力解析(未知为 nil)。 + case occupied(occupier: String?) + case unknown(detail: String) +} + +/// 每个端口字段的 field state:三个端口行复用同一套呈现逻辑。 +struct SettingsPortFieldState: Equatable, Sendable { + let id: SettingsPortID + /// 该端口当前草稿值。 + let draftValue: Int + /// 类型化占用事实;`nil` = 尚未探测。 + let occupancy: SettingsPortOccupancy? + /// 该端口的字段问题点名文案。 + let issues: [String] + /// 是否为运行中端口例外(代理在跑且端口未变;保存其他设置不会触发冲突)。 + let isRuntimePortException: Bool + /// 是否可建议空闲端口(被占用且不是运行中端口例外)。 + let canSuggestFreePort: Bool +} + +// MARK: - 统一确认事实 + +/// 确认事实(统一种类):seam 裁定动作是否需要确认及摘要内容;视图只持有 +/// alert 呈现状态并把用户选择作为 typed command 发回。 +enum SettingsConfirmation: Equatable, Sendable { + /// PAC 地址将失效(摘要来自既有 PAC 失效判定的点名文案)。 + case pacInvalidation(summary: String) + /// 重置偏好(摘要范围由 seam 裁定,与重置事务一致)。 + case resetPreferences(summary: String) + + var summary: String { + switch self { + case .pacInvalidation(let summary), .resetPreferences(let summary): + summary + } + } +} diff --git a/ShadowsocksX-NG2/Tests/SettingsWorkflowInterfaceTests.swift b/ShadowsocksX-NG2/Tests/SettingsWorkflowInterfaceTests.swift new file mode 100644 index 00000000..11afe56c --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SettingsWorkflowInterfaceTests.swift @@ -0,0 +1,453 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 设置工作流 UI-facing interface 测试(issue #44):观察平坦草稿、按字段 +/// 归位的问题、端口 field state、确认事实与操作区投影;写入缝与占用探测 +/// 全部注入替身,不碰真实偏好文件、钥匙串与系统端口。 +@MainActor +final class SettingsWorkflowInterfaceTests: XCTestCase { + private var committing: FakeSettingsCommitter! + private var probe: FakeOccupancyProbe! + + override func setUpWithError() throws { + try super.setUpWithError() + committing = FakeSettingsCommitter() + probe = FakeOccupancyProbe() + } + + private func makeWorkflow() -> SettingsWorkflow { + SettingsWorkflow(committing: committing, occupancyProbe: probe) + } + + // MARK: - 校验问题按字段归位 + + func testPortOutOfRangeIssueLandsOnThatPortField() { + let workflow = makeWorkflow() + workflow.draft.socksPort = 0 + + XCTAssertEqual( + workflow.fieldIssues, + [ + .port( + .socks, + message: PortSettingError.portOutOfRange(endpoint: .socks, port: 0).presentedReason) + ]) + XCTAssertEqual( + workflow.issues(for: .port(.socks)), + [PortSettingError.portOutOfRange(endpoint: .socks, port: 0).presentedReason]) + XCTAssertTrue(workflow.issues(for: .port(.pac)).isEmpty) + } + + func testDuplicatePortIssueLandsOnBothPortsInvolved() { + let workflow = makeWorkflow() + workflow.draft.httpPort = workflow.draft.socksPort + + let message = PortSettingError.duplicatePort( + endpoint: .socks, otherEndpoint: .http, port: 11086 + ).presentedReason + XCTAssertEqual( + workflow.fieldIssues, + [.port(.socks, message: message), .port(.http, message: message)]) + XCTAssertEqual(workflow.issues(for: .port(.socks)), [message]) + XCTAssertEqual(workflow.issues(for: .port(.http)), [message]) + XCTAssertTrue(workflow.issues(for: .port(.pac)).isEmpty) + } + + func testTimeoutIssueLandsOnTimeoutField() { + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 0 + + XCTAssertEqual( + workflow.fieldIssues, + [.timeoutSeconds(message: ProxySettingsValidationError.invalidTimeout(0).presentedReason)]) + XCTAssertTrue(workflow.issues(for: .port(.socks)).isEmpty) + } + + func testHostAddressIssueLandsOnAdvertisedAddressField() { + let workflow = makeWorkflow() + workflow.draft.isHostScope = true + workflow.draft.advertisedAddress = "127.0.0.1" + + XCTAssertEqual(workflow.fieldIssues.map(\.field), [.advertisedAddress]) + XCTAssertTrue(workflow.issues(for: .advertisedAddress)[0].contains("主机地址")) + } + + func testExternalPACURLIssueLandsOnItsField() { + let workflow = makeWorkflow() + workflow.draft.externalPACURL = "ftp://example.com/pac" + + XCTAssertEqual(workflow.fieldIssues.map(\.field), [.externalPACURL]) + XCTAssertTrue(workflow.issues(for: .externalPACURL)[0].contains("外部 PAC URL")) + } + + func testGFWListURLIssueLandsOnItsField() { + let workflow = makeWorkflow() + workflow.draft.gfwListURL = "not a url" + + XCTAssertEqual(workflow.fieldIssues.map(\.field), [.gfwListURL]) + XCTAssertTrue(workflow.issues(for: .gfwListURL)[0].contains("GFW List URL")) + } + + // MARK: - 端口 field state + + func testPortFieldStateReportsFreeOccupiedAndUnknownFacts() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [11087], unknownPorts: [11089]) + let workflow = makeWorkflow() + workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .pac).occupancy != nil) + + XCTAssertEqual(workflow.portFieldState(for: .socks).occupancy, .free) + XCTAssertEqual(workflow.portFieldState(for: .http).occupancy, .occupied(occupier: "other-app")) + XCTAssertEqual(workflow.portFieldState(for: .pac).occupancy, .unknown(detail: "无法判定")) + XCTAssertEqual(workflow.portFieldState(for: .socks).draftValue, 11086) + } + + func testOccupiedPortCarriesOccupierFact() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [11086]) + let workflow = makeWorkflow() + workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) + + XCTAssertEqual(workflow.portFieldState(for: .socks).occupancy, .occupied(occupier: "other-app")) + XCTAssertTrue(workflow.portFieldState(for: .socks).canSuggestFreePort) + } + + func testDisabledHTTPEndpointDoesNotParticipateInSaveGating() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [11087]) + let workflow = makeWorkflow() + workflow.draft.httpProxyEnabled = false + await waitUntil(workflow.portFieldState(for: .http).occupancy != nil) + + XCTAssertEqual(workflow.portFieldState(for: .http).occupancy, .occupied(occupier: "other-app")) + XCTAssertFalse(workflow.hasBlockingPortOccupancy) + XCTAssertTrue(workflow.canSave) + } + + // MARK: - 运行中端口例外 + + func testRuntimePortExceptionWhenProxyRunsAndPortUnchanged() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [11086]) + committing.isProxyRunning = true + let workflow = makeWorkflow() + workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) + + XCTAssertTrue(workflow.portFieldState(for: .socks).isRuntimePortException) + XCTAssertFalse(workflow.hasBlockingPortOccupancy) + XCTAssertFalse(workflow.portFieldState(for: .socks).canSuggestFreePort) + } + + func testNoRuntimePortExceptionWhenProxyStopped() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [11086]) + committing.isProxyRunning = false + let workflow = makeWorkflow() + workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) + + XCTAssertFalse(workflow.portFieldState(for: .socks).isRuntimePortException) + XCTAssertTrue(workflow.hasBlockingPortOccupancy) + } + + func testNoRuntimePortExceptionWhenPortChanged() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [12086]) + committing.isProxyRunning = true + let workflow = makeWorkflow() + workflow.draft.socksPort = 12086 + await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) + + XCTAssertFalse(workflow.portFieldState(for: .socks).isRuntimePortException) + XCTAssertTrue(workflow.hasBlockingPortOccupancy) + XCTAssertFalse(workflow.canSave) + } + + func testRuntimeExceptionRequiresCommittedHTTPInboundForHTTPPort() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [11087]) + committing.isProxyRunning = true + var committed = committing.committedSettings + committed.listen.httpProxyEnabled = false + committing.committedSettings = committed + + let workflow = makeWorkflow() + workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .http).occupancy != nil) + + XCTAssertFalse(workflow.portFieldState(for: .http).isRuntimePortException) + } + + // MARK: - 建议空闲端口 + + func testSuggestFreePortOnlyWritesThatPortFieldIntoDraft() async throws { + let workflow = makeWorkflow() + + workflow.suggestFreePort(for: .socks) + await waitUntil(workflow.draft.socksPort != 11086) + + XCTAssertEqual(workflow.draft.socksPort, 32768) + XCTAssertEqual(workflow.portFieldState(for: .socks).draftValue, 32768) + XCTAssertEqual(workflow.draft.httpPort, 11087) + XCTAssertEqual(workflow.draft.pacPort, 11089) + XCTAssertTrue(committing.updateCalls.isEmpty, "建议只改草稿,必须经用户保存") + } + + // MARK: - 保存门禁 + + func testValidationIssuesBlockSave() async throws { + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 0 + + XCTAssertFalse(workflow.canSave) + + workflow.save() + await waitUntil(!workflow.isCommitting && workflow.pendingConfirmation == nil) + XCTAssertTrue(committing.updateCalls.isEmpty) + } + + func testBlockingOccupancyBlocksSave() async throws { + probe = FakeOccupancyProbe(occupiedPorts: [12086]) + let workflow = makeWorkflow() + workflow.draft.socksPort = 12086 + await waitUntil(workflow.portFieldState(for: .socks).occupancy != nil) + + XCTAssertTrue(workflow.hasBlockingPortOccupancy) + XCTAssertFalse(workflow.canSave) + + workflow.save() + XCTAssertTrue(committing.updateCalls.isEmpty) + } + + func testSaveCommitsWhenGateClear() async throws { + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 120 + + XCTAssertTrue(workflow.canSave) + workflow.save() + await waitUntil(!committing.updateCalls.isEmpty) + + XCTAssertEqual(committing.updateCalls.first?.timeoutSeconds, 120) + } + + // MARK: - 脏态 + + func testDirtyStateTracksDraftAgainstCommittedSnapshot() async throws { + let workflow = makeWorkflow() + XCTAssertFalse(workflow.isDirty) + + workflow.draft.timeoutSeconds = 120 + XCTAssertTrue(workflow.isDirty) + + workflow.reloadFromCommitted() + XCTAssertFalse(workflow.isDirty) + + workflow.draft.timeoutSeconds = 120 + workflow.save() + await waitUntil(!workflow.isCommitting) + XCTAssertFalse(workflow.isDirty, "提交成功后草稿回到已提交快照") + XCTAssertEqual(workflow.draft.timeoutSeconds, 120) + } + + func testHiddenHostAddressLeftoverDoesNotReportDirty() { + let workflow = makeWorkflow() + workflow.draft.isHostScope = true + workflow.draft.advertisedAddress = "192.168.1.10" + XCTAssertTrue(workflow.isDirty) + + workflow.draft.isHostScope = false + XCTAssertFalse( + workflow.isDirty, + "仅本机模式下残留的地址文本不改变已提交快照,不算未保存修改") + } + + func testModeIsNotPartOfTheDraft() async throws { + var committed = committing.committedSettings + committed.preferredMode = .global + committing.committedSettings = committed + + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 120 + workflow.save() + await waitUntil(!workflow.isCommitting) + + XCTAssertEqual(committing.updateCalls.first?.preferredMode, .global, "草稿不编辑当前模式") + } + + // MARK: - PAC 失效确认 + + func testPACPortChangeArmsConfirmationBeforeCommit() async throws { + let workflow = makeWorkflow() + workflow.draft.pacPort = 13089 + + workflow.save() + + guard case .pacInvalidation(let summary)? = workflow.pendingConfirmation else { + return XCTFail("应挂起 PAC 失效确认,实际 \(String(describing: workflow.pendingConfirmation))") + } + XCTAssertTrue(summary.contains("13089")) + XCTAssertTrue(committing.updateCalls.isEmpty, "未确认失效提示不得提交") + + workflow.confirmPACNotice() + await waitUntil(!workflow.isCommitting) + XCTAssertNil(workflow.pendingConfirmation) + XCTAssertEqual(committing.updateCalls.first?.listen.pacPort, 13089) + } + + func testCancelingPACNoticeKeepsDraftAndDoesNotCommit() async throws { + let workflow = makeWorkflow() + workflow.draft.pacPort = 13089 + + workflow.save() + workflow.cancelPACNotice() + + XCTAssertNil(workflow.pendingConfirmation) + XCTAssertTrue(committing.updateCalls.isEmpty) + XCTAssertEqual(workflow.draft.pacPort, 13089, "取消后草稿保留待调整值") + XCTAssertEqual(committing.committedSettings.listen.pacPort, 11089) + XCTAssertTrue(workflow.isDirty) + } + + // MARK: - 重置确认 + + func testResetArmsConfirmationWithScopeSummaryFromSeam() { + let workflow = makeWorkflow() + + workflow.reset() + + guard case .resetPreferences(let summary)? = workflow.pendingConfirmation else { + return XCTFail("应挂起重置确认,实际 \(String(describing: workflow.pendingConfirmation))") + } + XCTAssertTrue(summary.contains("端口")) + XCTAssertTrue(summary.contains("监听范围")) + XCTAssertTrue(summary.contains("PAC")) + XCTAssertTrue(summary.contains("全部偏好"), "摘要范围与重置事务一致") + XCTAssertTrue(summary.contains("代理"), "摘要点名运行中代理会停止") + XCTAssertTrue(committing.updateCalls.isEmpty) + XCTAssertEqual(committing.resetCallCount, 0) + } + + func testConfirmingResetUsesTheResetCommitEntry() async throws { + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 120 + + workflow.reset() + workflow.confirmReset() + await waitUntil(!workflow.isCommitting) + XCTAssertEqual(committing.resetCallCount, 1) + + XCTAssertNil(workflow.pendingConfirmation) + XCTAssertTrue(committing.updateCalls.isEmpty, "重置走重置提交入口,不走设置提交") + XCTAssertEqual(workflow.draft, SettingsDraftAdapter.draft(from: ProxySettings())) + } + + func testCancelingResetDoesNotCommit() { + let workflow = makeWorkflow() + + workflow.reset() + workflow.cancelReset() + + XCTAssertNil(workflow.pendingConfirmation) + XCTAssertEqual(committing.resetCallCount, 0) + XCTAssertTrue(committing.updateCalls.isEmpty) + } + + // MARK: - 提交中与失败点名文案 + + func testCommitFailureSurfacesNamedReasonAndKeepsDraft() async throws { + committing.updateError = FakeCommitError.io + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 120 + + workflow.save() + await waitUntil(workflow.lastFailureMessage != nil) + + XCTAssertTrue(workflow.lastFailureMessage?.contains("fake-io-error") == true) + XCTAssertFalse(workflow.isCommitting) + XCTAssertEqual(workflow.draft.timeoutSeconds, 120, "失败不吞掉草稿") + XCTAssertEqual(committing.committedSettings.timeoutSeconds, 60, "失败不半提交") + XCTAssertTrue(workflow.isDirty) + } + + func testStorageAndCredentialFailuresNameTheReason() async throws { + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 120 + + committing.updateError = ProxySettingsStoreError.ioFailure(detail: "disk full") + workflow.save() + await waitUntil(!workflow.isCommitting) + XCTAssertEqual( + workflow.lastFailureMessage, ProxySettingsStoreError.ioFailure(detail: "x").presentedReason) + XCTAssertTrue(workflow.lastFailureMessage?.contains("偏好文件") == true) + + committing.updateError = ProxySettingsStoreError.missingCredential( + ProxySettingsFileStore.externalPACReference) + workflow.save() + await waitUntil(workflow.lastFailureMessage?.contains("钥匙串") == true) + XCTAssertEqual( + workflow.lastFailureMessage, + ProxySettingsStoreError.missingCredential(ProxySettingsFileStore.externalPACReference) + .presentedReason) + } + + func testCommittingStateBlocksRepeatedSave() async throws { + let workflow = makeWorkflow() + workflow.draft.timeoutSeconds = 120 + + workflow.save() + XCTAssertTrue(workflow.isCommitting) + XCTAssertFalse(workflow.canSave, "提交中保存门禁关闭") + + workflow.save() + await waitUntil(!workflow.isCommitting) + XCTAssertEqual(committing.updateCalls.count, 1, "提交中不得重复触发") + } + + func testResetFailureSurfacesNamedReason() async throws { + committing.resetError = FakeCommitError.io + let workflow = makeWorkflow() + + workflow.reset() + workflow.confirmReset() + await waitUntil(workflow.lastFailureMessage != nil) + + XCTAssertTrue(workflow.lastFailureMessage?.contains("fake-io-error") == true) + } + + // MARK: - 占用探测代际 + + func testStaleOccupancyResultDoesNotOverrideNewerGeneration() async throws { + let gated = GatedOccupancyProbe( + gatedAnswer: .occupied(occupier: "stale"), passThroughAnswer: .free) + let workflow = SettingsWorkflow(committing: committing, occupancyProbe: gated) + + await waitUntil(gated.entered > 0) + gated.passThrough(.free) + workflow.draft.socksPort = 30086 + await waitUntil(workflow.portFieldState(for: .socks).occupancy == .free) + + gated.releaseGatedCalls() + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual( + workflow.portFieldState(for: .socks).occupancy, .free, + "过期的占用结果不得覆盖较新一轮") + } + + func testReloadWithUnlistenChangesStillRefreshesOccupancy() async throws { + probe = FakeOccupancyProbe() + let workflow = makeWorkflow() + workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .socks).occupancy == .free) + + probe.setAnswer(.occupied(occupier: "later")) + workflow.reloadFromCommitted() + await waitUntil(workflow.portFieldState(for: .socks).occupancy != .free) + + XCTAssertEqual( + workflow.portFieldState(for: .socks).occupancy, .occupied(occupier: "later"), + "监听设置未变的刷新仍产生新占用事实") + } + + private enum FakeCommitError: Error, CustomStringConvertible { + case io + + var description: String { "fake-io-error" } + } +} diff --git a/ShadowsocksX-NG2/Tests/SettingsWorkflowTestSupport.swift b/ShadowsocksX-NG2/Tests/SettingsWorkflowTestSupport.swift new file mode 100644 index 00000000..1786d320 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SettingsWorkflowTestSupport.swift @@ -0,0 +1,142 @@ +import Foundation +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 轮询等待异步投影落定(设置工作流测试共享)。 +func waitUntil( + _ condition: @autoclosure () -> Bool, + timeout: TimeInterval = 2, + file: StaticString = #filePath, + line: UInt = #line +) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertTrue(condition(), file: file, line: line) +} + +/// 设置工作流测试的写入缝替身(issue #44):记录提交与重置调用,可注入 +/// 失败;不触碰真实偏好文件、钥匙串或运行时。 +@MainActor +final class FakeSettingsCommitter: SettingsCommitting { + var committedSettings: ProxySettings = ProxySettings() + var isProxyRunning = false + var updateError: Error? + var resetError: Error? + 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 + } + + func resetPreferences() async throws { + if let resetError { throw resetError } + resetCallCount += 1 + committedSettings = ProxySettings() + } +} + +/// 占用探测替身:按端口预设占用/不可判定,记录全部探测请求。 +final class FakeOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { + private let lock = NSLock() + private let occupiedPorts: Set + private let unknownPorts: Set + private var requestedPorts: [Int] = [] + private var answerOverride: PortOccupancy? + + init(occupiedPorts: Set = [], unknownPorts: Set = []) { + self.occupiedPorts = occupiedPorts + self.unknownPorts = unknownPorts + } + + var requested: [Int] { + lock.lock() + defer { lock.unlock() } + return requestedPorts + } + + var callCount: Int { + lock.lock() + defer { lock.unlock() } + return requestedPorts.count + } + + /// 覆盖全部端口的应答(占用代际测试用)。 + func setAnswer(_ answer: PortOccupancy?) { + lock.lock() + defer { lock.unlock() } + answerOverride = answer + } + + func occupancy(port: Int, bindAddress: String) -> PortOccupancy { + lock.lock() + defer { lock.unlock() } + requestedPorts.append(port) + if let answerOverride { return answerOverride } + if unknownPorts.contains(port) { + return .unknown(detail: "无法判定") + } + return occupiedPorts.contains(port) ? .occupied(occupier: "other-app") : .free + } +} + +/// 分阶段占用探测替身(占用代际测试用):门闩阶段的调用先记下自己的应答再 +/// 被扣住,`passThrough` 之后的调用立即返回新应答。用于把「旧一轮」结果钉在 +/// 新一轮之后落盘,验证过期结果不得覆盖。 +final class GatedOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { + private let lock = NSLock() + private let gate = DispatchGroup() + private var isGated = true + private var enteredCount = 0 + private var gatedAnswer: PortOccupancy + private var passThroughAnswer: PortOccupancy + + init(gatedAnswer: PortOccupancy, passThroughAnswer: PortOccupancy) { + self.gatedAnswer = gatedAnswer + self.passThroughAnswer = passThroughAnswer + gate.enter() + } + + /// 已进入探测的调用数(按端口标识序,socks 先于 http/pac)。 + var entered: Int { + lock.lock() + defer { lock.unlock() } + return enteredCount + } + + /// 之后的调用立即返回 `answer`;已进入且被扣住的调用仍持有各自应答。 + func passThrough(_ answer: PortOccupancy) { + lock.lock() + defer { lock.unlock() } + isGated = false + passThroughAnswer = answer + } + + /// 放行全部被扣住的调用(广播门闩)。 + func releaseGatedCalls() { + lock.lock() + let wasGated = isGated + isGated = false + lock.unlock() + if wasGated { + gate.leave() + } + } + + func occupancy(port: Int, bindAddress: String) -> PortOccupancy { + lock.lock() + enteredCount += 1 + let gated = isGated + let answer = gated ? gatedAnswer : passThroughAnswer + lock.unlock() + if gated { + gate.wait() + } + return answer + } +} diff --git a/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift b/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift index 1a33db9a..46a641bf 100644 --- a/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift @@ -2,9 +2,9 @@ import XCTest @testable import ShadowsocksX_NG2 -/// 设置工作流 module(Candidate 02):保存门禁、占用反应、「当前运行端口」例外、 -/// PAC 失效确认、建议端口与重置的编排路径不经 SwiftUI 即可全测(占用探测与 -/// 运行时依赖全部注入替身)。 +/// 设置工作流与真实 `ProxyRuntimeController` 的提交路径集成(issue #44): +/// 界面投影与命令面已由 `SettingsWorkflowInterfaceTests` 用 fake 写入缝覆盖; +/// 本文件保留确需端到端的场景,证明窄缝两端与运行时控制器的提交事务对齐。 @MainActor final class SettingsWorkflowTests: XCTestCase { private var runtime: ProxyRuntimeFixture.TemporaryRuntime! @@ -33,24 +33,18 @@ final class SettingsWorkflowTests: XCTestCase { try super.tearDownWithError() } - func testEditingListenSettingsRefreshesOccupancyAndBlocksSave() async throws { - probe = FakeOccupancyProbe(occupiedPorts: [12086]) + func testSaveCommitsThroughTheRuntimeController() async throws { let pair = makePair() - pair.workflow.draft.listen.socksPort = 12086 - await waitUntil(pair.workflow.occupancy[.socks] != nil) - - guard case .occupied = pair.workflow.occupancy[.socks] else { - XCTFail("应呈现占用,实际 \(String(describing: pair.workflow.occupancy[.socks]))") - return - } - XCTAssertFalse(pair.workflow.isCurrentRuntimePort(.socks)) - XCTAssertTrue(pair.workflow.hasOccupiedPort) - XCTAssertFalse(pair.workflow.canSave) - + pair.workflow.draft.timeoutSeconds = 120 pair.workflow.save() - await waitUntil(!pair.workflow.isSaving) - XCTAssertNil(settingsStore.saved, "门禁未过不得提交") + await waitUntil(!pair.workflow.isCommitting) + + XCTAssertEqual(settingsStore.saved?.timeoutSeconds, 120) + XCTAssertEqual(pair.controller.settings.timeoutSeconds, 120) + XCTAssertEqual( + pair.workflow.draft, SettingsDraftAdapter.draft(from: pair.controller.settings), + "提交后草稿回到已提交快照") } func testOccupiedPortMatchingTheRunningRuntimeDoesNotBlockSave() async throws { @@ -58,80 +52,51 @@ final class SettingsWorkflowTests: XCTestCase { let pair = try await makeRunningPair() pair.workflow.reloadFromCommitted() - await waitUntil(pair.workflow.occupancy[.socks] != nil) + await waitUntil(pair.workflow.portFieldState(for: .socks).occupancy != nil) - guard case .occupied = pair.workflow.occupancy[.socks] else { - XCTFail("替身应把运行端口报告为占用") - return - } - XCTAssertTrue(pair.workflow.isCurrentRuntimePort(.socks), "代理自身监听的端口不算冲突") - XCTAssertFalse(pair.workflow.hasOccupiedPort) + XCTAssertEqual( + pair.workflow.portFieldState(for: .socks).occupancy, .occupied(occupier: "other-app")) + XCTAssertTrue( + pair.workflow.portFieldState(for: .socks).isRuntimePortException, + "代理自身监听的端口不算冲突") + XCTAssertFalse(pair.workflow.hasBlockingPortOccupancy) XCTAssertTrue(pair.workflow.canSave) pair.workflow.draft.timeoutSeconds = 120 pair.workflow.save() await waitUntil(settingsStore.saved != nil) XCTAssertEqual(settingsStore.saved?.timeoutSeconds, 120) - XCTAssertEqual(pair.controller.settings.timeoutSeconds, 120) - XCTAssertEqual(pair.workflow.draft, pair.controller.settings, "提交后草稿回到已提交快照") } func testPACPortChangeRequiresConfirmationBeforeCommitting() async throws { let pair = makePair() - pair.workflow.draft.listen.pacPort = 13089 - await waitUntil(pair.workflow.occupancy[.pac] != nil) + pair.workflow.draft.pacPort = 13089 pair.workflow.save() - - XCTAssertNotNil(pair.workflow.pendingPACNotice) + XCTAssertNotNil(pair.workflow.pendingConfirmation) XCTAssertNil(settingsStore.saved, "未确认失效提示不得提交") pair.workflow.confirmPACNotice() await waitUntil(settingsStore.saved != nil) - XCTAssertNil(pair.workflow.pendingPACNotice) + XCTAssertNil(pair.workflow.pendingConfirmation) XCTAssertEqual(pair.controller.settings.listen.pacPort, 13089) } - func testCancelingPACNoticeDiscardsThePendingSave() async throws { - let pair = makePair() - - pair.workflow.draft.listen.pacPort = 13089 - await waitUntil(pair.workflow.occupancy[.pac] != nil) - pair.workflow.save() - pair.workflow.cancelPACNotice() - - XCTAssertNil(pair.workflow.pendingPACNotice) - XCTAssertNil(settingsStore.saved) - XCTAssertEqual(pair.controller.settings.listen.pacPort, 11089, "取消后已提交值保持") - } - - func testValidationErrorsBlockSave() async throws { - let pair = makePair() - - pair.workflow.draft.timeoutSeconds = 0 - XCTAssertFalse(pair.workflow.validationErrors.isEmpty) - XCTAssertFalse(pair.workflow.canSave) - - pair.workflow.save() - await waitUntil(!pair.workflow.isSaving) - XCTAssertNil(settingsStore.saved) - } - func testCommitFailureSurfacesPresentedReasonAndKeepsCommittedValues() async throws { settingsStore.saveError = FakeSaveError.io let pair = makePair() pair.workflow.draft.timeoutSeconds = 120 pair.workflow.save() - await waitUntil(pair.workflow.errorMessage != nil) + await waitUntil(pair.workflow.lastFailureMessage != nil) - XCTAssertTrue(pair.workflow.errorMessage?.contains("fake-io-error") == true) + XCTAssertTrue(pair.workflow.lastFailureMessage?.contains("fake-io-error") == true) XCTAssertNil(settingsStore.saved) XCTAssertEqual(pair.controller.settings.timeoutSeconds, 60, "失败保留旧值") XCTAssertEqual(pair.workflow.draft.timeoutSeconds, 120, "草稿保留待修改值") } - func testResetRestoresFactorySnapshotIntoTheDraft() async throws { + func testConfirmedResetRestoresFactorySnapshotThroughTheRuntimeController() async throws { let pair = makePair() var custom = pair.controller.settings custom.timeoutSeconds = 120 @@ -139,22 +104,14 @@ final class SettingsWorkflowTests: XCTestCase { XCTAssertEqual(pair.controller.settings.timeoutSeconds, 120) pair.workflow.reset() + XCTAssertNotNil(pair.workflow.pendingConfirmation, "重置恒先经 seam 裁定的确认") + pair.workflow.confirmReset() await waitUntil(pair.controller.settings == ProxySettings()) - XCTAssertEqual(pair.workflow.draft, ProxySettings()) + XCTAssertEqual(pair.workflow.draft, SettingsDraftAdapter.draft(from: ProxySettings())) XCTAssertNil(settingsStore.saved) } - func testSuggestPortWritesCandidateIntoDraftOnly() async throws { - let pair = makePair() - - pair.workflow.suggestPort(for: .socks) - await waitUntil(pair.workflow.draft.listen.socksPort != 11086) - - XCTAssertEqual(pair.workflow.draft.listen.socksPort, 32768, "高位段首个空闲且避开其他端点") - XCTAssertNil(settingsStore.saved, "建议只写草稿,必须经用户保存") - } - // MARK: - 夹具 private func makePair() -> (controller: ProxyRuntimeController, workflow: SettingsWorkflow) { @@ -174,7 +131,7 @@ final class SettingsWorkflowTests: XCTestCase { firewallExecutableURLs: [URL(fileURLWithPath: "/bundle/Helpers/sslocal")], firewallPollIntervalNanoseconds: 1_000_000, sendSignal: { _, _ in 0 }) - let workflow = SettingsWorkflow(controller: controller, occupancyProbe: probe) + let workflow = SettingsWorkflow(committing: controller, occupancyProbe: probe) return (controller, workflow) } @@ -192,19 +149,6 @@ final class SettingsWorkflowTests: XCTestCase { return pair } - private func waitUntil( - _ condition: @autoclosure () -> Bool, - timeout: TimeInterval = 2, - file: StaticString = #filePath, - line: UInt = #line - ) async { - let deadline = Date().addingTimeInterval(timeout) - while !condition() && Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - XCTAssertTrue(condition(), file: file, line: line) - } - private enum FakeSaveError: Error, CustomStringConvertible { case io @@ -229,32 +173,3 @@ final class SettingsWorkflowTests: XCTestCase { } } } - -/// 占用探测替身:按端口预设占用/不可判定,记录全部探测请求。 -final class FakeOccupancyProbe: PortOccupancyProbing, @unchecked Sendable { - private let lock = NSLock() - private let occupiedPorts: Set - private var unknownPorts: Set - private var requestedPorts: [Int] = [] - - init(occupiedPorts: Set = [], unknownPorts: Set = []) { - self.occupiedPorts = occupiedPorts - self.unknownPorts = unknownPorts - } - - var requested: [Int] { - lock.lock() - defer { lock.unlock() } - return requestedPorts - } - - func occupancy(port: Int, bindAddress: String) -> PortOccupancy { - lock.lock() - defer { lock.unlock() } - requestedPorts.append(port) - if unknownPorts.contains(port) { - return .unknown(detail: "无法判定") - } - return occupiedPorts.contains(port) ? .occupied(occupier: "other-app") : .free - } -}