refactor(ng2): use sslocal runtime defaults

This commit is contained in:
Qiu Yuzhou
2026-09-27 17:51:34 +08:00
parent 59665f9b76
commit 5a05b8a661
23 changed files with 65 additions and 158 deletions
+2 -2
View File
@@ -28,12 +28,12 @@
- **Proxy runtime**: The per-user background host process, independent of the GUI's lifetime, that runs the external tunnel service and loads the optional generated ACL sidecar.
- **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.
- **Runtime configuration file**: The derived JSON document used by the external tunnel service for the active target. Fields NG2 omits are left to the bundled `sslocal` defaults; the file 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 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.
- **Diagnostic log view**: The explicit view/copy surface for GUI event lines and the raw wrapper `agent.log` tail. Raw wrapper log text is not part of a diagnostic report.
- **Diagnostic log view**: The explicit view/copy surface for GUI event lines and the raw wrapper `agent.log` tail; child-process verbosity follows `sslocal` defaults unless an external `RUST_LOG` is inherited. Raw wrapper log text is not part of a diagnostic report.
- **Diagnostic facts**: Aggregate, redacted facts about the catalog and proxy runtime that can enter a diagnostic projection without exposing catalog entries, server addresses, remarks, credential references, URLs, paths, or other sensitive information.
- **System-level shortcut**: A user-configurable keyboard action registered by the app that can trigger an app action even when the app has no focus.
_Avoid_: global hotkey, global shortcut.
+1 -5
View File
@@ -48,11 +48,7 @@ func spawnSslocal(_ document: SslocalRuntimeDocument) -> Process? {
child.executableURL = sslocalURL
child.arguments = ["-c", contractURL.path]
var childEnvironment = environment
// 上游默认日志级别会把服务器地址写进普通日志(D5):常规模式压到 warn,
// 用户明确打开 verbose 后才放宽到 debug。显式外部 RUST_LOG 仍可用于诊断。
if childEnvironment["RUST_LOG"] == nil {
childEnvironment["RUST_LOG"] = document.listen.verbose ? "debug" : "warn"
}
// 不配置 sslocal 日志级别;未设置时使用上游默认值,显式外部 RUST_LOG 原样透传。
// macOS 的 resolv.conf 可能含带 zone id 的链路本地 nameserver(RA 下发的
// RDNSS,如 fe80::…%en0),hickory 解析不了该后缀(hickory-dns#3713),
// 每次启动都报错再回退 builtin。强制 builtin getaddrinfo 即回退后的实际
+1 -3
View File
@@ -128,7 +128,7 @@ extension AppPresentation {
static func message(for issue: SettingsFieldIssue) -> String {
switch issue {
case .port(_, let error), .advertisedAddress(let error), .timeoutSeconds(let error):
case .port(_, let error), .advertisedAddress(let error):
return message(for: error)
}
}
@@ -275,8 +275,6 @@ extension AppPresentation {
return Self.port(.portOutOfRange(endpoint: endpoint, port: port))
case .duplicatePort(let endpoint, let otherEndpoint, let port):
return Self.port(.duplicatePort(endpoint: endpoint, otherEndpoint: otherEndpoint, port: port))
case .invalidTimeout(let seconds):
return "超时 \(seconds) 秒无效,必须是 1–86400 之间的整数"
case .invalidHostAddress: return "主机地址无效,必须是非回环 IPv4 地址"
}
}
@@ -144,9 +144,7 @@ extension ProxyRuntimeController {
func deployListeningWithoutTarget() async {
let document = SslocalRuntimeDocument(
servers: [],
listen: settings.listen,
timeout: settings.timeoutSeconds,
verbose: settings.verboseLogging)
listen: settings.listen)
await deploy(document)
}
-12
View File
@@ -72,18 +72,6 @@ struct SettingsView: View {
}
}
.padding(.vertical, 4)
settingRow("超时", note: "连接超时秒数(1–86400)") {
Stepper(value: $workflow.draft.timeoutSeconds, in: 1...86_400) {
Text("\(workflow.draft.timeoutSeconds) 秒")
.monospacedDigit()
}
.frame(maxWidth: 160, alignment: .trailing)
}
issuesRow(.timeoutSeconds)
Toggle(isOn: $workflow.draft.verboseLogging) {
settingCopy("详细日志(verbose)", note: "仅在需要排障时临时启用")
}
.toggleStyle(.switch)
} header: {
sectionHeader("常规", subtitle: "应用启动与常用代理行为")
}
@@ -38,8 +38,6 @@ enum SettingsDraftAdapter {
advertisedAddress: advertisedAddress,
socksPort: settings.listen.socksPort,
httpPort: settings.listen.httpPort,
timeoutSeconds: settings.timeoutSeconds,
verboseLogging: settings.verboseLogging,
proxyExceptions: settings.proxyExceptions)
}
@@ -54,8 +52,6 @@ enum SettingsDraftAdapter {
listen.httpPort = draft.httpPort
return ProxySettings(
listen: listen,
timeoutSeconds: draft.timeoutSeconds,
verboseLogging: draft.verboseLogging,
proxyExceptions: draft.proxyExceptions,
preferredMode: base.preferredMode)
}
@@ -91,8 +87,6 @@ enum SettingsDraftAdapter {
.port(portID(for: endpoint), error: error),
.port(portID(for: otherEndpoint), error: error),
]
case .invalidTimeout:
return [.timeoutSeconds(error: error)]
case .invalidHostAddress:
return [.advertisedAddress(error: error)]
}
@@ -30,15 +30,13 @@ struct SettingsPortDraft: Equatable, Sendable {
}
/// 平坦的 UI 形状编辑草稿:字段全部是 UI 原语。监听范围拆成布尔开关与公布
/// 地址文本两个字段,端口与超时保持整数;持久化的当前模式不在草稿中。
/// 地址文本两个字段;持久化的当前模式不在草稿中。
/// 与 Domain 快照的互转只在 `SettingsDraftAdapter`。
struct SettingsDraft: Equatable, Sendable {
var isHostScope: Bool
var advertisedAddress: String
var socksPort: Int
var httpPort: Int
var timeoutSeconds: Int
var verboseLogging: Bool
var proxyExceptions: String
func portValue(for id: SettingsPortID) -> Int {
@@ -62,7 +60,6 @@ struct SettingsDraft: Equatable, Sendable {
enum SettingsFieldID: Hashable, Sendable {
case advertisedAddress
case port(SettingsPortID)
case timeoutSeconds
}
/// 按字段归位的校验问题:每条保留 Domain 的 typed fact;文案由 App
@@ -70,19 +67,17 @@ enum SettingsFieldID: Hashable, Sendable {
enum SettingsFieldIssue: Hashable, Sendable {
case port(SettingsPortID, error: ProxySettingsValidationError)
case advertisedAddress(error: ProxySettingsValidationError)
case timeoutSeconds(error: ProxySettingsValidationError)
var field: SettingsFieldID {
switch self {
case .port(let id, _): .port(id)
case .advertisedAddress: .advertisedAddress
case .timeoutSeconds: .timeoutSeconds
}
}
var error: ProxySettingsValidationError {
switch self {
case .port(_, let error), .advertisedAddress(let error), .timeoutSeconds(let error):
case .port(_, let error), .advertisedAddress(let error):
error
}
}
@@ -9,15 +9,12 @@ enum ActivationEffect: Equatable, Sendable {
/// 派生运行时文档的监听与行为设置;聚合状态机接口的传参。
struct RuntimeDocumentOptions: Equatable, Sendable {
var listen: SslocalListenSettings
var timeout: Int = 60
var verbose: Bool = false
}
extension ProxySettings {
/// 激活派生所用选项即用户设置中的运行时文档旋钮。
/// 激活派生所用的监听配置。
var runtimeDocumentOptions: RuntimeDocumentOptions {
RuntimeDocumentOptions(
listen: listen, timeout: timeoutSeconds, verbose: verboseLogging)
RuntimeDocumentOptions(listen: listen)
}
}
@@ -123,9 +120,7 @@ struct ActivationStateMachine: Equatable, Sendable {
targetID: target,
document: SslocalRuntimeDocument(
servers: servers,
listen: options.listen,
timeout: options.timeout,
verbose: options.verbose),
listen: options.listen),
skippedServers: skippedServers))
}
@@ -8,8 +8,6 @@ struct ProxySettings: Equatable, Sendable {
"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
var verboseLogging: Bool
var proxyExceptions: String
/// The persisted current mode: the mode selector's choice survives GUI
/// restarts. First run defaults to rule mode (issue #67).
@@ -25,8 +23,6 @@ struct ProxySettings: Equatable, Sendable {
init(
listen: SslocalListenSettings = SslocalListenSettings(),
timeoutSeconds: Int = 60,
verboseLogging: Bool = false,
proxyExceptions: String = ProxySettings.defaultProxyExceptions,
preferredMode: ProxyModeKind = .rule,
ruleDefaultAction: RuleDefaultAction = .proxyWhenUnmatched,
@@ -34,8 +30,6 @@ struct ProxySettings: Equatable, Sendable {
systemProxyEnabled: Bool = false
) {
self.listen = listen
self.timeoutSeconds = timeoutSeconds
self.verboseLogging = verboseLogging
self.proxyExceptions = proxyExceptions
self.preferredMode = preferredMode
self.ruleDefaultAction = ruleDefaultAction
@@ -60,9 +54,6 @@ struct ProxySettings: Equatable, Sendable {
var validationErrors: [ProxySettingsValidationError] {
var errors = listen.portValidationErrors().map(ProxySettingsValidationError.init)
if !(1...86_400).contains(timeoutSeconds) {
errors.append(.invalidTimeout(timeoutSeconds))
}
if case .host(let address) = listen.scope, !Self.isUsableHostAddress(address) {
errors.append(.invalidHostAddress(address))
}
@@ -80,7 +71,6 @@ struct ProxySettings: Equatable, Sendable {
enum ProxySettingsValidationError: Error, Hashable, Sendable {
case portOutOfRange(endpoint: ProxyEndpointKind, port: Int)
case duplicatePort(endpoint: ProxyEndpointKind, otherEndpoint: ProxyEndpointKind, port: Int)
case invalidTimeout(Int)
case invalidHostAddress(String)
init(_ error: PortSettingError) {
@@ -101,8 +101,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
return try validated(
ProxySettings(
listen: listen,
timeoutSeconds: record.timeoutSeconds,
verboseLogging: record.verboseLogging,
proxyExceptions: record.proxyExceptions,
preferredMode: record.preferredMode,
ruleDefaultAction: record.ruleDefaultAction,
@@ -119,8 +117,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
}()
record.socksPort = settings.listen.socksPort
record.httpPort = settings.listen.httpPort
record.timeoutSeconds = settings.timeoutSeconds
record.verboseLogging = settings.verboseLogging
record.proxyExceptions = settings.proxyExceptions
record.preferredMode = settings.preferredMode
record.ruleDefaultAction = settings.ruleDefaultAction
@@ -141,8 +137,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
var advertisedAddress: String?
var socksPort: Int = SslocalListenSettings.defaultSocksPort
var httpPort: Int = SslocalListenSettings.defaultHTTPPort
var timeoutSeconds: Int = 60
var verboseLogging: Bool = false
var proxyExceptions: String = ProxySettings.defaultProxyExceptions
var preferredMode: ProxyModeKind = .rule
var ruleDefaultAction: RuleDefaultAction = .proxyWhenUnmatched
@@ -161,8 +155,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
httpPort =
try container.decodeIfPresent(Int.self, forKey: .httpPort)
?? SslocalListenSettings.defaultHTTPPort
timeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .timeoutSeconds) ?? 60
verboseLogging = try container.decodeIfPresent(Bool.self, forKey: .verboseLogging) ?? false
proxyExceptions =
try container.decodeIfPresent(String.self, forKey: .proxyExceptions)
?? ProxySettings.defaultProxyExceptions
@@ -189,7 +181,7 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
extension ProxySettingsRecord {
fileprivate enum CodingKeys: String, CodingKey {
case scopeKind, advertisedAddress, socksPort, httpPort
case timeoutSeconds, verboseLogging, proxyExceptions
case proxyExceptions
case preferredMode
case ruleDefaultAction
case agentEnabled, systemProxyEnabled
@@ -82,10 +82,8 @@ struct RuntimeListenFacts: Equatable, Sendable {
}
/// 监听指纹(spec #21 D5/D7):服务器列表变化可热重载;任一本地入站、
/// ACL 内容/路径/摘要或 verbose(决定 spawn 时的 RUST_LOG)变化都必须由
/// wrapper 完整重启 sslocal。
/// ACL 内容/路径/摘要变化都必须由 wrapper 完整重启 sslocal。
struct SslocalListenFingerprint: Equatable, Sendable {
let locals: [SslocalLocalDocument]
let acl: ProxyACLDocument?
let verbose: Bool
}
@@ -52,30 +52,16 @@ struct SslocalLocalDocument: Codable, Equatable, Sendable {
/// wrapper 自有的监听身份扩展(issue #67 取代已删除的 PAC 扩展)。字段收在
/// `x_shadowsocksx_ng_listen` 下,上游 sslocal 会忽略该扩展;wrapper 与 GUI
/// 仍从同一原子文件读取同一份事实。verbose 决定 sslocal 的 RUST_LOG 级别。
/// 仍从同一原子文件读取同一份事实。
struct RuntimeListenDocument: Codable, Equatable, Sendable {
let listenScope: ListenScopeKind
let bindAddress: String
let advertisedAddress: String
let verbose: Bool
enum CodingKeys: String, CodingKey {
case listenScope = "listen_scope"
case bindAddress = "bind_address"
case advertisedAddress = "advertised_address"
case verbose
}
init(
listenScope: ListenScopeKind,
bindAddress: String,
advertisedAddress: String,
verbose: Bool = false
) {
self.listenScope = listenScope
self.bindAddress = bindAddress
self.advertisedAddress = advertisedAddress
self.verbose = verbose
}
}
@@ -85,14 +71,13 @@ struct SslocalRuntimeDocument: Codable, Equatable, Sendable {
let servers: [SslocalServerDocument]
let locals: [SslocalLocalDocument]
let listen: RuntimeListenDocument
let timeout: Int
/// Upstream sslocal ACL file path. The wrapper-owned extension carries the
/// matching content and digest so both inbounds use the same validated file.
let aclFilePath: String?
let aclRuntime: ProxyACLDocument?
enum CodingKeys: String, CodingKey {
case servers, locals, timeout
case servers, locals
case aclFilePath = "acl"
case aclRuntime = "x_shadowsocksx_ng_acl"
case listen = "x_shadowsocksx_ng_listen"
@@ -101,8 +86,6 @@ struct SslocalRuntimeDocument: Codable, Equatable, Sendable {
init(
servers: [SslocalServerDocument],
listen: SslocalListenSettings,
timeout: Int = 60,
verbose: Bool = false,
acl: ProxyACLDocument? = nil
) {
self.servers = servers
@@ -110,9 +93,7 @@ struct SslocalRuntimeDocument: Codable, Equatable, Sendable {
self.listen = RuntimeListenDocument(
listenScope: listen.scope.kind,
bindAddress: listen.bindAddress,
advertisedAddress: listen.advertisedAddress,
verbose: verbose)
self.timeout = timeout
advertisedAddress: listen.advertisedAddress)
aclFilePath = acl?.path
aclRuntime = acl
}
@@ -121,13 +102,11 @@ struct SslocalRuntimeDocument: Codable, Equatable, Sendable {
servers: [SslocalServerDocument],
locals: [SslocalLocalDocument],
listen: RuntimeListenDocument,
timeout: Int,
acl: ProxyACLDocument?
) {
self.servers = servers
self.locals = locals
self.listen = listen
self.timeout = timeout
aclFilePath = acl?.path
aclRuntime = acl
}
@@ -137,7 +116,6 @@ struct SslocalRuntimeDocument: Codable, Equatable, Sendable {
servers = try container.decode([SslocalServerDocument].self, forKey: .servers)
locals = try container.decode([SslocalLocalDocument].self, forKey: .locals)
listen = try container.decode(RuntimeListenDocument.self, forKey: .listen)
timeout = try container.decodeIfPresent(Int.self, forKey: .timeout) ?? 60
aclFilePath = try container.decodeIfPresent(String.self, forKey: .aclFilePath)
aclRuntime = try container.decodeIfPresent(ProxyACLDocument.self, forKey: .aclRuntime)
}
@@ -156,7 +134,6 @@ struct SslocalRuntimeDocument: Codable, Equatable, Sendable {
servers: servers,
locals: locals,
listen: listen,
timeout: timeout,
acl: acl)
}
@@ -205,7 +182,7 @@ extension SslocalRuntimeDocument {
}
var listenFingerprint: SslocalListenFingerprint {
SslocalListenFingerprint(locals: locals, acl: aclRuntime, verbose: listen.verbose)
SslocalListenFingerprint(locals: locals, acl: aclRuntime)
}
var socksLocal: SslocalLocalDocument? {
@@ -227,7 +204,6 @@ extension SslocalRuntimeDocument {
/// 接受空 servers 并照常绑定本地入站。
var isWellFormed: Bool {
guard
(1...86_400).contains(timeout),
!listen.advertisedAddress.isEmpty
else { return false }
@@ -101,7 +101,6 @@ final class AppPresentationTests: XCTestCase {
let issues: [SettingsFieldIssue] = [
.port(.socks, error: .portOutOfRange(endpoint: .socks, port: 0)),
.advertisedAddress(error: .invalidHostAddress("127.0.0.1")),
.timeoutSeconds(error: .invalidTimeout(0)),
]
for issue in issues {
XCTAssertFalse(AppPresentation.message(for: issue).isEmpty)
@@ -72,7 +72,6 @@ final class LegacyImportTests: XCTestCase {
var existingSettings = ProxySettings()
existingSettings.listen.socksPort = 2086
existingSettings.listen.httpPort = 2087
existingSettings.timeoutSeconds = 120
existingSettings.preferredMode = .global
let settingsStore = ProxySettingsFileStore(
fileURL: settingsURL,
@@ -78,16 +78,14 @@ extension ProxyRuntimeControllerTests {
await controller.setAgentEnabled(true)
var next = controller.settings
next.timeoutSeconds = 120
next.verboseLogging = true
next.listen.httpPort = 2088
next.proxyExceptions = "localhost, 127.0.0.1"
try await controller.updateSettings(next)
let document = try XCTUnwrap(RuntimeFileStore(fileURL: runtime.contract).loadDocument())
XCTAssertEqual(controller.settings, next)
XCTAssertEqual(settingsStore.saved, next)
XCTAssertEqual(document.timeout, 120)
XCTAssertTrue(document.listen.verbose)
XCTAssertEqual(document.httpPort, 2088)
XCTAssertEqual(
systemProxy.applied.last?.exceptions,
FixedLocalProxyRanges.systemProxyExceptions(including: ["localhost", "127.0.0.1"]),
@@ -42,8 +42,6 @@ final class ProxySettingsFileStoreTests: XCTestCase {
settings.listen.scope = .host(advertisedAddress: "192.168.2.89")
settings.listen.socksPort = 2086
settings.listen.httpPort = 2087
settings.timeoutSeconds = 120
settings.verboseLogging = true
settings.proxyExceptions = "localhost, 127.0.0.1"
settings.preferredMode = .direct
@@ -52,6 +50,8 @@ final class ProxySettingsFileStoreTests: XCTestCase {
XCTAssertEqual(try store.load(), settings)
let raw = try String(contentsOf: store.fileURL, encoding: .utf8)
XCTAssertFalse(raw.contains("udpRelayEnabled"))
XCTAssertFalse(raw.contains("timeoutSeconds"))
XCTAssertFalse(raw.contains("verboseLogging"))
XCTAssertEqual(try store.load().listen.mode, "tcp_and_udp")
}
@@ -86,6 +86,7 @@ final class ProxySettingsFileStoreTests: XCTestCase {
try writeRaw(
"""
{"preferredMode":"pac","socksPort":2086,"httpPort":2087,"timeoutSeconds":120,
"verboseLogging":true,
"pacUserRules":"@@example.com","gfwListURLConfigured":true,"pacPort":2089}
""")
@@ -93,18 +94,17 @@ final class ProxySettingsFileStoreTests: XCTestCase {
XCTAssertEqual(loaded.preferredMode, .rule)
XCTAssertEqual(loaded.listen.socksPort, 2086)
XCTAssertEqual(loaded.listen.httpPort, 2087)
XCTAssertEqual(loaded.timeoutSeconds, 120)
}
func testInvalidSaveDoesNotTouchExistingSettings() throws {
try store.save(ProxySettings())
var invalid = ProxySettings()
invalid.timeoutSeconds = 0
invalid.listen.socksPort = 0
XCTAssertThrowsError(try store.save(invalid)) { error in
XCTAssertEqual(
error as? ProxySettingsStoreError,
.invalid([.invalidTimeout(0)]))
.invalid([.portOutOfRange(endpoint: .socks, port: 0)]))
}
XCTAssertEqual(try store.load(), ProxySettings())
}
@@ -127,6 +127,5 @@ final class ProxySettingsFileStoreTests: XCTestCase {
let loaded = try store.load()
XCTAssertEqual(loaded.listen, legacy)
XCTAssertEqual(loaded.timeoutSeconds, 60)
}
}
@@ -10,8 +10,6 @@ final class ProxySettingsTests: XCTestCase {
XCTAssertEqual(settings.listen.socksPort, 11086)
XCTAssertEqual(settings.listen.httpPort, 11087)
XCTAssertEqual(settings.timeoutSeconds, 60)
XCTAssertFalse(settings.verboseLogging)
XCTAssertEqual(settings.listen.mode, "tcp_and_udp")
XCTAssertFalse(settings.agentEnabled, "全新用户无服务器配置,Agent 默认 off")
XCTAssertFalse(settings.systemProxyEnabled)
@@ -27,7 +25,6 @@ final class ProxySettingsTests: XCTestCase {
func testInvalidAdvancedSettingsNameEveryProblem() {
var settings = ProxySettings()
settings.timeoutSeconds = 0
settings.listen.scope = .host(advertisedAddress: "127.0.0.1")
settings.listen.socksPort = settings.listen.httpPort
@@ -35,7 +32,6 @@ final class ProxySettingsTests: XCTestCase {
settings.validationErrors,
[
.duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 11087),
.invalidTimeout(0),
.invalidHostAddress("127.0.0.1"),
])
}
@@ -49,7 +49,8 @@ final class RuntimeDocumentTests: XCTestCase {
XCTAssertEqual(listen["listen_scope"] as? String, "loopback")
XCTAssertEqual(listen["bind_address"] as? String, "127.0.0.1")
XCTAssertEqual(listen["advertised_address"] as? String, "127.0.0.1")
XCTAssertEqual(listen["verbose"] as? Bool, false)
XCTAssertFalse(listen.keys.contains("verbose"))
XCTAssertFalse(dictionary.keys.contains("timeout"))
let servers = try XCTUnwrap(dictionary["servers"] as? [[String: Any]])
XCTAssertEqual(servers.count, 1)
let server = servers[0]
@@ -86,21 +87,19 @@ final class RuntimeDocumentTests: XCTestCase {
XCTAssertEqual(listen.locals.map(\.mode), ["tcp_and_udp", "tcp_only"])
}
func testRuntimeDocumentCarriesTimeoutAndVerbose() throws {
func testRuntimeDocumentLeavesTimeoutAndLoggingAtUpstreamDefaults() throws {
let document = SslocalRuntimeDocument(
servers: [
SslocalServerDocument(
id: "server-1", remarks: "test", server: "example.com", serverPort: 8388,
password: "pw", method: "aes-256-gcm", plugin: nil, pluginOpts: nil)
],
listen: SslocalListenSettings(),
timeout: 120,
verbose: true)
listen: SslocalListenSettings())
let decoded = try XCTUnwrap(SslocalRuntimeDocument.decodeValidated(try document.jsonData()))
XCTAssertEqual(decoded.timeout, 120)
XCTAssertTrue(decoded.listen.verbose)
let dictionary = try encodedDictionary(document)
let listen = try XCTUnwrap(dictionary["x_shadowsocksx_ng_listen"] as? [String: Any])
XCTAssertFalse(dictionary.keys.contains("timeout"))
XCTAssertFalse(listen.keys.contains("verbose"))
}
func testACLPathAndMetadataRoundTripAsUpstreamAndWrapperFields() throws {
@@ -8,7 +8,7 @@ extension SettingsWorkflowInterfaceTests {
func testCommitFailureSurfacesNamedReasonAndKeepsDraft() async throws {
committing.updateError = FakeCommitError.io
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
_ = await workflow.save()
await waitUntil(workflow.lastFailure != nil)
@@ -16,14 +16,17 @@ extension SettingsWorkflowInterfaceTests {
XCTAssertEqual(workflow.lastFailure, .unknown)
XCTAssertEqual(workflow.lastFailure?.presentableMessage, AppPresentation.unknownError)
XCTAssertFalse(workflow.isCommitting)
XCTAssertEqual(workflow.draft.timeoutSeconds, 120, "失败不吞掉草稿")
XCTAssertEqual(committing.committedSettings.timeoutSeconds, 60, "失败不半提交")
XCTAssertEqual(workflow.draft.proxyExceptions, "localhost", "失败不吞掉草稿")
XCTAssertEqual(
committing.committedSettings.proxyExceptions,
ProxySettings.defaultProxyExceptions,
"失败不半提交")
XCTAssertTrue(workflow.isDirty)
}
func testStorageAndCredentialFailuresNameTheReason() async throws {
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
committing.updateError = ProxySettingsStoreError.ioFailure(detail: "disk full")
_ = await workflow.save()
@@ -47,7 +50,7 @@ extension SettingsWorkflowInterfaceTests {
func testCommittingStateBlocksRepeatedSave() async throws {
committing.updateGate = AsyncGate()
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
let first = Task { await workflow.save() }
await Task.yield()
@@ -52,16 +52,6 @@ final class SettingsWorkflowInterfaceTests: XCTestCase {
XCTAssertEqual(workflow.issues(for: .port(.http)), [.port(.http, error: error)])
}
func testTimeoutIssueLandsOnTimeoutField() {
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 0
XCTAssertEqual(
workflow.fieldIssues,
[.timeoutSeconds(error: .invalidTimeout(0))])
XCTAssertTrue(workflow.issues(for: .port(.socks)).isEmpty)
}
func testHostAddressIssueLandsOnAdvertisedAddressField() {
let workflow = makeWorkflow()
workflow.draft.isHostScope = true
@@ -196,7 +186,7 @@ extension SettingsWorkflowInterfaceTests {
func testValidationIssuesBlockSave() async throws {
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 0
workflow.draft.socksPort = 0
XCTAssertFalse(workflow.canSave)
@@ -220,24 +210,24 @@ extension SettingsWorkflowInterfaceTests {
func testSaveCommitsWhenGateClear() async throws {
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
XCTAssertTrue(workflow.canSave)
let outcome = await workflow.save()
XCTAssertEqual(outcome, .persisted(runtime: .notRunning))
XCTAssertEqual(committing.updateCalls.first?.timeoutSeconds, 120)
XCTAssertEqual(committing.updateCalls.first?.proxyExceptions, "localhost")
}
func testSaveReturnsAValidationRejectionWithoutStartingACommit() async throws {
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 0
workflow.draft.socksPort = 0
let outcome = await workflow.save()
XCTAssertEqual(
outcome,
.rejected(.validation([.timeoutSeconds(error: .invalidTimeout(0))])))
.rejected(.validation([.port(.socks, error: .portOutOfRange(endpoint: .socks, port: 0))])))
XCTAssertFalse(workflow.isCommitting)
XCTAssertTrue(committing.updateCalls.isEmpty)
}
@@ -246,19 +236,19 @@ extension SettingsWorkflowInterfaceTests {
let runtimeFailure = RuntimeFailureFacts.service(.persistence)
committing.updateOutcome = .failed(runtimeFailure)
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
let outcome = await workflow.save()
XCTAssertEqual(outcome, .persisted(runtime: .failed(runtimeFailure)))
XCTAssertEqual(workflow.lastFailure, .runtime(runtimeFailure))
XCTAssertEqual(committing.committedSettings.timeoutSeconds, 120)
XCTAssertEqual(committing.committedSettings.proxyExceptions, "localhost")
}
func testRepeatedSaveReturnsAnInProgressRejection() async throws {
committing.updateGate = AsyncGate()
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
let first = Task { await workflow.save() }
await Task.yield()
@@ -278,17 +268,17 @@ extension SettingsWorkflowInterfaceTests {
let workflow = makeWorkflow()
XCTAssertFalse(workflow.isDirty)
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
XCTAssertTrue(workflow.isDirty)
_ = await workflow.reloadFromCommitted()
XCTAssertFalse(workflow.isDirty)
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
_ = await workflow.save()
await waitUntil(!workflow.isCommitting)
XCTAssertFalse(workflow.isDirty, "提交成功后草稿回到已提交快照")
XCTAssertEqual(workflow.draft.timeoutSeconds, 120)
XCTAssertEqual(workflow.draft.proxyExceptions, "localhost")
}
func testHiddenHostAddressLeftoverDoesNotReportDirty() {
@@ -309,7 +299,7 @@ extension SettingsWorkflowInterfaceTests {
committing.committedSettings = committed
let workflow = makeWorkflow()
workflow.draft.timeoutSeconds = 120
workflow.draft.proxyExceptions = "localhost"
_ = await workflow.save()
await waitUntil(!workflow.isCommitting)
@@ -36,11 +36,11 @@ final class SettingsWorkflowTests: XCTestCase {
func testSaveCommitsThroughTheRuntimeController() async throws {
let pair = makePair()
pair.workflow.draft.timeoutSeconds = 120
pair.workflow.draft.proxyExceptions = "localhost"
_ = await pair.workflow.save()
XCTAssertEqual(settingsStore.saved?.timeoutSeconds, 120)
XCTAssertEqual(pair.controller.settings.timeoutSeconds, 120)
XCTAssertEqual(settingsStore.saved?.proxyExceptions, "localhost")
XCTAssertEqual(pair.controller.settings.proxyExceptions, "localhost")
XCTAssertEqual(
pair.workflow.draft, SettingsDraftAdapter.draft(from: pair.controller.settings),
"提交后草稿回到已提交快照")
@@ -61,23 +61,24 @@ final class SettingsWorkflowTests: XCTestCase {
XCTAssertFalse(pair.workflow.hasBlockingPortOccupancy)
XCTAssertTrue(pair.workflow.canSave)
pair.workflow.draft.timeoutSeconds = 120
pair.workflow.draft.proxyExceptions = "localhost"
_ = await pair.workflow.save()
XCTAssertEqual(settingsStore.saved?.timeoutSeconds, 120)
XCTAssertEqual(settingsStore.saved?.proxyExceptions, "localhost")
}
func testCommitFailureSurfacesPresentedReasonAndKeepsCommittedValues() async throws {
settingsStore.saveError = FakeSaveError.io
let pair = makePair()
pair.workflow.draft.timeoutSeconds = 120
pair.workflow.draft.proxyExceptions = "localhost"
_ = await pair.workflow.save()
XCTAssertEqual(pair.workflow.lastFailure, .unknown)
XCTAssertEqual(pair.workflow.lastFailure?.presentableMessage, AppPresentation.unknownError)
XCTAssertNil(settingsStore.saved)
XCTAssertEqual(pair.controller.settings.timeoutSeconds, 60, "失败保留旧值")
XCTAssertEqual(pair.workflow.draft.timeoutSeconds, 120, "草稿保留待修改值")
XCTAssertEqual(
pair.controller.settings.proxyExceptions, ProxySettings.defaultProxyExceptions, "失败保留旧值")
XCTAssertEqual(pair.workflow.draft.proxyExceptions, "localhost", "草稿保留待修改值")
}
// MARK: - 夹具
@@ -27,9 +27,9 @@ final class SystemProxyTests: XCTestCase {
// 无效端口 0,而应按模式错误拒绝。
let json = """
{"servers":[],"locals":[{"protocol":"socks","local_address":"127.0.0.1",\
"local_port":11086,"mode":"tcp_and_udp"}],"timeout":60,\
"local_port":11086,"mode":"tcp_and_udp"}],\
"x_shadowsocksx_ng_listen":{"listen_scope":"loopback",\
"bind_address":"127.0.0.1","advertised_address":"127.0.0.1","verbose":false}}
"bind_address":"127.0.0.1","advertised_address":"127.0.0.1"}}
"""
let document = try XCTUnwrap(SslocalRuntimeDocument.decodeValidated(Data(json.utf8)))
@@ -0,0 +1,3 @@
# Delegate timeout and log level to sslocal defaults
NG2 removes the user settings that overrode `sslocal`'s connection timeout and log level, omits those values from its runtime document, and no longer injects `RUST_LOG=warn` or `debug`; an explicitly inherited `RUST_LOG` remains effective. Stale `timeoutSeconds` and `verboseLogging` preference keys are ignored on load and omitted by later saves, with no migration. This keeps these controls with the bundled `sslocal` version while internal application timeouts and the diagnostic log view remain independent; upstream default logs may include server addresses in the raw `agent.log`.