Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/SystemProxyPlan.swift
T
Qiu Yuzhou d2a8d6105c feat(system-proxy): route writes through privileged LaunchDaemon helper
Implements issue #71 with ADR-0020 (supersedes ADR-0019's endpoint-signature
cleanup, startup cleanup, and health-gate cleanup).

- New ShadowsocksX-NG2SystemProxyHelper target: app-bundled root LaunchDaemon
  (SMAppService.daemon, on-demand via MachServices) that mechanically applies
  a closed typed configuration or executes an unconditional clear. Clients are
  accepted by code-signing bundle identifier only; requests run serially on a
  shared queue (last-writer-wins).
- SystemProxyConfiguration is now the closed typed apply payload (Codable over
  JSON XPC): explicit SOCKS/HTTP/HTTPS enable + endpoints, PAC and
  auto-discovery disabled with URL/JS removed, explicit simple-hostname
  exclusion, complete ExceptionsList. Unmodeled Proxies keys are preserved and
  equivalent values skip the commit.
- Cleanup is a side effect of a persisted system-proxy intent on-to-off
  transition only: the switch itself, or the agent-off cascade (persist both
  intents off, clear, then stop listeners; failed cascade persistence records
  no transition and clears nothing). Launch with the switch off, health-gate
  closure, invalid targets, and passive events never clear. Failed clears are
  surfaced without deferred retry.
- Endpoint-signature store, ownership migration, and direct Authorization
  Services writes are removed; old signature files stay inert. Helper
  unavailability or pending approval keeps an enabled intent pending and
  surfaces the Login Items approval path (HomeView button + menu label).
2026-09-30 12:26:40 +08:00

120 lines
4.5 KiB
Swift

import Foundation
/// 系统代理写入失败(issue #71):helper 内 SC 操作失败与 GUI 侧 XPC 失败共用
/// 同一族,可直接作为 XPC 应答 payload 编解码(Codable)。
enum SystemProxyError: Error, Equatable, Sendable, Codable {
case preferencesUnavailable
case preferencesBusy
case noCurrentNetworkSet
case noNetworkLocations
case noProxyServices
case unreadableService(String)
case invalidStoredConfiguration(String)
case cannotWriteService(String)
case commitFailed(String)
case applyFailed(String)
case invalidRequest(String)
case helperUnavailable(String)
}
/// A service identity includes its network location because service IDs can appear in
/// different locations during cleanup-all-locations scans.
struct SystemProxyServiceIdentifier: Equatable, Hashable, Sendable {
let locationID: String
let serviceID: String
}
/// One service's complete Proxies dictionary as a pure value.
struct SystemProxyServiceState: Equatable, Sendable {
let identifier: SystemProxyServiceIdentifier
/// `nil` means that the service has no Proxies entity configuration.
let configuration: Data?
}
/// One planned Proxies-entity write. `nil` clears the complete entity.
struct SystemProxyPlannedWrite: Equatable, Sendable {
let identifier: SystemProxyServiceIdentifier
let configuration: Data?
}
/// Result of an apply plan. `unchanged` avoids an unnecessary SCPreferences commit.
enum SystemProxyWriteOutcome: Equatable, Sendable, Codable {
case written
case unchanged
}
/// Pure configuration planning. Apply rewrites every differing service in the active
/// location, preserving unmodeled Proxies keys. Cleanup is unconditional: the complete
/// Proxies dictionary goes for every service in every scanned location, regardless of
/// which application or account wrote it (issue #71).
enum SystemProxyPlanner {
struct ApplyPlan: Equatable, Sendable {
let writes: [SystemProxyPlannedWrite]
var outcome: SystemProxyWriteOutcome {
writes.isEmpty ? .unchanged : .written
}
}
struct ClearPlan: Equatable, Sendable {
let writes: [SystemProxyPlannedWrite]
}
static func makeApplyPlan(
services: [SystemProxyServiceState], configuration: SystemProxyConfiguration
) throws -> ApplyPlan {
var writes: [SystemProxyPlannedWrite] = []
for service in services {
let original = try dictionary(
from: service.configuration, serviceID: service.identifier.serviceID)
let desired = try propertyListData(
from: SystemProxyPropertyList.applying(configuration, to: original),
serviceID: service.identifier.serviceID)
guard !equivalent(service.configuration, desired) else { continue }
writes.append(SystemProxyPlannedWrite(identifier: service.identifier, configuration: desired))
}
return ApplyPlan(writes: writes)
}
/// 无条件清理计划:凡持有 Proxies 实体的服务都整字典移除,不校验来源,
/// 也不保存或查询端点签名、所有权标记或历史快照(issue #71)。
static func makeClearPlan(services: [SystemProxyServiceState]) -> ClearPlan {
let writes =
services
.filter { $0.configuration != nil }
.map { SystemProxyPlannedWrite(identifier: $0.identifier, configuration: nil) }
return ClearPlan(writes: writes)
}
/// Property-list semantic equality; dictionary key order does not matter.
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)
}
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)
}
}
}