diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12b9c5d..9d94b15 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/Helper/main.swift b/Helper/main.swift index 56189d5..7cdf072 100644 --- a/Helper/main.swift +++ b/Helper/main.swift @@ -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) diff --git a/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift b/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift index 78a21cd..04d5a56 100644 --- a/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift +++ b/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift @@ -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: 程序坞 diff --git a/Packages/XStatsKit/Sources/HelperShared/HelperProtocol.swift b/Packages/XStatsKit/Sources/HelperShared/HelperProtocol.swift index a3b5039..0add96d 100644 --- a/Packages/XStatsKit/Sources/HelperShared/HelperProtocol.swift +++ b/Packages/XStatsKit/Sources/HelperShared/HelperProtocol.swift @@ -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)\"" } } diff --git a/Packages/XStatsKit/Sources/Localization/HelperSigningTranslations.swift b/Packages/XStatsKit/Sources/Localization/HelperSigningTranslations.swift new file mode 100644 index 0000000..31255eb --- /dev/null +++ b/Packages/XStatsKit/Sources/Localization/HelperSigningTranslations.swift @@ -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." + } + } +} diff --git a/Packages/XStatsKit/Sources/Metrics/PowerSampler.swift b/Packages/XStatsKit/Sources/Metrics/PowerSampler.swift index b2df07c..cd9d331 100644 --- a/Packages/XStatsKit/Sources/Metrics/PowerSampler.swift +++ b/Packages/XStatsKit/Sources/Metrics/PowerSampler.swift @@ -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...fromOpaque(raw).takeUnretainedValue() + return (0...fromOpaque(CFArrayGetValueAtIndex(array, $0)).takeUnretainedValue() + } } static func name(_ channel: CFDictionary) -> String { diff --git a/Packages/XStatsKit/Sources/WebDAVSync/WebDAVClient.swift b/Packages/XStatsKit/Sources/WebDAVSync/WebDAVClient.swift index 90f0c1f..fe12bee 100644 --- a/Packages/XStatsKit/Sources/WebDAVSync/WebDAVClient.swift +++ b/Packages/XStatsKit/Sources/WebDAVSync/WebDAVClient.swift @@ -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 } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Design/Charts.swift b/Packages/XStatsKit/Sources/XStatsUI/Design/Charts.swift index 61c0f22..7c6d785 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Design/Charts.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Design/Charts.swift @@ -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 { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift index b7c147f..1a13efc 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index a71674c..e082e31 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -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) } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/ActivityRanking.swift b/Packages/XStatsKit/Sources/XStatsUI/State/ActivityRanking.swift index 493c3eb..197f68c 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/ActivityRanking.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/ActivityRanking.swift @@ -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 } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/Diagnostics.swift b/Packages/XStatsKit/Sources/XStatsUI/State/Diagnostics.swift index f9b0fe2..e8ba218 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/Diagnostics.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/Diagnostics.swift @@ -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) } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/HelperClient.swift b/Packages/XStatsKit/Sources/XStatsUI/State/HelperClient.swift index 31d5fef..a33f9fb 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/HelperClient.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/HelperClient.swift @@ -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) 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) in let once = ResumeOnceValue(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) 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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/HelperRegistration.swift b/Packages/XStatsKit/Sources/XStatsUI/State/HelperRegistration.swift new file mode 100644 index 0000000..40f7542 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/State/HelperRegistration.swift @@ -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) in + service.unregister { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + } + } + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/NetworkController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/NetworkController.swift index 3aa16d3..0ee41cc 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/NetworkController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/NetworkController.swift @@ -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(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() diff --git a/Packages/XStatsKit/Tests/CleanerTests/AppUninstallerTests.swift b/Packages/XStatsKit/Tests/CleanerTests/AppUninstallerTests.swift index a673dad..d7c78b6 100644 --- a/Packages/XStatsKit/Tests/CleanerTests/AppUninstallerTests.swift +++ b/Packages/XStatsKit/Tests/CleanerTests/AppUninstallerTests.swift @@ -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")) } + } + } } diff --git a/Packages/XStatsKit/Tests/HelperSharedTests/HelperConstantsTests.swift b/Packages/XStatsKit/Tests/HelperSharedTests/HelperConstantsTests.swift new file mode 100644 index 0000000..756b028 --- /dev/null +++ b/Packages/XStatsKit/Tests/HelperSharedTests/HelperConstantsTests.swift @@ -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\"")) + } +} diff --git a/Packages/XStatsKit/Tests/WebDAVSyncTests/WebDAVClientTests.swift b/Packages/XStatsKit/Tests/WebDAVSyncTests/WebDAVClientTests.swift index 6071c1d..fbedf86 100644 --- a/Packages/XStatsKit/Tests/WebDAVSyncTests/WebDAVClientTests.swift +++ b/Packages/XStatsKit/Tests/WebDAVSyncTests/WebDAVClientTests.swift @@ -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") } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/HelperLifecycleTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/HelperLifecycleTests.swift new file mode 100644 index 0000000..03abfd3 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/HelperLifecycleTests.swift @@ -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) + } +} diff --git a/README.en.md b/README.en.md index c4346e5..2036def 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.ja.md b/README.ja.md index 9a59554..6dedabb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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 # 保存済みの公証プロファイルを指定 diff --git a/README.ko.md b/README.ko.md index 9a51527..b93fd63 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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 # 저장된 공증 프로필 지정 diff --git a/README.md b/README.md index ae99551..3ceb592 100644 --- a/README.md +++ b/README.md @@ -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 钥匙串凭据