fix(system-proxy): use BundleProgram in daemon plist and refresh stale registration

launchd 对 ProgramArguments[0] 的相对路径按 _PATH_STDPATH(PATH)解析而非
bundle:系统域 daemon 的 job 因此永远 spawn 失败(EX_CONFIG 节流循环,
launchctl print 显示 job state = spawn failed、runs 持续累加),客户端 XPC
调用只能等到超时。agent 之所以正常,是 GUI 域导入时被解析成绝对路径,system
域 submitted job 没有这一步。SMAppService 清单的正确键是 BundleProgram
(bundle 相对,launchd.plist(5)),改用之。

launchd 同时沿用注册提交时的定义快照:app 更新改动 LaunchDaemon 清单后旧
定义不会自动刷新(register() 对已注册服务返回 alreadyRegistered),必须注销
重注。门禁在已批准分支按清单内容 SHA-256 指纹(UserDefaults)检测漂移,漂移
才注销重注——审批与 bundle 签名绑定,重注不重新弹授权;首次注册成功即记指
纹,失败不记(下次启动重试),指纹一致为一次哈希比对的 no-op。
This commit is contained in:
Qiu Yuzhou
2026-09-30 18:54:19 +08:00
parent c1aba5a882
commit 43ea00cb44
8 changed files with 150 additions and 12 deletions
@@ -142,16 +142,31 @@ extension ProxyRuntimeController {
}
}
/// helper 可用性门禁(issue #71):已批准即通过;未注册则尝试注册(注册
/// 本身不弹授权框);待批准或注册失败时置位批准路径并保持待应用。
/// helper 可用性门禁(issue #71):已批准即通过——仅当 LaunchDaemon 清单
/// 与上次注册时的指纹漂移(app 更新改过清单)才注销重注刷新 launchd 的
/// job 定义;未注册则尝试注册(注册本身不弹授权框,成功即记指纹);待批准
/// 或注册失败时置位批准路径并保持待应用。
private func ensureHelperAvailableForApply() -> Bool {
switch systemProxyHelper.status {
case .approved:
systemProxyApprovalRequired = false
return true
if Self.helperRegistrationDrifted() {
do {
try systemProxyHelper.unregister()
try systemProxyHelper.register()
Self.storeHelperRegistrationStamp()
} catch {
// 刷新失败不记指纹,下次启动重试;状态退回后由下方分支接手。
RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: describe(error)))
}
}
if systemProxyHelper.status == .approved {
systemProxyApprovalRequired = false
return true
}
case .notRegistered:
do {
try systemProxyHelper.register()
Self.storeHelperRegistrationStamp()
} catch {
RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: describe(error)))
}
@@ -166,6 +181,19 @@ extension ProxyRuntimeController {
return false
}
private static func helperRegistrationDrifted(defaults: UserDefaults = .standard) -> Bool {
SystemProxyHelperRegistrationStamp.drifted(
plistData: SystemProxyHelperIdentity.launchDaemonPlistData,
storedStamp: defaults.string(forKey: SystemProxyHelperRegistrationStamp.defaultsKey))
}
private static func storeHelperRegistrationStamp(defaults: UserDefaults = .standard) {
guard let plistData = SystemProxyHelperIdentity.launchDaemonPlistData else { return }
defaults.set(
SystemProxyHelperRegistrationStamp.stamp(plistData),
forKey: SystemProxyHelperRegistrationStamp.defaultsKey)
}
/// 登录项批准路径(issue #71):打开系统设置的登录项面板并立即重试收敛;
/// 批准完成前意图保持待应用、系统设置不变。
func openSystemProxyHelperApproval() async {
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
import ServiceManagement
@@ -12,12 +13,13 @@ protocol SystemProxyControlling {
}
/// helper 注册/审批状态缝(issue #71):注册走 SMAppService.daemon;审批缺失
/// 时 GUI 据此呈现登录项批准路径,不提供直接授权回退。单测以 fake 替换
/// (真实注册会改动系统登录项状态)。
/// 时 GUI 据此呈现登录项批准路径,不提供直接授权回退。unregister 供注册清单
/// 漂移后的定义刷新使用。单测以 fake 替换(真实注册会改动系统登录项状态)。
@MainActor
protocol SystemProxyHelperServicing {
var status: SystemProxyHelperStatus { get }
func register() throws
func unregister() throws
func openApprovalPath()
}
@@ -49,11 +51,33 @@ struct SMAppServiceSystemProxyHelper: SystemProxyHelperServicing {
try service.register()
}
func unregister() throws {
try service.unregister()
}
func openApprovalPath() {
SMAppService.openSystemSettingsLoginItems()
}
}
/// 注册清单漂移指纹(issue #71 后续):launchd 对 SMAppService 提交的 job
/// 沿用注册时的定义快照,app 更新改动 LaunchDaemon 清单不会自动生效(旧定义
/// spawn 失败时 launchd 以 EX_CONFIG 节流循环)。以清单内容 SHA-256 检测漂移,
/// 漂移才注销重注;审批与 bundle 签名绑定,重注不重新弹授权。
enum SystemProxyHelperRegistrationStamp {
static let defaultsKey = "systemProxyHelperRegistrationStamp"
/// 清单不可读时不判漂移(无基准;未注册场景由注册路径自身兜底)。
static func drifted(plistData: Data?, storedStamp: String?) -> Bool {
guard let plistData else { return false }
return stamp(plistData) != storedStamp
}
static func stamp(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
}
/// 经特权 helper 的系统代理写入器(issue #71):每个请求一条按需连接,
/// MachService 连接触发 launchd 激活已注册的 LaunchDaemon。应答在
/// helper 不可达、连接失效、报错或超时时以 typed 错误呈现。
@@ -17,6 +17,16 @@ enum SystemProxyHelperIdentity {
static let plistName = "com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.plist"
static let launchdLabel = "com.qiuyuzhou.ShadowsocksX-NG2.systemproxy"
static let clientCodeSigningIdentifier = "com.qiuyuzhou.ShadowsocksX-NG2"
/// App bundle 内 LaunchDaemon 清单内容(注册漂移指纹的基准)。helper 进程
/// 内没有 bundle,返回 nil,调用方按「无基准不动作」处理。Contents/Library
/// 不是资源目录,Bundle 资源 API 搜不到,按固定路径直读。
static var launchDaemonPlistData: Data? {
let url = Bundle.main.bundleURL
.appendingPathComponent("Contents/Library/LaunchDaemons")
.appendingPathComponent(plistName)
return try? Data(contentsOf: url)
}
}
/// helper XPC 应答:成功携带 typed 结果,失败携带 typed 错误族。
@@ -2,12 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 可执行路径用 BundleProgram(SMAppService 专用键,bundle 相对):
ProgramArguments[0] 的相对路径按 _PATH_STDPATH(PATH)解析而非
bundle,launchd 找不到程序会以 EX_CONFIG 循环 spawn 失败。 -->
<key>BundleProgram</key>
<string>Contents/MacOS/ShadowsocksX-NG2SystemProxyHelper</string>
<key>Label</key>
<string>com.qiuyuzhou.ShadowsocksX-NG2.systemproxy</string>
<key>ProgramArguments</key>
<array>
<string>Contents/MacOS/ShadowsocksX-NG2SystemProxyHelper</string>
</array>
<key>MachServices</key>
<dict>
<key>com.qiuyuzhou.ShadowsocksX-NG2.systemproxy</key>
@@ -491,6 +491,43 @@ extension ProxyRuntimeControllerTests {
recorder.recorded.contains(.systemProxyUnchanged),
"跳过路径发事件行,实际 \(recorder.recorded)")
}
/// LaunchDaemon 清单漂移(issue #71 后续):launchd 沿用注册提交时的 job
/// 定义快照,app 更新改了清单后须注销重注才生效。已批准注册仅在清单指纹
/// 与注册时不一致时重注(审批与签名绑定,不重弹授权);首次注册成功即记录
/// 指纹,指纹一致不动作。
func testHelperRegistrationRefreshesOnlyOnPlistDrift() async throws {
let defaults = UserDefaults.standard
let stampKey = SystemProxyHelperRegistrationStamp.defaultsKey
defer { defaults.removeObject(forKey: stampKey) }
defaults.removeObject(forKey: stampKey)
let seeded = try makeSeededCatalog()
systemProxyHelper.setStatus(.notRegistered)
let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable())
try await controller.activate(seeded.server)
await controller.setAgentEnabled(true)
await controller.setSystemProxyEnabled(true)
let stampAfterRegister = try XCTUnwrap(
defaults.string(forKey: stampKey), "注册成功应记录 LaunchDaemon 清单指纹")
let registerCountAfterEnable = systemProxyHelper.registerCount
let unregisterCountAfterEnable = systemProxyHelper.unregisterCount
// 指纹一致:后续收敛不注销重注。
await controller.convergeSystemProxy()
XCTAssertEqual(systemProxyHelper.registerCount, registerCountAfterEnable, "指纹一致不重注")
XCTAssertEqual(systemProxyHelper.unregisterCount, unregisterCountAfterEnable)
XCTAssertEqual(controller.systemProxyState, .applied)
// 指纹漂移(模拟 app 更新改了清单):注销重注一次并更新指纹。
defaults.set("drifted-stamp", forKey: stampKey)
await controller.convergeSystemProxy()
XCTAssertEqual(systemProxyHelper.unregisterCount, unregisterCountAfterEnable + 1, "漂移触发重注")
XCTAssertEqual(systemProxyHelper.registerCount, registerCountAfterEnable + 1)
XCTAssertEqual(defaults.string(forKey: stampKey), stampAfterRegister, "重注后记录当前清单指纹")
XCTAssertEqual(controller.systemProxyState, .applied, "重注后照常收敛应用")
}
}
/// 测试专用事件接收缝(issue #70 事件行断言用)。
@@ -37,7 +37,9 @@ extension ProxyRuntimeFixture {
final class FakeSystemProxyHelperService: SystemProxyHelperServicing {
private(set) var currentStatus: SystemProxyHelperStatus
private(set) var registerCount = 0
private(set) var unregisterCount = 0
private(set) var registerError: Error?
private(set) var unregisterError: Error?
private(set) var openApprovalPathCount = 0
/// register() 成功后迁移到的状态(模拟 BTM 批准立即达成)。
var statusAfterRegister: SystemProxyHelperStatus = .approved
@@ -56,12 +58,22 @@ extension ProxyRuntimeFixture {
registerError = error
}
func setUnregisterError(_ error: Error?) {
unregisterError = error
}
func register() throws {
registerCount += 1
if let registerError { throw registerError }
currentStatus = statusAfterRegister
}
func unregister() throws {
unregisterCount += 1
if let unregisterError { throw unregisterError }
currentStatus = .notRegistered
}
func openApprovalPath() {
openApprovalPathCount += 1
}
@@ -214,4 +214,29 @@ final class SystemProxyTests: XCTestCase {
plan.writes.contains { $0.identifier == services[2].identifier },
"没有 Proxies 实体的服务无需写入")
}
// MARK: - 注册清单漂移指纹(issue #71 后续)
/// launchd 沿用注册提交时的 job 定义快照,清单变更须注销重注才生效;
/// 指纹检测决定是否重注。
func testRegistrationStampDriftDetection() {
let data = Data("plist-content".utf8)
XCTAssertTrue(
SystemProxyHelperRegistrationStamp.drifted(plistData: data, storedStamp: nil),
"无指纹(首次带检测代码启动)视为漂移")
XCTAssertTrue(
SystemProxyHelperRegistrationStamp.drifted(plistData: data, storedStamp: "outdated"),
"清单内容变更视为漂移")
XCTAssertFalse(
SystemProxyHelperRegistrationStamp.drifted(
plistData: data, storedStamp: SystemProxyHelperRegistrationStamp.stamp(data)),
"指纹一致不漂移")
XCTAssertFalse(
SystemProxyHelperRegistrationStamp.drifted(plistData: nil, storedStamp: nil),
"清单不可读无基准,不判漂移")
XCTAssertNotEqual(
SystemProxyHelperRegistrationStamp.stamp(Data("a".utf8)),
SystemProxyHelperRegistrationStamp.stamp(Data("b".utf8)),
"不同清单内容指纹不同")
}
}
+3 -2
View File
@@ -33,8 +33,9 @@ targets:
subpath: Contents/Library/LaunchAgents
# 特权系统代理 helper 的 LaunchDaemon 注册清单(issue #71):
# SMAppService.daemon(plistName:) 从 bundle 内
# Contents/Library/LaunchDaemons/ 读取;ProgramArguments 用 bundle 相对
# 路径;MachServices 键支撑 on-demand 激活。
# Contents/Library/LaunchDaemons/ 读取;可执行路径用 BundleProgram 键
# (bundle 相对;ProgramArguments 的相对路径按 PATH 解析会 spawn 失败);
# MachServices 键支撑 on-demand 激活。
- path: LaunchDaemons/com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.plist
buildPhase:
copyFiles: