mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 13:13:47 +00:00
feat(ng2): finalize settings workflow persistence boundary
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user