- 账号与同步:GitHub / Google / Apple 登录后把菜单栏项目与风格等偏好同步到 getopenstats.com,换一台 Mac 登录即恢复(AccountSync 模块、SyncController、SettingsDocument、账号设置页,openstats:// 回调) - 磁盘页成为工具页:已用 / 可清除 / 可用分段容量条、空间分析、文件系统检查、本地快照清理(辅助工具协议 v4)、其他磁盘推出 - 公网 IP:归属地数据源可选 CleanIP.io / ipapi.is / DB-IP / ipinfo.io / 本地 GeoLite2,每台 Mac 自己查、本地缓存 7 天;IPv4 / IPv6 双栈各自查询与切换;弹窗显示 ASN、运营方、原生 / 住宅等徽章与纯净度评分色带;接口区块可重置上传 / 下载统计 - 弹窗:右上角按钮改为该指标自己的图标 + 总设置;主窗口页面标题栏的开关点不动已修复;滚动条改为悬浮式,内容不再偏左;网络弹窗卡片被标题行撑宽的问题已修复 - 设置:⌘ 拖动排序说明;每个菜单栏项目单独选风格并带实时预览;网络与磁盘的进程榜单固定 5 行并平滑排序;CPU 持续高负载通知 - 设计系统:分段条、评分色带、标签徽章、胶囊切换、刷新按钮等组件与徽章配色 token - 更新日志、架构说明、README 与译文同步
266 lines
11 KiB
Swift
266 lines
11 KiB
Swift
//
|
|
// OpenStatsHelper
|
|
//
|
|
// 以 root 运行的辅助工具,由 SMAppService.daemon 注册。
|
|
// 只做固定的几件事:设置风扇转速、切换“合盖不睡眠”、刷新 DNS、释放内存、设置 DNS 服务器,
|
|
// 并在客户端断开时恢复风扇与睡眠设置。
|
|
//
|
|
|
|
import Foundation
|
|
import HelperShared
|
|
import os
|
|
import SMC
|
|
|
|
/// 写入系统统一日志,应用“导出诊断信息”时一并收集
|
|
private let log = Logger(subsystem: "com.openstats.helper", category: "helper")
|
|
|
|
final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProtocol, @unchecked Sendable {
|
|
/// 所有可变状态只在该队列上访问
|
|
private let queue = DispatchQueue(label: "com.openstats.helper.state")
|
|
private let listener = NSXPCListener(machServiceName: HelperConstants.machServiceName)
|
|
private let stateURL = URL(fileURLWithPath: "/Library/Application Support/OpenStats/helper-state.plist")
|
|
private let idleTimeout: TimeInterval = 30
|
|
/// 只接受与辅助工具自身同一团队签名的调用方
|
|
private let clientRequirement = HelperConstants.clientRequirement(teamIdentifier: CodeSigningInfo.currentTeamIdentifier())
|
|
|
|
private var connections: Set<ObjectIdentifier> = []
|
|
private var fans: FanControl?
|
|
private var manualFans: Set<Int> = []
|
|
private var sleepDisabledByHelper = false
|
|
private var idleExit: DispatchWorkItem?
|
|
|
|
func run() {
|
|
log.notice("辅助工具启动,协议版本 \(HelperConstants.protocolVersion)")
|
|
queue.sync {
|
|
fans = (try? SMCConnection()).map(FanControl.init)
|
|
if fans == nil { log.error("无法打开 SMC") }
|
|
recoverFromPreviousRun()
|
|
scheduleIdleExit()
|
|
}
|
|
listener.delegate = self
|
|
listener.resume()
|
|
dispatchMain()
|
|
}
|
|
|
|
// MARK: NSXPCListenerDelegate
|
|
|
|
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
|
|
// 系统在收到消息前校验调用方签名,不符合要求的连接会被直接失效
|
|
connection.setCodeSigningRequirement(clientRequirement)
|
|
connection.exportedInterface = NSXPCInterface(with: OpenStatsHelperProtocol.self)
|
|
connection.exportedObject = self
|
|
|
|
let id = ObjectIdentifier(connection)
|
|
connection.invalidationHandler = { [weak self] in
|
|
guard let self else { return }
|
|
self.queue.async { self.connectionClosed(id) }
|
|
}
|
|
queue.sync {
|
|
connections.insert(id)
|
|
idleExit?.cancel()
|
|
}
|
|
connection.resume()
|
|
return true
|
|
}
|
|
|
|
// MARK: OpenStatsHelperProtocol
|
|
|
|
func protocolVersion(reply: @escaping @Sendable (Int) -> Void) {
|
|
reply(HelperConstants.protocolVersion)
|
|
}
|
|
|
|
func setFanTarget(fan: Int, rpm: Double, reply: @escaping @Sendable (String?) -> Void) {
|
|
queue.async { [self] in
|
|
guard let fans else { return reply("无法访问 SMC") }
|
|
guard (0..<fans.fanCount).contains(fan), rpm.isFinite, rpm > 0 else { return reply("无效的风扇参数") }
|
|
do {
|
|
try fans.setManual(fan: fan, rpm: rpm)
|
|
manualFans.insert(fan)
|
|
reply(nil)
|
|
} catch {
|
|
log.error("设置风扇 \(fan) 失败:\(String(describing: error), privacy: .public)")
|
|
reply("设置风扇失败:\(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func setFanAutomatic(fan: Int, reply: @escaping @Sendable (String?) -> Void) {
|
|
queue.async { [self] in
|
|
guard let fans else { return reply("无法访问 SMC") }
|
|
guard (0..<fans.fanCount).contains(fan) else { return reply("无效的风扇编号") }
|
|
do {
|
|
try fans.setAutomatic(fan: fan)
|
|
manualFans.remove(fan)
|
|
reply(nil)
|
|
} catch {
|
|
log.error("恢复风扇 \(fan) 失败:\(String(describing: error), privacy: .public)")
|
|
reply("恢复风扇失败:\(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func resetAllFans(reply: @escaping @Sendable (String?) -> Void) {
|
|
queue.async { [self] in
|
|
reply(resetFans())
|
|
}
|
|
}
|
|
|
|
func setSleepDisabled(_ disabled: Bool, reply: @escaping @Sendable (String?) -> Void) {
|
|
queue.async { [self] in
|
|
reply(applySleepDisabled(disabled))
|
|
}
|
|
}
|
|
|
|
func flushDNSCache(reply: @escaping @Sendable (String?) -> Void) {
|
|
queue.async { [self] in reply(run(.flushDNS)) }
|
|
}
|
|
|
|
func purgeMemory(reply: @escaping @Sendable (String?) -> Void) {
|
|
queue.async { [self] in reply(run(.purgeMemory)) }
|
|
}
|
|
|
|
func setDNSServers(service: String, servers: [String], reply: @escaping @Sendable (String?) -> Void) {
|
|
queue.async { [self] in
|
|
guard DNSConfiguration.isValidServiceName(service), servers.count <= DNSConfiguration.maxServers,
|
|
servers.allSatisfy(DNSConfiguration.isValidAddress) else { return reply("无效的 DNS 参数") }
|
|
// 服务名必须是系统里已有的网络服务
|
|
let list = runTool("/usr/sbin/networksetup", ["-listallnetworkservices"])
|
|
let names = list.output.split(whereSeparator: \.isNewline).dropFirst()
|
|
.map { $0.hasPrefix("*") ? String($0.dropFirst()) : String($0) }
|
|
guard list.status == 0, names.contains(service) else { return reply("找不到网络服务“\(service)”") }
|
|
|
|
let result = runTool("/usr/sbin/networksetup", DNSConfiguration.networksetupArguments(service: service, servers: servers))
|
|
guard result.status == 0 else {
|
|
log.error("networksetup 失败:\(result.output, privacy: .public)")
|
|
return reply("networksetup 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))")
|
|
}
|
|
reply(run(.flushDNS))
|
|
}
|
|
}
|
|
|
|
func deleteLocalSnapshots(identifiers: [String], reply: @escaping @Sendable (String?) -> Void) {
|
|
queue.async { [self] in
|
|
guard !identifiers.isEmpty, identifiers.count <= LocalSnapshotCommand.maxCount,
|
|
identifiers.allSatisfy(LocalSnapshotCommand.isValidIdentifier) else { return reply("无效的快照标识") }
|
|
for identifier in identifiers {
|
|
let result = runTool(LocalSnapshotCommand.tool, LocalSnapshotCommand.arguments(identifier: identifier))
|
|
guard result.status == 0 else {
|
|
log.error("删除快照失败:\(result.output, privacy: .public)")
|
|
return reply("tmutil 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))")
|
|
}
|
|
}
|
|
log.notice("已删除 \(identifiers.count) 个本地快照")
|
|
reply(nil)
|
|
}
|
|
}
|
|
|
|
// MARK: 状态恢复
|
|
|
|
private func connectionClosed(_ id: ObjectIdentifier) {
|
|
connections.remove(id)
|
|
guard connections.isEmpty else { return }
|
|
// 应用已退出或崩溃:把系统恢复到默认状态
|
|
log.info("客户端全部断开,恢复风扇与睡眠设置")
|
|
_ = resetFans()
|
|
if sleepDisabledByHelper { _ = applySleepDisabled(false) }
|
|
scheduleIdleExit()
|
|
}
|
|
|
|
private func recoverFromPreviousRun() {
|
|
guard let data = try? Data(contentsOf: stateURL),
|
|
let state = try? PropertyListDecoder().decode(PersistedState.self, from: data) else { return }
|
|
log.notice("上次异常退出,恢复风扇与睡眠设置")
|
|
if state.sleepDisabled {
|
|
sleepDisabledByHelper = true
|
|
_ = applySleepDisabled(false)
|
|
}
|
|
if state.fansOverridden { _ = resetFans() }
|
|
}
|
|
|
|
private func scheduleIdleExit() {
|
|
idleExit?.cancel()
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self, self.connections.isEmpty else { return }
|
|
log.info("空闲退出")
|
|
exit(0)
|
|
}
|
|
idleExit = work
|
|
queue.asyncAfter(deadline: .now() + idleTimeout, execute: work)
|
|
}
|
|
|
|
// MARK: 风扇
|
|
|
|
private func resetFans() -> String? {
|
|
guard let fans else { return manualFans.isEmpty ? nil : "无法访问 SMC" }
|
|
do {
|
|
try fans.resetAll()
|
|
manualFans.removeAll()
|
|
persist()
|
|
return nil
|
|
} catch {
|
|
return "恢复风扇失败:\(error)"
|
|
}
|
|
}
|
|
|
|
// MARK: 睡眠
|
|
|
|
private func applySleepDisabled(_ disabled: Bool) -> String? {
|
|
let result = runTool("/usr/bin/pmset", ["-a", "disablesleep", disabled ? "1" : "0"])
|
|
guard result.status == 0 else {
|
|
return "pmset 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))"
|
|
}
|
|
sleepDisabledByHelper = disabled
|
|
persist()
|
|
return nil
|
|
}
|
|
|
|
// MARK: 系统维护
|
|
|
|
private func run(_ command: MaintenanceCommand) -> String? {
|
|
for step in command.steps {
|
|
let result = runTool(step.path, step.arguments)
|
|
// killall 在 mDNSResponder 恰好重启时可能返回非 0,不视为失败
|
|
if result.status != 0, step.path != "/usr/bin/killall" {
|
|
return "\((step.path as NSString).lastPathComponent) 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))"
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func runTool(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: path)
|
|
process.arguments = arguments
|
|
let pipe = Pipe()
|
|
process.standardOutput = pipe
|
|
process.standardError = pipe
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
return (-1, error.localizedDescription)
|
|
}
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
process.waitUntilExit()
|
|
return (process.terminationStatus, String(decoding: data, as: UTF8.self))
|
|
}
|
|
|
|
// MARK: 持久化
|
|
|
|
private struct PersistedState: Codable {
|
|
var sleepDisabled: Bool
|
|
var fansOverridden: Bool
|
|
}
|
|
|
|
private func persist() {
|
|
let state = PersistedState(sleepDisabled: sleepDisabledByHelper, fansOverridden: !manualFans.isEmpty)
|
|
guard state.sleepDisabled || state.fansOverridden else {
|
|
try? FileManager.default.removeItem(at: stateURL)
|
|
return
|
|
}
|
|
try? FileManager.default.createDirectory(at: stateURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try? PropertyListEncoder().encode(state).write(to: stateURL, options: .atomic)
|
|
}
|
|
}
|
|
|
|
HelperService().run()
|