mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-02 16:53:45 +00:00
feat(ng2): persist proxy mode selection and drop enabled-modes
模式选择(菜单唯一入口)随设置快照持久化,GUI 重启恢复所选模式;持久化 失败保留旧模式并点名呈现,不静默切换。外部 PAC 模式下保存设置时,模式从 新快照解析后与 URL 一起作为一个逻辑变更提交(清空 URL 回落 PAC 并同步 持久化),校验失败先于写入。重置偏好把模式回 PAC。 enabledModes 自 ADR-0002 停止偏好迁移后无任何写入方,且 2.0 无编辑 UI, 按死状态删除:内置三模式恒可选,外部 PAC 仍以已配置的有效 URL 为门槛 (ADR-0004)。 测试:模式持久化与重启恢复、持久化失败保留旧值、外部 PAC 一个逻辑变更 回落;makeController 的 proxyMode 参数改为可选以便测试走恢复路径。
This commit is contained in:
+2
-1
@@ -12,7 +12,7 @@
|
||||
- **Activation candidate**: A server configuration with no known local blocking problem for proxy configuration generation. This status does not guarantee remote reachability or correctness of opaque plugin options.
|
||||
- **Managed plugin**: A SIP003 plugin executable that the product packages, hash-verifies, re-signs, and distributes only inside the app bundle. The managed set changes only with app releases; users cannot supply their own plugin executables.
|
||||
- **Proxy mode**: The user-facing way traffic is routed through the local proxy, such as PAC, global, manual, or an external PAC configuration.
|
||||
- **Proxy mode selector**: The common user action for choosing one mutually exclusive proxy mode; it is distinct from enabling or disabling the proxy runtime and from configuring an external PAC URL.
|
||||
- **Proxy mode selector**: The common user action for choosing one mutually exclusive proxy mode; it is distinct from enabling or disabling the proxy runtime and from configuring an external PAC URL. Its choice persists across GUI restarts.
|
||||
- **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.
|
||||
- **HTTP proxy mode**: A local proxy mode that accepts HTTP/HTTPS proxy requests directly through the Shadowsocks runtime; it does not require a separate adapter process.
|
||||
- **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.
|
||||
@@ -58,4 +58,5 @@
|
||||
- The ports of the locally provided proxy endpoints are explicit user configuration: the system never rewrites a port on its own, and a port conflict rejects proxy start with an error naming the endpoint until the user explicitly chooses a new port. A recovery suggestion only proposes candidate ports (32768–65535, free and distinct from the other endpoints' configured values) and applies only after the user confirms and saves; changing the PAC port warns that previously shared PAC URLs become invalid.
|
||||
- The PAC HTTP endpoint and the external tunnel service start and stop together inside the proxy runtime: both are reachable exactly while proxying is on, and neither depends on the GUI being open. The GUI writes the system proxy configuration and gates that write on endpoint health.
|
||||
- Proxy modes are mutually exclusive: PAC writes the runtime PAC URL, global writes only the local SOCKS proxy, manual restores the user's prior system proxy state, and external PAC writes a validated HTTP/HTTPS URL. System proxy writes are allowed only after endpoint health; 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.
|
||||
- The proxy mode selector's choice persists with the settings snapshot and survives GUI restarts; a persistence failure keeps the previous mode in force and names the reason. The built-in modes are always available; the external PAC mode requires a configured valid URL. Resetting preferences returns the mode to PAC.
|
||||
- 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.
|
||||
|
||||
@@ -208,8 +208,21 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
/// Changes the current mode without rebuilding the tunnel runtime. A mode
|
||||
/// that writes system settings reuses the same endpoint health gate; manual
|
||||
/// mode restores the snapshot immediately and leaves system settings alone.
|
||||
/// The choice persists with the settings snapshot first, so a GUI restart
|
||||
/// restores it; a persistence failure keeps the previous mode in force and
|
||||
/// names the reason instead of switching silently.
|
||||
func setProxyMode(_ mode: ProxyMode) async {
|
||||
guard mode != proxyMode else { return }
|
||||
var next = settings
|
||||
next.preferredMode = mode.kind
|
||||
do {
|
||||
try settingsStore.save(next)
|
||||
} catch {
|
||||
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
|
||||
state = .serviceFailed(detail: String(describing: error))
|
||||
return
|
||||
}
|
||||
settings = next
|
||||
proxyMode = mode
|
||||
guard state != .off, let document = lastDocument ?? runtimeFileStore.loadDocument() else {
|
||||
return
|
||||
@@ -456,21 +469,27 @@ extension ProxyRuntimeController {
|
||||
}
|
||||
|
||||
/// Persists a fully validated settings snapshot and, when the proxy is
|
||||
/// active, re-derives the same runtime path with the new snapshot.
|
||||
func updateSettings(_ next: ProxySettings) async throws {
|
||||
try settingsStore.save(next)
|
||||
settings = next
|
||||
listenUnreadableReason = nil
|
||||
settingsUnreadableReason = nil
|
||||
|
||||
/// active, re-derives the same runtime path with the new snapshot. While the
|
||||
/// live mode is external PAC, the mode is re-resolved from the new snapshot
|
||||
/// before persistence, so the mode kind and its URL commit as one logical
|
||||
/// change and a validation failure precedes the write.
|
||||
func updateSettings(_ proposed: ProxySettings) async throws {
|
||||
var next = proposed
|
||||
var resolvedMode = proxyMode
|
||||
if case .externalPAC = proxyMode {
|
||||
if let url = URL(string: next.externalPACURL), !next.externalPACURL.isEmpty {
|
||||
try ProxyMode.validateExternalPACURL(url)
|
||||
proxyMode = .externalPAC(url)
|
||||
resolvedMode = .externalPAC(url)
|
||||
} else {
|
||||
proxyMode = .pac
|
||||
resolvedMode = .pac
|
||||
next.preferredMode = .pac
|
||||
}
|
||||
}
|
||||
try settingsStore.save(next)
|
||||
settings = next
|
||||
proxyMode = resolvedMode
|
||||
listenUnreadableReason = nil
|
||||
settingsUnreadableReason = nil
|
||||
|
||||
guard state != .off else { return }
|
||||
switch reexpand() {
|
||||
|
||||
@@ -39,17 +39,12 @@ struct ProxyStatusMenu: View {
|
||||
Task { await controller.setProxyEnabled(!summary.isOn) }
|
||||
}
|
||||
|
||||
// ③ 模式选择(勾选态)
|
||||
// ③ 模式选择(勾选态):内置三模式恒可选;外部 PAC 仅在已配置有效 URL
|
||||
// 时成为选项。
|
||||
Picker("模式", selection: modeBinding) {
|
||||
if isModeAvailable(.pac) {
|
||||
Text(ProxyMode.pac.label).tag(ProxyMode.pac)
|
||||
}
|
||||
if isModeAvailable(.global) {
|
||||
Text(ProxyMode.global.label).tag(ProxyMode.global)
|
||||
}
|
||||
if isModeAvailable(.manual) {
|
||||
Text(ProxyMode.manual.label).tag(ProxyMode.manual)
|
||||
}
|
||||
Text(ProxyMode.pac.label).tag(ProxyMode.pac)
|
||||
Text(ProxyMode.global.label).tag(ProxyMode.global)
|
||||
Text(ProxyMode.manual.label).tag(ProxyMode.manual)
|
||||
if let externalMode = configuredExternalPACMode {
|
||||
Text(externalMode.label).tag(externalMode)
|
||||
}
|
||||
@@ -120,16 +115,12 @@ struct ProxyStatusMenu: View {
|
||||
set: { mode in Task { await controller.setProxyMode(mode) } })
|
||||
}
|
||||
|
||||
private func isModeAvailable(_ mode: ProxyMode) -> Bool {
|
||||
mode.kind == controller.proxyMode.kind || controller.settings.enabledModes.contains(mode.kind)
|
||||
}
|
||||
|
||||
/// 外部 PAC 仅在已配置有效 URL 时成为菜单选项;URL 的编辑面在设置区。
|
||||
private var configuredExternalPACMode: ProxyMode? {
|
||||
if case .externalPAC(let url) = controller.proxyMode {
|
||||
return .externalPAC(url)
|
||||
}
|
||||
guard controller.settings.enabledModes.contains(.externalPAC),
|
||||
let url = URL(string: controller.settings.externalPACURL),
|
||||
guard let url = URL(string: controller.settings.externalPACURL),
|
||||
!controller.settings.externalPACURL.isEmpty,
|
||||
(try? ProxyMode.validateExternalPACURL(url)) != nil
|
||||
else { return nil }
|
||||
|
||||
@@ -6,8 +6,7 @@ import Foundation
|
||||
struct ProxySettings: Equatable, Sendable {
|
||||
static let defaultGFWListURL = "https://cdn.jsdelivr.net/gh/gfwlist/gfwlist/gfwlist.txt"
|
||||
static let defaultProxyExceptions =
|
||||
"127.0.0.1, localhost, 192.168.0.0/16, 10.0.0.0/8, FE80::/64, ::1, FD00::/8"
|
||||
static let defaultEnabledModes: Set<ProxyModeKind> = [.pac, .global, .manual]
|
||||
"127.0.0.1, localhost, 192.168.0.0/16, 10.0.0/8, FE80::/64, ::1, FD00::/8"
|
||||
|
||||
var listen: SslocalListenSettings
|
||||
var timeoutSeconds: Int
|
||||
@@ -16,12 +15,10 @@ struct ProxySettings: Equatable, Sendable {
|
||||
var externalPACURL: String
|
||||
var gfwListURL: String
|
||||
var pacUserRules: String
|
||||
/// Mode selected after a restart. An external PAC URL is resolved from
|
||||
/// `externalPACURL` when this kind is `.externalPAC`.
|
||||
/// The persisted current mode: the mode selector's choice survives GUI
|
||||
/// restarts. An external PAC URL is resolved from `externalPACURL` when
|
||||
/// this kind is `.externalPAC`.
|
||||
var preferredMode: ProxyModeKind
|
||||
/// The set is used by the menu/settings views to keep disabled modes out of
|
||||
/// the cycle.
|
||||
var enabledModes: Set<ProxyModeKind>
|
||||
|
||||
init(
|
||||
listen: SslocalListenSettings = SslocalListenSettings(),
|
||||
@@ -31,8 +28,7 @@ struct ProxySettings: Equatable, Sendable {
|
||||
externalPACURL: String = "",
|
||||
gfwListURL: String = ProxySettings.defaultGFWListURL,
|
||||
pacUserRules: String = "",
|
||||
preferredMode: ProxyModeKind = .pac,
|
||||
enabledModes: Set<ProxyModeKind> = ProxySettings.defaultEnabledModes
|
||||
preferredMode: ProxyModeKind = .pac
|
||||
) {
|
||||
self.listen = listen
|
||||
self.timeoutSeconds = timeoutSeconds
|
||||
@@ -42,7 +38,6 @@ struct ProxySettings: Equatable, Sendable {
|
||||
self.gfwListURL = gfwListURL
|
||||
self.pacUserRules = pacUserRules
|
||||
self.preferredMode = preferredMode
|
||||
self.enabledModes = enabledModes
|
||||
}
|
||||
|
||||
/// 系统代理的 ExceptionsList;输入顺序保留,重复项只保留第一次出现的值。
|
||||
@@ -356,8 +351,7 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
|
||||
externalPACURL: externalPACURL,
|
||||
gfwListURL: gfwListURL,
|
||||
pacUserRules: record.pacUserRules,
|
||||
preferredMode: record.preferredMode,
|
||||
enabledModes: Set(record.enabledModes)))
|
||||
preferredMode: record.preferredMode))
|
||||
}
|
||||
|
||||
private func record(from settings: ProxySettings) -> ProxySettingsRecord {
|
||||
@@ -382,7 +376,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
|
||||
record.gfwListURLConfigured = true
|
||||
record.pacUserRules = settings.pacUserRules
|
||||
record.preferredMode = settings.preferredMode
|
||||
record.enabledModes = settings.enabledModes.sorted { $0.rawValue < $1.rawValue }
|
||||
return record
|
||||
}
|
||||
|
||||
@@ -409,9 +402,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
|
||||
var gfwListURLConfigured: Bool = false
|
||||
var pacUserRules: String = ""
|
||||
var preferredMode: ProxyModeKind = .pac
|
||||
var enabledModes: [ProxyModeKind] = ProxySettings.defaultEnabledModes.sorted {
|
||||
$0.rawValue < $1.rawValue
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
@@ -444,9 +434,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
|
||||
pacUserRules = try container.decodeIfPresent(String.self, forKey: .pacUserRules) ?? ""
|
||||
preferredMode =
|
||||
try container.decodeIfPresent(ProxyModeKind.self, forKey: .preferredMode) ?? .pac
|
||||
enabledModes =
|
||||
try container.decodeIfPresent([ProxyModeKind].self, forKey: .enabledModes)
|
||||
?? ProxySettings.defaultEnabledModes.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +442,6 @@ extension ProxySettingsRecord {
|
||||
case scopeKind, advertisedAddress, socksPort, httpProxyEnabled, httpPort, pacPort
|
||||
case udpRelayEnabled, timeoutSeconds, verboseLogging, proxyExceptions
|
||||
case externalPACCredentialReference, gfwListCredentialReference, gfwListURLConfigured
|
||||
case pacUserRules, preferredMode, enabledModes
|
||||
case pacUserRules, preferredMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,6 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
|
||||
existingSettings.listen.httpPort = 2087
|
||||
existingSettings.listen.pacPort = 2089
|
||||
existingSettings.preferredMode = .global
|
||||
existingSettings.enabledModes = [.global]
|
||||
let settingsStore = ProxySettingsFileStore(
|
||||
fileURL: settingsURL,
|
||||
legacyListenFileURL: workDir.appendingPathComponent("legacy-listen.json"),
|
||||
|
||||
@@ -72,7 +72,6 @@ final class LegacyImportTests: XCTestCase {
|
||||
existingSettings.listen.pacPort = 2089
|
||||
existingSettings.timeoutSeconds = 120
|
||||
existingSettings.preferredMode = .global
|
||||
existingSettings.enabledModes = [.global]
|
||||
let settingsStore = ProxySettingsFileStore(
|
||||
fileURL: settingsURL,
|
||||
legacyListenFileURL: directory.appendingPathComponent("legacy-listen.json"),
|
||||
|
||||
@@ -70,7 +70,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
settingsStore: ProxySettingsStoring? = nil,
|
||||
settingsRestore: RestoredProxySettings? = nil,
|
||||
pacProbe: PACHealthProbing = ProxyRuntimeFixture.FakePACProbe(),
|
||||
proxyMode: ProxyMode = .pac,
|
||||
proxyMode: ProxyMode? = .pac,
|
||||
systemProxy: SystemProxyControlling? = nil,
|
||||
firewallChecker: FirewallStatusChecking = ProxyRuntimeFixture.FakeFirewallChecker(),
|
||||
firewallExecutableURLs: [URL] = [URL(fileURLWithPath: "/bundle/Helpers/sslocal")],
|
||||
@@ -427,6 +427,67 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
}
|
||||
|
||||
extension ProxyRuntimeControllerTests {
|
||||
func testSetProxyModePersistsTheChoiceAndARestoreRestoresIt() async throws {
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(), settingsStore: settingsStore)
|
||||
|
||||
await controller.setProxyMode(.global)
|
||||
|
||||
XCTAssertEqual(controller.proxyMode, .global)
|
||||
XCTAssertEqual(controller.settings.preferredMode, .global)
|
||||
XCTAssertEqual(settingsStore.saved?.preferredMode, .global, "模式选择随快照持久化")
|
||||
|
||||
// GUI 重启路径:恢复出的控制器不注入显式模式,从持久快照读回。
|
||||
let restored = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
||||
settingsRestore: RestoredProxySettings(
|
||||
settings: try XCTUnwrap(settingsStore.load()), unreadableError: nil),
|
||||
proxyMode: nil)
|
||||
XCTAssertEqual(restored.proxyMode, .global)
|
||||
}
|
||||
|
||||
func testSetProxyModePersistenceFailureKeepsPreviousModeAndNamesReason() async throws {
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
settingsStore.saveError = FakeSettingsSaveError.system
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(), settingsStore: settingsStore)
|
||||
|
||||
await controller.setProxyMode(.global)
|
||||
|
||||
XCTAssertEqual(controller.proxyMode, .pac, "持久化失败保留旧模式")
|
||||
XCTAssertEqual(controller.settings.preferredMode, .pac)
|
||||
guard case .serviceFailed(let detail) = controller.state else {
|
||||
XCTFail("应点名持久化失败,实际 \(controller.state)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(detail.contains("fake-save-error"))
|
||||
}
|
||||
|
||||
func testUpdateSettingsWhileInExternalPACModeCommitsKindAndURLAsOneChange() async throws {
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
let externalURL = URL(string: "https://pac.example.test/proxy.pac")!
|
||||
var external = ProxySettings()
|
||||
external.externalPACURL = externalURL.absoluteString
|
||||
external.preferredMode = .externalPAC
|
||||
settingsStore.saved = external
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
||||
settingsStore: settingsStore,
|
||||
settingsRestore: RestoredProxySettings(settings: external, unreadableError: nil),
|
||||
proxyMode: nil)
|
||||
XCTAssertEqual(controller.proxyMode, .externalPAC(externalURL))
|
||||
|
||||
// 清空外部 PAC URL:模式回落 PAC 且持久化的 kind 同步回落。
|
||||
var cleared = external
|
||||
cleared.externalPACURL = ""
|
||||
try await controller.updateSettings(cleared)
|
||||
|
||||
XCTAssertEqual(controller.proxyMode, .pac)
|
||||
XCTAssertEqual(settingsStore.saved?.preferredMode, .pac)
|
||||
XCTAssertEqual(settingsStore.saved?.externalPACURL, "")
|
||||
}
|
||||
|
||||
func testLegacyImportBoundaryStopsRuntimeWithoutRestoringSystemProxy() async throws {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
@@ -497,14 +558,22 @@ extension ProxyRuntimeControllerTests {
|
||||
["localhost", "127.0.0.1"])
|
||||
}
|
||||
|
||||
private enum FakeSettingsSaveError: Error, CustomStringConvertible {
|
||||
case system
|
||||
|
||||
var description: String { "fake-save-error" }
|
||||
}
|
||||
|
||||
private final class InMemoryProxySettingsStore: ProxySettingsStoring {
|
||||
var saved: ProxySettings?
|
||||
var saveError: Error?
|
||||
|
||||
func load() throws -> ProxySettings {
|
||||
saved ?? ProxySettings()
|
||||
}
|
||||
|
||||
func save(_ settings: ProxySettings) throws {
|
||||
if let saveError { throw saveError }
|
||||
saved = settings
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ final class ProxySettingsFileStoreTests: XCTestCase {
|
||||
settings.gfwListURL = "https://lists.example.test/gfw.txt?token=secret"
|
||||
settings.pacUserRules = "@@||example.com^"
|
||||
settings.preferredMode = .externalPAC
|
||||
settings.enabledModes = [.pac, .externalPAC]
|
||||
|
||||
try store.save(settings)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Persist the proxy mode selector and drop enabled-modes
|
||||
|
||||
The proxy mode selector (status menu, the single entry point since the settings-page selector was removed) persists its choice with the settings snapshot, so a GUI restart restores the selected mode instead of reverting to PAC. A persistence failure keeps the previous mode in force and names the reason rather than switching silently. While the live mode is external PAC, saving settings resolves the mode from the new snapshot before persisting, so the mode kind and its URL commit as one logical change. The `enabledModes` set is removed from the persisted snapshot: since ADR-0002 dropped Legacy preference migration it had no writer, and 2.0 exposes no editing UI for it. The built-in modes (PAC, global, manual) are always available; the external PAC mode requires a configured valid URL.
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Considered options
|
||||
|
||||
- Keep mode switches as transient runtime state: rejected because the selector is the common control for how macOS traffic is routed; losing the choice on every GUI restart contradicts its persisted-snapshot slot (`preferredMode`) and surprises users.
|
||||
- Persist the mode in a separate preference key: rejected because the mode already belongs to the settings snapshot (an external PAC mode resolves its URL from it); a second key would split one logical change into two stores.
|
||||
- Keep `enabledModes` as read-only legacy data: rejected because a persisted set with no writer is dead state that still gates the menu for no product meaning.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `setProxyMode` writes the settings snapshot before switching the live mode; on write failure it presents a service failure and leaves the mode unchanged.
|
||||
- `updateSettings` re-resolves an external-PAC mode from the incoming snapshot before persisting; clearing the URL falls back to PAC and persists that fallback.
|
||||
- Resetting preferences returns the mode to PAC, consistent with the factory snapshot.
|
||||
- The persisted settings document no longer contains `enabledModes`; existing files simply stop reading it, and no migration is needed because no release shipped with it populated by any 2.0 path.
|
||||
Reference in New Issue
Block a user