feat(ng2): finalize settings workflow persistence boundary

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