mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 13:43:49 +00:00
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:
@@ -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)),
|
||||
"不同清单内容指纹不同")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user