fix(system-proxy): skip write when applied values already match desired

System proxy convergence runs after every successful deploy (activation,
mode change, settings saves, health convergence, launch resync), and each
apply locked SCPreferences and rewrote every owned service even when the
projected dictionaries were semantically equal to what macOS already
applies — so the admin authorization prompt fired on every redeploy while
intent was on, including server switches and GUI relaunches that cannot
change the system proxy dictionary at all.

New SystemProxyPlanner (Domain, pure) decides per service by comparing the
desired Proxies dictionary with the live value: equal services are not
written, and an all-equal apply returns .unchanged without locking or
committing, so no authorization is requested. A foreign change that
coincidentally equals the desired values (or an unowned service already at
them) is adopted into ownership with the recorded original preserved
instead of reported as a conflict. Owned services keep their recorded
original verbatim — nil stays nil — fixing a latent restore bug where a
second real change on a service originally taken over with no proxies
dictionary recorded 2.0's own previous values as the restore point. Real
value changes still write and authorize once.

SystemProxyControlling.apply returns written/unchanged; the gate emits a
systemProxyUnchanged event line (diagnostic log view and redacted report)
so it is observable why no prompt appeared. SystemProxyError moves to
Domain with the planner. CONTEXT.md invariant extended; decision table and
test points in issue #70. Closes #70.
This commit is contained in:
Qiu Yuzhou
2026-09-29 09:34:10 +08:00
parent e0f9016116
commit 53c84384a4
9 changed files with 425 additions and 118 deletions
+1 -1
View File
@@ -86,7 +86,7 @@
- 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: rule mode with proxy-when-unmatched writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with a `[proxy_all]` ACL whose bypass list is the fixed local safety rules plus China domain and China IPv4 CIDR direct candidates from the built-in snapshots; rule mode with direct-when-unmatched runs sslocal with a `[bypass_all]` ACL whose proxy list holds only expressible, unshadowed GFWList proxy candidates (the two default actions never unconditionally union all built-in sources), so unmatched targets and IP literals with no matching rule go direct; global writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with a `[proxy_all]` ACL whose bypass list is only the fixed local safety rules (no China list, GFWList, or custom rules); direct mode writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with `[bypass_all]`, so SOCKS and HTTP requests go direct without a Shadowsocks server. IP-CIDR matching may cause sslocal to issue local DNS queries for unmatched hostnames; the product does not promise that all DNS queries travel through the remote Shadowsocks server. In every mode, the system proxy ExceptionsList contains fixed local IP ranges, `localhost` and `.local` hosts, plus the user's additional system-proxy exceptions (empty by default); while 2.0 owns those settings it also fixes simple-hostname exclusion on. User-added exceptions affect only macOS system-proxy traffic and never enter the ACL. The ACL keeps its fixed local IP and host rules, including the no-dot hostname rule, as the direct-routing guarantee for requests that reach sslocal, including LAN-sharing clients. CGNAT is excluded. IPv6 CIDR strings in system exceptions are not a verified bypass. GFWList `@@` exceptions shadowed by a broader proxy rule are omitted from the ACL and recorded in the conversion report; the product does not claim complete reproduction of GFWList URL/protocol conditions. A change to the active ACL's summary or digest requires a full sslocal restart; the ACL's runtime path is a stable reference and does not change when the proxy mode switches. The wrapper publishes the ACL-backed receipt only after the child owns every configured TCP listener; the controller retains the previous mode, runtime, and applied system proxy until this instance is verified, and restores the previous runtime on verification failure. System proxy writes are allowed only while the system proxy switch is on and the agent is healthy with an available exit; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict. When a rule/global active target becomes invalid, the agent keeps listening but the held system proxy configuration is safely withdrawn while the system proxy intent stays on. Only traffic that honors the system SOCKS or HTTP/HTTPS proxy settings or explicitly connects to a local inbound is routed; the product does not claim interception of all application or UDP traffic.
- Proxy modes are mutually exclusive: rule mode with proxy-when-unmatched writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with a `[proxy_all]` ACL whose bypass list is the fixed local safety rules plus China domain and China IPv4 CIDR direct candidates from the built-in snapshots; rule mode with direct-when-unmatched runs sslocal with a `[bypass_all]` ACL whose proxy list holds only expressible, unshadowed GFWList proxy candidates (the two default actions never unconditionally union all built-in sources), so unmatched targets and IP literals with no matching rule go direct; global writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with a `[proxy_all]` ACL whose bypass list is only the fixed local safety rules (no China list, GFWList, or custom rules); direct mode writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with `[bypass_all]`, so SOCKS and HTTP requests go direct without a Shadowsocks server. IP-CIDR matching may cause sslocal to issue local DNS queries for unmatched hostnames; the product does not promise that all DNS queries travel through the remote Shadowsocks server. In every mode, the system proxy ExceptionsList contains fixed local IP ranges, `localhost` and `.local` hosts, plus the user's additional system-proxy exceptions (empty by default); while 2.0 owns those settings it also fixes simple-hostname exclusion on. User-added exceptions affect only macOS system-proxy traffic and never enter the ACL. The ACL keeps its fixed local IP and host rules, including the no-dot hostname rule, as the direct-routing guarantee for requests that reach sslocal, including LAN-sharing clients. CGNAT is excluded. IPv6 CIDR strings in system exceptions are not a verified bypass. GFWList `@@` exceptions shadowed by a broader proxy rule are omitted from the ACL and recorded in the conversion report; the product does not claim complete reproduction of GFWList URL/protocol conditions. A change to the active ACL's summary or digest requires a full sslocal restart; the ACL's runtime path is a stable reference and does not change when the proxy mode switches. The wrapper publishes the ACL-backed receipt only after the child owns every configured TCP listener; the controller retains the previous mode, runtime, and applied system proxy until this instance is verified, and restores the previous runtime on verification failure. System proxy writes are allowed only while the system proxy switch is on and the agent is healthy with an available exit; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict. When the desired per-service Proxies dictionaries are already semantically equivalent to the values macOS currently applies, 2.0 performs no write and no authorization is required; a foreign change that coincidentally equals the desired values is adopted into ownership — with the recorded original preserved — rather than reported as a conflict. When a rule/global active target becomes invalid, the agent keeps listening but the held system proxy configuration is safely withdrawn while the system proxy intent stays on. Only traffic that honors the system SOCKS or HTTP/HTTPS proxy settings or explicitly connects to a local inbound is routed; the product does not claim interception of all application or UDP traffic.
- Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once.
- The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available.
- The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model.
@@ -30,6 +30,8 @@ extension RuntimeLogEvent {
return "contract written (servers=\(serverCount))"
case .contractUnchanged:
return "contract unchanged, write skipped"
case .systemProxyUnchanged:
return "system proxy values unchanged, write skipped"
case .runtimeFilesDeleted:
return "runtime files deleted"
case .runtimePersistFailed:
@@ -133,7 +133,11 @@ extension ProxyRuntimeController {
do {
let configuration = try proxyMode.systemProxyConfiguration(
for: document, exceptions: settings.proxyExceptionList)
try systemProxy.apply(configuration)
let outcome = try systemProxy.apply(configuration)
if outcome == .unchanged {
// 值语义等价的零写入路径:事件行供排障回答「这次为何没有授权弹窗」。
RuntimeLog.emit(.systemProxyUnchanged)
}
systemProxyState = .applied
} catch {
systemProxyState = .failed(systemProxyFacts(for: error))
@@ -4,35 +4,28 @@ import SystemConfiguration
/// System proxy write seam. The controller calls it only after endpoint health
/// succeeds; tests replace it without touching the user's network settings.
/// `apply` 返回 written / unchanged:值语义等价时零写入零授权(issue #70)。
protocol SystemProxyControlling {
func apply(_ configuration: SystemProxyConfiguration) throws
@discardableResult
func apply(_ configuration: SystemProxyConfiguration) throws -> SystemProxyWriteOutcome
func restore() throws
}
enum SystemProxyError: Error, Equatable, Sendable {
case authorizationFailed(Int32)
case preferencesUnavailable
case preferencesBusy
case noCurrentNetworkSet
case noProxyServices
case unreadableService(String)
case ownershipConflict(String)
case invalidStoredConfiguration(String)
case cannotWriteService(String)
case commitFailed(String)
case applyFailed(String)
case ownershipStoreFailed(String)
}
/// Writes the Proxies entity of every service in the current network set.
/// Before the first write it snapshots each complete dictionary. Later writes
/// are allowed only while every previously applied dictionary is unchanged;
/// this prevents 2.0 from restoring over a user's manual or MDM change.
/// 期望值与当前值语义等价的 service 不写入;全部等价时不加锁、不提交,
/// 授权弹窗只发生在存在真实值变更的提交上(issue #70)。
final class SystemConfigurationProxyController: SystemProxyControlling {
private struct ServiceSnapshot {
let id: String
let proxyProtocol: SCNetworkProtocol
let configuration: Data?
var plannerState: SystemProxyServiceState {
SystemProxyServiceState(serviceID: id, configuration: configuration)
}
}
private let ownershipStore: SystemProxyOwnershipStoring
@@ -43,23 +36,32 @@ final class SystemConfigurationProxyController: SystemProxyControlling {
self.ownershipStore = ownershipStore
}
func apply(_ configuration: SystemProxyConfiguration) throws {
func apply(_ configuration: SystemProxyConfiguration) throws -> SystemProxyWriteOutcome {
try withPreferences { preferences in
let services = try serviceSnapshots(in: preferences)
guard !services.isEmpty else { throw SystemProxyError.noProxyServices }
let existing = try loadOwnership()
let plan = try makePlan(
services: services, existing: existing, configuration: configuration)
let plan = try SystemProxyPlanner.makePlan(
services: services.map(\.plannerState),
existing: existing,
configuration: configuration)
guard !plan.writes.isEmpty else {
// 期望值与系统当前值语义等价:零写入、零授权。adopt 发生时仍需刷新
// ownership record(本地文件写入,无需授权)。
if plan.ownershipChanged { try saveOwnership(plan.ownership) }
return .unchanged
}
let servicesByID = Dictionary(uniqueKeysWithValues: services.map { ($0.id, $0) })
try SCPreferencesLockOrThrow(preferences)
defer { SCPreferencesUnlock(preferences) }
try saveOwnership(plan.ownership)
do {
for (service, entry) in zip(services, plan.entries) {
guard setConfiguration(entry.appliedConfiguration, on: service.proxyProtocol) else {
throw SystemProxyError.cannotWriteService(service.id)
}
for entry in plan.writes {
guard let service = servicesByID[entry.serviceID],
setConfiguration(entry.appliedConfiguration, on: service.proxyProtocol)
else { throw SystemProxyError.cannotWriteService(entry.serviceID) }
}
try commitAndApply(preferences)
} catch {
@@ -70,53 +72,10 @@ final class SystemConfigurationProxyController: SystemProxyControlling {
}
throw error
}
return .written
}
}
private func makePlan(
services: [ServiceSnapshot],
existing: SystemProxyOwnershipRecord?,
configuration: SystemProxyConfiguration
) throws -> (
entries: [SystemProxyOwnershipRecord.Entry],
ownership: SystemProxyOwnershipRecord
) {
let existingByID = Dictionary(
uniqueKeysWithValues: existing?.entries.map { ($0.serviceID, $0) } ?? [])
var entries: [SystemProxyOwnershipRecord.Entry] = []
for service in services {
if let owned = existingByID[service.id] {
guard
!hasOwnershipConflict(
current: service.configuration, applied: owned.appliedConfiguration
)
else {
throw SystemProxyError.ownershipConflict(service.id)
}
}
let original = existingByID[service.id]?.originalConfiguration ?? service.configuration
let applied = try managedConfiguration(
basedOn: original, configuration: configuration, serviceID: service.id)
entries.append(
SystemProxyOwnershipRecord.Entry(
serviceID: service.id,
originalConfiguration: original,
appliedConfiguration: applied))
}
var allEntries = Dictionary(
uniqueKeysWithValues: existing?.entries.map { ($0.serviceID, $0) } ?? [])
for entry in entries {
allEntries[entry.serviceID] = entry
}
return (
entries: entries,
ownership: SystemProxyOwnershipRecord(
entries: allEntries.values.sorted { $0.serviceID < $1.serviceID })
)
}
func restore() throws {
guard let ownership = try loadOwnership() else { return }
try withPreferences { preferences in
@@ -126,9 +85,11 @@ final class SystemConfigurationProxyController: SystemProxyControlling {
for entry in ownership.entries {
guard let service = servicesByID[entry.serviceID] else { continue }
if equivalent(service.configuration, entry.appliedConfiguration) {
if SystemProxyPlanner.equivalent(service.configuration, entry.appliedConfiguration) {
servicesToRestore.append((service, entry))
} else if equivalent(service.configuration, entry.originalConfiguration) {
} else if SystemProxyPlanner.equivalent(
service.configuration, entry.originalConfiguration)
{
continue
} else {
throw SystemProxyError.ownershipConflict(entry.serviceID)
@@ -211,28 +172,11 @@ final class SystemConfigurationProxyController: SystemProxyControlling {
return ServiceSnapshot(
id: serviceID,
proxyProtocol: proxyProtocol,
configuration: try propertyListData(from: SCNetworkProtocolGetConfiguration(proxyProtocol)))
configuration: try propertyListData(
from: SCNetworkProtocolGetConfiguration(proxyProtocol)))
}
}
private func managedConfiguration(
basedOn original: Data?, configuration: SystemProxyConfiguration, serviceID: String
) throws -> Data {
let originalDictionary = try dictionary(from: original, serviceID: serviceID)
let dictionary = SystemProxyPropertyList.applying(configuration, to: originalDictionary)
return try propertyListData(from: dictionary, serviceID: serviceID)
}
private func dictionary(from data: Data?, serviceID: String) throws -> [String: Any] {
guard let data else { return [:] }
guard
let propertyList = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil),
let dictionary = propertyList as? [String: Any]
else { throw SystemProxyError.invalidStoredConfiguration(serviceID) }
return dictionary
}
private func propertyListData(
from value: CFPropertyList?, serviceID: String = ""
) throws -> Data? {
@@ -240,16 +184,7 @@ final class SystemConfigurationProxyController: SystemProxyControlling {
guard let dictionary = value as? [String: Any] else {
throw SystemProxyError.unreadableService(serviceID)
}
return try propertyListData(from: dictionary, serviceID: serviceID)
}
private func propertyListData(from dictionary: [String: Any], serviceID: String) throws -> Data {
do {
return try PropertyListSerialization.data(
fromPropertyList: dictionary, format: .binary, options: 0)
} catch {
throw SystemProxyError.unreadableService(serviceID)
}
return try SystemProxyPlanner.propertyListData(from: dictionary, serviceID: serviceID)
}
private func setConfiguration(_ data: Data?, on proxyProtocol: SCNetworkProtocol) -> Bool {
@@ -262,21 +197,6 @@ final class SystemConfigurationProxyController: SystemProxyControlling {
return SCNetworkProtocolSetConfiguration(proxyProtocol, dictionary as CFDictionary)
}
private func equivalent(_ lhs: Data?, _ rhs: Data?) -> Bool {
guard let lhs, let rhs else { return lhs == nil && rhs == nil }
guard
let left = try? PropertyListSerialization.propertyList(from: lhs, options: [], format: nil)
as? NSDictionary,
let right = try? PropertyListSerialization.propertyList(from: rhs, options: [], format: nil)
as? NSDictionary
else { return lhs == rhs }
return left.isEqual(right)
}
private func hasOwnershipConflict(current: Data?, applied: Data?) -> Bool {
!equivalent(current, applied)
}
private func commitAndApply(_ preferences: SCPreferences) throws {
guard SCPreferencesCommitChanges(preferences) else {
throw SystemProxyError.commitFailed(systemConfigurationError())
@@ -286,7 +206,7 @@ final class SystemConfigurationProxyController: SystemProxyControlling {
}
}
private func withPreferences(_ body: (SCPreferences) throws -> Void) throws {
private func withPreferences<T>(_ body: (SCPreferences) throws -> T) throws -> T {
var authorization: AuthorizationRef?
let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize]
let authorizationStatus = AuthorizationCreate(nil, nil, flags, &authorization)
@@ -299,7 +219,7 @@ final class SystemConfigurationProxyController: SystemProxyControlling {
let preferences = SCPreferencesCreateWithAuthorization(
nil, "ShadowsocksX-NG2" as CFString, nil, authorization)
else { throw SystemProxyError.preferencesUnavailable }
try body(preferences)
return try body(preferences)
}
private func systemConfigurationError() -> String {
+4
View File
@@ -10,6 +10,8 @@ enum RuntimeLogEvent: Equatable, CustomStringConvertible, Sendable {
case contractWritten(serverCount: Int)
/// 契约内容与磁盘一致,跳过写入(幂等)。
case contractUnchanged
/// 系统代理期望值与系统当前应用值语义等价,跳过写入(零授权,issue #70)。
case systemProxyUnchanged
/// 运行时文件已清理。
case runtimeFilesDeleted
/// 激活状态等运行时元数据落盘失败(不含内容)。
@@ -47,6 +49,8 @@ enum RuntimeLogEvent: Equatable, CustomStringConvertible, Sendable {
return "contract written (servers=\(serverCount))"
case .contractUnchanged:
return "contract unchanged, write skipped"
case .systemProxyUnchanged:
return "system proxy values unchanged, write skipped"
case .runtimeFilesDeleted:
return "runtime files deleted"
case .runtimePersistFailed(let detail):
@@ -0,0 +1,155 @@
import Foundation
/// 系统代理写入操作的封闭错误族;planner 与 SC 写入层共用。
enum SystemProxyError: Error, Equatable, Sendable {
case authorizationFailed(Int32)
case preferencesUnavailable
case preferencesBusy
case noCurrentNetworkSet
case noProxyServices
case unreadableService(String)
case ownershipConflict(String)
case invalidStoredConfiguration(String)
case cannotWriteService(String)
case commitFailed(String)
case applyFailed(String)
case ownershipStoreFailed(String)
}
/// 一次系统代理应用决策的逐 service 输入快照(纯值,不含 SystemConfiguration
/// 句柄):planner 只看字典数据,写入句柄由 App 层持有。
struct SystemProxyServiceState: Equatable, Sendable {
let serviceID: String
/// 当前完整的 Proxies 字典;nil 表示该 service 尚无代理配置。
let configuration: Data?
}
/// 系统代理应用结果:written = 至少写入一个 service(需要一次授权);
/// unchanged = 期望字典与系统当前值语义等价,零写入、零授权(issue #70)。
enum SystemProxyWriteOutcome: Equatable, Sendable {
case written
case unchanged
}
/// 纯决策核心(issue #70):逐 service 比较期望的 per-service Proxies 字典与
/// 系统当前字典,语义等价的服务零写入。全等时调用方不得加锁、不得提交,
/// 从根上避免无意义的授权弹窗。外部改动恰好等于期望值时按 adopt 语义刷新
/// ownership(original 保留),不报 ownershipConflict。
enum SystemProxyPlanner {
struct Plan: Equatable, Sendable {
/// 需要写入的 service 条目;空 = 零写入。
let writes: [SystemProxyOwnershipRecord.Entry]
/// 应用后的完整 ownership record(含 adopt 刷新与新增接管)。
let ownership: SystemProxyOwnershipRecord
/// record 相对输入是否变化(adopt 或新增接管时为 true)。
let ownershipChanged: Bool
var outcome: SystemProxyWriteOutcome {
writes.isEmpty ? .unchanged : .written
}
}
static func makePlan(
services: [SystemProxyServiceState],
existing: SystemProxyOwnershipRecord?,
configuration: SystemProxyConfiguration
) throws -> Plan {
let existingByID = Dictionary(
uniqueKeysWithValues: (existing?.entries ?? []).map { ($0.serviceID, $0) })
var writes: [SystemProxyOwnershipRecord.Entry] = []
var recordByID = existingByID
for service in services {
let owned = existingByID[service.serviceID]
// 已接管服务严格保留记录中的 original(nil = 接管前无代理配置,二次
// 变更后 restore 才能清回无配置态);未接管服务记当前值快照。
let original: Data?
if let owned {
original = owned.originalConfiguration
} else {
original = service.configuration
}
let desired = try propertyListData(
from: managedDictionary(
basedOn: original, configuration: configuration, serviceID: service.serviceID),
serviceID: service.serviceID)
if let owned {
if !equivalent(service.configuration, owned.appliedConfiguration) {
// 外部改动:恰好等于期望值 → adopt;否则冲突(绝不覆盖)。
guard equivalent(service.configuration, desired) else {
throw SystemProxyError.ownershipConflict(service.serviceID)
}
recordByID[service.serviceID] = SystemProxyOwnershipRecord.Entry(
serviceID: service.serviceID,
originalConfiguration: owned.originalConfiguration,
appliedConfiguration: desired)
continue
}
if equivalent(service.configuration, desired) {
continue
}
} else if equivalent(service.configuration, desired) {
// 未接管但系统值已等于期望(如用户手动配过相同端点):adopt 免授权。
recordByID[service.serviceID] = SystemProxyOwnershipRecord.Entry(
serviceID: service.serviceID,
originalConfiguration: service.configuration,
appliedConfiguration: desired)
continue
}
let entry = SystemProxyOwnershipRecord.Entry(
serviceID: service.serviceID,
originalConfiguration: original,
appliedConfiguration: desired)
recordByID[service.serviceID] = entry
writes.append(entry)
}
let ownership = SystemProxyOwnershipRecord(
entries: recordByID.values.sorted { $0.serviceID < $1.serviceID })
return Plan(
writes: writes,
ownership: ownership,
ownershipChanged: ownership != existing)
}
// MARK: - 语义比较与字典投影(SC 写入层共用)
/// 属性列表语义等价(键序无关),nil 仅与 nil 等价。
static func equivalent(_ lhs: Data?, _ rhs: Data?) -> Bool {
guard let lhs, let rhs else { return lhs == nil && rhs == nil }
guard
let left = try? PropertyListSerialization.propertyList(from: lhs, options: [], format: nil)
as? NSDictionary,
let right = try? PropertyListSerialization.propertyList(from: rhs, options: [], format: nil)
as? NSDictionary
else { return lhs == rhs }
return left.isEqual(right)
}
/// 期望字典 = 托管投影应用到 original(保留原字典中的未知附加键)。
static func managedDictionary(
basedOn original: Data?, configuration: SystemProxyConfiguration, serviceID: String
) throws -> [String: Any] {
let originalDictionary = try dictionary(from: original, serviceID: serviceID)
return SystemProxyPropertyList.applying(configuration, to: originalDictionary)
}
static func dictionary(from data: Data?, serviceID: String) throws -> [String: Any] {
guard let data else { return [:] }
guard
let propertyList = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil),
let dictionary = propertyList as? [String: Any]
else { throw SystemProxyError.invalidStoredConfiguration(serviceID) }
return dictionary
}
static func propertyListData(from dictionary: [String: Any], serviceID: String) throws -> Data {
do {
return try PropertyListSerialization.data(
fromPropertyList: dictionary, format: .binary, options: 0)
} catch {
throw SystemProxyError.unreadableService(serviceID)
}
}
}
@@ -264,4 +264,45 @@ extension ProxyRuntimeControllerTests {
XCTAssertEqual(controller.state, .running, "意图已开启时开启命令重走收敛")
XCTAssertEqual(controller.systemProxyState, .idle)
}
/// 等值零写入路径(issue #70):seam 返回 unchanged 时仍呈现 applied,并
/// 发一条 GUI 事件行供诊断日志回答「这次为何没有授权弹窗」。
func testUnchangedSystemProxyValuesStayAppliedAndEmitEventLine() async throws {
let seeded = try makeSeededCatalog()
systemProxy.applyOutcome = .unchanged
let recorder = RuntimeLogRecorder()
RuntimeLog.setSink(recorder)
defer { RuntimeLog.setSink(nil) }
let controller = makeController(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settings: ProxySettings(
listen: ActivationFixture.listen, agentEnabled: true, systemProxyEnabled: true))
try await controller.activate(seeded.server)
await controller.setAgentEnabled(true)
XCTAssertEqual(controller.systemProxyState, .applied, "unchanged 仍视为已应用")
XCTAssertGreaterThanOrEqual(
systemProxy.applied.count, 1, "收敛调用照常发生,跳过发生在 seam 内")
XCTAssertTrue(
recorder.recorded.contains(.systemProxyUnchanged),
"跳过路径发事件行,实际 \(recorder.recorded)")
}
}
/// 测试专用事件接收缝(issue #70 事件行断言用)。
private final class RuntimeLogRecorder: RuntimeEventSink, @unchecked Sendable {
private let lock = NSLock()
private var events: [RuntimeLogEvent] = []
var recorded: [RuntimeLogEvent] {
lock.lock()
defer { lock.unlock() }
return events
}
func append(event: RuntimeLogEvent, timestamp: Date) {
lock.lock()
events.append(event)
lock.unlock()
}
}
@@ -223,13 +223,16 @@ enum ProxyRuntimeFixture {
private(set) var restoreCount = 0
var applyError: Error?
var restoreError: Error?
/// 注入返回值:模拟系统值已与期望等价的免授权跳过路径(issue #70)。
var applyOutcome: SystemProxyWriteOutcome = .written
/// 可选共享事件日志(次序断言用)。
weak var eventLog: ProxyRuntimeEventLog?
func apply(_ configuration: SystemProxyConfiguration) throws {
func apply(_ configuration: SystemProxyConfiguration) throws -> SystemProxyWriteOutcome {
eventLog?.record("apply")
if let applyError { throw applyError }
applied.append(configuration)
return applyOutcome
}
func restore() throws {
@@ -107,4 +107,182 @@ final class SystemProxyTests: XCTestCase {
try store.clear()
XCTAssertNil(try store.load())
}
// MARK: - Planner 决策表(issue #70)
private let configuration = SystemProxyConfiguration(
socks: .init(host: "127.0.0.1", port: 1086),
http: .init(host: "127.0.0.1", port: 1087))
private func serialized(_ dictionary: [String: Any]) throws -> Data {
try PropertyListSerialization.data(fromPropertyList: dictionary, format: .binary, options: 0)
}
private func service(_ id: String, configuration data: Data?) -> SystemProxyServiceState {
SystemProxyServiceState(serviceID: id, configuration: data)
}
func testPlannerSkipsWriteWhenOwnedValuesAlreadyEqualDesired() throws {
let applied = try serialized(SystemProxyPropertyList.applying(configuration, to: [:]))
let existing = SystemProxyOwnershipRecord(
entries: [
SystemProxyOwnershipRecord.Entry(
serviceID: "service-1", originalConfiguration: nil, appliedConfiguration: applied)
])
let plan = try SystemProxyPlanner.makePlan(
services: [service("service-1", configuration: applied)],
existing: existing,
configuration: configuration)
XCTAssertTrue(plan.writes.isEmpty, "语义等价零写入")
XCTAssertEqual(plan.outcome, .unchanged)
XCTAssertFalse(plan.ownershipChanged, "record 原样保留,不重写文件")
XCTAssertEqual(plan.ownership, existing)
}
func testPlannerWritesWhenPortConfigurationActuallyChanges() throws {
let applied = try serialized(SystemProxyPropertyList.applying(configuration, to: [:]))
let existing = SystemProxyOwnershipRecord(
entries: [
SystemProxyOwnershipRecord.Entry(
serviceID: "service-1", originalConfiguration: nil, appliedConfiguration: applied)
])
let changed = SystemProxyConfiguration(
socks: .init(host: "127.0.0.1", port: 2086),
http: configuration.http,
exceptions: configuration.exceptions)
let plan = try SystemProxyPlanner.makePlan(
services: [service("service-1", configuration: applied)],
existing: existing,
configuration: changed)
XCTAssertEqual(plan.writes.count, 1)
XCTAssertEqual(plan.outcome, .written)
XCTAssertTrue(plan.ownershipChanged)
XCTAssertNil(plan.writes[0].originalConfiguration, "original 保持首次接管时的记录")
XCTAssertTrue(
SystemProxyPlanner.equivalent(
plan.writes[0].appliedConfiguration,
try serialized(SystemProxyPropertyList.applying(changed, to: [:]))))
}
func testPlannerWritesOnlyDifferingServiceAmongSeveral() throws {
let applied = try serialized(SystemProxyPropertyList.applying(configuration, to: [:]))
let junk = try serialized(
[
SystemProxyPropertyList.pacEnabled: 1,
SystemProxyPropertyList.pacURL: "http://127.0.0.1:1089/proxy.pac",
])
let existing = SystemProxyOwnershipRecord(
entries: [
SystemProxyOwnershipRecord.Entry(
serviceID: "service-1", originalConfiguration: nil, appliedConfiguration: applied)
])
let plan = try SystemProxyPlanner.makePlan(
services: [
service("service-1", configuration: applied),
service("service-2", configuration: junk),
],
existing: existing,
configuration: configuration)
XCTAssertEqual(plan.writes.map(\.serviceID), ["service-2"], "等值 service 零写入")
XCTAssertTrue(
SystemProxyPlanner.equivalent(plan.writes[0].originalConfiguration, junk))
XCTAssertEqual(
Set(plan.ownership.entries.map(\.serviceID)), ["service-1", "service-2"])
}
func testPlannerAdoptsForeignChangeThatCoincidentallyEqualsDesired() throws {
let stale = SystemProxyConfiguration(
socks: .init(host: "127.0.0.1", port: 2086),
http: .init(host: "127.0.0.1", port: 1087))
let original = try serialized(["SOCKSEnable": 0])
let existing = SystemProxyOwnershipRecord(
entries: [
SystemProxyOwnershipRecord.Entry(
serviceID: "service-1",
originalConfiguration: original,
appliedConfiguration: try serialized(
SystemProxyPropertyList.applying(stale, to: [:])))
])
let foreign = try serialized(SystemProxyPropertyList.applying(configuration, to: [:]))
let plan = try SystemProxyPlanner.makePlan(
services: [service("service-1", configuration: foreign)],
existing: existing,
configuration: configuration)
XCTAssertTrue(plan.writes.isEmpty, "外部改动恰好等于期望值:adopt 零写入零授权")
XCTAssertEqual(plan.outcome, .unchanged)
XCTAssertTrue(plan.ownershipChanged, "record 需刷新 applied")
XCTAssertTrue(
SystemProxyPlanner.equivalent(
plan.ownership.entries[0].appliedConfiguration, foreign))
XCTAssertTrue(
SystemProxyPlanner.equivalent(plan.ownership.entries[0].originalConfiguration, original),
"adopt 保留 original,恢复完整性不受影响")
}
func testPlannerStillReportsConflictWhenForeignValuesDifferFromDesired() throws {
let applied = try serialized(SystemProxyPropertyList.applying(configuration, to: [:]))
let existing = SystemProxyOwnershipRecord(
entries: [
SystemProxyOwnershipRecord.Entry(
serviceID: "service-1", originalConfiguration: nil, appliedConfiguration: applied)
])
let junk = try serialized(
[
SystemProxyPropertyList.pacEnabled: 1,
SystemProxyPropertyList.pacURL: "http://127.0.0.1:1089/proxy.pac",
])
XCTAssertThrowsError(
try SystemProxyPlanner.makePlan(
services: [service("service-1", configuration: junk)],
existing: existing,
configuration: configuration)
) { error in
XCTAssertEqual(error as? SystemProxyError, .ownershipConflict("service-1"))
}
}
func testPlannerAdoptsUnownedServiceAlreadyAtDesiredValues() throws {
let current = try serialized(SystemProxyPropertyList.applying(configuration, to: [:]))
let plan = try SystemProxyPlanner.makePlan(
services: [service("service-1", configuration: current)],
existing: nil,
configuration: configuration)
XCTAssertTrue(plan.writes.isEmpty, "首次接管时系统值已等于期望:免授权 adopt")
XCTAssertEqual(plan.outcome, .unchanged)
XCTAssertTrue(plan.ownershipChanged)
XCTAssertEqual(plan.ownership.entries.count, 1)
XCTAssertTrue(
SystemProxyPlanner.equivalent(plan.ownership.entries[0].originalConfiguration, current),
"original 记当前值,restore 语义与既有 record 丢失路径一致")
}
func testPlannerWritesUnownedServiceWhoseValuesDiffer() throws {
let junk = try serialized(
[
SystemProxyPropertyList.pacEnabled: 1,
SystemProxyPropertyList.pacURL: "http://127.0.0.1:1089/proxy.pac",
])
let plan = try SystemProxyPlanner.makePlan(
services: [service("service-1", configuration: junk)],
existing: nil,
configuration: configuration)
XCTAssertEqual(plan.writes.count, 1)
XCTAssertEqual(plan.outcome, .written)
XCTAssertTrue(
SystemProxyPlanner.equivalent(plan.writes[0].originalConfiguration, junk),
"original 记接管前的用户配置,供 restore 归还")
}
}