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
This commit is contained in:
缘生 authored and GitHub committed 2026-09-21 09:34:01 +08:00
1 parent ef0bd2f5ee
commit 6a7aeffb83
23 files changed
+478 -103

No files matched your search

+9 -2
View File
@@ -89,6 +89,13 @@ a fan in manual mode that XStats did not set is shown as controlled by another p
## Helper
Protocol 5 removes the identifier-only ad-hoc authentication fallback. Ad-hoc apps refuse helper
registration and unregister any previously registered helper without connecting to it. Fan and
lid-closed controls require team signing; maintenance operations keep their existing admin-prompt fallback.
Readiness requires an authenticated version handshake, not just an enabled SMAppService registration.
For older helpers, the client unregisters the service before re-registering the current bundle and
verifying its version. Unregister/handshake failures leave privileged calls disabled and surface an error.
Registered with `SMAppService.daemon`; `RunAtLoad` so that an unclean exit is repaired at
boot. The XPC interface is a fixed list of operations — no arbitrary commands — and each
connection gets `setCodeSigningRequirement`. State that must be undone (manual fans, disabled
@@ -201,8 +208,8 @@ version match, `SecStaticCodeCheckValidity` passes with a requirement pinned to
team, and `spctl --assess` accepts it (notarized). The old bundle is renamed into a same-volume
temporary folder, the new one moved into place (restored on failure; an administrator prompt is
used when the folder is not writable), and a detached shell waits for the process to exit before
reopening the app. After an update the old helper may still be running; the app disconnects, waits
for its 30 s idle exit and checks the protocol version again before asking for a reinstall.
reopening the app. After an update the old helper may still be running; the app unregisters an outdated
helper, re-registers the bundled version, and verifies the protocol before privileged calls resume.
## WebDAV settings sync
+11
View File
@@ -1,3 +1,8 @@
// 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.
//
// XStatsHelper
//
@@ -45,6 +50,12 @@ final class HelperService: NSObject, NSXPCListenerDelegate, XStatsHelperProtocol
// MARK: NSXPCListenerDelegate
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
// 没有团队签名(ad-hoc 开发构建)时无法安全鉴别调用方,一律拒绝服务。
// 客户端禁用安装与风扇/合盖控制;支持回退的维护操作走一次性管理员授权。
guard let clientRequirement else {
log.error("辅助工具未经团队签名,拒绝 XPC 连接")
return false
}
// 系统在收到消息前校验调用方签名,不符合要求的连接会被直接失效
connection.setCodeSigningRequirement(clientRequirement)
connection.exportedInterface = NSXPCInterface(with: XStatsHelperProtocol.self)
@@ -1,3 +1,8 @@
// 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 Localization
@@ -140,8 +145,9 @@ public enum AppUninstaller {
scan("Containers", .containers)
scan("Application Scripts", .containers)
scan("Group Containers", .containers) { name in
guard supportsDerivedNames(id) else { return false }
// 形如 “TEAMID.com.example.app” 或 “group.com.example.app”
name.hasSuffix("." + id) || name == "group." + id || name.hasPrefix("group." + id + ".")
return name.hasSuffix("." + id) || name == "group." + id || name.hasPrefix("group." + id + ".")
}
scan("Saved Application State", .savedState)
scan("Logs", .logs) { $0 == app.name }
@@ -153,11 +159,22 @@ public enum AppUninstaller {
}
/// 名字等于包名,或是包名后接“.”的派生名(com.example.app.plist、com.example.app.savedState、com.example.app.helper)
///
/// 前缀匹配要求包名至少三段反向域名:`leftovers` 会扫描 Preferences、Caches、Containers 等
/// 十余个目录,若放行 “com” 这类残缺包名,`hasPrefix("com.")` 会把 com.apple.dock.plist 在内的
/// 所有 com.* 条目都当成残留列出并默认勾选。精确匹配不受影响,短包名仍能删掉自己的同名条目。
static func matchesIdentifier(_ name: String, _ identifier: String) -> Bool {
guard !identifier.isEmpty else { return false }
let lowered = name.lowercased()
let id = identifier.lowercased()
return lowered == id || lowered.hasPrefix(id + ".")
if lowered == id { return true }
guard supportsDerivedNames(identifier) else { return false }
return lowered.hasPrefix(id + ".")
}
private static func supportsDerivedNames(_ identifier: String) -> Bool {
let parts = identifier.split(separator: ".", omittingEmptySubsequences: false)
return parts.count >= 3 && parts.allSatisfy { !$0.isEmpty }
}
// MARK: 程序坞
@@ -1,3 +1,8 @@
// 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 Security
@@ -6,14 +11,24 @@ public enum HelperConstants {
public static let launchdPlistName = "work.12306.xstats.helper.plist"
public static let appBundleIdentifier = "work.12306.xstats.app"
/// 与 App 版本同步;App 发现辅助工具版本不一致时提示重新安装
public static let protocolVersion = 4
// 第 5 版起移除 ad-hoc 鉴权降级,旧版必须注销后升级,不能继续保持连接。
public static let protocolVersion = 5
public static func isCompatible(version: Int?) -> Bool {
guard let version else { return false }
return version >= protocolVersion
}
/// 辅助工具对调用方的签名要求:与辅助工具自身同一团队签名的 XStats。
/// ad-hoc 签名的开发构建没有团队,只能校验 bundle identifier。
public static func clientRequirement(teamIdentifier: String?) -> String {
let identifier = "identifier \"\(appBundleIdentifier)\""
guard let teamIdentifier, !teamIdentifier.isEmpty else { return identifier }
return identifier + " and anchor apple generic and certificate leaf[subject.OU] = \"\(teamIdentifier)\""
///
/// ad-hoc 签名的开发构建没有团队,此时返回 nil 表示“无法安全鉴权”。不能退化成只校验
/// bundle identifier:单独的 `identifier` 不含 anchor 约束,任何进程 `codesign -s -`
/// 伪造同一个 identifier 即可通过校验,进而以 root 驱动 DNS、睡眠、快照与风扇。
/// 在线升级在 ad-hoc 下同样是直接禁用(见 UpdateController),两处保持一致。
public static func clientRequirement(teamIdentifier: String?) -> String? {
guard let teamIdentifier, !teamIdentifier.isEmpty else { return nil }
return "identifier \"\(appBundleIdentifier)\" and anchor apple generic"
+ " and certificate leaf[subject.OU] = \"\(teamIdentifier)\""
}
}
@@ -0,0 +1,19 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
extension L10n {
/// 安全补丁分支早于九语言 enum;使用稳定的持久化值,同时覆盖主分支的语言。
public static var helperSigningMessage: String {
switch language.rawValue {
case "chinese": "辅助工具需要团队签名;当前构建不支持风扇和合盖控制,维护操作可使用管理员授权。"
case "traditionalChinese": "輔助工具需要團隊簽名;目前版本不支援風扇和合蓋控制,維護操作可使用管理員授權。"
case "japanese": "ヘルパーにはチーム署名が必要です。このビルドではファン・蓋閉じ制御は使えません。メンテナンスは管理者認証で実行できます。"
case "korean": "보조 도구에는 팀 서명이 필요합니다. 이 빌드에서는 팬 및 덮개 제어를 사용할 수 없으며 유지 관리 작업은 관리자 인증으로 실행할 수 있습니다."
case "german": "Das Hilfsprogramm benötigt eine Teamsignatur. Lüfter- und Deckelsteuerung sind in diesem Build nicht verfügbar; Wartungsaufgaben können die Administratorfreigabe verwenden."
case "spanish": "El asistente requiere una firma de equipo. Este compilado no permite controlar ventiladores ni la tapa; el mantenimiento puede usar autorización de administrador."
case "french": "L’assistant nécessite une signature d’équipe. Le contrôle des ventilateurs et du capot est indisponible dans cette version ; la maintenance peut utiliser une autorisation administrateur."
case "arabic": "تتطلب الأداة المساعدة توقيع فريق. لا يتوفر التحكم بالمراوح والغطاء في هذا الإصدار؛ يمكن تنفيذ الصيانة بتفويض المسؤول."
default: "The helper requires team signing. Fan and lid-closed controls are unavailable in this build; maintenance can use administrator authorization."
}
}
}
@@ -205,6 +205,11 @@ private final class Subscription {
self.subscription = subscription
}
// subscription 是 OpaquePointer,不受 ARC 管理,本类也不释放它:IOReport 是私有框架,
// 没有公开的释放入口,而它是否为 CF 对象无法验证——释放错了会在 dealloc 时崩溃。
// 目前全进程只有 MetricsHub 持有的那一个 PowerSampler,两个 Subscription 各只创建一次且
// 随进程存在,不会增长。若将来 PowerSampler 改成可反复创建,必须先确认正确的释放方式再补 deinit。
func sample() -> CFDictionary? {
IOReportCreateSamples(subscription, channels, nil)?.takeRetainedValue()
}
@@ -212,8 +217,10 @@ private final class Subscription {
static func channels(in sample: CFDictionary) -> [CFDictionary] {
let key = "IOReportChannels" as CFString
guard let raw = CFDictionaryGetValue(sample, Unmanaged.passUnretained(key).toOpaque()) else { return [] }
let array = unsafeBitCast(raw, to: CFArray.self)
return (0..<CFArrayGetCount(array)).map { unsafeBitCast(CFArrayGetValueAtIndex(array, $0), to: CFDictionary.self) }
let array = Unmanaged<CFArray>.fromOpaque(raw).takeUnretainedValue()
return (0..<CFArrayGetCount(array)).map {
Unmanaged<CFDictionary>.fromOpaque(CFArrayGetValueAtIndex(array, $0)).takeUnretainedValue()
}
}
static func name(_ channel: CFDictionary) -> String {
@@ -48,11 +48,18 @@ public struct WebDAVConfiguration: Equatable, Sendable {
components.query == nil, components.fragment == nil,
!text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }),
!components.path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }),
let url = components.url else { throw WebDAVError.invalidAddress }
components.url != nil else { throw WebDAVError.invalidAddress }
guard !username.isEmpty, !username.contains(":"),
!username.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
else { throw WebDAVError.invalidUsername }
directoryURL = url.hasDirectoryPath ? url : url.appendingPathComponent("", isDirectory: true)
// macOS 15 对空 path component 的目录拼接可能产生双斜杠;显式保留一个尾斜杠,
// 并操作编码后的路径,避免中文或百分号被再次转义而改变端点/凭据键。
var directory = components
var path = directory.percentEncodedPath
while path.hasSuffix("/") { path.removeLast() }
directory.percentEncodedPath = path + "/"
guard let normalized = directory.url else { throw WebDAVError.invalidAddress }
directoryURL = normalized
self.username = username
}
@@ -1,3 +1,8 @@
// 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 Localization
import Metrics
import SwiftUI
@@ -512,66 +517,85 @@ struct ScoreBand: View {
private static let markerHeight: CGFloat = DS.Size.iconStandalone + DS.Space.s1
private static let tickHeight: CGFloat = DS.Space.s3
private var clamped: Int { min(100, max(0, score)) }
var body: some View {
let bands = DS.Grade.bands
let clamped = min(100, max(0, score))
GeometryReader { proxy in
let width = proxy.size.width
ZStack(alignment: .topLeading) {
VStack(spacing: DS.Space.s1 / 2) {
HStack(spacing: 1) {
ForEach(Array(bands.enumerated()), id: \.offset) { index, band in
let lower = index == 0 ? 0 : bands[index - 1].upper
let segmentWidth = width * CGFloat(band.upper - lower) / 100
ZStack {
Rectangle().fill(band.color)
if segmentWidth >= DS.Space.s6 {
Text(verbatim: band.grade)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.onPrimary)
}
}
.frame(width: max(0, segmentWidth - (index == bands.count - 1 ? 0 : 1)))
}
}
.frame(height: height)
ZStack(alignment: .topLeading) {
// A 与 A+ 的分界太密,刻度上不标 95;弹窗那么窄时 85 也挤不下
ForEach([0] + bands.map(\.upper).filter { $0 != 95 && ($0 != 85 || width >= DS.Size.panelWidth / 2) }, id: \.self) { tick in
Text(verbatim: "\(tick)")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
.fixedSize()
.alignmentGuide(.leading) { dimensions in
// 0 靠左、100 靠右,其余居中对齐刻度
tick == 0 ? 0 : tick == 100 ? dimensions.width - width : dimensions.width / 2 - width * CGFloat(tick) / 100
}
}
}
.frame(width: width, height: Self.tickHeight, alignment: .topLeading)
segments(width: proxy.size.width)
tickLabels(width: proxy.size.width)
}
.padding(.top, Self.markerHeight)
// 得分标记:气泡里写等级,尖角指向色带
let color = DS.Grade.color(for: clamped)
VStack(spacing: 0) {
Text(verbatim: DS.Grade.bands.first { clamped < $0.upper }?.grade ?? "A+")
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.onPrimary)
.padding(.horizontal, DS.Space.s1)
.frame(height: DS.Size.iconInline)
.background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
Triangle().fill(color).frame(width: DS.Space.s2, height: DS.Space.s1)
}
.fixedSize()
.alignmentGuide(.leading) { dimensions in dimensions.width / 2 - width * CGFloat(clamped) / 100 }
marker(width: proxy.size.width)
}
}
.frame(height: height + Self.markerHeight + Self.tickHeight + DS.Space.s1 / 2)
.accessibilityLabel(tr("纯净度 \(clamped) 分"))
}
// 拆分独立视图约束,避免 CI 工具链对整个评分条的嵌套泛型进行超时推导。
private func segments(width: CGFloat) -> some View {
HStack(spacing: 1) {
ForEach(DS.Grade.bands.indices, id: \.self) { index in
let band = DS.Grade.bands[index]
let lower = index == 0 ? 0 : DS.Grade.bands[index - 1].upper
let segmentWidth = width * CGFloat(band.upper - lower) / 100
ZStack {
Rectangle().fill(band.color)
if segmentWidth >= DS.Space.s6 {
Text(verbatim: band.grade)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.onPrimary)
}
}
.frame(width: max(0, segmentWidth - (index == DS.Grade.bands.count - 1 ? 0 : 1)))
}
}
.frame(height: height)
}
private func ticks(width: CGFloat) -> [Int] {
// A 与 A+ 的分界太密,不标 95;窄弹窗下也不标 85。
let upperBounds: [Int] = DS.Grade.bands.map { $0.upper }
return [0] + upperBounds.filter { tick in
tick != 95 && (tick != 85 || width >= DS.Size.panelWidth / 2)
}
}
private func tickLabels(width: CGFloat) -> some View {
ZStack(alignment: .topLeading) {
ForEach(ticks(width: width), id: \.self) { tick in
Text(verbatim: "\(tick)")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
.fixedSize()
.alignmentGuide(.leading) { dimensions in
if tick == 0 { return 0 }
if tick == 100 { return dimensions.width - width }
return dimensions.width / 2 - width * CGFloat(tick) / 100
}
}
}
.frame(width: width, height: Self.tickHeight, alignment: .topLeading)
}
private func marker(width: CGFloat) -> some View {
let color = DS.Grade.color(for: clamped)
let grade = DS.Grade.bands.first { clamped < $0.upper }?.grade ?? "A+"
return VStack(spacing: 0) {
Text(verbatim: grade)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.onPrimary)
.padding(.horizontal, DS.Space.s1)
.frame(height: DS.Size.iconInline)
.background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
Triangle().fill(color).frame(width: DS.Space.s2, height: DS.Space.s1)
}
.fixedSize()
.alignmentGuide(.leading) { dimensions in dimensions.width / 2 - width * CGFloat(clamped) / 100 }
}
}
private struct Triangle: Shape {
@@ -178,7 +178,7 @@ private struct MainSidebar: View {
private func badge(for tab: PanelTab) -> Tone? {
switch tab {
case .settingsAbout: model.updates.release != nil ? .primary : nil
case .settingsHelper: model.helper.isReady && model.helper.isOutdated ? .warning : nil
case .settingsHelper: model.helper.isOutdated ? .warning : nil
case .settingsAccount:
if model.sync.pendingDownload != nil { .primary } else if case .failed = model.sync.phase { .warning } else { nil }
default: nil
@@ -689,7 +689,7 @@ struct HelperSettings: View {
if case .unavailable(let reason) = helper.status {
InfoBanner(icon: "exclamationmark.triangle.fill", text: reason, tone: .error)
}
if helper.isReady && helper.isOutdated {
if helper.canInstall && helper.isOutdated {
InfoBanner(icon: "arrow.triangle.2.circlepath", text: HelperClient.outdatedMessage, tone: .warning) {
Button(tr("重新安装")) { Task { await helper.reinstall() } }
.buttonStyle(DSButtonStyle(kind: .primary))
@@ -702,7 +702,7 @@ struct HelperSettings: View {
if let team = CodeSigningInfo.currentTeamIdentifier() {
InfoBanner(icon: "checkmark.seal", text: tr("已使用 Developer ID 签名(团队 \(team)),辅助工具只接受同一团队签名的 XStats。"), tone: .success)
} else {
InfoBanner(icon: "info.circle", text: tr("当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。"), tone: .neutral)
InfoBanner(icon: "info.circle", text: HelperClient.signingMessage, tone: .neutral)
}
}
@@ -1,3 +1,8 @@
// 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
/// 排行榜的平滑打分:给每个条目按指数移动平均记一个分数,列表按分数排序。
@@ -27,9 +32,12 @@ struct ActivityRanking: Equatable {
}
func ranked(limit: Int) -> [Entry] {
Array(scores.map { Entry(id: $0.key, score: $0.value) }
.sorted { $0.score == $1.score ? $0.id < $1.id : $0.score > $1.score }
.prefix(limit))
let entries: [Entry] = scores.map { Entry(id: $0.key, score: $0.value) }
let sorted = entries.sorted { left, right in
if left.score == right.score { return left.id < right.id }
return left.score > right.score
}
return Array(sorted.prefix(limit))
}
var isEmpty: Bool { scores.isEmpty }
@@ -147,8 +147,12 @@ final class DiagnosticsExporter {
if !crashes.isEmpty {
let crashFolder = folder.appendingPathComponent("crashes")
try manager.createDirectory(at: crashFolder, withIntermediateDirectories: true)
// 崩溃报告同样要脱敏:.ips 里可能夹带进程内存中的字符串(WebDAV 目录、用户名、公网 IP、
// 主机名),而界面承诺导出内容不含 IP、序列号与硬件地址。读不出文本的不放进压缩包。
for crash in crashes {
try? manager.copyItem(at: crash, to: crashFolder.appendingPathComponent(crash.lastPathComponent))
guard let text = try? String(contentsOf: crash, encoding: .utf8) else { continue }
try? redact(text).write(to: crashFolder.appendingPathComponent(crash.lastPathComponent),
atomically: true, encoding: .utf8)
}
}
@@ -1,3 +1,8 @@
// 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
@@ -34,11 +39,28 @@ public final class HelperClient {
@ObservationIgnored var onReconnect: (() -> Void)?
@ObservationIgnored private var connection: NSXPCConnection?
@ObservationIgnored private let service = SMAppService.daemon(plistName: HelperConstants.launchdPlistName)
@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 init() {}
public convenience init() {
self.init(teamIdentifier: CodeSigningInfo.currentTeamIdentifier(), service: SystemHelperRegistration())
}
public var isReady: Bool { status == .enabled }
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("辅助工具版本比应用旧,部分功能可能无法使用,请重新安装一次(需要管理员授权)。") }
@@ -46,6 +68,7 @@ public final class HelperClient {
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
@@ -59,9 +82,19 @@ public final class HelperClient {
: .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
@@ -74,6 +107,8 @@ public final class HelperClient {
refreshStatus()
if status == .requiresApproval {
SMAppService.openSystemSettingsLoginItems()
} else if status == .enabled, !isCheckingVersion {
Task { await verifyVersion() }
}
}
@@ -83,13 +118,10 @@ public final class HelperClient {
lastError = nil
connection?.invalidate()
connection = nil
hasVerifiedConnection = false
do {
// 使用回调版本:不把非 Sendable 的 SMAppService 跨隔离域传递
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
service.unregister { error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
try await service.unregister()
} catch {
lastError = tr("卸载失败:\(error.localizedDescription)")
Log.helper.error("卸载失败:\(error.localizedDescription, privacy: .public)")
@@ -99,35 +131,42 @@ public final class HelperClient {
/// 卸载后重新登记,让 launchd 运行当前应用包里的辅助工具
public func reinstall() async {
guard !isWorking else { return }
await uninstall()
guard lastError == nil, canInstall else { return }
install()
await verifyVersion(allowRestart: false)
}
/// 核对辅助工具的协议版本。版本旧通常是替换应用后旧的辅助工具进程还在运行(新应用一连上它就不会空闲退出):
/// 先断开连接让它空闲 30 秒后退出,再连接时 launchd 会启动应用包里的新版本;仍然旧才提示重新安装
/// 注册状态不等于可安全调用。版本 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()
Log.helper.info("辅助工具状态 \(self.status.title, privacy: .public),协议版本 \(remote.map(String.init) ?? "未连接", privacy: .public)")
guard let version = remote, version < HelperConstants.protocolVersion else {
guard let version = remote else { hasVerifiedConnection = false; isOutdated = service.status == .enabled; return }
guard !HelperConstants.isCompatible(version: version) else {
hasVerifiedConnection = true
isOutdated = false
return
}
guard allowRestart else {
isOutdated = true
return
}
isOutdated = true
connection?.invalidate()
connection = nil
try? await Task.sleep(for: .seconds(40))
guard let again = await remoteProtocolVersion() else { return }
isOutdated = again < HelperConstants.protocolVersion
if isOutdated {
Log.helper.error("辅助工具协议版本 \(again) 低于应用的 \(HelperConstants.protocolVersion)")
} else {
// 新的辅助工具进程没有之前的风扇与睡眠设置,重新下发
onReconnect?()
}
// 不能等待旧进程“自行空闲退出”:其他连接可能一直让它存活。
// 注销失败时保留过期状态,禁止后续特权调用,也不继续注册。
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() {
@@ -170,7 +209,8 @@ public final class HelperClient {
/// 已安装的辅助工具的协议版本;旧版本不认识新增的方法,调用前先确认
func remoteProtocolVersion() async -> Int? {
refreshStatus()
guard isReady else { return nil }
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)
@@ -180,7 +220,10 @@ public final class HelperClient {
}
}
/// 退出应用时使用:同步恢复风扇与睡眠设置,每步最多等待 1 秒
/// 退出应用时使用:同步恢复风扇与睡眠设置。
///
/// 在主线程上阻塞,因此总等待时间设上限;每一步互相独立——取代理失败只跳过当前这步,
/// 不能提前 return,否则第一步失败就会连带跳过“恢复睡眠设置”,让 Mac 保持禁止睡眠。
func restoreDefaultsSynchronously() {
guard isReady else { return }
let connection = ensureConnection()
@@ -188,17 +231,22 @@ public final class HelperClient {
{ 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 { return }
as? XStatsHelperProtocol else { continue }
step(proxy) { _ in semaphore.signal() }
_ = semaphore.wait(timeout: .now() + 1)
_ = 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
@@ -219,11 +267,20 @@ public final class HelperClient {
if let connection { return connection }
let connection = NSXPCConnection(machServiceName: HelperConstants.machServiceName, options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: XStatsHelperProtocol.self)
connection.interruptionHandler = { [weak self] in
Task { @MainActor in self?.onReconnect?() }
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] in
Task { @MainActor in self?.connection = nil }
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
@@ -0,0 +1,28 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import HelperShared
import ServiceManagement
/// 把注册生命周期与 XPC 调用分开,使升级流程可在不启动 root 服务的情况下验证。
@MainActor
protocol HelperRegistration {
var status: SMAppService.Status { get }
func register() throws
func unregister() async throws
}
@MainActor
struct SystemHelperRegistration: HelperRegistration {
private let service = SMAppService.daemon(plistName: HelperConstants.launchdPlistName)
var status: SMAppService.Status { service.status }
func register() throws { try service.register() }
func unregister() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
service.unregister { error in
if let error { continuation.resume(throwing: error) }
else { continuation.resume() }
}
}
}
}
@@ -33,7 +33,11 @@ public final class NetworkController {
guard hasDualStack else { return nil }
return publicResults[publicFamily] != nil ? publicFamily : (publicFamily == .v4 ? .v6 : .v4)
}
/// 仅用于界面转圈:有缓存可显示时不转圈,后台悄悄核对
public private(set) var isLookingUpPublic = false
/// 仅用于重入保护,与转圈状态相反——有缓存时也必须挡住并发查询,
/// 否则两个 Task 会各发三次请求、并发写缓存,失败结果可能覆盖好结果
@ObservationIgnored private var isFetchingPublic = false
public private(set) var probes = History<ProbeSample>(capacity: probeCapacity)
public private(set) var processes: [NetworkProcessUsage] = []
/// 各进程流量的平滑排行,列表按它排序而不是按瞬时速率
@@ -203,11 +207,14 @@ public final class NetworkController {
/// 因为它只查请求方自己):地址没变、结果不满 7 天的那一族沿用缓存,否则重新查并写回缓存。
/// `force` 为真时(用户点了刷新)两族都重查
func lookUpPublicAddresses(force: Bool = false) {
guard settings.publicIPLookup, !isLookingUpPublic else { return }
guard settings.publicIPLookup, !isFetchingPublic else { return }
isFetchingPublic = true
// 有缓存可显示时不转圈,后台悄悄核对
isLookingUpPublic = publicResults.isEmpty || force
let localIPv4 = details?.physical?.ipv4 ?? []
Task {
// 无论正常结束、抛出还是被取消都要放开重入锁,否则查询会永久卡死
defer { isFetchingPublic = false }
let base = await PublicAddressLookup.fetch(includeGeo: false)
let cached = Self.loadCache()
let now = Date()
@@ -1,3 +1,8 @@
// 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 Testing
@testable import Cleaner
@@ -62,6 +67,27 @@ import Testing
#expect(throws: Never.self) { try AppUninstaller.validate(normal) }
}
/// leftovers 会扫描 Preferences、Caches、Containers 等十余个目录,前缀匹配一旦放行残缺包名,
/// 用户点一次卸载就会把 com.apple.dock.plist 在内的所有 com.* 条目移进废纸篓。
@Test func prefixMatchRequiresThreeSegmentIdentifier() {
#expect(!AppUninstaller.matchesIdentifier("com.apple.dock.plist", "com"))
#expect(!AppUninstaller.matchesIdentifier("com.example.foo.plist", "com"))
#expect(!AppUninstaller.matchesIdentifier("com.google.Chrome.plist", "com.google"))
// 精确同名仍要能删掉,否则短包名的应用会留下自己的残留
#expect(AppUninstaller.matchesIdentifier("com", "com"))
#expect(AppUninstaller.matchesIdentifier("com.google", "com.google"))
}
@Test func prefixMatchStillFindsDerivedNames() {
#expect(AppUninstaller.matchesIdentifier("com.example.foo", "com.example.foo"))
#expect(AppUninstaller.matchesIdentifier("com.example.foo.plist", "com.example.foo"))
#expect(AppUninstaller.matchesIdentifier("com.example.foo.savedState", "com.example.foo"))
#expect(AppUninstaller.matchesIdentifier("COM.EXAMPLE.FOO.helper", "com.example.foo"))
// 兄弟应用不能被当成残留
#expect(!AppUninstaller.matchesIdentifier("com.example.foobar", "com.example.foo"))
#expect(!AppUninstaller.matchesIdentifier("", "com.example.foo"))
}
@Test func removesDockTile() {
let tiles: [[String: Any]] = [
["tile-data": ["file-data": ["_CFURLString": "file:///Applications/Foo.app/", "_CFURLStringType": 15]]],
@@ -71,4 +97,22 @@ import Testing
#expect(result?.count == 1)
#expect(AppUninstaller.removingDockTile(for: URL(fileURLWithPath: "/Applications/Baz.app"), from: tiles) == nil)
}
@Test func shortIdentifiersCannotSelectOtherGroupContainers() throws {
let home = try makeHome()
defer { try? FileManager.default.removeItem(at: home) }
let appURL = home.appendingPathComponent("Applications/Test.app")
try touch(appURL, directory: true)
for name in ["group.com.apple.example", "group.com.google.example", "TEAM.com.google", "com"] {
try touch(home.appendingPathComponent("Library/Group Containers/" + name), directory: true)
}
for identifier in ["com", "com.google", "com..google"] {
let app = InstalledApp(url: appURL, name: "Test", bundleIdentifier: identifier, version: nil, teamIdentifier: nil)
let found = Set(AppUninstaller.leftovers(for: app, home: home.path).map { $0.url.lastPathComponent })
#expect(found.contains("group.com.apple.example") == false)
#expect(found.contains("group.com.google.example") == false)
#expect(found.contains("TEAM.com.google") == false)
if identifier == "com" { #expect(found.contains("com")) }
}
}
}
@@ -0,0 +1,28 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import HelperShared
import Testing
@Suite struct HelperConstantsTests {
@Test func unsafeHelperVersionsRequireUpgrade() {
#expect(HelperConstants.protocolVersion == 5)
#expect(HelperConstants.isCompatible(version: 4) == false)
#expect(HelperConstants.isCompatible(version: nil) == false)
#expect(HelperConstants.isCompatible(version: 5))
}
/// 辅助工具以 root 运行。没有团队签名时必须拒绝服务,不能退化成只校验 bundle identifier——
/// 单独的 `identifier` 不含 anchor 约束,任何进程 `codesign -s -` 伪造同名即可连上。
@Test func adHocBuildHasNoClientRequirement() {
#expect(HelperConstants.clientRequirement(teamIdentifier: nil) == nil)
#expect(HelperConstants.clientRequirement(teamIdentifier: "") == nil)
}
@Test func teamSignedRequirementPinsAnchorAndTeam() throws {
let requirement = try #require(HelperConstants.clientRequirement(teamIdentifier: "ABCDE12345"))
#expect(requirement.contains("identifier \"\(HelperConstants.appBundleIdentifier)\""))
// anchor 缺失时 identifier 可被任意 ad-hoc 签名伪造,这一条是整个鉴权的根基
#expect(requirement.contains("anchor apple generic"))
#expect(requirement.contains("certificate leaf[subject.OU] = \"ABCDE12345\""))
}
}
@@ -11,6 +11,18 @@ private struct Transport: WebDAVTransport {
}
struct WebDAVClientTests {
@Test func normalizesTrailingSlashesWithoutDoubleEncoding() throws {
for base in ["https://dav.example.com", "https://dav.example.com/dav/My%20Settings", "https://dav.example.com/dav/我的设置"] {
let plain = try WebDAVConfiguration(address: base, username: "alice")
for suffix in ["/", "//", "///"] {
let other = try WebDAVConfiguration(address: base + suffix, username: "alice")
#expect(plain.fileURL == other.fileURL)
#expect(plain.credentialAccount == other.credentialAccount)
}
#expect(plain.fileURL.path.contains("//") == false)
#expect(plain.fileURL.absoluteString.contains("%2520") == false)
}
}
private func configuration() throws -> WebDAVConfiguration {
try WebDAVConfiguration(address: "https://dav.example.com/dav/我的设置", username: "alice")
}
@@ -0,0 +1,72 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import ServiceManagement
import Testing
@testable import XStatsUI
@MainActor
private final class Registration: HelperRegistration {
var status: SMAppService.Status = .enabled
var operations: [String] = []
var failUnregister = false
func register() { operations.append("register"); status = .enabled }
func unregister() async throws {
operations.append("unregister")
if failUnregister { throw NSError(domain: "test", code: 1) }
status = .notRegistered
}
}
@MainActor
struct HelperLifecycleTests {
@Test func adHocDisablesRegistrationAndRemovesOldDaemonWithoutConnecting() async {
let service = Registration()
let helper = HelperClient(teamIdentifier: nil, service: service, versionProbe: {
Issue.record("ad-hoc must not connect to the old daemon")
return 4
})
helper.refreshStatus()
#expect(helper.isReady == false)
helper.install()
#expect(service.operations.isEmpty)
await helper.verifyVersion()
#expect(service.operations == ["unregister"])
#expect(helper.canInstall == false)
#expect(helper.isReady == false)
if case .unavailable = helper.status {} else { Issue.record("Must show signing limitation") }
}
@Test func runningVersionFourIsUnregisteredAndReplacedBeforeReady() async {
let service = Registration()
var versions = [4, 5]
let helper = HelperClient(teamIdentifier: "ABCDE12345", service: service, versionProbe: { versions.removeFirst() })
helper.refreshStatus()
#expect(helper.isReady == false)
await helper.verifyVersion()
#expect(service.operations == ["unregister", "register"])
#expect(versions.isEmpty)
#expect(helper.isReady)
#expect(helper.isOutdated == false)
}
@Test func unregisterFailureDoesNotReinstallOrTrustOldHelper() async {
let service = Registration()
service.failUnregister = true
let helper = HelperClient(teamIdentifier: "ABCDE12345", service: service, versionProbe: { 4 })
await helper.verifyVersion()
#expect(service.operations == ["unregister"])
#expect(helper.isReady == false)
#expect(helper.isOutdated)
#expect(helper.lastError != nil)
}
@Test func failedVersionHandshakeNeverReportsReady() async {
let service = Registration()
let helper = HelperClient(teamIdentifier: "ABCDE12345", service: service, versionProbe: { nil })
await helper.verifyVersion()
#expect(helper.isReady == false)
#expect(service.operations.isEmpty)
}
}
+2
View File
@@ -264,6 +264,8 @@ apps' caches and installing a privileged helper, which is most of the feature se
the keychain and fall back to ad-hoc without one. The helper reads its own team at run time
and accepts only callers signed by the same team.
Ad-hoc builds cannot install or use the privileged helper, so fan and lid-closed controls are unavailable. Maintenance operations with an existing fallback can request one-time administrator authorization. Startup unregisters previously registered ad-hoc helpers. Team-signed builds unregister and upgrade helpers older than protocol 5 before allowing privileged calls.
```bash
make release # sign, notarize, staple, build the DMG and a Homebrew cask
NOTARY_PROFILE=XStats make release # use another notarytool keychain profile
+2
View File
@@ -202,6 +202,8 @@ SMC、他アプリのキャッシュ、特権ヘルパーを使うため、App S
ローカルビルドは Developer ID 証明書があれば使用し、なければ ad-hoc 署名を使います。ad-hoc ビルドはローカル検証用です。
一般配布には Developer ID 署名と Apple の公証が必要です。ヘルパーは自身と同じチームの署名を持つアプリだけを受け付けます。
ad-hoc ビルドでは特権ヘルパーを登録・使用できず、ファンと蓋閉じ制御は利用できません。既存の代替経路があるメンテナンスは管理者認証で実行できます。起動時に旧 ad-hoc ヘルパーを登録解除し、チーム署名ビルドではプロトコル 5 未満のヘルパーを登録解除・更新してから特権操作を許可します。
~~~bash
make release # 署名、公証、ステープル、DMG と cask の生成
NOTARY_PROFILE=XStats make release # 保存済みの公証プロファイルを指定
+2
View File
@@ -202,6 +202,8 @@ SMC, 다른 앱의 캐시, 권한 있는 보조 프로그램을 사용하므로
로컬 빌드는 Developer ID 인증서가 있으면 사용하고, 없으면 ad-hoc 서명을 사용합니다. ad-hoc 빌드는 로컬 검증용입니다.
일반 배포에는 Developer ID 서명과 Apple 공증이 필요합니다. 보조 프로그램은 자신과 같은 팀으로 서명된 앱만 허용합니다.
ad-hoc 빌드에서는 권한 있는 보조 도구를 등록하거나 사용할 수 없어 팬 및 덮개 제어가 비활성화됩니다. 기존 대체 경로가 있는 유지 관리 작업은 관리자 인증으로 실행할 수 있습니다. 시작 시 이전 ad-hoc 도구의 등록을 해제하며, 팀 서명 빌드는 프로토콜 5 미만의 도구를 등록 해제하고 업데이트한 뒤에만 권한 작업을 허용합니다.
~~~bash
make release # 서명, 공증, 티켓 첨부, DMG와 cask 생성
NOTARY_PROFILE=XStats make release # 저장된 공증 프로필 지정
+2
View File
@@ -267,6 +267,8 @@ make open # 生成工程并用 Xcode 打开
`make build` 与 `make install` 会自动使用钥匙串里的第一个 Developer ID Application 证书签名,没有证书时退回 ad-hoc。
辅助工具在运行时读取自己的签名团队,只接受同一团队签名的调用方。
ad-hoc 构建不安装或使用特权辅助工具,风扇控制与合盖运行不可用;DNS、内存维护等已有回退的操作仍可请求一次性管理员授权。启动时会注销已注册的旧 ad-hoc 服务。团队签名构建会注销并升级协议版本低于 5 的辅助工具,验证成功后才允许特权调用。
```bash
make release # 签名、公证、装订,生成 DMG 与 Homebrew cask
NOTARY_PROFILE=XStats make release # 使用其他 notarytool 钥匙串凭据