mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-31 06:33:44 +00:00
feat(settings): remove holistic preferences reset
Not every preference warrants a reset; future resets are per configuration item, added only where a field truly needs one (tracked in #69). The whole chain goes: the 恢复默认 button and its confirmation alert in SettingsView, the SettingsWorkflow reset/confirm/cancel commands and the confirmation machinery they existed for (the SettingsConfirmation enum had a single case), the SettingsCommitting.resetPreferences seam entry, the runtime resetPreferences() and ProxySettingsFileStore.reset() with its file-rollback helpers and the rollbackFailed store error. CONTEXT.md drops the three stale reset phrases and now records the domain decision: no restore-all-factory-defaults action for preferences; a reset, where it exists, is scoped to one configuration item. ADR-0004/0005/0011 carry amendment notes since their reset sentences describe a removed capability (0004's also predate the PAC removal).
This commit is contained in:
+4
-3
@@ -17,9 +17,9 @@
|
||||
- **Built-in rule snapshot**: An app-shipped, pinned rule set produced by an explicit maintainer update; ordinary builds only read local snapshots and never fetch or convert. Three sources ship today: **geolocation-cn** (China domain direct candidates), **china-ipv4** (China mainland IPv4 CIDR direct candidates from gaoyifan/china-operator-ip), and **gfwlist** (proxy candidates from the official Base64 AutoProxy GFWList; only rules losslessly expressible as target domains, with `@@` exceptions shadowed by a broader proxy rule omitted and reported). Each snapshot carries its own source identity, digest, license, attribution, and conversion loss report.
|
||||
- **Custom rule**: A user-persisted proxy or direct rule with an exact domain, domain suffix, or IPv4/IPv6 CIDR match and a stable opaque UUID. Custom rules merge with the active rule sub-mode's built-in sources into the sslocal ACL; global and direct modes do not load them. Rules that conflict with the fixed local scope or are shadowed by sslocal's domain-proxy / IP-direct priority are rejected at save or compile with an explainable reason and are never marked in effect. Diagnostics expose only a count and content-version summary, never raw custom domains. _Avoid_: user rule.
|
||||
- **Proxy mode selector**: The common user action for choosing rule, global, or direct mode; it is distinct from enabling or disabling the proxy runtime, from the system proxy switch, and from custom-rule management. Its choice persists across GUI restarts.
|
||||
- **Agent switch**: The user's independent on/off choice for the proxy agent (the per-user background proxy runtime). It defaults to off: a fresh user has no server configuration, so neither install nor first launch starts the agent until the user turns it on. An explicit choice is persisted, survives GUI restarts, and quitting the GUI never stops the launchd-hosted agent once it is running. The settings view does not expose it, and resetting preferences never changes the choice. Turning the agent off first restores the system proxy settings 2.0 holds and then stops the local listeners. _Avoid_: proxy switch (ambiguous with the system proxy switch).
|
||||
- **Agent switch**: The user's independent on/off choice for the proxy agent (the per-user background proxy runtime). It defaults to off: a fresh user has no server configuration, so neither install nor first launch starts the agent until the user turns it on. An explicit choice is persisted, survives GUI restarts, and quitting the GUI never stops the launchd-hosted agent once it is running. The settings view does not expose it. Turning the agent off first restores the system proxy settings 2.0 holds and then stops the local listeners. _Avoid_: proxy switch (ambiguous with the system proxy switch).
|
||||
- **System proxy switch**: The user's independent choice to have 2.0 take over macOS system proxy settings. It defaults to off, so a first run never rewrites the user's proxy dictionary. Turning it off restores only the settings 2.0 holds and never unregisters the agent or stops the local SOCKS/HTTP listeners. Its intent (persisted), actual application, and typed failures are presented as separate facts, distinct from the agent's run state. _Avoid_: proxy switch (ambiguous with the agent switch).
|
||||
- **Login item**: The GUI app's registration in the user's login items. It is an independent preference domain: off until the user enables it, its state is only ever the system login-item registration status, and resetting preferences never changes it.
|
||||
- **Login item**: The GUI app's registration in the user's login items. It is an independent preference domain: off until the user enables it, and its state is only ever the system login-item registration status.
|
||||
- **HTTP proxy inbound**: The permanent local inbound that accepts HTTP/HTTPS proxy requests through the external tunnel service and its active ACL; it is not a user preference and cannot be disabled, and it is distinct from proxy modes and from the Legacy Privoxy adapter. _Avoid_: HTTP proxy mode.
|
||||
- **SOCKS5 UDP relay**: The fixed capability of the local SOCKS5 endpoint to accept UDP ASSOCIATE traffic and relay it through the proxy runtime. It is not a user preference and does not imply that all system UDP traffic is proxied. _Avoid_: system-wide UDP proxy, automatic UDP proxying.
|
||||
- **Legacy Privoxy adapter**: The frozen implementation's HTTP(S)-to-local-SOCKS5 bridge. It is a Legacy-only dependency and is not migrated into 2.0.
|
||||
@@ -79,7 +79,8 @@
|
||||
- A diagnostic report presents fixed proxy-state categories rather than free-form failure detail. If its safe projection cannot be constructed, no report is emitted; an unavailable individual fact source remains a marked partial fact instead.
|
||||
- A diagnostic export is considered complete only after the user-selected report file is successfully written; opening the export panel or preparing report text is not an export completion.
|
||||
- Proxy modes are mutually exclusive: rule mode with proxy-when-unmatched writes the local SOCKS proxy and runs sslocal with a `[proxy_all]` ACL whose bypass list is the fixed local safety rules plus China domain and China IPv4 CIDR direct candidates from the built-in snapshots; rule mode with direct-when-unmatched runs sslocal with a `[bypass_all]` ACL whose proxy list holds only expressible, unshadowed GFWList proxy candidates (the two default actions never unconditionally union all built-in sources), so unmatched targets and IP literals with no matching rule go direct; global writes the local SOCKS proxy and runs sslocal with a `[proxy_all]` ACL whose bypass list is only the fixed local safety rules (no China list, GFWList, or custom rules); direct mode writes the local SOCKS proxy and runs sslocal with `[bypass_all]`, so SOCKS and HTTP requests go direct without a Shadowsocks server. IP-CIDR matching may cause sslocal to issue local DNS queries for unmatched hostnames; the product does not promise that all DNS queries travel through the remote Shadowsocks server. In every mode the SystemConfiguration exceptions include the same fixed local scope — IPv4 loopback, RFC1918, link-local, IPv6 loopback, link-local, unique-local, `localhost`, `.local`, and no-dot hostnames — merged with the user's exception list; CGNAT is excluded. IPv6 CIDR strings in system exceptions are not a verified bypass; the ACL is the routing guarantee for requests that enter sslocal, including LAN-sharing clients. GFWList `@@` exceptions shadowed by a broader proxy rule are omitted from the ACL and recorded in the conversion report; the product does not claim complete reproduction of GFWList URL/protocol conditions. A change to the active ACL's summary or digest requires a full sslocal restart; the ACL's runtime path is a stable reference and does not change when the proxy mode switches. The wrapper publishes the ACL-backed receipt only after the child owns every configured TCP listener; the controller retains the previous mode, runtime, and applied system proxy until this instance is verified, and restores the previous runtime on verification failure. System proxy writes are allowed only while the system proxy switch is on and the agent is healthy with an available exit; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict. When a rule/global active target becomes invalid, the agent keeps listening but the held system proxy configuration is safely withdrawn while the system proxy intent stays on. Only traffic that honors the system SOCKS setting or explicitly connects to the local inbound is routed; the product does not claim interception of all application or UDP traffic.
|
||||
- The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available. Resetting preferences returns the mode to rule (proxy-when-unmatched) and the rule default action to proxy-when-unmatched.
|
||||
- Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once.
|
||||
- The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available.
|
||||
- The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model.
|
||||
- The presentation form is governed solely by the workspace window: opening it enters the foreground form, and closing it returns the app to the background form while the GUI process keeps running with the status menu available; hiding the window without closing it does not leave the foreground form.
|
||||
- Every launch of the GUI presents the workspace window at startup.
|
||||
|
||||
@@ -126,13 +126,6 @@ extension AppPresentation {
|
||||
}
|
||||
}
|
||||
|
||||
static func message(for confirmation: SettingsConfirmation) -> String {
|
||||
switch confirmation {
|
||||
case .resetPreferences:
|
||||
return "端口、监听范围等全部偏好都会恢复为出厂值,运行中的代理会停止。"
|
||||
}
|
||||
}
|
||||
|
||||
static func message(for issue: SettingsFieldIssue) -> String {
|
||||
switch issue {
|
||||
case .port(_, let error), .advertisedAddress(let error), .timeoutSeconds(let error):
|
||||
@@ -294,7 +287,6 @@ extension AppPresentation {
|
||||
case .ioFailure: return "偏好文件读写失败"
|
||||
case .missingCredential: return "偏好中的敏感 URL 无法从钥匙串读取"
|
||||
case .credentialFailure: return "偏好中的敏感 URL 无法写入钥匙串"
|
||||
case .rollbackFailed: return "偏好保存失败,旧设置或凭据未能完整恢复"
|
||||
case .legacyListenSettings(let error): return "旧版监听设置无法读取:\(listenStore(error))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,34 +101,6 @@ extension ProxyRuntimeController {
|
||||
return settingsRuntimeOutcome()
|
||||
}
|
||||
|
||||
/// Restores factory defaults and rewrites the persisted snapshot. The agent
|
||||
/// switch is an independent choice: reset never changes it and never stops a
|
||||
/// running agent (ADR-0011). Factory intents are agent off / system proxy off,
|
||||
/// so a held system-proxy application is returned and a still-enabled agent
|
||||
/// converges onto the factory runtime instead of keeping a stale contract.
|
||||
func resetPreferences() async throws -> SettingsRuntimeOutcome {
|
||||
let preservedAgentEnabled = settings.agentEnabled
|
||||
let heldSystemProxy = settings.systemProxyEnabled
|
||||
try settingsStore.reset()
|
||||
settings = ProxySettings()
|
||||
settings.agentEnabled = preservedAgentEnabled
|
||||
try settingsStore.save(settings)
|
||||
listenSettingsUnreadable = false
|
||||
settingsUnreadable = false
|
||||
proxyMode = .rule
|
||||
lastActivationFailure = nil
|
||||
if heldSystemProxy {
|
||||
systemProxyState = restoreSystemProxyOutcome()
|
||||
}
|
||||
if preservedAgentEnabled, state != .off {
|
||||
await convergeAgent()
|
||||
}
|
||||
if case .failed(let facts) = systemProxyState {
|
||||
return .failed(.systemProxy(facts))
|
||||
}
|
||||
return preservedAgentEnabled ? settingsRuntimeOutcome() : .stopped
|
||||
}
|
||||
|
||||
/// Applies the post-import 2.0 runtime boundary without touching
|
||||
/// SystemConfiguration. Importing data leaves the user's system proxy
|
||||
/// dictionary untouched; any currently running 2.0 runtime is stopped and
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 设置分区(issue #33/#44,地图 #52 票 #57):按原型重排为常规 / 代理端点 /
|
||||
/// 高级三张分组卡,行式呈现(主文案 + 次说明 + 行尾控件);保存/恢复默认是
|
||||
/// 表单级提交动作,固定在本视图内容顶部行(不进窗口工具栏,避免动作与表单
|
||||
/// 脱节),确认弹窗由本视图的 alert 呈现。
|
||||
/// 高级三张分组卡,行式呈现(主文案 + 次说明 + 行尾控件);保存是表单级提交
|
||||
/// 动作,固定在本视图内容顶部行(不进窗口工具栏,避免动作与表单脱节)。
|
||||
/// 字段绑定编辑 workflow 的扁平 UI-shaped draft;issues 按字段渲染;端口行读
|
||||
/// typed field state;是否需要确认及摘要来自 seam 的统一事实;登录项开关
|
||||
/// 绑定独立控制器。
|
||||
/// typed field state;登录项开关绑定独立控制器。
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var workflow: SettingsWorkflow
|
||||
@ObservedObject var loginController: LaunchAtLoginController
|
||||
@@ -26,32 +24,15 @@ struct SettingsView: View {
|
||||
.onAppear {
|
||||
Task { _ = await workflow.reloadFromCommitted() }
|
||||
}
|
||||
.alert(
|
||||
workflow.pendingConfirmation.map { confirmationPresentation(for: $0).title } ?? "",
|
||||
isPresented: confirmationBinding,
|
||||
presenting: workflow.pendingConfirmation
|
||||
) { confirmation in
|
||||
let presentation = confirmationPresentation(for: confirmation)
|
||||
Button(
|
||||
presentation.confirmTitle, role: presentation.confirmRole, action: presentation.confirm)
|
||||
Button("取消", role: .cancel, action: presentation.cancel)
|
||||
} message: { confirmation in
|
||||
Text(AppPresentation.message(for: confirmation))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 提交动作行(内容顶部,固定不随表单滚动)
|
||||
|
||||
/// 保存/恢复默认(原壳动作槽位,票 #57):表单级提交动作与表单同置;
|
||||
/// 保存走 Return 默认键位。恢复默认的确认经 pendingConfirmation 由本视图
|
||||
/// alert 呈现。
|
||||
/// 保存(原壳动作槽位,票 #57):表单级提交动作与表单同置;保存走 Return
|
||||
/// 默认键位。
|
||||
private var commitActionsRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
Spacer(minLength: 0)
|
||||
Button("恢复默认") {
|
||||
Task { _ = await workflow.reset() }
|
||||
}
|
||||
.disabled(workflow.isCommitting)
|
||||
Button(workflow.isCommitting ? "保存中…" : "保存设置") {
|
||||
Task { _ = await workflow.save() }
|
||||
}
|
||||
@@ -233,44 +214,6 @@ struct SettingsView: View {
|
||||
control()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 确认 alert 呈现(视图只持有呈现状态,事实来自 seam)
|
||||
|
||||
private func confirmationPresentation(
|
||||
for confirmation: SettingsConfirmation
|
||||
) -> ConfirmationPresentation {
|
||||
switch confirmation {
|
||||
case .resetPreferences:
|
||||
ConfirmationPresentation(
|
||||
title: "重置所有偏好?",
|
||||
confirmTitle: "重置",
|
||||
confirmRole: .destructive,
|
||||
confirm: { Task { _ = await workflow.confirmReset() } },
|
||||
cancel: { Task { _ = await workflow.cancelReset() } })
|
||||
}
|
||||
}
|
||||
|
||||
private var confirmationBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { workflow.pendingConfirmation != nil },
|
||||
set: { presented in
|
||||
if !presented, let confirmation = workflow.pendingConfirmation {
|
||||
confirmationPresentation(for: confirmation).cancel()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 确认 alert 呈现(同文件扩展,保持 private 访问)
|
||||
|
||||
extension SettingsView {
|
||||
private struct ConfirmationPresentation {
|
||||
let title: String
|
||||
let confirmTitle: String
|
||||
let confirmRole: ButtonRole?
|
||||
let confirm: () -> Void
|
||||
let cancel: () -> Void
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 端口行(同文件扩展,保持 private 访问)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// 写入侧窄 seam(issue #48):设置工作流只依赖已提交快照、当前 runtime 的
|
||||
/// typed listener facts 与两个提交入口。生产实现是 `ProxyRuntimeController`
|
||||
/// typed listener facts 与提交入口。生产实现是 `ProxyRuntimeController`
|
||||
/// 的薄扩展;测试注入 fake。Domain 快照类型只出现在本缝与 adapter。读取侧是
|
||||
/// 运行时控制器的主 actor 状态,本缝随之主 actor 绑定。
|
||||
@MainActor
|
||||
@@ -9,7 +9,6 @@ protocol SettingsCommitting: AnyObject {
|
||||
var committedSettings: ProxySettings { get }
|
||||
var runtimeListenFacts: RuntimeListenFacts? { get }
|
||||
func updateSettings(_ proposed: ProxySettings) async throws -> SettingsRuntimeOutcome
|
||||
func resetPreferences() async throws -> SettingsRuntimeOutcome
|
||||
}
|
||||
|
||||
extension ProxyRuntimeController: SettingsCommitting {
|
||||
|
||||
@@ -2,10 +2,9 @@ import Foundation
|
||||
|
||||
/// 设置工作流 module(issue #48):设置窗口的唯一 UI-facing seam。以平坦的
|
||||
/// UI 形状草稿为编辑态唯一 source of truth,向 UI 只提供字段归位的校验问题、
|
||||
/// 端口 field state、保存门禁/脏态/提交中/typed outcome、统一确认事实与具名
|
||||
/// async command。视图不再拆装 Domain 枚举、翻译占用事实、推导门禁或自备
|
||||
/// 确认文案;alert、sheet 与窗口状态仍由 UI 持有。登录启动项是独立偏好域,
|
||||
/// 不经本 module。
|
||||
/// 端口 field state、保存门禁/脏态/提交中/typed outcome 与具名 async command。
|
||||
/// 视图不再拆装 Domain 枚举或翻译占用事实;alert、sheet 与窗口状态仍由 UI
|
||||
/// 持有。登录启动项是独立偏好域,不经本 module。
|
||||
///
|
||||
/// 内部沿用既有深 module:`ProxySettings` 的点名校验、`ProxyPortSemantics` 的
|
||||
/// 端口互异/建议算法与 typed occupancy request。写入侧只依赖窄 seam
|
||||
@@ -23,14 +22,12 @@ final class SettingsWorkflow: ObservableObject {
|
||||
|
||||
/// 各端口的尽力而为占用判定(非权威,只影响编辑期提示与保存门禁)。
|
||||
@Published private var occupancyByPort: [SettingsPortID: SettingsPortOccupancy] = [:]
|
||||
/// 提交/重置进行中(按钮进入进行中状态且不可重复触发)。
|
||||
/// 提交进行中(按钮进入进行中状态且不可重复触发)。
|
||||
@Published private(set) var isCommitting = false
|
||||
/// 最近一次提交/重置失败的 typed fact(nil = 无)。
|
||||
/// 最近一次提交失败的 typed fact(nil = 无)。
|
||||
@Published private(set) var lastFailure: SettingsWorkflowFailure?
|
||||
/// 最近一次离散 command 的结构化结果,供 UI 观察而无需解析字符串。
|
||||
@Published private(set) var lastOutcome: SettingsCommandOutcome?
|
||||
/// 等待用户裁定的确认事实(重置偏好);视图只持有 alert 呈现状态。
|
||||
@Published private(set) var pendingConfirmation: SettingsConfirmation?
|
||||
|
||||
private let committing: SettingsCommitting
|
||||
private let occupancyProbe: PortOccupancyProbing
|
||||
@@ -95,12 +92,11 @@ final class SettingsWorkflow: ObservableObject {
|
||||
|
||||
// MARK: - 操作区只读投影
|
||||
|
||||
/// 保存门禁:校验问题清零、无阻塞性占用、不在提交中且没有待裁定确认。
|
||||
/// 保存门禁:校验问题清零、无阻塞性占用且不在提交中。
|
||||
var canSave: Bool {
|
||||
fieldIssues.isEmpty
|
||||
&& blockingPortIDs.isEmpty
|
||||
&& !isCommitting
|
||||
&& pendingConfirmation == nil
|
||||
}
|
||||
|
||||
/// 脏态:草稿若提交会不会改变已提交快照(草稿经唯一 adapter 归一后比较,
|
||||
@@ -116,9 +112,6 @@ final class SettingsWorkflow: ObservableObject {
|
||||
@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)))
|
||||
}
|
||||
@@ -128,51 +121,6 @@ final class SettingsWorkflow: ObservableObject {
|
||||
return await commit(makeSettings(from: draft))
|
||||
}
|
||||
|
||||
/// 重置偏好:seam 裁定恒需确认,挂起重置确认事实(摘要范围与重置事务
|
||||
/// 一致)。登录启动项与快捷键意图是独立偏好域,不在此事务内。
|
||||
@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))
|
||||
}
|
||||
|
||||
/// 用户裁定重置后走重置提交入口:恢复出厂值并停止运行中的代理。
|
||||
@discardableResult
|
||||
func confirmReset() async -> SettingsCommandOutcome {
|
||||
guard case .resetPreferences = pendingConfirmation else {
|
||||
return record(.rejected(.noPendingConfirmation))
|
||||
}
|
||||
pendingConfirmation = nil
|
||||
guard !isCommitting else { return record(.rejected(.inProgress)) }
|
||||
isCommitting = true
|
||||
lastFailure = nil
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消重置:不发生任何提交。
|
||||
@discardableResult
|
||||
func cancelReset() async -> SettingsCommandOutcome {
|
||||
guard case .resetPreferences = pendingConfirmation else {
|
||||
return record(.rejected(.noPendingConfirmation))
|
||||
}
|
||||
pendingConfirmation = nil
|
||||
return record(.confirmationCancelled)
|
||||
}
|
||||
|
||||
/// 为端口建议一个空闲端口:只把候选写进草稿对应字段,绝不替用户保存。
|
||||
@discardableResult
|
||||
func suggestFreePort(for id: SettingsPortID) async -> SettingsCommandOutcome {
|
||||
|
||||
@@ -93,25 +93,16 @@ struct SettingsPortFieldState: Equatable, Sendable {
|
||||
let canSuggestFreePort: Bool
|
||||
}
|
||||
|
||||
// MARK: - 统一确认事实
|
||||
|
||||
/// 确认事实(统一种类):seam 裁定动作是否需要确认及摘要内容;视图只持有
|
||||
/// alert 呈现状态并把用户选择作为 typed command 发回。
|
||||
enum SettingsConfirmation: Equatable, Sendable {
|
||||
/// 重置偏好;范围由这个 typed fact 固定,文案由 presentation edge 派生。
|
||||
case resetPreferences
|
||||
}
|
||||
// MARK: - 命令结果与拒绝原因
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -130,12 +121,10 @@ enum SettingsPersistenceFailure: Error, Equatable, Sendable {
|
||||
case unknown
|
||||
}
|
||||
|
||||
/// Result returned by every discrete SettingsWorkflow command. Confirmation,
|
||||
/// draft-only changes, persistence and runtime facts remain distinguishable.
|
||||
/// Result returned by every discrete SettingsWorkflow command. 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)
|
||||
|
||||
@@ -97,7 +97,6 @@ enum ProxySettingsValidationError: Error, Equatable, Sendable {
|
||||
protocol ProxySettingsStoring {
|
||||
func load() throws -> ProxySettings
|
||||
func save(_ settings: ProxySettings) throws
|
||||
func reset() throws
|
||||
}
|
||||
|
||||
enum ProxySettingsStoreError: Error, Equatable {
|
||||
@@ -106,7 +105,6 @@ enum ProxySettingsStoreError: Error, Equatable {
|
||||
case ioFailure(detail: String)
|
||||
case missingCredential(CredentialReference)
|
||||
case credentialFailure(detail: String)
|
||||
case rollbackFailed
|
||||
case legacyListenSettings(ListenSettingsStoreError)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,23 +63,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
|
||||
}
|
||||
}
|
||||
|
||||
func reset() throws {
|
||||
let settingsSnapshot = try fileSnapshot(at: fileURL)
|
||||
let legacySnapshot = try fileSnapshot(at: legacyListenFileURL)
|
||||
do {
|
||||
try removeIfPresent(fileURL)
|
||||
try removeIfPresent(legacyListenFileURL)
|
||||
} catch {
|
||||
let filesRestored =
|
||||
restore(settingsSnapshot, at: fileURL)
|
||||
&& restore(legacySnapshot, at: legacyListenFileURL)
|
||||
guard filesRestored else {
|
||||
throw ProxySettingsStoreError.rollbackFailed
|
||||
}
|
||||
throw asStoreError(error)
|
||||
}
|
||||
}
|
||||
|
||||
static func restored(store: ProxySettingsFileStore = ProxySettingsFileStore())
|
||||
-> RestoredProxySettings
|
||||
{
|
||||
@@ -151,47 +134,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}()
|
||||
|
||||
private func asStoreError(_ error: Error) -> ProxySettingsStoreError {
|
||||
if let error = error as? ProxySettingsStoreError { return 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 {
|
||||
|
||||
@@ -65,35 +65,6 @@ extension ProxyRuntimeControllerTests {
|
||||
XCTAssertTrue(systemProxy.applied.isEmpty, "导入边界不应重新应用系统代理")
|
||||
}
|
||||
|
||||
/// 偏好重置不触碰 Agent switch,也不停掉正在运行的 Agent(ADR-0011 连带);
|
||||
/// 系统代理意图被重置为 off 时归还已应用设置,Agent 仍开则收敛到出厂运行时。
|
||||
func testResetPreferencesKeepsAgentChoiceAndDoesNotStopAgent() async throws {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
||||
settingsStore: settingsStore,
|
||||
settings: ProxySettings(
|
||||
listen: ActivationFixture.listen, agentEnabled: true, systemProxyEnabled: true))
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setAgentEnabled(true)
|
||||
XCTAssertEqual(controller.state, .running)
|
||||
XCTAssertEqual(controller.systemProxyState, .applied)
|
||||
// 夹具约定的存活 wrapper pid:收敛路径才走 signalReload 而不是重注册。
|
||||
try Data("42".utf8).write(to: runtime.pidFile)
|
||||
let unregisterBefore = agent.unregisterCount
|
||||
|
||||
let outcome = try await controller.resetPreferences()
|
||||
|
||||
XCTAssertEqual(controller.settings.agentEnabled, true, "Agent switch 不受偏好重置影响")
|
||||
XCTAssertEqual(settingsStore.saved?.agentEnabled, true)
|
||||
XCTAssertFalse(controller.settings.systemProxyEnabled, "系统代理意图回到出厂 off")
|
||||
XCTAssertEqual(systemProxy.restoreCount, 1, "已应用的系统代理设置被归还")
|
||||
XCTAssertEqual(agent.unregisterCount, unregisterBefore, "重置不停止 Agent")
|
||||
XCTAssertEqual(controller.state, .running, "保留的 Agent 收敛到出厂运行时")
|
||||
XCTAssertNotEqual(outcome, .stopped, "重置不是停止协议")
|
||||
}
|
||||
|
||||
func testUpdatingSettingsPersistsAndReactivatesTheRuntimeContract() async throws {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
|
||||
@@ -285,8 +285,4 @@ final class InMemoryProxySettingsStore: ProxySettingsStoring {
|
||||
if let saveError { throw saveError }
|
||||
saved = settings
|
||||
}
|
||||
|
||||
func reset() throws {
|
||||
saved = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +129,4 @@ final class ProxySettingsFileStoreTests: XCTestCase {
|
||||
XCTAssertEqual(loaded.listen, legacy)
|
||||
XCTAssertEqual(loaded.timeoutSeconds, 60)
|
||||
}
|
||||
|
||||
func testResetRemovesSettingsAndLeavesFactoryDefaultsAvailable() throws {
|
||||
try store.save(ProxySettings())
|
||||
try store.reset()
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: store.fileURL.path))
|
||||
XCTAssertEqual(try store.load(), ProxySettings())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,17 +62,6 @@ extension SettingsWorkflowInterfaceTests {
|
||||
XCTAssertEqual(committing.updateCalls.count, 1, "提交中不得重复触发")
|
||||
}
|
||||
|
||||
func testResetFailureSurfacesNamedReason() async throws {
|
||||
committing.resetError = FakeCommitError.io
|
||||
let workflow = makeWorkflow()
|
||||
|
||||
_ = await workflow.reset()
|
||||
_ = await workflow.confirmReset()
|
||||
await waitUntil(workflow.lastFailure != nil)
|
||||
|
||||
XCTAssertEqual(workflow.lastFailure, .unknown)
|
||||
}
|
||||
|
||||
private enum FakeCommitError: Error, CustomStringConvertible {
|
||||
// swiftlint:disable:next identifier_name
|
||||
case io
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
extension SettingsWorkflowInterfaceTests {
|
||||
// MARK: - 重置确认
|
||||
|
||||
func testResetArmsConfirmationWithScopeSummaryFromSeam() async throws {
|
||||
let workflow = makeWorkflow()
|
||||
|
||||
_ = await workflow.reset()
|
||||
|
||||
guard case .resetPreferences? = workflow.pendingConfirmation else {
|
||||
return XCTFail("应挂起重置确认,实际 \(String(describing: workflow.pendingConfirmation))")
|
||||
}
|
||||
let summary = AppPresentation.message(for: .resetPreferences)
|
||||
XCTAssertTrue(summary.contains("端口"))
|
||||
XCTAssertTrue(summary.contains("监听范围"))
|
||||
XCTAssertTrue(summary.contains("全部偏好"), "摘要范围与重置事务一致")
|
||||
XCTAssertTrue(summary.contains("代理"), "摘要点名运行中代理会停止")
|
||||
XCTAssertTrue(committing.updateCalls.isEmpty)
|
||||
XCTAssertEqual(committing.resetCallCount, 0)
|
||||
}
|
||||
|
||||
func testConfirmingResetUsesTheResetCommitEntry() async throws {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.draft.timeoutSeconds = 120
|
||||
|
||||
_ = await workflow.reset()
|
||||
_ = await workflow.confirmReset()
|
||||
await waitUntil(!workflow.isCommitting)
|
||||
XCTAssertEqual(committing.resetCallCount, 1)
|
||||
|
||||
XCTAssertNil(workflow.pendingConfirmation)
|
||||
XCTAssertTrue(committing.updateCalls.isEmpty, "重置走重置提交入口,不走设置提交")
|
||||
XCTAssertEqual(workflow.draft, SettingsDraftAdapter.draft(from: ProxySettings()))
|
||||
}
|
||||
|
||||
func testResetReturnsPersistedDefaultsAndIndependentRuntimeFailure() async throws {
|
||||
let runtimeFailure = RuntimeFailureFacts.systemProxy(.ownershipConflict)
|
||||
committing.resetOutcome = .failed(runtimeFailure)
|
||||
let workflow = makeWorkflow()
|
||||
|
||||
_ = 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)
|
||||
XCTAssertTrue(committing.updateCalls.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ extension SettingsWorkflowInterfaceTests {
|
||||
XCTAssertFalse(workflow.canSave)
|
||||
|
||||
_ = await workflow.save()
|
||||
await waitUntil(!workflow.isCommitting && workflow.pendingConfirmation == nil)
|
||||
await waitUntil(!workflow.isCommitting)
|
||||
XCTAssertTrue(committing.updateCalls.isEmpty)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,19 +17,16 @@ func waitUntil(
|
||||
XCTAssertTrue(condition(), file: file, line: line)
|
||||
}
|
||||
|
||||
/// 设置工作流测试的写入缝替身(issue #44):记录提交与重置调用,可注入
|
||||
/// 失败;不触碰真实偏好文件、钥匙串或运行时。
|
||||
/// 设置工作流测试的写入缝替身(issue #44):记录提交调用,可注入失败;
|
||||
/// 不触碰真实偏好文件、钥匙串或运行时。
|
||||
@MainActor
|
||||
final class FakeSettingsCommitter: SettingsCommitting {
|
||||
var committedSettings: ProxySettings = ProxySettings()
|
||||
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
|
||||
|
||||
var isProxyRunning: Bool {
|
||||
get { runtimeListenFacts != nil }
|
||||
@@ -45,13 +42,6 @@ final class FakeSettingsCommitter: SettingsCommitting {
|
||||
committedSettings = proposed
|
||||
return updateOutcome
|
||||
}
|
||||
|
||||
func resetPreferences() async throws -> SettingsRuntimeOutcome {
|
||||
if let resetError { throw resetError }
|
||||
resetCallCount += 1
|
||||
committedSettings = ProxySettings()
|
||||
return resetOutcome
|
||||
}
|
||||
}
|
||||
|
||||
/// 可重复使用的异步门闩,用于证明 workflow 在提交等待期间拒绝第二次命令。
|
||||
|
||||
@@ -80,23 +80,6 @@ final class SettingsWorkflowTests: XCTestCase {
|
||||
XCTAssertEqual(pair.workflow.draft.timeoutSeconds, 120, "草稿保留待修改值")
|
||||
}
|
||||
|
||||
func testConfirmedResetRestoresFactorySnapshotThroughTheRuntimeController() async throws {
|
||||
let pair = makePair()
|
||||
var custom = pair.controller.settings
|
||||
custom.timeoutSeconds = 120
|
||||
_ = try await pair.controller.updateSettings(custom)
|
||||
XCTAssertEqual(pair.controller.settings.timeoutSeconds, 120)
|
||||
|
||||
_ = await pair.workflow.reset()
|
||||
XCTAssertNotNil(pair.workflow.pendingConfirmation, "重置恒先经 seam 裁定的确认")
|
||||
_ = await pair.workflow.confirmReset()
|
||||
|
||||
XCTAssertEqual(pair.workflow.draft, SettingsDraftAdapter.draft(from: ProxySettings()))
|
||||
XCTAssertEqual(
|
||||
settingsStore.saved, ProxySettings(),
|
||||
"重置后落盘的是出厂默认;Agent switch 的现值被特判保留(此处默认即 off)")
|
||||
}
|
||||
|
||||
// MARK: - 夹具
|
||||
|
||||
private func makePair() -> (controller: ProxyRuntimeController, workflow: SettingsWorkflow) {
|
||||
@@ -162,9 +145,5 @@ final class SettingsWorkflowTests: XCTestCase {
|
||||
if let saveError { throw saveError }
|
||||
saved = settings
|
||||
}
|
||||
|
||||
func reset() throws {
|
||||
saved = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ The proxy mode selector (status menu, the single entry point since the settings-
|
||||
|
||||
## Status
|
||||
|
||||
Superseded by [ADR-0007](0007-remove-manual-and-external-pac.md).
|
||||
Superseded by [ADR-0007](0007-remove-manual-and-external-pac.md). (2026-09: the holistic preferences reset referenced below has since been removed from the product; the reset and PAC wording here is historical — the factory mode was rule before that removal.)
|
||||
|
||||
## Considered options
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ The GUI's login-item registration is its own preference domain, separate from pr
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
Accepted. (2026-09: the holistic preferences reset referenced below has since been removed from the product; the statements about it describe a capability that no longer exists.)
|
||||
|
||||
## Considered options
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**Status**: accepted
|
||||
|
||||
(2026-09: the holistic preferences reset referenced below has since been removed from the product.)
|
||||
|
||||
**Supersedes in part**: ADR-0009 and ADR-0010's "ACL path, content, digest, and summary are runtime identity" — content is no longer carried in the contract, and the ACL path is a stable link that does not change across mode switches. The restart-on-change rule itself stands.
|
||||
|
||||
## Context
|
||||
|
||||
Reference in New Issue
Block a user