mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-02 16:53:45 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user