refactor(ng2): deepen settings draft projection for new settings UI (#44)

加深 SettingsWorkflow,让平坦 UI 形状草稿成为编辑态唯一 source of truth,把
字段问题、端口占用事实、保存门禁、脏态、PAC 失效确认与重置确认收进 seam,
为重写设置 UI 提供稳定访问面。

- UI-facing interface 只含 UI 形状投影与 typed command:平坦 SettingsDraft
  (监听范围 = 布尔开关 + 公布地址文本,端口/超时为整数,不含持久化的当前
  模式)、按字段归位的 SettingsFieldIssue(端口问题用端口标识变体,每条载带
  点名文案)、按 SettingsPortID 索引的端口 field state(草稿值/类型化占用
  事实/字段问题/运行中端口例外/可否建议空闲端口)、保存门禁·脏态·提交中·
  失败点名文案投影、统一确认事实 SettingsConfirmation;离散动作走具名 typed
  command(save、reset、suggestFreePort(for:)、confirmPACNotice、
  cancelPACNotice、confirmReset、cancelReset、reloadFromCommitted)。
  module 不依赖 SwiftUI。
- 写入侧收窄为窄 seam SettingsCommitting(已提交快照、代理是否在跑、两个
  提交入口),ProxyRuntimeController 薄扩展即生产实现,测试注入 fake。
- 平坦草稿与 ProxySettings 互转集中在 SettingsDraftAdapter,校验错误归位与
  端口标识映射同属字段清单并收在同文件;Domain 快照类型只出现在后端缝与
  adapter。Domain 点名校验、端口互异/建议算法、PAC 失效判定与占用探测缝
  原样复用,行为不改写。
- 设置视图只绑投影与 typed command:删除 ListenScope 枚举拆装、探测层占用
  翻译、门禁推导与确认文案。重置确认摘要来自 seam 的范围裁定,并补齐重置
  事务的实际范围(全部偏好回出厂值、运行中代理停止);端口互异问题归位到
  冲突双方端口;隐藏的地址残留不再误报未保存修改。

用户可见变化:校验红字改为按字段归位显示;重置确认摘要由 seam 裁定。其余
行为与现状一致。

测试:新增 SettingsWorkflowInterfaceTests(30 项,fake 写入缝 + fake 探测)
覆盖校验归位、端口 field state、运行中端口例外、建议端口、保存门禁、脏态、
PAC/重置确认裁决、提交中与失败点名、占用探测代际;SettingsWorkflowTests
收敛为真实 ProxyRuntimeController 提交路径集成(5 项)。全量 427 项通过。
This commit is contained in:
Qiu Yuzhou
2026-09-22 22:28:39 +08:00
parent cdf1c8ab8f
commit 713c627deb
8 changed files with 1215 additions and 325 deletions
+3 -2
View File
@@ -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(
+125 -99
View File
@@ -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<Bool> {
Binding(
get: { workflow.pendingPACNotice != nil },
set: { presented in
if !presented, workflow.pendingPACNotice != nil {
workflow.cancelPACNotice()
}
})
}
}
extension SettingsView {
private func portRow(_ endpoint: ProxyEndpointKind, value: Binding<Int>) -> 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<Bool> {
private func portBinding(for id: SettingsPortID) -> Binding<Int> {
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<String> {
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<Bool> {
Binding(
get: { workflow.pendingConfirmation != nil },
set: { presented in
if !presented, let confirmation = workflow.pendingConfirmation {
confirmationPresentation(for: confirmation).cancel()
}
})
}
}
@@ -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)
}
}
}
@@ -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
}
@@ -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
}
}
}
@@ -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" }
}
}
@@ -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<Int>
private let unknownPorts: Set<Int>
private var requestedPorts: [Int] = []
private var answerOverride: PortOccupancy?
init(occupiedPorts: Set<Int> = [], unknownPorts: Set<Int> = []) {
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
}
}
@@ -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<Int>
private var unknownPorts: Set<Int>
private var requestedPorts: [Int] = []
init(occupiedPorts: Set<Int> = [], unknownPorts: Set<Int> = []) {
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
}
}