refactor(ng2): remove manual and external PAC

This commit is contained in:
Qiu Yuzhou
2026-09-23 18:47:15 +08:00
parent e65d7112f1
commit 9fc6446237
21 changed files with 50 additions and 399 deletions
+7 -5
View File
@@ -11,8 +11,9 @@
- **Active target**: The persisted server configuration or configuration group selected for proxying. When a group is active, the group UUID remains the target even when `sslocal` chooses an individual descendant server.
- **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. Its choice persists across GUI restarts.
- **Proxy mode**: The user-facing way traffic is routed through the local proxy: local PAC or global SOCKS.
- **Local PAC**: The PAC document generated and served by the 2.0 proxy runtime; it is distinct from a user-provided remote PAC URL.
- **Proxy mode selector**: The common user action for choosing local PAC or global SOCKS; it is distinct from enabling or disabling the proxy runtime and from editing PAC user rules. 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.
@@ -21,7 +22,7 @@
- **Legacy configuration**: The server list and preferences persisted by the frozen implementation in `Legacy/`.
- **Legacy import**: The user-confirmed, read-only translation of a Legacy configuration's server records into a separate manual group in 2.0. It migrates no preferences, activates no target, and neither starts proxying nor alters the Legacy source.
- **Runtime configuration file**: The derived JSON document used by the external tunnel service for the active target; it is not the user-managed server configuration or subscription document.
- **Sensitive information**: Server passwords, plugin options (sensitive in their entirety), and nonempty user-provided remote URLs, including subscription, external PAC, and custom GFW List URLs.
- **Sensitive information**: Server passwords, plugin options (sensitive in their entirety), and nonempty user-provided remote URLs, including subscription and custom GFW List URLs.
- **Credential exposure boundary**: This product reduces accidental disclosure and exposure to other user accounts, but does not guarantee protection against a compromised same-user process, root access, or APFS snapshots and backups; it does not promise secure erasure.
- **Credential reference**: A non-secret association from a server configuration, subscription, or remote-URL setting to its durable credential; the credential value is kept outside the configuration tree and resolved only when needed.
- **Diagnostic report**: A user-requested, redacted document containing only approved runtime state, endpoint metadata, aggregate catalog facts, managed-plugin presence, file metadata, and safe GUI event lines. It never contains passwords, plugin options, Keychain values, full remote URLs or tokens, runtime JSON, server addresses, remarks, or raw wrapper log text.
@@ -46,6 +47,7 @@
- The active target remains the selected node ID and is not silently replaced by a descendant chosen by `sslocal`. If it is deleted, has no activation candidate, or is otherwise invalid, the target is cleared and proxying stops rather than falling back silently.
- After a committed edit to the active target's subtree, the target is re-expanded immediately: a non-empty set of activation candidates updates the runtime atomically; no activation candidate clears the target and stops proxying without fallback.
- Manual nodes may move between the catalog root and manual groups, carrying their subtree and child order without changing identity or ownership; cross-source moves, shared parents, and cycles are rejected.
- Manual catalog ownership is independent of proxy mode: removing the manual proxy mode does not remove manual groups, manual servers, or records imported into a manual group.
- Empty groups may persist and remain editable, but cannot be activated. Deleting a server configuration or an empty manual group takes one user confirmation. Deleting a non-empty manual group recursively deletes its descendants and takes a second confirmation that names the subtree scale and that the subtree's server credentials are removed.
- A subscription owns its subscription group and its refreshed members; manual groups remain user-owned. Remote connection fields, membership, structure, order, and `remarks` are authoritative; subscription nodes have no local eligibility overlay.
- Subscription server and nested-group identities use provider-supplied stable IDs scoped to that subscription. When a remote record has no stable ID, continuity is allowed only for an exactly matching canonical record; endpoint, name, or path heuristics must not merge identities.
@@ -66,6 +68,6 @@
- Exported GUI event lines are an allowlisted, field-cleaned subset of runtime events; raw event detail remains available only through the explicit diagnostic log view.
- 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: 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.
- Proxy modes are mutually exclusive: local PAC writes the runtime PAC URL and global writes only the local SOCKS proxy. 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. Local PAC and global are always available, and user-provided remote PAC URLs are not accepted as proxy settings. Resetting preferences returns the mode to local 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.
+1 -8
View File
@@ -134,7 +134,7 @@ extension AppPresentation {
static func message(for issue: SettingsFieldIssue) -> String {
switch issue {
case .port(_, let error), .advertisedAddress(let error), .timeoutSeconds(let error),
.externalPACURL(let error), .gfwListURL(let error):
.gfwListURL(let error):
return message(for: error)
}
}
@@ -288,11 +288,6 @@ extension AppPresentation {
switch error {
case .invalidLocalPACURL: return "本地 PAC URL 无效"
case .invalidSOCKSPort: return "SOCKS 端口无效"
case .externalPACURLHasNoScheme: return "外部 PAC URL 缺少协议"
case .unsupportedExternalPACScheme: return "外部 PAC 不支持该协议"
case .externalPACURLHasNoHost: return "外部 PAC URL 缺少主机"
case .externalPACURLContainsCredentials: return "外部 PAC URL 不得包含凭据"
case .externalPACURLTooLong: return "外部 PAC URL 过长"
}
}
@@ -315,7 +310,6 @@ extension AppPresentation {
case .invalidTimeout(let seconds):
return "超时 \(seconds) 秒无效,必须是 1–86400 之间的整数"
case .invalidHostAddress: return "主机地址无效,必须是非回环 IPv4 地址"
case .invalidExternalPACURL(let error): return "外部 PAC URL 无效:\(proxyMode(error))"
case .invalidGFWListURL: return "GFW List URL 无效"
}
}
@@ -406,7 +400,6 @@ extension AppPresentation {
switch facts {
case .operation(let failure): return systemProxy(failure)
case .mode(let error): return proxyMode(error)
case .externalPAC(let cause): return "外部 PAC 未就绪(\(endpointFailure(cause)))"
case .ownershipConflict: return "系统代理配置已被其他设置改动,未覆盖"
case .unknown: return "系统代理未能应用"
}
@@ -208,14 +208,14 @@ 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.
/// Changes the current mode without rebuilding the tunnel runtime. Every
/// supported mode reuses the same endpoint health gate before writing system
/// settings.
/// 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 ProxyMode.availableModes(for: settings).contains(mode) else { return }
guard ProxyMode.availableModes.contains(mode) else { return }
guard mode != proxyMode else { return }
var next = settings
next.preferredMode = mode.kind
@@ -232,18 +232,6 @@ final class ProxyRuntimeController: ObservableObject {
return
}
if case .manual = mode {
cancelFirewallObservation()
flowGeneration += 1
do {
try systemProxy.restore()
state = .running
} catch {
state = .systemProxyFailed(systemProxyFacts(for: error))
}
return
}
state = .starting
await presentLaunchHealth(document)
}
@@ -488,26 +476,11 @@ extension ProxyRuntimeController {
}
/// Persists a fully validated settings snapshot and, when the proxy is
/// 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.
/// active, re-derives the same runtime path with the new snapshot.
func updateSettings(_ proposed: ProxySettings) async throws {
let catalog = catalogSnapshotReader.catalogSnapshot
var next = proposed
var resolvedMode = proxyMode
if case .externalPAC = proxyMode {
if let url = URL(string: next.externalPACURL), !next.externalPACURL.isEmpty {
try ProxyMode.validateExternalPACURL(url)
resolvedMode = .externalPAC(url)
} else {
resolvedMode = .pac
next.preferredMode = .pac
}
}
try settingsStore.save(next)
settings = next
proxyMode = resolvedMode
try settingsStore.save(proposed)
settings = proposed
listenSettingsUnreadable = false
settingsUnreadable = false
@@ -585,14 +558,6 @@ extension ProxyRuntimeController {
}
pacOutcome = await pacProbe.probe(url: healthURL, timeout: 1.5)
if pacOutcome == .reachable {
if case .externalPAC(let externalURL) = proxyMode {
let externalOutcome = await pacProbe.probe(url: externalURL, timeout: 1.5)
if case .failed = externalOutcome {
state = .systemProxyFailed(
.externalPAC(runtimeEndpointFailure(from: externalOutcome)))
return
}
}
guard generation == flowGeneration else { return }
pacURL = document.pac.publicURL
guard applySystemProxy(for: document) else { return }
@@ -764,13 +729,9 @@ extension ProxyRuntimeController {
private func applySystemProxy(for document: SslocalRuntimeDocument) -> Bool {
do {
if let configuration = try proxyMode.systemProxyConfiguration(
let configuration = try proxyMode.systemProxyConfiguration(
for: document, exceptions: settings.proxyExceptionList)
{
try systemProxy.apply(configuration)
} else {
try systemProxy.restore()
}
try systemProxy.apply(configuration)
return true
} catch {
state = .systemProxyFailed(systemProxyFacts(for: error))
@@ -846,6 +807,6 @@ extension ProxyRuntimeController {
extension ProxyRuntimeController {
fileprivate static func makeProxyMode(from settings: ProxySettings) -> ProxyMode {
ProxyMode.availableModes(for: settings).first { $0.kind == settings.preferredMode } ?? .pac
ProxyMode.availableModes.first { $0.kind == settings.preferredMode } ?? .pac
}
}
@@ -76,7 +76,6 @@ enum SystemProxyOperationFailure: Equatable, Sendable {
enum SystemProxyFailureFacts: Equatable, Sendable {
case operation(SystemProxyOperationFailure)
case mode(ProxyModeError)
case externalPAC(RuntimeEndpointFailure)
case ownershipConflict
case unknown
}
+1 -1
View File
@@ -41,7 +41,7 @@ struct ProxyStatusMenu: View {
// ③ 模式选择(勾选态):可选性与顺序由 Domain 唯一策略裁定。
Picker("模式", selection: modeBinding) {
ForEach(ProxyMode.availableModes(for: controller.settings), id: \.self) { mode in
ForEach(ProxyMode.availableModes, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
+1 -3
View File
@@ -98,11 +98,9 @@ struct SettingsView: View {
portRow(.pac)
TextField("绕过列表(逗号或空格分隔)", text: $workflow.draft.proxyExceptions)
TextField("外部 PAC URL(可选)", text: $workflow.draft.externalPACURL)
fieldIssues(.externalPACURL)
TextField("GFW List URL", text: $workflow.draft.gfwListURL)
fieldIssues(.gfwListURL)
Text("外部内容只保存 URL;本设置页不负责远程内容校验或自动更新。")
Text("远程内容只保存 URL;本设置页不负责远程内容校验或自动更新。")
.font(.caption)
.foregroundStyle(.secondary)
@@ -45,7 +45,6 @@ enum SettingsDraftAdapter {
timeoutSeconds: settings.timeoutSeconds,
verboseLogging: settings.verboseLogging,
proxyExceptions: settings.proxyExceptions,
externalPACURL: settings.externalPACURL,
gfwListURL: settings.gfwListURL,
pacUserRules: settings.pacUserRules)
}
@@ -67,7 +66,6 @@ enum SettingsDraftAdapter {
timeoutSeconds: draft.timeoutSeconds,
verboseLogging: draft.verboseLogging,
proxyExceptions: draft.proxyExceptions,
externalPACURL: draft.externalPACURL,
gfwListURL: draft.gfwListURL,
pacUserRules: draft.pacUserRules,
preferredMode: base.preferredMode)
@@ -98,8 +96,6 @@ enum SettingsDraftAdapter {
return [.timeoutSeconds(error: error)]
case .invalidHostAddress:
return [.advertisedAddress(error: error)]
case .invalidExternalPACURL:
return [.externalPACURL(error: error)]
case .invalidGFWListURL:
return [.gfwListURL(error: error)]
}
@@ -24,7 +24,6 @@ struct SettingsDraft: Equatable, Sendable {
var timeoutSeconds: Int
var verboseLogging: Bool
var proxyExceptions: String
var externalPACURL: String
var gfwListURL: String
var pacUserRules: String
@@ -52,7 +51,6 @@ enum SettingsFieldID: Hashable, Sendable {
case advertisedAddress
case port(SettingsPortID)
case timeoutSeconds
case externalPACURL
case gfwListURL
}
@@ -62,7 +60,6 @@ enum SettingsFieldIssue: Equatable, Sendable {
case port(SettingsPortID, error: ProxySettingsValidationError)
case advertisedAddress(error: ProxySettingsValidationError)
case timeoutSeconds(error: ProxySettingsValidationError)
case externalPACURL(error: ProxySettingsValidationError)
case gfwListURL(error: ProxySettingsValidationError)
var field: SettingsFieldID {
@@ -70,7 +67,6 @@ enum SettingsFieldIssue: Equatable, Sendable {
case .port(let id, _): .port(id)
case .advertisedAddress: .advertisedAddress
case .timeoutSeconds: .timeoutSeconds
case .externalPACURL: .externalPACURL
case .gfwListURL: .gfwListURL
}
}
@@ -78,7 +74,7 @@ enum SettingsFieldIssue: Equatable, Sendable {
var error: ProxySettingsValidationError {
switch self {
case .port(_, let error), .advertisedAddress(let error), .timeoutSeconds(let error),
.externalPACURL(let error), .gfwListURL(let error):
.gfwListURL(let error):
error
}
}
+4 -67
View File
@@ -1,13 +1,9 @@
import Foundation
/// Persisted identity of a proxy mode. Unlike `ProxyMode`, this type does not
/// carry the URL of an external PAC, so it is safe to use in preferences and
/// mode-availability settings.
/// Persisted identity of a proxy mode.
enum ProxyModeKind: String, Codable, CaseIterable, Equatable, Hashable, Sendable {
case pac
case global
case manual
case externalPAC
}
/// The mutually exclusive ways in which 2.0 exposes the local proxy to macOS.
@@ -15,19 +11,14 @@ enum ProxyModeKind: String, Codable, CaseIterable, Equatable, Hashable, Sendable
enum ProxyMode: Codable, Equatable, Hashable, Sendable {
case pac
case global
case manual
case externalPAC(URL)
private enum CodingKeys: String, CodingKey {
case kind
case url
}
private enum Kind: String, Codable {
case pac
case global
case manual
case externalPAC
}
init(from decoder: Decoder) throws {
@@ -37,10 +28,6 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable {
self = .pac
case .global:
self = .global
case .manual:
self = .manual
case .externalPAC:
self = .externalPAC(try container.decode(URL.self, forKey: .url))
}
}
@@ -51,11 +38,6 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable {
try container.encode(Kind.pac, forKey: .kind)
case .global:
try container.encode(Kind.global, forKey: .kind)
case .manual:
try container.encode(Kind.manual, forKey: .kind)
case .externalPAC(let url):
try container.encode(Kind.externalPAC, forKey: .kind)
try container.encode(url, forKey: .url)
}
}
@@ -65,10 +47,6 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable {
"PAC"
case .global:
"全局"
case .manual:
"手动"
case .externalPAC:
"外部 PAC"
}
}
@@ -76,30 +54,17 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable {
switch self {
case .pac: .pac
case .global: .global
case .manual: .manual
case .externalPAC: .externalPAC
}
}
/// Returns every mode the current settings can support, in the product's
/// stable selector order. Built-in modes are always available; external PAC
/// is available only when its configured URL passes the existing validator.
static func availableModes(for settings: ProxySettings) -> [ProxyMode] {
let builtInModes: [ProxyMode] = [.pac, .global, .manual]
guard !settings.externalPACURL.isEmpty,
let url = URL(string: settings.externalPACURL),
(try? validateExternalPACURL(url)) != nil
else { return builtInModes }
return builtInModes + [.externalPAC(url)]
}
/// Returns every mode supported by the product in stable selector order.
static var availableModes: [ProxyMode] { [.pac, .global] }
/// Derives the only system-proxy state that this mode is allowed to own.
/// `nil` means manual mode: restore the user's previous system settings and
/// leave them under the user's control.
func systemProxyConfiguration(
for document: SslocalRuntimeDocument,
exceptions: [String]? = nil
) throws -> SystemProxyConfiguration? {
) throws -> SystemProxyConfiguration {
switch self {
case .pac:
guard let url = document.pac.publicURL else {
@@ -112,29 +77,6 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable {
}
return SystemProxyConfiguration(
target: .socks(host: "127.0.0.1", port: document.socksPort), exceptions: exceptions)
case .manual:
return nil
case .externalPAC(let url):
try Self.validateExternalPACURL(url)
return SystemProxyConfiguration(target: .pac(url), exceptions: exceptions)
}
}
static func validateExternalPACURL(_ url: URL) throws {
guard let scheme = url.scheme?.lowercased() else {
throw ProxyModeError.externalPACURLHasNoScheme
}
guard scheme == "http" || scheme == "https" else {
throw ProxyModeError.unsupportedExternalPACScheme(scheme)
}
guard url.host != nil else {
throw ProxyModeError.externalPACURLHasNoHost
}
guard url.user == nil && url.password == nil else {
throw ProxyModeError.externalPACURLContainsCredentials
}
guard url.absoluteString.utf8.count <= 2048 else {
throw ProxyModeError.externalPACURLTooLong
}
}
}
@@ -160,9 +102,4 @@ struct SystemProxyConfiguration: Equatable, Sendable {
enum ProxyModeError: Error, Equatable, Sendable {
case invalidLocalPACURL
case invalidSOCKSPort(Int)
case externalPACURLHasNoScheme
case unsupportedExternalPACScheme(String)
case externalPACURLHasNoHost
case externalPACURLContainsCredentials
case externalPACURLTooLong
}
+4 -42
View File
@@ -12,12 +12,10 @@ struct ProxySettings: Equatable, Sendable {
var timeoutSeconds: Int
var verboseLogging: Bool
var proxyExceptions: String
var externalPACURL: String
var gfwListURL: String
var pacUserRules: String
/// 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`.
/// restarts.
var preferredMode: ProxyModeKind
init(
@@ -25,7 +23,6 @@ struct ProxySettings: Equatable, Sendable {
timeoutSeconds: Int = 60,
verboseLogging: Bool = false,
proxyExceptions: String = ProxySettings.defaultProxyExceptions,
externalPACURL: String = "",
gfwListURL: String = ProxySettings.defaultGFWListURL,
pacUserRules: String = "",
preferredMode: ProxyModeKind = .pac
@@ -34,7 +31,6 @@ struct ProxySettings: Equatable, Sendable {
self.timeoutSeconds = timeoutSeconds
self.verboseLogging = verboseLogging
self.proxyExceptions = proxyExceptions
self.externalPACURL = externalPACURL
self.gfwListURL = gfwListURL
self.pacUserRules = pacUserRules
self.preferredMode = preferredMode
@@ -63,29 +59,12 @@ struct ProxySettings: Equatable, Sendable {
if case .host(let address) = listen.scope, !Self.isUsableHostAddress(address) {
errors.append(.invalidHostAddress(address))
}
if !externalPACURL.isEmpty, let error = Self.externalPACError(for: externalPACURL) {
errors.append(.invalidExternalPACURL(error))
}
if !gfwListURL.isEmpty, !Self.isUsableRemoteURL(gfwListURL) {
errors.append(.invalidGFWListURL(gfwListURL))
}
return errors
}
private static func externalPACError(for value: String) -> ProxyModeError? {
guard let url = URL(string: value) else {
return .externalPACURLHasNoScheme
}
do {
try ProxyMode.validateExternalPACURL(url)
return nil
} catch let error as ProxyModeError {
return error
} catch {
return .externalPACURLHasNoScheme
}
}
private static func isUsableRemoteURL(_ value: String) -> Bool {
guard let url = URL(string: value), let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https", url.host != nil,
@@ -107,7 +86,6 @@ enum ProxySettingsValidationError: Error, Equatable, Sendable {
case duplicatePort(endpoint: ProxyEndpointKind, otherEndpoint: ProxyEndpointKind, port: Int)
case invalidTimeout(Int)
case invalidHostAddress(String)
case invalidExternalPACURL(ProxyModeError)
case invalidGFWListURL(String)
init(_ error: PortSettingError) {
@@ -141,11 +119,9 @@ struct RestoredProxySettings: Equatable {
let unreadableError: ProxySettingsStoreError?
}
/// #33 偏好存储。非敏感字段写入受保护的 JSON;外部 PAC 与 GFW URL 只把
/// 固定引用写入 JSON,真实值由 KeychainCredentialStore 持有。
/// #33 偏好存储。非敏感字段写入受保护的 JSON;GFW URL 只把固定引用写入
/// JSON,真实值由 KeychainCredentialStore 持有。
struct ProxySettingsFileStore: ProxySettingsStoring {
static let externalPACReference =
CredentialReference(rawValue: "settings.external-pac-url")
static let gfwListReference =
CredentialReference(rawValue: "settings.gfw-list-url")
@@ -201,7 +177,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
func save(_ settings: ProxySettings) throws {
try validated(settings)
do {
try saveOrDelete(settings.externalPACURL, reference: Self.externalPACReference)
try saveOrDelete(settings.gfwListURL, reference: Self.gfwListReference)
} catch let error as ProxySettingsStoreError {
throw error
@@ -221,7 +196,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
func reset() throws {
do {
try credentials.delete(Self.externalPACReference)
try credentials.delete(Self.gfwListReference)
for url in [fileURL, legacyListenFileURL]
where FileManager.default.fileExists(atPath: url.path) {
@@ -295,12 +269,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
listen.pacPort = record.pacPort
listen.udpRelayEnabled = record.udpRelayEnabled
let externalPACURL =
if let reference = record.externalPACCredentialReference {
try requiredSecret(for: reference)
} else {
""
}
let gfwListURL: String
if let reference = record.gfwListCredentialReference {
gfwListURL = try requiredSecret(for: reference)
@@ -313,7 +281,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
timeoutSeconds: record.timeoutSeconds,
verboseLogging: record.verboseLogging,
proxyExceptions: record.proxyExceptions,
externalPACURL: externalPACURL,
gfwListURL: gfwListURL,
pacUserRules: record.pacUserRules,
preferredMode: record.preferredMode))
@@ -334,8 +301,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
record.timeoutSeconds = settings.timeoutSeconds
record.verboseLogging = settings.verboseLogging
record.proxyExceptions = settings.proxyExceptions
record.externalPACCredentialReference =
settings.externalPACURL.isEmpty ? nil : Self.externalPACReference
record.gfwListCredentialReference =
settings.gfwListURL.isEmpty ? nil : Self.gfwListReference
record.gfwListURLConfigured = true
@@ -362,7 +327,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
var timeoutSeconds: Int = 60
var verboseLogging: Bool = false
var proxyExceptions: String = ProxySettings.defaultProxyExceptions
var externalPACCredentialReference: CredentialReference?
var gfwListCredentialReference: CredentialReference?
var gfwListURLConfigured: Bool = false
var pacUserRules: String = ""
@@ -390,8 +354,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
proxyExceptions =
try container.decodeIfPresent(String.self, forKey: .proxyExceptions)
?? ProxySettings.defaultProxyExceptions
externalPACCredentialReference = try container.decodeIfPresent(
CredentialReference.self, forKey: .externalPACCredentialReference)
gfwListCredentialReference = try container.decodeIfPresent(
CredentialReference.self, forKey: .gfwListCredentialReference)
gfwListURLConfigured =
@@ -406,7 +368,7 @@ extension ProxySettingsRecord {
fileprivate enum CodingKeys: String, CodingKey {
case scopeKind, advertisedAddress, socksPort, httpProxyEnabled, httpPort, pacPort
case udpRelayEnabled, timeoutSeconds, verboseLogging, proxyExceptions
case externalPACCredentialReference, gfwListCredentialReference, gfwListURLConfigured
case gfwListCredentialReference, gfwListURLConfigured
case pacUserRules, preferredMode
}
}
@@ -70,7 +70,6 @@ final class AppPresentationTests: XCTestCase {
.serviceFailed(.unknown),
.systemProxyFailed(.operation(.applyFailed)),
.systemProxyFailed(.mode(.invalidSOCKSPort(0))),
.systemProxyFailed(.externalPAC(.invalidResponse)),
.systemProxyFailed(.ownershipConflict),
.systemProxyFailed(.unknown),
]
@@ -94,7 +93,6 @@ final class AppPresentationTests: XCTestCase {
.port(.socks, error: .portOutOfRange(endpoint: .socks, port: 0)),
.advertisedAddress(error: .invalidHostAddress("127.0.0.1")),
.timeoutSeconds(error: .invalidTimeout(0)),
.externalPACURL(error: .invalidExternalPACURL(.unsupportedExternalPACScheme("ftp"))),
.gfwListURL(error: .invalidGFWListURL("not a url")),
]
for issue in issues {
@@ -8,32 +8,7 @@ import XCTest
final class ProxyModeAvailabilityTests: XCTestCase {
func testBuiltInModesAreAlwaysAvailableInProductOrder() {
XCTAssertEqual(
ProxyMode.availableModes(for: ProxySettings()).map(\.kind),
[.pac, .global, .manual])
}
func testValidExternalPACIsAppendedAfterBuiltInModes() {
let url = URL(string: "https://pac.example.test/proxy.pac")!
let settings = ProxySettings(externalPACURL: url.absoluteString)
XCTAssertEqual(
ProxyMode.availableModes(for: settings),
[.pac, .global, .manual, .externalPAC(url)])
}
func testInvalidExternalPACValuesAreNotAvailable() {
let values = [
"ftp://pac.example.test/proxy.pac",
"https://",
"https://user:password@pac.example.test/proxy.pac",
"https://pac.example.test/\(String(repeating: "a", count: 2048))",
]
for value in values {
XCTAssertEqual(
ProxyMode.availableModes(for: ProxySettings(externalPACURL: value)).map(\.kind),
[.pac, .global, .manual],
"无效的外部 PAC 不应进入可选模式:\(value)")
}
ProxyMode.availableModes.map(\.kind),
[.pac, .global])
}
}
@@ -23,89 +23,6 @@ extension ProxyRuntimeControllerTests {
failure: .activation(.noActiveTarget)))
}
func testRestoreUsesConfiguredExternalPACWhenItIsAvailable() {
let url = URL(string: "https://pac.example.test/proxy.pac")!
var restoredSettings = ProxySettings(externalPACURL: url.absoluteString)
restoredSettings.preferredMode = .externalPAC
let controller = makeController(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settingsRestore: RestoredProxySettings(settings: restoredSettings, unreadableError: nil),
proxyMode: nil)
XCTAssertEqual(controller.proxyMode, .externalPAC(url))
}
func testRestoreFallsBackToPACWhenExternalPACIsUnavailable() {
for url in ["", "ftp://pac.example.test/proxy.pac"] {
var restoredSettings = ProxySettings(externalPACURL: url)
restoredSettings.preferredMode = .externalPAC
let controller = makeController(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settingsRestore: RestoredProxySettings(settings: restoredSettings, unreadableError: nil),
proxyMode: nil)
XCTAssertEqual(controller.proxyMode, .pac, "不可用外部 PAC 应恢复为 PAC:\(url)")
}
}
func testSetProxyModePersistsConfiguredExternalPAC() async {
let url = URL(string: "https://pac.example.test/proxy.pac")!
let settingsStore = InMemoryProxySettingsStore()
let settings = ProxySettings(externalPACURL: url.absoluteString)
let controller = makeController(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settingsStore: settingsStore,
settingsRestore: RestoredProxySettings(settings: settings, unreadableError: nil))
await controller.setProxyMode(.externalPAC(url))
XCTAssertEqual(controller.proxyMode, .externalPAC(url))
XCTAssertEqual(controller.settings.preferredMode, .externalPAC)
XCTAssertEqual(settingsStore.saved?.preferredMode, .externalPAC)
}
func testSetProxyModeIgnoresUnconfiguredExternalPACWithoutSideEffects() async {
let settingsStore = InMemoryProxySettingsStore()
let controller = makeController(
probe: ProxyRuntimeFixture.FakeProbe.reachable(), settingsStore: settingsStore)
let originalSettings = controller.settings
let requestedURL = URL(string: "https://pac.example.test/proxy.pac")!
await controller.setProxyMode(.externalPAC(requestedURL))
XCTAssertEqual(controller.settings, originalSettings)
XCTAssertEqual(controller.proxyMode, .pac)
XCTAssertNil(settingsStore.saved)
XCTAssertEqual(controller.state, .off)
XCTAssertTrue(systemProxy.applied.isEmpty)
XCTAssertEqual(agent.registerCount, 0)
}
func testSetProxyModeIgnoresExternalPACWithDifferentConfiguredURL() async {
let configuredURL = URL(string: "https://configured.example.test/proxy.pac")!
let requestedURL = URL(string: "https://other.example.test/proxy.pac")!
let settingsStore = InMemoryProxySettingsStore()
var configuredSettings = ProxySettings(externalPACURL: configuredURL.absoluteString)
configuredSettings.preferredMode = .externalPAC
settingsStore.saved = configuredSettings
let controller = makeController(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settingsStore: settingsStore,
settingsRestore: RestoredProxySettings(settings: configuredSettings, unreadableError: nil),
proxyMode: nil)
await controller.setProxyMode(.externalPAC(requestedURL))
XCTAssertEqual(controller.settings, configuredSettings)
XCTAssertEqual(controller.proxyMode, .externalPAC(configuredURL))
XCTAssertEqual(settingsStore.saved, configuredSettings)
XCTAssertEqual(controller.state, .off)
XCTAssertTrue(systemProxy.applied.isEmpty)
XCTAssertEqual(agent.registerCount, 0)
}
}
/// Coordinator/controller integration for the shared committed catalog source.
@@ -156,33 +156,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
XCTAssertEqual(probe.ports, [11086, 11087], "系统代理写入前必须探测 SOCKS 和 HTTP 入站")
}
func testExternalPACHealthFailureDoesNotWriteSystemProxy() async throws {
let seeded = try makeSeededCatalog()
let externalURL = URL(string: "https://pac.example.test/proxy.pac")!
let pacProbe = ProxyRuntimeFixture.FakePACProbe(
outcomes: [.reachable, .failed(detail: "HTTP 状态异常")])
let controller = makeController(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
pacProbe: pacProbe,
proxyMode: .externalPAC(externalURL))
try await controller.activate(seeded.server)
await controller.setProxyEnabled(true)
guard case .systemProxyFailed(.externalPAC(let cause)) = controller.state else {
XCTFail("外部 PAC 不健康时应阻止系统代理写入,实际 \(controller.state)")
return
}
XCTAssertEqual(cause, .invalidResponse)
XCTAssertTrue(systemProxy.applied.isEmpty)
XCTAssertEqual(
pacProbe.urls,
[
URL(string: "http://127.0.0.1:11089/v1/proxy.pac")!, externalURL,
])
}
func testSwitchingGlobalAndManualModesIsImmediateAndRestoresOnStop() async throws {
func testSwitchingGlobalAndPACModesIsImmediateAndRestoresOnStop() async throws {
let seeded = try makeSeededCatalog()
let controller = makeController(
probe: ProxyRuntimeFixture.FakeProbe.reachable(), proxyMode: .global)
@@ -197,10 +171,6 @@ final class ProxyRuntimeControllerTests: XCTestCase {
exceptions: ProxySettings().proxyExceptionList)
])
await controller.setProxyMode(.manual)
XCTAssertEqual(controller.state, .running)
XCTAssertEqual(systemProxy.restoreCount, 1, "手动模式立即恢复原系统代理设置")
await controller.setProxyMode(.pac)
XCTAssertEqual(controller.state, .running)
XCTAssertEqual(systemProxy.applied.count, 2)
@@ -212,7 +182,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
await controller.setProxyEnabled(false)
XCTAssertEqual(controller.state, .off)
XCTAssertEqual(systemProxy.restoreCount, 2, "停止代理撤除 2.0 写入的系统代理")
XCTAssertEqual(systemProxy.restoreCount, 1, "停止代理撤除 2.0 写入的系统代理")
}
func testEnableWhenProbeNeverSucceedsPresentsFailureNamingEndpointAndPort() async throws {
@@ -463,30 +433,6 @@ extension ProxyRuntimeControllerTests {
}
}
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()
@@ -3,7 +3,7 @@ import XCTest
@testable import ShadowsocksX_NG2
/// #33 偏好持久化缝:敏感 URL 只进入凭据存储,设置文件只保存引用;重置
/// #33 偏好持久化缝:GFW List URL 只进入凭据存储,设置文件只保存引用;重置
/// 不触碰配置目录与活动目标使用的其他文件。
final class ProxySettingsFileStoreTests: XCTestCase {
private var directory: URL!
@@ -42,19 +42,15 @@ final class ProxySettingsFileStoreTests: XCTestCase {
settings.timeoutSeconds = 120
settings.verboseLogging = true
settings.proxyExceptions = "localhost, 127.0.0.1"
settings.externalPACURL = "https://pac.example.test/proxy.pac?token=secret"
settings.gfwListURL = "https://lists.example.test/gfw.txt?token=secret"
settings.pacUserRules = "@@||example.com^"
settings.preferredMode = .externalPAC
try store.save(settings)
XCTAssertEqual(try store.load(), settings)
let raw = try String(contentsOf: store.fileURL, encoding: .utf8)
XCTAssertFalse(raw.contains("pac.example.test"))
XCTAssertFalse(raw.contains("lists.example.test"))
XCTAssertFalse(raw.contains("token=secret"))
XCTAssertNotNil(try credentials.secret(for: ProxySettingsFileStore.externalPACReference))
XCTAssertNotNil(try credentials.secret(for: ProxySettingsFileStore.gfwListReference))
}
@@ -106,12 +102,12 @@ final class ProxySettingsFileStoreTests: XCTestCase {
func testResetRemovesSettingsAndCredentialReferencesButLeavesFactoryDefaultsAvailable() throws {
var settings = ProxySettings()
settings.externalPACURL = "https://pac.example.test/proxy.pac"
settings.gfwListURL = "https://lists.example.test/gfw.txt"
try store.save(settings)
try store.reset()
XCTAssertFalse(FileManager.default.fileExists(atPath: store.fileURL.path))
XCTAssertNil(try credentials.secret(for: ProxySettingsFileStore.externalPACReference))
XCTAssertNil(try credentials.secret(for: ProxySettingsFileStore.gfwListReference))
XCTAssertEqual(try store.load(), ProxySettings())
}
}
@@ -27,7 +27,6 @@ final class ProxySettingsTests: XCTestCase {
func testInvalidAdvancedSettingsNameEveryProblem() {
var settings = ProxySettings()
settings.timeoutSeconds = 0
settings.externalPACURL = "file:///tmp/proxy.pac"
settings.gfwListURL = "ftp://example.com/gfw.txt"
settings.listen.scope = .host(advertisedAddress: "127.0.0.1")
settings.listen.socksPort = settings.listen.httpPort
@@ -38,7 +37,6 @@ final class ProxySettingsTests: XCTestCase {
.duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 11087),
.invalidTimeout(0),
.invalidHostAddress("127.0.0.1"),
.invalidExternalPACURL(.unsupportedExternalPACScheme("file")),
.invalidGFWListURL("ftp://example.com/gfw.txt"),
])
}
@@ -73,16 +73,6 @@ final class SettingsWorkflowInterfaceTests: XCTestCase {
AppPresentation.message(for: workflow.issues(for: .advertisedAddress)[0]).contains("主机地址"))
}
func testExternalPACURLIssueLandsOnItsField() {
let workflow = makeWorkflow()
workflow.draft.externalPACURL = "ftp://example.com/pac"
XCTAssertEqual(workflow.fieldIssues.map(\.field), [.externalPACURL])
XCTAssertTrue(
AppPresentation.message(for: workflow.issues(for: .externalPACURL)[0])
.contains("外部 PAC URL"))
}
func testGFWListURLIssueLandsOnItsField() {
let workflow = makeWorkflow()
workflow.draft.gfwListURL = "not a url"
@@ -388,15 +378,15 @@ final class SettingsWorkflowInterfaceTests: XCTestCase {
XCTAssertTrue(workflow.lastFailure?.presentableMessage.contains("偏好文件") == true)
committing.updateError = ProxySettingsStoreError.missingCredential(
ProxySettingsFileStore.externalPACReference)
ProxySettingsFileStore.gfwListReference)
workflow.save()
await waitUntil(
workflow.lastFailure
== .store(
.missingCredential(ProxySettingsFileStore.externalPACReference)))
.missingCredential(ProxySettingsFileStore.gfwListReference)))
XCTAssertEqual(
workflow.lastFailure,
.store(.missingCredential(ProxySettingsFileStore.externalPACReference)))
.store(.missingCredential(ProxySettingsFileStore.gfwListReference)))
}
func testCommittingStateBlocksRepeatedSave() async throws {
@@ -87,7 +87,7 @@ final class StatusMenuModelTests: XCTestCase {
func testSummaryProjectsEveryStableRuntimeStatus() {
for testCase in Self.runtimeSummaryCases {
let summary = StatusMenuModel.summary(
facts: testCase.facts, mode: .manual, targetPath: nil)
facts: testCase.facts, mode: .pac, targetPath: nil)
XCTAssertEqual(summary.isOn, testCase.expectedIsOn)
XCTAssertEqual(summary.status, testCase.expectedStatus)
XCTAssertEqual(
@@ -99,9 +99,9 @@ final class StatusMenuModelTests: XCTestCase {
func testSummaryCarriesModeLabelAndTargetPath() {
let summary = StatusMenuModel.summary(
facts: ProxyRuntimeFacts(status: .running, isOn: true),
mode: .externalPAC(URL(string: "http://example.com/pac")!),
mode: .global,
targetPath: "订阅分组 / 嵌套分组 / 日本 02")
XCTAssertEqual(summary.modeLabel, "外部 PAC")
XCTAssertEqual(summary.modeLabel, "全局")
XCTAssertEqual(summary.targetPath, "订阅分组 / 嵌套分组 / 日本 02")
}
+2 -20
View File
@@ -5,10 +5,9 @@ import XCTest
/// System proxy mode mapping and ownership persistence are pure/testable seams;
/// no test in this file writes the host's real SystemConfiguration state.
final class SystemProxyTests: XCTestCase {
func testFourModesProduceMutuallyExclusiveSystemProxyIntent() throws {
func testSupportedModesProduceMutuallyExclusiveSystemProxyIntent() throws {
let document = ProxyRuntimeFixture.makeDocument(
localAddress: "192.168.2.89", localPort: 2086, pacPort: 2089)
let externalURL = URL(string: "https://pac.example.test/proxy.pac")!
XCTAssertEqual(
try ProxyMode.pac.systemProxyConfiguration(for: document),
@@ -17,10 +16,6 @@ final class SystemProxyTests: XCTestCase {
XCTAssertEqual(
try ProxyMode.global.systemProxyConfiguration(for: document),
SystemProxyConfiguration(target: .socks(host: "127.0.0.1", port: 2086)))
XCTAssertNil(try ProxyMode.manual.systemProxyConfiguration(for: document))
XCTAssertEqual(
try ProxyMode.externalPAC(externalURL).systemProxyConfiguration(for: document),
SystemProxyConfiguration(target: .pac(externalURL)))
}
func testSystemConfigurationProjectionEnablesOnlyPACOrSOCKS() {
@@ -59,20 +54,7 @@ final class SystemProxyTests: XCTestCase {
["localhost", "127.0.0.1"])
}
func testExternalPACRejectsUnverifiedSchemesAndCredentials() {
XCTAssertThrowsError(
try ProxyMode.validateExternalPACURL(URL(string: "file:///tmp/proxy.pac")!)
) { error in
XCTAssertEqual(error as? ProxyModeError, .unsupportedExternalPACScheme("file"))
}
XCTAssertThrowsError(
try ProxyMode.validateExternalPACURL(URL(string: "https://user:pass@example.com/pac")!)
) { error in
XCTAssertEqual(error as? ProxyModeError, .externalPACURLContainsCredentials)
}
}
func testHTTPExternalPACURLPassesTargetHealthProbe() async throws {
func testLocalPACPassesTargetHealthProbe() async throws {
let port = try ProxyRuntimeFixture.unusedLoopbackPort()
let settings = SslocalListenSettings(httpProxyEnabled: false, pacPort: port)
let server = PACServer(configuration: settings.pac)
+1 -1
View File
@@ -4,7 +4,7 @@ The proxy mode selector (status menu, the single entry point since the settings-
## Status
Accepted.
Superseded by [ADR-0007](0007-remove-manual-and-external-pac.md).
## Considered options
@@ -0,0 +1,5 @@
# Remove manual proxy mode and external PAC
**Status**: accepted
NG2 supports only local PAC and global SOCKS as system proxy modes. Remove `ProxyMode.manual` and `ProxyMode.externalPAC`, including the external PAC URL setting, Keychain reference, validation, health-check path, and related UI and presentation surfaces; retain manual catalog groups and servers, local PAC, PAC user rules, and the GFW List URL. The affected NG2 version has not shipped, so no persisted-settings migration or upgrade transition is required.