Files
XStats/Packages/XStatsKit/Sources/XStatsUI/State/HelperClient.swift
T
缘生 6a7aeffb83 fix(security): 修复辅助工具鉴权、升级路径与卸载误删 (#1)
* fix(security): 修复辅助工具鉴权降级与五处正确性缺陷

代码审查(安全/质量/性能/架构四维度)发现的问题,逐条修复。

辅助工具鉴权(High)
  ad-hoc 构建下 clientRequirement 退化为只校验 bundle identifier,不含
  anchor 约束,任何进程 codesign -s - 伪造同名即可连上 root 辅助工具,
  驱动 DNS、睡眠、本地快照与风扇。project.yml 默认就是 ad-hoc 签名,
  而 make build 是文档化的安装路径,开发机上属常态。
  改为无团队签名时返回 nil 并拒绝连接,与在线升级在 ad-hoc 下直接禁用
  的策略一致;此时 App 走一次性管理员授权回退,功能不丢。

卸载器批量误删
  validate 只拒绝 com.apple. 前缀,包名为 "com" 的应用可通过;随后
  hasPrefix("com.") 把 leftovers 扫描的十余个目录(Preferences、Caches、
  Containers、Saved Application State 等)下所有 com.* 条目当成残留列出
  并默认勾选,一次点击即整体移入废纸篓。前缀匹配改为要求至少三段反向
  域名,精确匹配不受影响。

退出时静默跳过恢复睡眠
  restoreDefaultsSynchronously 的 guard 在循环体内用 return 而非 continue,
  第一步取代理失败就连带跳过第二步,Mac 保持禁止睡眠。同时把两步各等 1 秒
  改为总预算 1.5 秒,避免主线程退出卡顿。

公网 IP 查询重入保护失效
  isLookingUpPublic 同时承担重入锁与转圈开关两个相反职责,有缓存且非强制
  刷新时恒为 false,guard 形同虚设,两个 Task 可各发三次请求并发写缓存,
  失败结果可能覆盖好结果。拆出独立的 isFetchingPublic,并用 defer 确保
  所有退出路径复位。

诊断导出的崩溃报告未脱敏
  日志与清理记录都走了 redact,但 .ips 崩溃报告原样入包,其中可能夹带
  进程内存中的 WebDAV 目录、用户名、公网 IP 与主机名,与界面“不含 IP、
  序列号与硬件地址”的声明不符。改为同样脱敏后写入。

PowerSampler
  channels(in:) 的 unsafeBitCast 改为 Unmanaged.fromOpaque.takeUnretainedValue。
  未加 deinit 释放 IOReport 订阅:IOReport 是私有框架,无公开释放入口且
  无法验证其是否为 CF 对象,而全进程只有一个 PowerSampler、订阅各只创建
  一次且随进程存在,不是增长型泄漏——已就此写明注释。

补回归测试:clientRequirement 的 ad-hoc 分支与 anchor 约束、
matchesIdentifier 的过宽前缀与派生名匹配。

make test 141 项通过(新增 4 项),Release 完整构建通过。

* fix(security): 完善辅助工具升级与卸载边界校验

* fix(ui): 拆分评分条视图以解决 CI 类型检查超时

* fix(webdav): 规范化目录路径以兼容 macOS 15
2026-09-21 09:34:01 +08:00

310 lines
13 KiB
Swift

// Copyright (c) 2026 GiantAccel, LLC
// XStats modifications Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
import Foundation
import HelperShared
import Localization
import Observation
import ServiceManagement
/// 与 root 辅助工具的连接。安装通过 SMAppService.daemon,用户需在“登录项”中批准一次。
@MainActor
@Observable
public final class HelperClient {
public enum Status: Equatable {
case notInstalled
case requiresApproval
case enabled
case unavailable(String)
var title: String {
switch self {
case .notInstalled: tr("未安装")
case .requiresApproval: tr("等待批准")
case .enabled: tr("已启用")
case .unavailable: tr("不可用")
}
}
}
public private(set) var status: Status = .notInstalled
public private(set) var isWorking = false
public private(set) var lastError: String?
/// 正在运行的辅助工具比应用旧:登记的是另一份应用包里的辅助工具,或替换应用后旧进程还没退出
public private(set) var isOutdated = false
/// 连接中断后重新连上时回调,用于重新下发风扇 / 防休眠状态
@ObservationIgnored var onReconnect: (() -> Void)?
@ObservationIgnored private var connection: NSXPCConnection?
@ObservationIgnored private let service: any HelperRegistration
@ObservationIgnored private let teamIdentifier: String?
@ObservationIgnored private let versionProbe: (@MainActor () async -> Int?)?
private var isCheckingVersion = false
private var hasVerifiedConnection = false
public convenience init() {
self.init(teamIdentifier: CodeSigningInfo.currentTeamIdentifier(), service: SystemHelperRegistration())
}
init(teamIdentifier: String?, service: any HelperRegistration,
versionProbe: (@MainActor () async -> Int?)? = nil) {
self.teamIdentifier = teamIdentifier
self.service = service
self.versionProbe = versionProbe
}
public var canInstall: Bool { HelperConstants.clientRequirement(teamIdentifier: teamIdentifier) != nil }
public var isReady: Bool { canInstall && status == .enabled && hasVerifiedConnection && !isOutdated && !isWorking && !isCheckingVersion }
static var signingMessage: String {
L10n.helperSigningMessage
}
static var outdatedMessage: String { tr("辅助工具版本比应用旧,部分功能可能无法使用,请重新安装一次(需要管理员授权)。") }
/// 未安装、待批准或版本过旧,需要用户处理
var needsAttention: Bool { !isReady || isOutdated }
public func refreshStatus() {
guard canInstall else { status = .unavailable(Self.signingMessage); return }
switch service.status {
case .enabled: status = .enabled
case .requiresApproval: status = .requiresApproval
case .notRegistered: status = .notInstalled
case .notFound:
// 首次注册前系统同样返回 notFound,需结合包内文件判断
let plist = Bundle.main.bundleURL
.appendingPathComponent("Contents/Library/LaunchDaemons/\(HelperConstants.launchdPlistName)")
status = FileManager.default.fileExists(atPath: plist.path)
? .notInstalled
: .unavailable(tr("应用包内未找到辅助工具,请使用完整构建的 XStats.app"))
@unknown default: status = .unavailable(tr("未知状态"))
}
if status == .enabled, !hasVerifiedConnection, !isCheckingVersion, !isWorking, !isOutdated {
Task {
guard !hasVerifiedConnection, !isOutdated else { return }
await verifyVersion()
}
}
}
public func install() {
guard canInstall, !isWorking else {
if !canInstall { status = .unavailable(Self.signingMessage); lastError = Self.signingMessage }
return
}
isWorking = true
defer { isWorking = false }
lastError = nil
do {
try service.register()
} catch {
lastError = tr("安装失败:\(error.localizedDescription)")
Log.helper.error("安装失败:\(error.localizedDescription, privacy: .public)")
}
refreshStatus()
if status == .requiresApproval {
SMAppService.openSystemSettingsLoginItems()
} else if status == .enabled, !isCheckingVersion {
Task { await verifyVersion() }
}
}
public func uninstall() async {
isWorking = true
defer { isWorking = false }
lastError = nil
connection?.invalidate()
connection = nil
hasVerifiedConnection = false
do {
// 使用回调版本:不把非 Sendable 的 SMAppService 跨隔离域传递
try await service.unregister()
} catch {
lastError = tr("卸载失败:\(error.localizedDescription)")
Log.helper.error("卸载失败:\(error.localizedDescription, privacy: .public)")
}
refreshStatus()
}
/// 卸载后重新登记,让 launchd 运行当前应用包里的辅助工具
public func reinstall() async {
guard !isWorking else { return }
await uninstall()
guard lastError == nil, canInstall else { return }
install()
await verifyVersion(allowRestart: false)
}
/// 注册状态不等于可安全调用。版本 5 之前必须注销旧服务,再注册并核对当前版本。
func verifyVersion(allowRestart: Bool = true) async {
guard !isCheckingVersion, !isWorking else { return }
isCheckingVersion = true
defer { isCheckingVersion = false }
// 没有团队签名时不连接旧 daemon,注销已注册服务以关闭旧的宽松鉴权进程。
guard canInstall else {
if service.status == .enabled || service.status == .requiresApproval { await uninstall() }
refreshStatus()
return
}
let remote = await remoteProtocolVersion()
guard let version = remote else { hasVerifiedConnection = false; isOutdated = service.status == .enabled; return }
guard !HelperConstants.isCompatible(version: version) else {
hasVerifiedConnection = true
isOutdated = false
return
}
isOutdated = true
connection?.invalidate()
connection = nil
// 不能等待旧进程“自行空闲退出”:其他连接可能一直让它存活。
// 注销失败时保留过期状态,禁止后续特权调用,也不继续注册。
await uninstall()
guard lastError == nil, allowRestart else { return }
install()
guard lastError == nil, service.status == .enabled else { return }
isOutdated = !HelperConstants.isCompatible(version: await remoteProtocolVersion())
hasVerifiedConnection = !isOutdated
}
public func openLoginItemsSettings() {
SMAppService.openSystemSettingsLoginItems()
}
// MARK: 远程调用
func setFanTarget(fan: Int, rpm: Double) async -> String? {
await call { proxy, reply in proxy.setFanTarget(fan: fan, rpm: rpm, reply: reply) }
}
func setFanAutomatic(fan: Int) async -> String? {
await call { proxy, reply in proxy.setFanAutomatic(fan: fan, reply: reply) }
}
func resetAllFans() async -> String? {
await call { proxy, reply in proxy.resetAllFans(reply: reply) }
}
func setSleepDisabled(_ disabled: Bool) async -> String? {
await call { proxy, reply in proxy.setSleepDisabled(disabled, reply: reply) }
}
func run(_ command: MaintenanceCommand) async -> String? {
switch command {
case .flushDNS: await call { proxy, reply in proxy.flushDNSCache(reply: reply) }
case .purgeMemory: await call { proxy, reply in proxy.purgeMemory(reply: reply) }
}
}
func setDNSServers(service: String, servers: [String]) async -> String? {
await call { proxy, reply in proxy.setDNSServers(service: service, servers: servers, reply: reply) }
}
func deleteLocalSnapshots(identifiers: [String]) async -> String? {
await call { proxy, reply in proxy.deleteLocalSnapshots(identifiers: identifiers, reply: reply) }
}
/// 已安装的辅助工具的协议版本;旧版本不认识新增的方法,调用前先确认
func remoteProtocolVersion() async -> Int? {
refreshStatus()
guard canInstall, status == .enabled else { return nil }
if let versionProbe { return await versionProbe() }
let connection = ensureConnection()
return await withCheckedContinuation { (continuation: CheckedContinuation<Int?, Never>) in
let once = ResumeOnceValue<Int?>(continuation)
let proxy = connection.remoteObjectProxyWithErrorHandler { _ in once.resume(nil) } as? XStatsHelperProtocol
guard let proxy else { return once.resume(nil) }
proxy.protocolVersion { once.resume($0) }
}
}
/// 退出应用时使用:同步恢复风扇与睡眠设置。
///
/// 在主线程上阻塞,因此总等待时间设上限;每一步互相独立——取代理失败只跳过当前这步,
/// 不能提前 return,否则第一步失败就会连带跳过“恢复睡眠设置”,让 Mac 保持禁止睡眠。
func restoreDefaultsSynchronously() {
guard isReady else { return }
let connection = ensureConnection()
let steps: [(XStatsHelperProtocol, @escaping @Sendable (String?) -> Void) -> Void] = [
{ proxy, reply in proxy.resetAllFans(reply: reply) },
{ proxy, reply in proxy.setSleepDisabled(false, reply: reply) },
]
let deadline = DispatchTime.now() + Self.restoreBudget
for step in steps {
let semaphore = DispatchSemaphore(value: 0)
guard let proxy = connection.remoteObjectProxyWithErrorHandler({ _ in semaphore.signal() })
as? XStatsHelperProtocol else { continue }
step(proxy) { _ in semaphore.signal() }
_ = semaphore.wait(timeout: deadline)
}
}
/// 退出时为恢复操作预留的总时长;辅助工具无响应时不让退出卡住超过这个值
private static let restoreBudget: DispatchTimeInterval = .milliseconds(1500)
private func call(_ body: (XStatsHelperProtocol, @escaping @Sendable (String?) -> Void) -> Void) async -> String? {
refreshStatus()
if canInstall { await verifyVersion() }
guard isReady else { return tr("辅助工具未启用") }
let connection = ensureConnection()
return await withCheckedContinuation { (continuation: CheckedContinuation<String?, Never>) in
let once = ResumeOnce(continuation)
let proxy = connection.remoteObjectProxyWithErrorHandler { error in
Log.helper.error("XPC 调用失败:\(error.localizedDescription, privacy: .public)")
once.resume(tr("无法连接辅助工具:\(error.localizedDescription)"))
} as? XStatsHelperProtocol
guard let proxy else {
once.resume(tr("无法连接辅助工具"))
return
}
body(proxy) { once.resume($0) }
}
}
private func ensureConnection() -> NSXPCConnection {
if let connection { return connection }
let connection = NSXPCConnection(machServiceName: HelperConstants.machServiceName, options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: XStatsHelperProtocol.self)
connection.interruptionHandler = { [weak self, weak connection] in
Task { @MainActor in
guard let self, self.connection === connection else { return }
self.hasVerifiedConnection = false
await self.verifyVersion()
if self.isReady { self.onReconnect?() }
}
}
connection.invalidationHandler = { [weak self, weak connection] in
Task { @MainActor in
guard let self, self.connection === connection else { return }
self.connection = nil
self.hasVerifiedConnection = false
}
}
connection.resume()
self.connection = connection
return connection
}
}
private typealias ResumeOnce = ResumeOnceValue<String?>
/// XPC 的错误回调和正常回调可能都会触发,保证 continuation 只恢复一次
private final class ResumeOnceValue<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Value, Never>?
init(_ continuation: CheckedContinuation<Value, Never>) {
self.continuation = continuation
}
func resume(_ value: Value) {
lock.lock()
let pending = continuation
continuation = nil
lock.unlock()
pending?.resume(returning: value)
}
}