OpenStats:macOS 菜单栏系统监控初始版本

- 菜单栏:CPU/GPU/内存多样式、网速两行、温度、风扇
- 面板:概览仪表盘、进程、散热、防休眠,液态玻璃与浅色/深色主题
- 采集层:CPU/内存/网络/GPU/磁盘/电池/进程/SMC 温度与风扇,按界面分级采样
- root 辅助工具:风扇调速、合盖不睡眠,断开时自动恢复
- XcodeGen 工程、单元测试、截图模式
This commit is contained in:
gentpan committed 2026-09-13 02:44:05 +05:00
commit 4741d40e1b
65 files changed
+6903

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.DS_Store
build/
*.xcodeproj/
xcuserdata/
.build/
.swiftpm/
Packages/OpenStatsKit/Package.resolved
@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh-Hans</string>
<key>CFBundleDisplayName</key>
<string>OpenStats</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>OpenStats</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026 OpenStats</string>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
import OpenStatsUI
OpenStatsApplication.main()
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.openstats.helper</string>
<key>BundleProgram</key>
<string>Contents/MacOS/OpenStatsHelper</string>
<key>MachServices</key>
<dict>
<key>com.openstats.helper</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.openstats.app</string>
</array>
<!-- 开机时启动一次:若上次异常退出时留下了“禁止睡眠”,在这里恢复 -->
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
+209
View File
@@ -0,0 +1,209 @@
//
// OpenStatsHelper
//
// 以 root 运行的辅助工具,由 SMAppService.daemon 注册。
// 只做三件事:设置风扇转速、切换“合盖不睡眠”、在客户端断开时恢复默认。
//
import Foundation
import HelperShared
import SMC
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 var connections: Set<ObjectIdentifier> = []
private var fans: FanControl?
private var manualFans: Set<Int> = []
private var sleepDisabledByHelper = false
private var idleExit: DispatchWorkItem?
func run() {
queue.sync {
fans = (try? SMCConnection()).map(FanControl.init)
recoverFromPreviousRun()
scheduleIdleExit()
}
listener.delegate = self
listener.resume()
dispatchMain()
}
// MARK: NSXPCListenerDelegate
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
// 系统在收到消息前校验调用方签名,不符合要求的连接会被直接失效
connection.setCodeSigningRequirement(HelperConstants.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 {
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 {
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 sleepDisabled(reply: @escaping @Sendable (Bool) -> Void) {
queue.async { [self] in
reply(currentSleepDisabled())
}
}
// MARK: 状态恢复
private func connectionClosed(_ id: ObjectIdentifier) {
connections.remove(id)
guard connections.isEmpty else { return }
// 应用已退出或崩溃:把系统恢复到默认状态
_ = 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 }
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 }
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 = runPmset(["-a", "disablesleep", disabled ? "1" : "0"])
guard result.status == 0 else {
return "pmset 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))"
}
sleepDisabledByHelper = disabled
persist()
return nil
}
private func currentSleepDisabled() -> Bool {
let output = runPmset(["-g"]).output
return output.split(separator: "\n").contains { line in
let parts = line.split(whereSeparator: \.isWhitespace)
return parts.first == "SleepDisabled" && parts.last == "1"
}
}
private func runPmset(_ arguments: [String]) -> (status: Int32, output: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
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()
+41
View File
@@ -0,0 +1,41 @@
PROJECT := OpenStats.xcodeproj
SCHEME := OpenStats
CONFIG ?= Debug
DERIVED := build/DerivedData
APP := $(DERIVED)/Build/Products/$(CONFIG)/OpenStats.app
.PHONY: generate build run stop test snapshot icon open clean
## 由 project.yml 生成 Xcode 工程
generate:
xcodegen generate --quiet
## 编译 App(含辅助工具)
build: generate
xcodebuild -project $(PROJECT) -scheme $(SCHEME) -configuration $(CONFIG) \
-derivedDataPath $(DERIVED) -destination 'platform=macOS' -quiet build
## 编译并启动(会先结束正在运行的 OpenStats)
run: build stop
open $(APP)
stop:
-pkill -x OpenStats
## 单元测试
test:
cd Packages/OpenStatsKit && swift test
## 用本机实时数据渲染各页面截图到 build/snapshots
snapshot: build
$(APP)/Contents/MacOS/OpenStats --snapshot build/snapshots
## 重新生成应用图标
icon:
swift scripts/make-icon.swift App/Assets.xcassets/AppIcon.appiconset
open: generate
open $(PROJECT)
clean:
rm -rf build
+22
View File
@@ -0,0 +1,22 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "OpenStatsKit",
defaultLocalization: "zh-Hans",
platforms: [.macOS(.v14)],
products: [
.library(name: "SMC", targets: ["SMC"]),
.library(name: "Metrics", targets: ["Metrics"]),
.library(name: "HelperShared", targets: ["HelperShared"]),
.library(name: "OpenStatsUI", targets: ["OpenStatsUI"]),
],
targets: [
.target(name: "SMC"),
.target(name: "Metrics", dependencies: ["SMC"]),
.target(name: "HelperShared"),
.target(name: "OpenStatsUI", dependencies: ["Metrics", "SMC", "HelperShared"]),
.testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]),
],
swiftLanguageModes: [.v6]
)
@@ -0,0 +1,30 @@
import Foundation
public enum HelperConstants {
public static let machServiceName = "com.openstats.helper"
public static let launchdPlistName = "com.openstats.helper.plist"
public static let appBundleIdentifier = "com.openstats.app"
/// 与 App 版本同步;App 发现辅助工具版本不一致时提示重新安装
public static let protocolVersion = 1
/// 用 Developer ID 签名后填入 Team ID,辅助工具会据此严格校验调用方。
/// 为空时只校验 bundle identifier,仅适用于本地开发。
public static let teamIdentifier = ""
public static var clientRequirement: String {
let identifier = "identifier \"\(appBundleIdentifier)\""
guard !teamIdentifier.isEmpty else { return identifier }
return identifier + " and anchor apple generic and certificate leaf[subject.OU] = \"\(teamIdentifier)\""
}
}
/// 辅助工具以 root 运行,只暴露固定的少量操作,不提供任意命令执行能力。
/// 所有回调中的 `String?` 为错误描述,nil 表示成功。
@objc public protocol OpenStatsHelperProtocol {
func protocolVersion(reply: @escaping @Sendable (Int) -> Void)
func setFanTarget(fan: Int, rpm: Double, reply: @escaping @Sendable (String?) -> Void)
func setFanAutomatic(fan: Int, reply: @escaping @Sendable (String?) -> Void)
func resetAllFans(reply: @escaping @Sendable (String?) -> Void)
func setSleepDisabled(_ disabled: Bool, reply: @escaping @Sendable (String?) -> Void)
func sleepDisabled(reply: @escaping @Sendable (Bool) -> Void)
}
@@ -0,0 +1,84 @@
import Darwin
import Foundation
public struct CPUSampler {
private var previousTicks: [[UInt32]] = []
public init() {}
public static func topology() -> CPUTopology {
let brand = Sysctl.string("machdep.cpu.brand_string") ?? "CPU"
let logical = Sysctl.int("hw.logicalcpu") ?? ProcessInfo.processInfo.activeProcessorCount
let levels = Sysctl.int("hw.nperflevels") ?? 1
guard levels > 1 else {
return CPUTopology(brand: brand, logicalCores: logical,
clusters: [CPUCluster(id: 0, name: "核心", coreIndices: Array(0..<logical))])
}
// 核心序号从最低性能档开始排列:perflevel(n-1) 在前,perflevel0 在后
var clusters: [CPUCluster] = []
var offset = 0
for level in stride(from: levels - 1, through: 0, by: -1) {
let count = Sysctl.int("hw.perflevel\(level).logicalcpu") ?? 0
guard count > 0 else { continue }
let raw = Sysctl.string("hw.perflevel\(level).name") ?? ""
clusters.append(CPUCluster(id: level, name: localizedClusterName(raw), coreIndices: Array(offset..<offset + count)))
offset += count
}
return CPUTopology(brand: brand, logicalCores: logical, clusters: clusters.sorted { $0.id < $1.id })
}
private static func localizedClusterName(_ raw: String) -> String {
switch raw.lowercased() {
case "super": "超级核"
case "performance": "性能核"
case "efficiency": "能效核"
default: raw.isEmpty ? "核心" : raw
}
}
public mutating func sample() -> CPULoad? {
var cpuCount: natural_t = 0
var info: processor_info_array_t?
var infoCount: mach_msg_type_number_t = 0
let kr = host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &cpuCount, &info, &infoCount)
guard kr == KERN_SUCCESS, let info else { return nil }
// 内核分配的数组必须归还,否则每次采样都会泄漏
defer {
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
vm_size_t(Int(infoCount) * MemoryLayout<integer_t>.stride))
}
let states = Int(CPU_STATE_MAX)
var ticks: [[UInt32]] = []
ticks.reserveCapacity(Int(cpuCount))
for core in 0..<Int(cpuCount) {
ticks.append((0..<states).map { UInt32(bitPattern: info[core * states + $0]) })
}
defer { previousTicks = ticks }
guard previousTicks.count == ticks.count else { return nil }
var perCore: [Double] = []
perCore.reserveCapacity(ticks.count)
var sumUser: UInt64 = 0, sumSystem: UInt64 = 0, sumAll: UInt64 = 0
for (now, before) in zip(ticks, previousTicks) {
let delta = zip(now, before).map { UInt64($0 &- $1) }
let user = delta[Int(CPU_STATE_USER)] + delta[Int(CPU_STATE_NICE)]
let system = delta[Int(CPU_STATE_SYSTEM)]
let all = user + system + delta[Int(CPU_STATE_IDLE)]
perCore.append(all == 0 ? 0 : Double(user + system) / Double(all))
sumUser += user
sumSystem += system
sumAll += all
}
guard sumAll > 0 else { return nil }
var averages = [Double](repeating: 0, count: 3)
let loadCount = getloadavg(&averages, 3)
let user = Double(sumUser) / Double(sumAll)
let system = Double(sumSystem) / Double(sumAll)
return CPULoad(total: min(1, user + system), user: user, system: system, perCore: perCore,
loadAverage: loadCount > 0 ? Array(averages.prefix(Int(loadCount))) : [])
}
}
@@ -0,0 +1,110 @@
import Foundation
public struct History<Element: Sendable>: Sendable {
public let capacity: Int
public private(set) var elements: [Element] = []
public init(capacity: Int) {
self.capacity = max(1, capacity)
elements.reserveCapacity(self.capacity)
}
public mutating func append(_ element: Element) {
if elements.count == capacity { elements.removeFirst() }
elements.append(element)
}
public var last: Element? { elements.last }
}
public enum Format {
public enum ByteBase: Sendable {
case binary // 内存:1 GB = 1024³,与活动监视器一致
case decimal // 磁盘:1 GB = 1000³,与访达一致
}
private static let units = ["B", "KB", "MB", "GB", "TB", "PB"]
public static func bytes(_ value: UInt64, base: ByteBase = .binary) -> String {
bytes(Double(value), base: base)
}
public static func bytes(_ value: Double, base: ByteBase = .binary) -> String {
let step: Double = base == .binary ? 1024 : 1000
var amount = max(0, value)
var index = 0
while amount >= step, index < units.count - 1 {
amount /= step
index += 1
}
if index == 0 { return "\(Int(amount)) B" }
let digits = amount >= 100 ? 0 : 1
return "\(amount.formatted(.number.precision(.fractionLength(digits)))) \(units[index])"
}
public static func rate(_ bytesPerSecond: Double) -> String {
bytes(bytesPerSecond) + "/s"
}
/// 菜单栏用的紧凑写法:692K、1.2M
public static func compactRate(_ bytesPerSecond: Double) -> String {
let value = max(0, bytesPerSecond)
switch value {
case ..<1024: return "\(Int(value))B"
case ..<(1024 * 1024): return "\(Int(value / 1024))K"
case ..<(1024 * 1024 * 1024):
let mb = value / 1024 / 1024
return mb >= 10 ? "\(Int(mb))M" : "\(mb.formatted(.number.precision(.fractionLength(1))))M"
default:
return "\((value / 1024 / 1024 / 1024).formatted(.number.precision(.fractionLength(1))))G"
}
}
/// 菜单栏网速:保留完整单位,数字最多 3 位,宽度稳定
/// 例:512 B/s、12 KB/s、154 KB/s、1.2 MB/s、12 MB/s、1.1 GB/s
public static func menuBarRate(_ bytesPerSecond: Double) -> String {
let units = ["B/s", "KB/s", "MB/s", "GB/s"]
var value = max(0, bytesPerSecond)
var index = 0
// 超过 999 就进位,避免出现 1023 KB/s 这种 4 位数
while value >= 999.5, index < units.count - 1 {
value /= 1024
index += 1
}
let text = index >= 2 && value < 9.95
? value.formatted(.number.precision(.fractionLength(1)))
: String(Int(value.rounded()))
return "\(text) \(units[index])"
}
public static func percent(_ fraction: Double) -> String {
"\(Int((min(1, max(0, fraction)) * 100).rounded()))%"
}
public static func temperature(_ celsius: Double, fahrenheit: Bool = false) -> String {
fahrenheit ? "\(Int((celsius * 9 / 5 + 32).rounded()))°F" : "\(Int(celsius.rounded()))°C"
}
public static func rpm(_ value: Double) -> String {
"\(Int(value.rounded())) RPM"
}
public static func duration(minutes: Int) -> String {
let hours = minutes / 60
let rest = minutes % 60
switch (hours, rest) {
case (0, _): return "\(rest) 分钟"
case (_, 0): return "\(hours) 小时"
default: return "\(hours) 小时 \(rest) 分钟"
}
}
public static func uptime(since date: Date, now: Date = Date()) -> String {
let minutes = max(0, Int(now.timeIntervalSince(date) / 60))
let days = minutes / (60 * 24)
let hours = (minutes / 60) % 24
if days > 0 { return hours > 0 ? "\(days) 天 \(hours) 小时" : "\(days) 天" }
if hours > 0 { return "\(hours) 小时" }
return "\(minutes) 分钟"
}
}
@@ -0,0 +1,114 @@
import Foundation
import IOKit
import IOKit.ps
// MARK: - 磁盘
public enum DiskSampler {
public static func sample(volume: URL = URL(fileURLWithPath: "/")) -> DiskUsage? {
let keys: Set<URLResourceKey> = [.volumeLocalizedNameKey, .volumeTotalCapacityKey,
.volumeAvailableCapacityForImportantUsageKey]
guard let values = try? volume.resourceValues(forKeys: keys),
let total = values.volumeTotalCapacity else { return nil }
// “重要用途可用空间”包含可清除空间,与访达显示一致
let available = values.volumeAvailableCapacityForImportantUsage ?? 0
return DiskUsage(volumeName: values.volumeLocalizedName ?? "磁盘",
total: UInt64(total), available: UInt64(max(0, available)))
}
}
// MARK: - 电池
public enum BatterySampler {
public static func sample() -> BatteryStatus? {
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef] else { return nil }
for source in sources {
guard let description = IOPSGetPowerSourceDescription(blob, source)?.takeUnretainedValue() as? [String: Any],
description[kIOPSTypeKey] as? String == kIOPSInternalBatteryType else { continue }
let current = description[kIOPSCurrentCapacityKey] as? Int ?? 0
let maximum = description[kIOPSMaxCapacityKey] as? Int ?? 100
let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
let pluggedIn = description[kIOPSPowerSourceStateKey] as? String == kIOPSACPowerValue
let charged = description[kIOPSIsChargedKey] as? Bool ?? false
let minutesKey = isCharging ? kIOPSTimeToFullChargeKey : kIOPSTimeToEmptyKey
let minutes = (description[minutesKey] as? Int).flatMap { $0 > 0 ? $0 : nil }
var status = BatteryStatus(level: maximum > 0 ? Double(current) / Double(maximum) : 0,
isCharging: isCharging, isPluggedIn: pluggedIn, isFullyCharged: charged,
minutesRemaining: pluggedIn && !isCharging ? nil : minutes)
fillRegistryDetails(&status)
return status
}
return nil
}
private static func fillRegistryDetails(_ status: inout BatteryStatus) {
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
guard service != 0 else { return }
defer { IOObjectRelease(service) }
status.cycleCount = IORegistry.property(service, "CycleCount") as? Int
if let data = IORegistry.property(service, "BatteryData") as? [String: Any],
let design = data["DesignCapacity"] as? Int, design > 0,
let capacity = (data["NominalChargeCapacity"] as? Int) ?? (data["FullChargeCapacity"] as? Int) {
status.health = min(1, Double(capacity) / Double(design))
}
if let adapter = IORegistry.property(service, "AdapterDetails") as? [String: Any] {
status.adapterWatts = adapter["Watts"] as? Int
}
}
}
// MARK: - GPU
public enum GPUSampler {
public static func sample() -> GPUUsage? {
var iterator: io_iterator_t = 0
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOAccelerator"), &iterator) == KERN_SUCCESS else {
return nil
}
defer { IOObjectRelease(iterator) }
var best: GPUUsage?
while case let service = IOIteratorNext(iterator), service != 0 {
defer { IOObjectRelease(service) }
// 只读取需要的属性,避免复制整张属性表
guard let stats = IORegistry.property(service, "PerformanceStatistics") as? [String: Any],
let utilization = stats["Device Utilization %"] as? Int else { continue }
let usage = GPUUsage(name: IORegistry.property(service, "model") as? String ?? "GPU",
utilization: Double(min(100, max(0, utilization))) / 100,
coreCount: IORegistry.property(service, "gpu-core-count") as? Int)
if best == nil || usage.utilization > best!.utilization { best = usage }
}
return best
}
}
// MARK: - 系统信息
public enum SystemInfoReader {
public static func read() -> SystemInfo {
let product = IORegistryEntryFromPath(kIOMainPortDefault, "IODeviceTree:/product")
var modelName = Sysctl.string("hw.model") ?? "Mac"
if product != 0 {
if let name = IORegistry.string(fromData: IORegistry.property(product, "product-name")), !name.isEmpty {
modelName = name
}
IOObjectRelease(product)
}
let version = ProcessInfo.processInfo.operatingSystemVersion
let patch = version.patchVersion > 0 ? ".\(version.patchVersion)" : ""
var bootTime = timeval()
var size = MemoryLayout<timeval>.size
let boot = sysctlbyname("kern.boottime", &bootTime, &size, nil, 0) == 0
? Date(timeIntervalSince1970: TimeInterval(bootTime.tv_sec)) : nil
return SystemInfo(modelName: modelName,
osVersion: "macOS \(version.majorVersion).\(version.minorVersion)\(patch)",
bootDate: boot)
}
}
@@ -0,0 +1,42 @@
import Darwin
import Foundation
public struct MemorySampler {
private let total = ProcessInfo.processInfo.physicalMemory
private let pageSize: UInt64 = {
var size: vm_size_t = 0
host_page_size(mach_host_self(), &size)
return UInt64(size)
}()
public init() {}
public func sample() -> MemoryUsage? {
var stats = vm_statistics64()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.stride / MemoryLayout<integer_t>.stride)
let kr = withUnsafeMutablePointer(to: &stats) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard kr == KERN_SUCCESS else { return nil }
// 与“活动监视器”口径一致:App 内存 = internal - purgeable
let internalPages = UInt64(stats.internal_page_count)
let purgeable = UInt64(stats.purgeable_count)
let app = (internalPages > purgeable ? internalPages - purgeable : 0) * pageSize
var swap = xsw_usage()
var swapSize = MemoryLayout<xsw_usage>.size
let swapUsed = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0) == 0 ? swap.xsu_used : 0
let level = Sysctl.int("kern.memorystatus_vm_pressure_level") ?? 1
return MemoryUsage(total: total,
app: app,
wired: UInt64(stats.wire_count) * pageSize,
compressed: UInt64(stats.compressor_page_count) * pageSize,
cached: (UInt64(stats.external_page_count) + purgeable) * pageSize,
swapUsed: swapUsed,
pressure: MemoryPressure(rawValue: level) ?? .normal)
}
}
@@ -0,0 +1,115 @@
import Foundation
import SMC
/// 描述当前界面需要哪些指标。面板关闭时只采菜单栏用到的项目。
public struct MetricsDemand: Sendable, Equatable {
public var interval: Duration = .seconds(2)
public var memory = false
public var network = false
public var gpu = false
public var disk = false
public var battery = false
public var processes = false
public var temperatures: Set<TemperatureGroup> = []
public var fans = false
public init() {}
}
public actor MetricsHub {
private var cpu = CPUSampler()
private let memory = MemorySampler()
private var network = NetworkSampler()
private var processes = ProcessSampler()
private let sensors = SensorSampler()
private var demand = MetricsDemand()
private var handler: (@MainActor @Sendable (MetricsSnapshot) -> Void)?
private var loop: Task<Void, Never>?
private var paused = false
private var lastDisk = Date.distantPast
private var lastBattery = Date.distantPast
private var lastInterface = Date.distantPast
public init() {}
public func start(handler: @escaping @MainActor @Sendable (MetricsSnapshot) -> Void) {
self.handler = handler
restart()
}
public func update(_ newDemand: MetricsDemand) {
guard newDemand != demand else { return }
let needsImmediateRefresh = newDemand.interval < demand.interval
|| (newDemand.processes && !demand.processes)
|| (newDemand.disk && !demand.disk)
demand = newDemand
if needsImmediateRefresh {
lastDisk = .distantPast
lastBattery = .distantPast
lastInterface = .distantPast
}
restart()
}
/// 锁屏、屏幕休眠或系统睡眠时暂停采样
public func setPaused(_ value: Bool) {
guard value != paused else { return }
paused = value
restart()
}
public func stop() {
loop?.cancel()
loop = nil
handler = nil
}
private func restart() {
loop?.cancel()
guard !paused, handler != nil else {
loop = nil
return
}
loop = Task { await self.run() }
}
private func run() async {
while !Task.isCancelled {
let snapshot = collect()
if let handler { await handler(snapshot) }
// 容差让系统合并定时器唤醒,降低功耗
try? await Task.sleep(for: demand.interval, tolerance: demand.interval / 4)
}
}
private func collect() -> MetricsSnapshot {
let now = Date()
var snapshot = MetricsSnapshot()
snapshot.cpu = cpu.sample()
if demand.memory { snapshot.memory = memory.sample() }
if demand.network {
snapshot.network = network.sample()
if now.timeIntervalSince(lastInterface) > 15 {
snapshot.networkInterface = NetworkSampler.primaryInterface()
lastInterface = now
}
}
if demand.gpu { snapshot.gpu = GPUSampler.sample() }
if demand.disk, now.timeIntervalSince(lastDisk) > 30 {
snapshot.disk = DiskSampler.sample()
lastDisk = now
}
if demand.battery, now.timeIntervalSince(lastBattery) > 10 {
snapshot.battery = BatterySampler.sample()
lastBattery = now
}
if demand.processes { snapshot.processes = processes.sample() }
if !demand.temperatures.isEmpty || demand.fans {
snapshot.sensors = sensors.sample(groups: demand.temperatures, includeFans: demand.fans)
}
return snapshot
}
}
@@ -0,0 +1,139 @@
import Foundation
import SMC
public struct CPUCluster: Sendable, Equatable, Identifiable {
public let id: Int // perflevel 序号,0 为最高性能档
public let name: String // 超级核 / 性能核 / 能效核
public let coreIndices: [Int] // 对应 host_processor_info 的核心序号
}
public struct CPUTopology: Sendable, Equatable {
public let brand: String
public let logicalCores: Int
public let clusters: [CPUCluster]
}
public struct CPULoad: Sendable, Equatable {
public var total: Double
public var user: Double
public var system: Double
public var perCore: [Double]
public var loadAverage: [Double]
}
public enum MemoryPressure: Int, Sendable {
case normal = 1, warning = 2, critical = 4
public var title: String {
switch self {
case .normal: "正常"
case .warning: "偏高"
case .critical: "严重"
}
}
}
public struct MemoryUsage: Sendable, Equatable {
public var total: UInt64
public var app: UInt64
public var wired: UInt64
public var compressed: UInt64
public var cached: UInt64
public var swapUsed: UInt64
public var pressure: MemoryPressure
public var used: UInt64 { app + wired + compressed }
public var usedFraction: Double { total == 0 ? 0 : min(1, Double(used) / Double(total)) }
}
public struct NetworkInterfaceInfo: Sendable, Equatable {
public var bsdName: String
public var displayName: String
public var ipv4: String?
}
public struct NetworkRate: Sendable, Equatable {
public var downloadBytesPerSecond: Double
public var uploadBytesPerSecond: Double
public var totalDownloaded: UInt64
public var totalUploaded: UInt64
}
public struct DiskUsage: Sendable, Equatable {
public var volumeName: String
public var total: UInt64
public var available: UInt64
public var used: UInt64 { total > available ? total - available : 0 }
public var usedFraction: Double { total == 0 ? 0 : Double(used) / Double(total) }
}
public struct BatteryStatus: Sendable, Equatable {
public var level: Double // 0...1
public var isCharging: Bool
public var isPluggedIn: Bool
public var isFullyCharged: Bool
public var minutesRemaining: Int? // 放电剩余或充满所需时间
public var cycleCount: Int?
public var health: Double? // 0...1
public var adapterWatts: Int?
}
public struct GPUUsage: Sendable, Equatable {
public var name: String
public var utilization: Double // 0...1
public var coreCount: Int?
}
public struct ProcessUsage: Sendable, Equatable, Identifiable {
public var id: Int32 { pid }
public let pid: Int32
public let name: String
public let executablePath: String?
public let appBundlePath: String?
public var cpu: Double // 以单核为 100%,与活动监视器一致
public var memory: UInt64 // phys_footprint
}
public struct TemperatureSummary: Sendable, Equatable, Identifiable {
public var id: TemperatureGroup { group }
public let group: TemperatureGroup
public var average: Double
public var maximum: Double
public var sensorCount: Int
}
public struct SensorReadings: Sendable, Equatable {
public var temperatures: [TemperatureSummary]
public var fans: [FanState]
public init(temperatures: [TemperatureSummary], fans: [FanState]) {
self.temperatures = temperatures
self.fans = fans
}
public func temperature(_ group: TemperatureGroup) -> TemperatureSummary? {
temperatures.first { $0.group == group }
}
}
public struct SystemInfo: Sendable, Equatable {
public var modelName: String
public var osVersion: String
public var bootDate: Date?
}
public struct MetricsSnapshot: Sendable {
public var date = Date()
public var cpu: CPULoad?
public var memory: MemoryUsage?
public var network: NetworkRate?
public var networkInterface: NetworkInterfaceInfo?
public var disk: DiskUsage?
public var battery: BatteryStatus?
public var gpu: GPUUsage?
public var processes: [ProcessUsage]?
public var sensors: SensorReadings?
public init() {}
}
@@ -0,0 +1,71 @@
import Darwin
import Foundation
import SystemConfiguration
public struct NetworkSampler {
private var last: (download: UInt64, upload: UInt64, time: UInt64)?
public init() {}
/// `getifaddrs` 中的 `if_data.ifi_ibytes` 只有 32 位,超过 4 GiB 会回绕;
/// 这里用 NET_RT_IFLIST2 读取 `if_data64`。
private static func counters() -> (download: UInt64, upload: UInt64)? {
var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0]
var length = 0
guard sysctl(&mib, u_int(mib.count), nil, &length, nil, 0) == 0 else { return nil }
var buffer = [UInt8](repeating: 0, count: length)
guard sysctl(&mib, u_int(mib.count), &buffer, &length, nil, 0) == 0 else { return nil }
var download: UInt64 = 0
var upload: UInt64 = 0
buffer.withUnsafeBytes { raw in
var offset = 0
while offset + MemoryLayout<if_msghdr>.size <= length {
let header = raw.loadUnaligned(fromByteOffset: offset, as: if_msghdr.self)
guard header.ifm_msglen > 0 else { break }
if Int32(header.ifm_type) == RTM_IFINFO2,
offset + MemoryLayout<if_msghdr2>.size <= length {
let header2 = raw.loadUnaligned(fromByteOffset: offset, as: if_msghdr2.self)
if header2.ifm_flags & IFF_LOOPBACK == 0 {
download += header2.ifm_data.ifi_ibytes
upload += header2.ifm_data.ifi_obytes
}
}
offset += Int(header.ifm_msglen)
}
}
return (download, upload)
}
public mutating func sample() -> NetworkRate? {
guard let now = Self.counters() else { return nil }
let time = clock_gettime_nsec_np(CLOCK_UPTIME_RAW)
defer { last = (now.download, now.upload, time) }
guard let last, time > last.time else { return nil }
let seconds = Double(time - last.time) / 1_000_000_000
// 接口重置时计数器可能变小,此时本次速率记为 0
let down = now.download >= last.download ? Double(now.download - last.download) / seconds : 0
let up = now.upload >= last.upload ? Double(now.upload - last.upload) / seconds : 0
return NetworkRate(downloadBytesPerSecond: down, uploadBytesPerSecond: up,
totalDownloaded: now.download, totalUploaded: now.upload)
}
public static func primaryInterface() -> NetworkInterfaceInfo? {
guard let store = SCDynamicStoreCreate(nil, "OpenStats" as CFString, nil, nil),
let global = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString) as? [String: Any],
let bsdName = global["PrimaryInterface"] as? String else { return nil }
let interfaces = SCNetworkInterfaceCopyAll() as? [SCNetworkInterface] ?? []
let systemName = interfaces
.first { SCNetworkInterfaceGetBSDName($0) as String? == bsdName }
.flatMap { SCNetworkInterfaceGetLocalizedDisplayName($0) as String? }
// utun / ipsec / ppp 是 VPN 或代理隧道,系统不提供显示名
let tunnelPrefixes = ["utun", "ipsec", "ppp"]
let displayName = systemName
?? (tunnelPrefixes.contains { bsdName.hasPrefix($0) } ? "VPN 隧道" : bsdName)
let state = SCDynamicStoreCopyValue(store, "State:/Network/Interface/\(bsdName)/IPv4" as CFString) as? [String: Any]
let address = (state?["Addresses"] as? [String])?.first
return NetworkInterfaceInfo(bsdName: bsdName, displayName: displayName, ipv4: address)
}
}
@@ -0,0 +1,90 @@
import Darwin
import Foundation
public struct ProcessSampler {
private struct Identity: Hashable {
let pid: Int32
let startTime: UInt64
}
private struct Meta {
let name: String
let path: String?
let bundle: String?
}
private var previousCPU: [Identity: UInt64] = [:]
private var previousTime: UInt64 = 0
private var metaCache: [Identity: Meta] = [:]
public init() {}
/// 返回按 CPU 排序的进程;无权限读取的进程(通常属于 root)会被跳过。
public mutating func sample(limit: Int = 50) -> [ProcessUsage] {
let capacity = Int(proc_listallpids(nil, 0)) + 64
guard capacity > 64 else { return [] }
var pids = [pid_t](repeating: 0, count: capacity)
let count = Int(proc_listallpids(&pids, Int32(capacity * MemoryLayout<pid_t>.stride)))
guard count > 0 else { return [] }
// CPU 时间与墙钟时间都用 mach 绝对时间单位,比值无需换算时基
let now = mach_absolute_time()
let elapsed = previousTime > 0 ? now - previousTime : 0
var cpuTimes: [Identity: UInt64] = [:]
var liveMeta: [Identity: Meta] = [:]
var results: [ProcessUsage] = []
results.reserveCapacity(count)
for pid in pids.prefix(count) where pid > 0 {
var info = rusage_info_v4()
let status = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
proc_pid_rusage(pid, RUSAGE_INFO_V4, $0)
}
}
guard status == 0 else { continue }
let identity = Identity(pid: pid, startTime: info.ri_proc_start_abstime)
let cpuTime = info.ri_user_time + info.ri_system_time
cpuTimes[identity] = cpuTime
let meta = metaCache[identity] ?? Self.meta(for: pid)
liveMeta[identity] = meta
var cpu = 0.0
if elapsed > 0, let before = previousCPU[identity], cpuTime >= before {
cpu = Double(cpuTime - before) / Double(elapsed)
}
results.append(ProcessUsage(pid: pid, name: meta.name, executablePath: meta.path,
appBundlePath: meta.bundle, cpu: cpu, memory: info.ri_phys_footprint))
}
previousCPU = cpuTimes
previousTime = now
metaCache = liveMeta
results.sort { $0.cpu == $1.cpu ? $0.memory > $1.memory : $0.cpu > $1.cpu }
return Array(results.prefix(limit))
}
private static func meta(for pid: pid_t) -> Meta {
var pathBuffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN))
let pathLength = proc_pidpath(pid, &pathBuffer, UInt32(pathBuffer.count))
let path = pathLength > 0 ? String(nullTerminated: pathBuffer) : nil
var name = path.map { URL(fileURLWithPath: $0).lastPathComponent } ?? ""
if name.isEmpty {
var nameBuffer = [CChar](repeating: 0, count: 256)
proc_name(pid, &nameBuffer, UInt32(nameBuffer.count))
name = String(nullTerminated: nameBuffer)
}
// 取最外层 .app,辅助进程也能显示主应用图标
let bundle = path.flatMap { path -> String? in
guard let range = path.range(of: ".app/") else { return nil }
return String(path[..<range.lowerBound]) + ".app"
}
return Meta(name: name.isEmpty ? "PID \(pid)" : name, path: path, bundle: bundle)
}
}
@@ -0,0 +1,30 @@
import Foundation
import SMC
/// 温度与风扇(只读,普通权限即可)。
public final class SensorSampler {
private let smc: SMCConnection?
private let fans: FanControl?
private lazy var catalog: [TemperatureGroup: [SMCKey]] = smc.map(TemperatureCatalog.discover) ?? [:]
public init() {
smc = try? SMCConnection()
fans = smc.map(FanControl.init)
}
public var isAvailable: Bool { smc != nil }
public func sample(groups: Set<TemperatureGroup> = Set(TemperatureGroup.allCases), includeFans: Bool = true) -> SensorReadings? {
guard let smc else { return nil }
var summaries: [TemperatureSummary] = []
for group in TemperatureGroup.allCases where groups.contains(group) {
let values = (catalog[group] ?? []).compactMap(smc.double).filter(TemperatureCatalog.plausibleRange.contains)
guard !values.isEmpty else { continue }
summaries.append(TemperatureSummary(group: group,
average: values.reduce(0, +) / Double(values.count),
maximum: values.max() ?? 0,
sensorCount: values.count))
}
return SensorReadings(temperatures: summaries, fans: includeFans ? (fans?.read() ?? []) : [])
}
}
@@ -0,0 +1,38 @@
import Darwin
import Foundation
import IOKit
enum Sysctl {
static func int(_ name: String) -> Int? {
var value: Int64 = 0
var size = MemoryLayout<Int64>.size
guard sysctlbyname(name, &value, &size, nil, 0) == 0 else { return nil }
// 32 位值只写入低 4 字节
return size == MemoryLayout<Int32>.size ? Int(Int32(truncatingIfNeeded: value)) : Int(value)
}
static func string(_ name: String) -> String? {
var size = 0
guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil }
var buffer = [CChar](repeating: 0, count: size)
guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil }
return String(nullTerminated: buffer)
}
}
enum IORegistry {
static func property(_ service: io_registry_entry_t, _ key: String) -> Any? {
IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue()
}
static func string(fromData value: Any?) -> String? {
guard let data = value as? Data else { return value as? String }
return String(decoding: data.prefix { $0 != 0 }, as: UTF8.self)
}
}
extension String {
init(nullTerminated buffer: [CChar]) {
self = String(decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self)
}
}
@@ -0,0 +1,229 @@
import AppKit
import Metrics
import SwiftUI
@MainActor
public final class AppController: NSObject, NSApplicationDelegate {
private let model = AppModel()
private var statusItem: NSStatusItem!
private var panel: StatusPanel!
private var settingsWindow: NSWindow?
private var workspaceObservers: [NSObjectProtocol] = []
public override init() {
super.init()
}
public func applicationDidFinishLaunching(_ notification: Notification) {
setUpStatusItem()
setUpPanel()
model.openSettings = { [weak self] in self?.showSettings() }
model.quit = { NSApp.terminate(nil) }
model.helper.refreshStatus()
model.helper.onReconnect = { [weak self] in
Task { [weak self] in
await self?.model.fans.reapply()
await self?.model.keepAwake.reapply()
}
}
observeWorkspace()
observeModel()
applyAppearance()
// 开发调试:启动后展开并固定面板,便于测量面板打开时的资源占用
if CommandLine.arguments.contains("--show-panel") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.togglePanel()
self?.panel.isPinned = true
}
}
let model = self.model
Task { [weak self] in
await model.hub.update(model.demand)
await model.hub.start { [weak self] snapshot in
model.handle(snapshot)
self?.refreshStatusItem()
}
}
}
public func applicationWillTerminate(_ notification: Notification) {
model.keepAwake.releaseForTermination()
if model.fans.mode != .automatic || model.keepAwake.lidClosedActive {
model.helper.restoreDefaultsSynchronously()
}
}
// MARK: 菜单栏
private func setUpStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
guard let button = statusItem.button else { return }
button.target = self
button.action = #selector(statusItemClicked(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
button.imagePosition = .imageOnly
refreshStatusItem()
}
private func refreshStatusItem() {
statusItem.button?.image = MenuBarRenderer.image(for: model)
}
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
if NSApp.currentEvent?.type == .rightMouseUp {
showContextMenu()
} else {
togglePanel()
}
}
private func togglePanel() {
guard let button = statusItem.button, let window = button.window else { return }
let frame = window.convertToScreen(button.convert(button.bounds, to: nil))
panel.toggle(below: frame, on: window.screen)
}
private func showContextMenu() {
panel.dismiss()
let menu = NSMenu()
menu.addItem(withTitle: "打开面板", action: #selector(openPanelFromMenu), keyEquivalent: "").target = self
menu.addItem(withTitle: "设置…", action: #selector(openSettingsFromMenu), keyEquivalent: ",").target = self
menu.addItem(.separator())
menu.addItem(withTitle: "退出 OpenStats", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
statusItem.menu = menu
statusItem.button?.performClick(nil)
statusItem.menu = nil
}
@objc private func openPanelFromMenu() { togglePanel() }
@objc private func openSettingsFromMenu() { showSettings() }
// MARK: 面板
private func setUpPanel() {
let model = self.model
panel = StatusPanel(content: { PanelRootView().environment(model) },
measuring: { PanelRootView().environment(model).environment(\.isSnapshot, true) })
panel.onVisibilityChange = { [weak self] visible in
guard let self else { return }
self.model.isPanelVisible = visible
self.statusItem.button?.highlight(visible)
if visible {
self.model.helper.refreshStatus()
// 首次采样到进程、电池等数据后内容会变高,稍后重新计算一次
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.panel.refreshHeight() }
}
}
}
// MARK: 设置窗口
private func showSettings() {
panel.dismiss()
if settingsWindow == nil {
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: DS.Size.settingsWidth, height: DS.Size.settingsHeight),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "OpenStats 设置"
window.isReleasedWhenClosed = false
window.contentView = NSHostingView(rootView: SettingsView().environment(model))
window.center()
settingsWindow = window
}
NSApp.activate()
settingsWindow?.makeKeyAndOrderFront(nil)
}
// MARK: 观察
/// 设置或面板状态变化时,更新采样范围并重绘菜单栏
private func observeModel() {
withObservationTracking {
_ = model.demand
_ = model.settings.menuBarItems
_ = model.settings.gaugeStyles
_ = model.settings.colorizeHighLoad
_ = model.settings.useFahrenheit
_ = model.keepAwake.isActive
_ = model.settings.appearance
_ = model.settings.panelTab
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
await self.model.hub.update(self.model.demand)
self.applyAppearance()
self.panel.refreshHeight()
self.refreshStatusItem()
self.observeModel()
}
}
}
private func applyAppearance() {
switch model.settings.appearance {
case .system: NSApp.appearance = nil
case .light: NSApp.appearance = NSAppearance(named: .aqua)
case .dark: NSApp.appearance = NSAppearance(named: .darkAqua)
}
}
/// 屏幕休眠、系统睡眠、切换用户时暂停采样;唤醒后重新下发风扇设置
private func observeWorkspace() {
let center = NSWorkspace.shared.notificationCenter
let pauses: [Notification.Name] = [NSWorkspace.screensDidSleepNotification,
NSWorkspace.willSleepNotification,
NSWorkspace.sessionDidResignActiveNotification]
let resumes: [Notification.Name] = [NSWorkspace.screensDidWakeNotification,
NSWorkspace.didWakeNotification,
NSWorkspace.sessionDidBecomeActiveNotification]
for name in pauses {
workspaceObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.panel.dismiss()
Task { await self.model.hub.setPaused(true) }
}
})
}
for name in resumes {
workspaceObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
Task {
await self.model.hub.setPaused(false)
await self.model.fans.reapply()
}
}
})
}
}
}
public enum OpenStatsApplication {
@MainActor
public static func main() {
let app = NSApplication.shared
if let index = CommandLine.arguments.firstIndex(of: "--snapshot") {
let path = CommandLine.arguments.dropFirst(index + 1).first ?? "snapshots"
app.setActivationPolicy(.prohibited)
Task {
await SnapshotRenderer.run(outputDirectory: URL(fileURLWithPath: path))
exit(0)
}
app.run()
return
}
let controller = AppController()
app.delegate = controller
app.setActivationPolicy(.accessory)
withExtendedLifetime(controller) {
app.run()
}
}
}
@@ -0,0 +1,292 @@
import AppKit
import Metrics
/// 菜单栏图标自绘:每次刷新只生成一张小图,避免在菜单栏里重建 SwiftUI 视图
@MainActor
enum MenuBarRenderer {
/// 菜单栏只有 22pt 高,字号与基线对齐 Stats 的迷你样式:标签 7pt 细体、数值 12pt 常规、网速两行 9pt 细体
private enum Metrics {
@MainActor static let labelFont = NSFont.systemFont(ofSize: 7, weight: .light)
@MainActor static let valueFont = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular)
@MainActor static let networkFont = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .light)
static let upperBaseline: CGFloat = 12 // 上行基线(距底部)
static let lowerBaseline: CGFloat = 1 // 下行基线
static let symbolSize: CGFloat = 11
static let itemGap = DS.Space.s3
static let innerGap = DS.Space.s1
static let barCount = 10
static let barWidth: CGFloat = 2
static let barGap: CGFloat = 1
static let chartHeight: CGFloat = 16
static let smallChartHeight: CGFloat = 9
static let gaugeDiameter: CGFloat = 14
static let ringWidth: CGFloat = 2.5
static let networkDot: CGFloat = 6
static let trackAlpha: CGFloat = 0.25
static let highLoad = 0.85
}
private struct Segment {
let width: CGFloat
let draw: (NSRect) -> Void
}
static func image(for model: AppModel) -> NSImage {
let settings = model.settings
let height = NSStatusBar.system.thickness
var segments: [Segment] = []
if model.keepAwake.isActive {
segments.append(symbolSegment("cup.and.saucer.fill"))
}
var usesColor = false
for item in settings.orderedMenuBarItems {
let (segment, colored) = self.segment(for: item, model: model)
segments.append(segment)
usesColor = usesColor || colored
}
if segments.isEmpty {
segments.append(symbolSegment("waveform.path.ecg"))
}
let totalWidth = segments.reduce(0) { $0 + $1.width } + CGFloat(segments.count - 1) * Metrics.itemGap
let image = NSImage(size: NSSize(width: ceil(totalWidth), height: height), flipped: false) { rect in
var x: CGFloat = 0
for segment in segments {
segment.draw(NSRect(x: x, y: 0, width: segment.width, height: rect.height))
x += segment.width + Metrics.itemGap
}
return true
}
// 模板图自动适配浅色 / 深色菜单栏;含彩色元素时保留真实颜色,文字用 labelColor 跟随外观
image.isTemplate = !usesColor
image.accessibilityDescription = accessibilityText(for: model)
return image
}
/// 设置页预览用:按指定的浅色 / 深色菜单栏外观绘制
static func preview(_ image: NSImage, dark: Bool) -> NSImage {
let appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
return NSImage(size: image.size, flipped: false) { rect in
let draw = {
image.draw(in: rect)
if image.isTemplate {
(dark ? NSColor.white : NSColor.black).set()
rect.fill(using: .sourceAtop)
}
}
if let appearance { appearance.performAsCurrentDrawingAppearance(draw) } else { draw() }
return true
}
}
// MARK: 各指标
private static func segment(for item: MenuBarItem, model: AppModel) -> (Segment, colored: Bool) {
let store = model.store
let settings = model.settings
switch item {
case .cpu, .gpu, .memory:
let value: Double?
let history: [Double]
switch item {
case .cpu: (value, history) = (store.cpu?.total, store.cpuTotal.elements)
case .gpu: (value, history) = (store.gpu?.utilization, store.gpuHistory.elements)
default: (value, history) = (store.memory?.usedFraction, store.memoryHistory.elements)
}
let alert = settings.colorizeHighLoad && (value ?? 0) >= Metrics.highLoad
return (gaugeSegment(label: item.menuBarLabel, value: value, history: history,
style: settings.gaugeStyle(for: item), alert: alert), alert)
case .network:
return (networkSegment(store.network), true)
case .temperature:
let text = store.sensors?.temperature(.cpu).map { reading -> String in
let celsius = reading.maximum
let degrees = settings.useFahrenheit ? celsius * 9 / 5 + 32 : celsius
return "\(Int(degrees.rounded()))°"
} ?? "—"
return (stackedSegment(label: item.menuBarLabel, value: text, sample: "100°"), false)
case .fan:
let text = store.fastestFan.map { "\(Int($0.current))" } ?? "—"
return (stackedSegment(label: item.menuBarLabel, value: text, sample: "8888"), false)
}
}
private static func gaugeSegment(label: String, value: Double?, history: [Double], style: GaugeStyle, alert: Bool) -> Segment {
let text = value.map { Format.percent($0) } ?? "—"
let fraction = value ?? 0
let stacked = stackedSegment(label: label, value: text, sample: "100%", alert: alert)
switch style {
case .value:
return stacked
case .bars:
return barsWithLabel(label: label, history: history, alert: alert)
case .barsAndValue:
return combine([barsSegment(history: history, height: Metrics.chartHeight, alert: alert), stacked])
case .ring:
return combine([ringSegment(fraction: fraction, alert: alert), stacked])
case .pie:
return combine([pieSegment(fraction: fraction, alert: alert), stacked])
}
}
// MARK: 基础片段
private static func foreground(_ alert: Bool) -> NSColor {
alert ? .systemRed : .labelColor
}
/// 颜色必须保持动态(labelColor),在绘制时才按菜单栏外观解析;
/// 提前调用 withAlphaComponent 会按应用外观固定颜色,深色菜单栏上就会发黑
private static var labelAttributes: [NSAttributedString.Key: Any] {
[.font: Metrics.labelFont, .foregroundColor: NSColor.labelColor]
}
/// 上方小标签、下方数值,右对齐
private static func stackedSegment(label: String, value: String, sample: String, alert: Bool = false) -> Segment {
let labelAttributes = labelAttributes
let valueAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.valueFont, .foregroundColor: foreground(alert)]
let width = ceil([
(label as NSString).size(withAttributes: labelAttributes).width,
(value as NSString).size(withAttributes: valueAttributes).width,
(sample as NSString).size(withAttributes: valueAttributes).width,
].max() ?? 0)
return Segment(width: width) { rect in
drawText(label, attributes: labelAttributes, rightEdge: rect.maxX, baseline: rect.minY + Metrics.upperBaseline)
drawText(value, attributes: valueAttributes, rightEdge: rect.maxX, baseline: rect.minY + Metrics.lowerBaseline)
}
}
/// 以基线定位(与 Stats 相同的 draw(with:) 方式),右对齐
private static func drawText(_ text: String, attributes: [NSAttributedString.Key: Any],
rightEdge: CGFloat, baseline: CGFloat) {
let string = NSAttributedString(string: text, attributes: attributes)
let width = string.size().width
string.draw(with: NSRect(x: rightEdge - width, y: baseline, width: width, height: 0))
}
private static func barsSegment(history: [Double], height: CGFloat, alert: Bool) -> Segment {
let recent = Array(history.suffix(Metrics.barCount))
let padded = Array(repeating: 0, count: Metrics.barCount - recent.count) + recent
let width = CGFloat(Metrics.barCount) * Metrics.barWidth + CGFloat(Metrics.barCount - 1) * Metrics.barGap
return Segment(width: width) { rect in
let baseline = rect.midY - height / 2
for (index, value) in padded.enumerated() {
let x = rect.minX + CGFloat(index) * (Metrics.barWidth + Metrics.barGap)
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setFill()
NSBezierPath(rect: NSRect(x: x, y: baseline, width: Metrics.barWidth, height: height)).fill()
foreground(alert && value >= Metrics.highLoad).setFill()
NSBezierPath(rect: NSRect(x: x, y: baseline, width: Metrics.barWidth,
height: height * min(1, max(0, value)))).fill()
}
}
}
/// 柱状图模式:上方小标签,下方矮柱
private static func barsWithLabel(label: String, history: [Double], alert: Bool) -> Segment {
let labelAttributes = labelAttributes
let bars = barsSegment(history: history, height: Metrics.smallChartHeight, alert: alert)
let width = max(bars.width, ceil((label as NSString).size(withAttributes: labelAttributes).width))
return Segment(width: width) { rect in
drawText(label, attributes: labelAttributes, rightEdge: rect.maxX, baseline: rect.minY + Metrics.upperBaseline)
// 矮柱放在标签下方的区域内
let area = NSRect(x: rect.maxX - bars.width, y: rect.minY, width: bars.width,
height: Metrics.upperBaseline - 1)
bars.draw(area)
}
}
private static func ringSegment(fraction: Double, alert: Bool) -> Segment {
Segment(width: Metrics.gaugeDiameter) { rect in
let inset = Metrics.ringWidth / 2
let circle = NSRect(x: rect.minX + inset, y: rect.midY - Metrics.gaugeDiameter / 2 + inset,
width: Metrics.gaugeDiameter - Metrics.ringWidth,
height: Metrics.gaugeDiameter - Metrics.ringWidth)
let track = NSBezierPath(ovalIn: circle)
track.lineWidth = Metrics.ringWidth
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setStroke()
track.stroke()
let clamped = min(1, max(0, fraction))
guard clamped > 0 else { return }
let arc = NSBezierPath()
arc.appendArc(withCenter: NSPoint(x: circle.midX, y: circle.midY), radius: circle.width / 2,
startAngle: 90, endAngle: 90 - 360 * clamped, clockwise: true)
arc.lineWidth = Metrics.ringWidth
arc.lineCapStyle = .round
foreground(alert).setStroke()
arc.stroke()
}
}
private static func pieSegment(fraction: Double, alert: Bool) -> Segment {
Segment(width: Metrics.gaugeDiameter) { rect in
let circle = NSRect(x: rect.minX, y: rect.midY - Metrics.gaugeDiameter / 2,
width: Metrics.gaugeDiameter, height: Metrics.gaugeDiameter)
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setFill()
NSBezierPath(ovalIn: circle).fill()
let clamped = min(1, max(0, fraction))
guard clamped > 0 else { return }
let center = NSPoint(x: circle.midX, y: circle.midY)
let wedge = NSBezierPath()
wedge.move(to: center)
wedge.appendArc(withCenter: center, radius: circle.width / 2,
startAngle: 90, endAngle: 90 - 360 * clamped, clockwise: true)
wedge.close()
foreground(alert).setFill()
wedge.fill()
}
}
private static func symbolSegment(_ name: String) -> Segment {
let configuration = NSImage.SymbolConfiguration(pointSize: Metrics.symbolSize, weight: .medium)
.applying(.init(paletteColors: [.labelColor]))
let symbol = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
.withSymbolConfiguration(configuration)
let size = symbol?.size ?? NSSize(width: Metrics.symbolSize, height: Metrics.symbolSize)
return Segment(width: ceil(size.width)) { rect in
symbol?.draw(in: NSRect(x: rect.minX, y: rect.midY - size.height / 2, width: size.width, height: size.height))
}
}
/// 两行:上传(绿点)在上,下载(蓝点)在下,数值右对齐
private static func networkSegment(_ rate: NetworkRate?) -> Segment {
let attributes: [NSAttributedString.Key: Any] = [.font: Metrics.networkFont, .foregroundColor: NSColor.labelColor]
let rows: [(String, NSColor)] = [
(rate.map { Format.menuBarRate($0.uploadBytesPerSecond) } ?? "— KB/s", DS.NetworkPalette.upload),
(rate.map { Format.menuBarRate($0.downloadBytesPerSecond) } ?? "— KB/s", DS.NetworkPalette.download),
]
let sampleWidth = ("999 KB/s" as NSString).size(withAttributes: attributes).width
let textWidth = ceil(rows.map { ($0.0 as NSString).size(withAttributes: attributes).width }.reduce(sampleWidth, max))
let width = Metrics.networkDot + Metrics.innerGap + textWidth
return Segment(width: width) { rect in
let capHeight = Metrics.networkFont.capHeight
for (index, row) in rows.enumerated() {
let baseline = rect.minY + (index == 0 ? Metrics.upperBaseline : Metrics.lowerBaseline + 1)
row.1.setFill()
NSBezierPath(ovalIn: NSRect(x: rect.minX, y: baseline + capHeight / 2 - Metrics.networkDot / 2,
width: Metrics.networkDot, height: Metrics.networkDot)).fill()
drawText(row.0, attributes: attributes, rightEdge: rect.maxX, baseline: baseline)
}
}
}
private static func combine(_ parts: [Segment], gap: CGFloat = Metrics.innerGap) -> Segment {
let width = parts.reduce(0) { $0 + $1.width } + CGFloat(max(0, parts.count - 1)) * gap
return Segment(width: width) { rect in
var x = rect.minX
for part in parts {
part.draw(NSRect(x: x, y: rect.minY, width: part.width, height: rect.height))
x += part.width + gap
}
}
}
private static func accessibilityText(for model: AppModel) -> String {
let cpu = model.store.cpu.map { "CPU \(Format.percent($0.total))" } ?? "CPU"
return "OpenStats,\(cpu)"
}
}
@@ -0,0 +1,143 @@
import AppKit
import SwiftUI
/// 菜单栏下方的玻璃浮动面板。点击面板外部、按 Esc 或切换应用时关闭。
/// 高度按内容自动计算,屏幕放得下就不出现滚动。
@MainActor
final class StatusPanel: NSPanel {
var onVisibilityChange: ((Bool) -> Void)?
/// 开发调试用:固定面板,不因点击外部或失去焦点而收起
var isPinned = false
private var globalMonitor: Any?
private var localMonitor: Any?
private var anchor: NSRect = .zero
private weak var anchorScreen: NSScreen?
/// 面板隐藏时不保留 SwiftUI 视图,避免后台随数据刷新重绘
private let makeContent: () -> NSView
/// 平铺布局(不含滚动容器)的视图,只用来测量内容的自然高度
private let makeMeasuringContent: () -> NSView
init<Content: View, Measuring: View>(content: @escaping () -> Content, measuring: @escaping () -> Measuring) {
makeContent = { NSHostingView(rootView: content()) }
makeMeasuringContent = { NSHostingView(rootView: measuring()) }
super.init(contentRect: NSRect(x: 0, y: 0, width: DS.Size.panelWidth, height: DS.Size.panelMinHeight),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true)
isFloatingPanel = true
level = .statusBar
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
isOpaque = false
backgroundColor = .clear
hasShadow = true
hidesOnDeactivate = false
isReleasedWhenClosed = false
animationBehavior = .utilityWindow
isMovable = false
}
override var canBecomeKey: Bool { true }
func toggle(below anchor: NSRect, on screen: NSScreen?) {
isVisible ? dismiss() : present(below: anchor, on: screen)
}
func present(below anchor: NSRect, on screen: NSScreen?) {
self.anchor = anchor
anchorScreen = screen
contentView = GlassBackdrop.make(containing: makeContent(), cornerRadius: DS.Radius.xl)
setFrame(targetFrame(), display: false)
alphaValue = 0
makeKeyAndOrderFront(nil)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
animator().alphaValue = 1
}
installMonitors()
onVisibilityChange?(true)
}
/// 切换标签页或数据首次到达后重新计算高度,保持顶部对齐
func refreshHeight() {
guard isVisible else { return }
let frame = targetFrame()
guard abs(frame.height - self.frame.height) > 1 else { return }
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
animator().setFrame(frame, display: true)
}
}
func dismiss() {
guard isVisible, !isPinned else { return }
removeMonitors()
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.1
animator().alphaValue = 0
}, completionHandler: { [weak self] in
MainActor.assumeIsolated {
self?.orderOut(nil)
self?.contentView = nil
self?.alphaValue = 1
}
})
onVisibilityChange?(false)
}
override func resignKey() {
super.resignKey()
// 弹出菜单时面板也会失去 key,稍后确认没有其他 key 窗口再关闭
DispatchQueue.main.async { [weak self] in
MainActor.assumeIsolated {
guard let self, self.isVisible, !self.isKeyWindow, self.attachedSheet == nil else { return }
if NSApp.keyWindow?.level == .popUpMenu { return }
self.dismiss()
}
}
}
// MARK: 尺寸
private func targetFrame() -> NSRect {
let visible = (anchorScreen ?? NSScreen.main)?.visibleFrame ?? .zero
let margin = DS.Space.s2
let top = min(anchor.minY - DS.Size.panelGap, visible.maxY)
let available = max(DS.Size.panelMinHeight, top - visible.minY - margin)
let natural = makeMeasuringContent().fittingSize.height
let height = min(max(natural, DS.Size.panelMinHeight), available)
var x = anchor.midX - DS.Size.panelWidth / 2
x = min(max(x, visible.minX + margin), visible.maxX - DS.Size.panelWidth - margin)
return NSRect(x: x, y: top - height, width: DS.Size.panelWidth, height: height)
}
// MARK: 事件
private func installMonitors() {
removeMonitors()
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
MainActor.assumeIsolated { self?.dismiss() }
}
localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in
// Esc
if event.keyCode == 53, event.window === self {
MainActor.assumeIsolated { self?.dismiss() }
return nil
}
return event
}
}
private func removeMonitors() {
if let globalMonitor { NSEvent.removeMonitor(globalMonitor) }
if let localMonitor { NSEvent.removeMonitor(localMonitor) }
globalMonitor = nil
localMonitor = nil
}
}
@@ -0,0 +1,196 @@
import Metrics
import SwiftUI
// 面板每秒刷新,图表全部用 Canvas 直接绘制:比 Swift Charts 轻得多,也能被 ImageRenderer 截图。
// 刷新时不加隐式动画,避免每秒触发一次持续重绘。
/// 历史柱形图:每个采样一根柱子,空位画基线
struct BarHistoryChart: View {
let values: [Double]
var capacity: Int = 24
var color: Color = DS.Palette.primary
var warnColor: Color = DS.Palette.warning
var warnThreshold: Double = 0.8
var height: CGFloat = DS.Size.chartHeight
var body: some View {
Canvas { context, size in
let gap = DS.Space.s1 / 2
let barWidth = max(1, (size.width - gap * CGFloat(capacity - 1)) / CGFloat(capacity))
let recent = values.suffix(capacity)
let offset = capacity - recent.count
let baseline = DS.Size.stroke * 2
for index in 0..<capacity {
let x = CGFloat(index) * (barWidth + gap)
guard index >= offset else {
context.fill(Path(CGRect(x: x, y: size.height - baseline, width: barWidth, height: baseline)),
with: .color(DS.Palette.track))
continue
}
let value = min(1, max(0, recent[recent.startIndex + index - offset]))
let barHeight = max(baseline, size.height * value)
let rect = CGRect(x: x, y: size.height - barHeight, width: barWidth, height: barHeight)
let fill = value >= warnThreshold ? warnColor : color
context.fill(Path(roundedRect: rect, cornerRadius: min(DS.Radius.sm / 2, barWidth / 2)),
with: .color(value < 0.02 ? DS.Palette.neutral300 : fill))
}
}
.frame(height: height)
.accessibilityHidden(true)
}
}
/// 历史折线(可带实色半透明填充)
struct LineHistoryChart: View {
let values: [Double]
var capacity: Int = MetricsStore.historyCapacity
var maxValue: Double = 1
var color: Color = DS.Palette.primary
var filled = true
var height: CGFloat = DS.Size.chartHeight
var body: some View {
Canvas { context, size in
let line = LineHistoryChart.path(values: values, capacity: capacity, maxValue: maxValue, in: size)
guard let line else { return }
if filled {
var area = line
area.addLine(to: CGPoint(x: size.width, y: size.height))
area.addLine(to: CGPoint(x: area.boundingRect.minX, y: size.height))
area.closeSubpath()
context.fill(area, with: .color(color.opacity(0.14)))
}
context.stroke(line, with: .color(color),
style: StrokeStyle(lineWidth: DS.Size.chartLine, lineCap: .round, lineJoin: .round))
}
.frame(height: height)
.accessibilityHidden(true)
}
/// 右对齐:最新的点在最右侧
static func path(values: [Double], capacity: Int, maxValue: Double, in size: CGSize) -> Path? {
let recent = Array(values.suffix(capacity))
guard recent.count > 1 else { return nil }
let step = size.width / CGFloat(max(1, capacity - 1))
let inset = DS.Size.chartLine
let usable = size.height - inset * 2
let peak = max(maxValue, .leastNonzeroMagnitude)
var path = Path()
for (index, value) in recent.enumerated() {
let x = size.width - CGFloat(recent.count - 1 - index) * step
let y = inset + usable * (1 - CGFloat(min(1, max(0, value / peak))))
index == 0 ? path.move(to: CGPoint(x: x, y: y)) : path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
}
/// 上传 / 下载双折线,按两者峰值归一化
struct DualLineChart: View {
let upload: [Double]
let download: [Double]
var capacity: Int = MetricsStore.historyCapacity
var height: CGFloat = DS.Size.chartHeight
var body: some View {
Canvas { context, size in
let peak = max(upload.max() ?? 0, download.max() ?? 0, 64 * 1024)
let style = StrokeStyle(lineWidth: DS.Size.chartLine, lineCap: .round, lineJoin: .round)
let series: [([Double], Color)] = [(download, Color(nsColor: DS.NetworkPalette.download)),
(upload, Color(nsColor: DS.NetworkPalette.upload))]
for (values, color) in series {
guard let line = LineHistoryChart.path(values: values, capacity: capacity, maxValue: peak * 1.1, in: size) else { continue }
var area = line
area.addLine(to: CGPoint(x: size.width, y: size.height))
area.addLine(to: CGPoint(x: area.boundingRect.minX, y: size.height))
area.closeSubpath()
context.fill(area, with: .color(color.opacity(0.10)))
context.stroke(line, with: .color(color), style: style)
}
}
.frame(height: height)
.accessibilityHidden(true)
}
}
/// 环形进度(饼图风格),中间可放图标或文字
struct RingGauge<Center: View>: View {
let fraction: Double
var color: Color = DS.Palette.success
var lineWidth: CGFloat = DS.Space.s1 + DS.Space.s1 / 2
var size: CGFloat = DS.Space.s12 + DS.Space.s4
@ViewBuilder var center: Center
var body: some View {
ZStack {
Circle().stroke(DS.Palette.track, lineWidth: lineWidth)
Circle()
.trim(from: 0, to: min(1, max(0, fraction)))
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
center
}
.padding(lineWidth / 2)
.frame(width: size, height: size)
}
}
/// 每个核心一根竖条,按性能档分组(高性能档在左)
struct CoreClusterBars: View {
let topology: CPUTopology
let perCore: [Double]
var barHeight: CGFloat = DS.Size.coreBarHeight
private static let barGap = DS.Space.s1 / 2
private static let groupGap = DS.Space.s3
private static func barWidth(for width: CGFloat, clusters: [CPUCluster], total: Int) -> CGFloat {
let gaps = CGFloat(total - clusters.count) * barGap + CGFloat(max(0, clusters.count - 1)) * groupGap
return max(1, (width - gaps) / CGFloat(total))
}
var body: some View {
let clusters = topology.clusters.sorted { ($0.coreIndices.first ?? 0) > ($1.coreIndices.first ?? 0) }
let total = max(1, clusters.reduce(0) { $0 + $1.coreIndices.count })
VStack(alignment: .leading, spacing: DS.Space.s1) {
Canvas { context, size in
var x: CGFloat = 0
let barWidth = Self.barWidth(for: size.width, clusters: clusters, total: total)
for cluster in clusters {
for index in cluster.coreIndices {
let value = index < perCore.count ? min(1, max(0, perCore[index])) : 0
let track = CGRect(x: x, y: 0, width: barWidth, height: size.height)
context.fill(Path(roundedRect: track, cornerRadius: DS.Radius.sm / 2), with: .color(DS.Palette.track))
let fillHeight = size.height * value
let fill = CGRect(x: x, y: size.height - fillHeight, width: barWidth, height: fillHeight)
context.fill(Path(roundedRect: fill, cornerRadius: DS.Radius.sm / 2),
with: .color(value >= 0.8 ? DS.Palette.warning : DS.Palette.primary))
x += barWidth + Self.barGap
}
x += Self.groupGap - Self.barGap
}
}
.frame(height: barHeight)
// 标签宽度与上方柱组一致
GeometryReader { proxy in
let barWidth = Self.barWidth(for: proxy.size.width, clusters: clusters, total: total)
HStack(spacing: Self.groupGap) {
ForEach(clusters) { cluster in
let count = CGFloat(cluster.coreIndices.count)
Text(verbatim: "\(cluster.name) · \(cluster.coreIndices.count)")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
.frame(width: count * barWidth + (count - 1) * Self.barGap, alignment: .leading)
}
}
}
.frame(height: DS.TextSize.xs.rawValue * 1.5)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("各核心负载")
}
}
@@ -0,0 +1,654 @@
import Metrics
import SwiftUI
/// ImageRenderer 无法渲染 ScrollView 等 AppKit 承载的控件,截图模式下改用平铺布局。
extension EnvironmentValues {
@Entry var isSnapshot = false
}
// MARK: - 卡片
struct Card<Content: View>: View {
var padding: CGFloat = DS.Space.s4
var spacing: CGFloat = DS.Space.s3
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: spacing) {
content
}
.padding(padding)
// 在等高行里撑满高度,背景随之延伸
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke))
}
}
struct CardHeader<Trailing: View>: View {
let icon: String
let title: String
@ViewBuilder var trailing: Trailing
var body: some View {
HStack(spacing: DS.Space.s2) {
Image(systemName: icon)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
Text(title)
.dsFont(.sm, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Spacer(minLength: DS.Space.s2)
trailing
}
}
}
extension CardHeader where Trailing == Text {
init(icon: String, title: String, detail: String) {
self.icon = icon
self.title = title
self.trailing = Text(detail).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
extension CardHeader where Trailing == EmptyView {
init(icon: String, title: String) {
self.icon = icon
self.title = title
self.trailing = EmptyView()
}
}
struct HairlineDivider: View {
var body: some View {
Rectangle().fill(DS.Palette.border).frame(height: DS.Size.stroke)
}
}
// MARK: - 数值
struct BigValue: View {
let value: String
var unit: String?
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) {
Text(value)
.dsFont(.xxl, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
if let unit {
Text(unit).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
}
}
.lineLimit(1)
}
}
struct InlineStat: View {
let label: String
let value: String
var alignment: HorizontalAlignment = .leading
var body: some View {
VStack(alignment: alignment, spacing: 0) {
Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(value)
.dsFont(.sm, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
}
}
}
struct LegendItem: View {
let color: Color
let label: String
let value: String
var body: some View {
HStack(spacing: DS.Space.s1) {
Circle().fill(color).frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(value).dsFont(.xs, weight: .semibold).monospacedDigit().foregroundStyle(DS.Palette.textPrimary)
}
.lineLimit(1)
}
}
// MARK: - 进度条
struct ProgressTrack: View {
let fraction: Double
var color: Color = DS.Palette.primary
var height: CGFloat = DS.Size.barHeight
var body: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: DS.Radius.sm).fill(DS.Palette.track)
RoundedRectangle(cornerRadius: DS.Radius.sm)
.fill(color)
.frame(width: proxy.size.width * min(1, max(0, fraction)))
}
}
.frame(height: height)
}
}
struct StackedBar: View {
struct Segment: Identifiable {
let id: String
let fraction: Double
let color: Color
}
let segments: [Segment]
var body: some View {
GeometryReader { proxy in
HStack(spacing: 0) {
ForEach(segments) { segment in
Rectangle()
.fill(segment.color)
.frame(width: proxy.size.width * min(1, max(0, segment.fraction)))
}
Spacer(minLength: 0)
}
.background(DS.Palette.track)
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm))
}
.frame(height: DS.Size.barHeight)
}
}
// MARK: - 徽标
enum Tone {
case neutral, primary, success, warning, error
var color: Color {
switch self {
case .neutral: DS.Palette.textSecondary
case .primary: DS.Palette.primary
case .success: DS.Palette.success
case .warning: DS.Palette.warning
case .error: DS.Palette.error
}
}
static func forTemperature(_ celsius: Double) -> Tone {
celsius >= DS.Thermal.hot ? .error : celsius >= DS.Thermal.warm ? .warning : .primary
}
}
struct StatusBadge: View {
let text: String
var tone: Tone = .neutral
var body: some View {
HStack(spacing: DS.Space.s1) {
Circle().fill(tone.color).frame(width: DS.Space.s2 - DS.Space.s1 / 2, height: DS.Space.s2 - DS.Space.s1 / 2)
Text(text).dsFont(.xs, weight: .medium).foregroundStyle(tone.color)
}
.padding(.horizontal, DS.Space.s2)
.padding(.vertical, DS.Space.s1 / 2)
.background(tone.color.opacity(0.12), in: RoundedRectangle(cornerRadius: DS.Radius.sm))
.lineLimit(1)
}
}
// MARK: - 按钮
struct DSButtonStyle: ButtonStyle {
enum Kind { case primary, secondary, ghost }
var kind: Kind = .secondary
func makeBody(configuration: Configuration) -> some View {
DSButtonBody(configuration: configuration, kind: kind)
}
}
private struct DSButtonBody: View {
let configuration: ButtonStyle.Configuration
let kind: DSButtonStyle.Kind
@State private var hovering = false
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.dsFont(.sm, weight: .medium)
.foregroundStyle(foreground)
.padding(.horizontal, DS.Space.s3)
.frame(height: DS.Size.controlHeight)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay {
if kind == .secondary {
RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)
}
}
.opacity(isEnabled ? 1 : 0.5)
.contentShape(Rectangle())
.onHover { hovering = $0 }
.animation(DS.Motion.quick, value: hovering)
}
private var active: Bool { hovering || configuration.isPressed }
private var foreground: Color {
switch kind {
case .primary: DS.Palette.onPrimary
case .secondary: DS.Palette.textPrimary
case .ghost: DS.Palette.primary
}
}
private var background: Color {
switch kind {
case .primary: active ? DS.Palette.primaryHover : DS.Palette.primary
case .secondary: active ? DS.Palette.surfaceHover : DS.Palette.surface
case .ghost: active ? DS.Palette.surfaceHover : .clear
}
}
}
struct IconButton: View {
let systemName: String
let help: String
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.foregroundStyle(hovering ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight)
.dsGlass(in: Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.help(help)
.accessibilityLabel(help)
}
}
// MARK: - 分段控件
struct SegmentedControl<Value: Hashable>: View {
@Binding var selection: Value
let options: [(value: Value, title: String)]
var body: some View {
HStack(spacing: 0) {
ForEach(options, id: \.value) { option in
let selected = option.value == selection
Button {
selection = option.value
} label: {
Text(option.title)
.dsFont(.sm, weight: selected ? .semibold : .regular)
.foregroundStyle(selected ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.lineLimit(1)
.frame(maxWidth: .infinity)
.frame(height: DS.Size.segmentHeight)
.background {
if selected {
RoundedRectangle(cornerRadius: DS.Radius.md - DS.Space.s1)
.fill(DS.Palette.surface)
.dsShadow(DS.Shadow.level1)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
.padding(DS.Space.s1)
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.animation(DS.Motion.quick, value: selection)
}
}
// MARK: - 开关
struct DSToggle: View {
@Binding var isOn: Bool
var label: String = ""
var body: some View {
Button {
isOn.toggle()
} label: {
ZStack(alignment: isOn ? .trailing : .leading) {
Capsule().fill(isOn ? DS.Palette.primary : DS.Palette.neutral300)
Circle()
.fill(DS.Palette.onPrimary)
.frame(width: DS.Size.switchKnob, height: DS.Size.switchKnob)
.dsShadow(DS.Shadow.level1)
.padding(DS.Space.s1)
}
.frame(width: DS.Size.switchWidth, height: DS.Size.switchHeight)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.animation(DS.Motion.quick, value: isOn)
.accessibilityLabel(label)
.accessibilityValue(isOn ? "开" : "关")
}
}
// MARK: - 单选行
struct RadioRow: View {
let title: String
let subtitle: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(alignment: .top, spacing: DS.Space.s3) {
ZStack {
Circle()
.strokeBorder(isSelected ? DS.Palette.primary : DS.Palette.neutral300, lineWidth: DS.Size.chartLine)
if isSelected {
Circle().fill(DS.Palette.primary).frame(width: DS.Space.s2, height: DS.Space.s2)
}
}
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
.padding(.top, DS.Space.s1 / 2)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(title).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
Text(subtitle).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
// MARK: - 设置行
struct SettingRow<Control: View>: View {
let title: String
var subtitle: String?
var icon: String?
@ViewBuilder var control: Control
var body: some View {
HStack(spacing: DS.Space.s3) {
if let icon {
Image(systemName: icon)
.font(.system(size: DS.TextSize.base.rawValue))
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone)
}
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(title).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
if let subtitle {
Text(subtitle).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: DS.Space.s4)
control
}
}
}
// MARK: - 提示条
struct InfoBanner<Action: View>: View {
let icon: String
let text: String
var tone: Tone = .primary
@ViewBuilder var action: Action
var body: some View {
HStack(alignment: .center, spacing: DS.Space.s3) {
Image(systemName: icon)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
.foregroundStyle(tone.color)
.frame(width: DS.Size.iconInline)
Text(text)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
action
}
.padding(DS.Space.s3)
.background(tone.color.opacity(0.08), in: RoundedRectangle(cornerRadius: DS.Radius.md))
}
}
extension InfoBanner where Action == EmptyView {
init(icon: String, text: String, tone: Tone = .primary) {
self.init(icon: icon, text: text, tone: tone) { EmptyView() }
}
}
// MARK: - 滑块(自绘,保证截图与界面风格一致)
struct DSSlider: View {
@Binding var value: Double // 0...1
var onEditingEnded: () -> Void = {}
var body: some View {
GeometryReader { proxy in
let width = proxy.size.width
let knob = DS.Size.switchKnob
let x = (width - knob) * min(1, max(0, value))
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: DS.Radius.sm).fill(DS.Palette.track)
.frame(height: DS.Space.s1 + DS.Space.s1 / 2)
RoundedRectangle(cornerRadius: DS.Radius.sm).fill(DS.Palette.primary)
.frame(width: x + knob / 2, height: DS.Space.s1 + DS.Space.s1 / 2)
Circle()
.fill(DS.Palette.surface)
.overlay(Circle().strokeBorder(DS.Palette.primary, lineWidth: DS.Size.chartLine))
.frame(width: knob, height: knob)
.dsShadow(DS.Shadow.level1)
.offset(x: x)
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
value = min(1, max(0, (gesture.location.x - knob / 2) / max(1, width - knob)))
}
.onEnded { _ in onEditingEnded() }
)
}
.frame(height: DS.Size.segmentHeight)
.accessibilityElement()
.accessibilityValue(Format.percent(value))
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: value = min(1, value + 0.1)
case .decrement: value = max(0, value - 0.1)
@unknown default: break
}
onEditingEnded()
}
}
}
// MARK: - 滚动容器
struct PageScroll<Content: View>: View {
@Environment(\.isSnapshot) private var isSnapshot
@ViewBuilder var content: Content
var body: some View {
let stack = VStack(alignment: .leading, spacing: DS.Space.s3) { content }
.padding(DS.Space.s3)
if isSnapshot {
stack
} else {
ScrollView { stack }.scrollIndicators(.never)
}
}
}
// MARK: - 徽章 / 标签
struct Chip: View {
let text: String
var icon: String?
var tone: Tone = .neutral
var body: some View {
HStack(spacing: DS.Space.s1) {
if let icon {
Image(systemName: icon).font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
}
Text(verbatim: text).dsFont(.xs, weight: .medium).monospacedDigit()
}
.foregroundStyle(tone == .neutral ? DS.Palette.textSecondary : tone.color)
.padding(.horizontal, DS.Space.s2)
.padding(.vertical, DS.Space.s1 / 2)
.background(tone == .neutral ? DS.Palette.track : tone.color.opacity(0.12),
in: RoundedRectangle(cornerRadius: DS.Radius.sm))
.lineLimit(1)
.fixedSize()
}
}
/// 可点击的小标签,用于风扇模式等快捷切换
struct ChipButton: View {
let title: String
let isSelected: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
Text(title)
.dsFont(.xs, weight: isSelected ? .semibold : .medium)
.foregroundStyle(isSelected ? DS.Palette.primary : DS.Palette.textSecondary)
.lineLimit(1)
.frame(maxWidth: .infinity)
.frame(height: DS.Size.segmentHeight)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.sm)
.strokeBorder(isSelected ? DS.Palette.primary.opacity(0.4) : DS.Palette.border, lineWidth: DS.Size.stroke))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private var background: Color {
if isSelected { return DS.Palette.primary.opacity(0.12) }
return hovering ? DS.Palette.surfaceHover : .clear
}
}
/// 自动换行排列(徽章行)
struct FlowLayout: Layout {
var spacing: CGFloat = DS.Space.s2
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = arrange(subviews: subviews, maxWidth: proposal.width ?? .infinity)
let height = rows.last.map { $0.y + $0.height } ?? 0
let width = rows.map(\.width).max() ?? 0
return CGSize(width: proposal.width ?? width, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
for row in arrange(subviews: subviews, maxWidth: bounds.width) {
var x = bounds.minX
for index in row.indices {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(at: CGPoint(x: x, y: bounds.minY + row.y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
}
}
private struct Row {
var indices: [Int] = []
var y: CGFloat = 0
var width: CGFloat = 0
var height: CGFloat = 0
}
private func arrange(subviews: Subviews, maxWidth: CGFloat) -> [Row] {
var rows: [Row] = []
var current = Row()
for (index, subview) in subviews.enumerated() {
let size = subview.sizeThatFits(.unspecified)
let proposedWidth = current.indices.isEmpty ? size.width : current.width + spacing + size.width
if proposedWidth > maxWidth, !current.indices.isEmpty {
rows.append(current)
current = Row(y: current.y + current.height + spacing)
}
current.width = current.indices.isEmpty ? size.width : current.width + spacing + size.width
current.height = max(current.height, size.height)
current.indices.append(index)
}
if !current.indices.isEmpty { rows.append(current) }
return rows
}
}
// MARK: - 指标小卡片
struct MetricTile<Chart: View, Footer: View>: View {
let icon: String
let title: String
var chip: String?
var chipTone: Tone = .neutral
let value: String
var unit: String?
@ViewBuilder var chart: Chart
@ViewBuilder var footer: Footer
var body: some View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: icon)
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.textSecondary)
Text(title)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
Spacer(minLength: DS.Space.s1)
if let chip { Chip(text: chip, tone: chipTone) }
}
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) {
Text(verbatim: value)
.dsFont(.xl, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
if let unit {
Text(verbatim: unit).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
}
}
.lineLimit(1)
.minimumScaleFactor(0.8)
chart
.frame(height: DS.Size.tileChart)
footer
.font(.system(size: DS.TextSize.xs.rawValue))
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
.lineLimit(1)
}
}
}
@@ -0,0 +1,54 @@
import AppKit
import SwiftUI
// 液态玻璃:macOS 26 起使用系统 Liquid Glass,更早的系统退回毛玻璃材质。
// 截图模式无法捕获窗口背后的模糊,统一改用实色。
extension View {
@ViewBuilder
func dsGlass<S: Shape>(in shape: S, interactive: Bool = false, tint: Color? = nil) -> some View {
if #available(macOS 26.0, *) {
glassEffect(interactive ? Glass.regular.tint(tint).interactive() : Glass.regular.tint(tint), in: shape)
} else {
background(.ultraThinMaterial, in: shape)
}
}
}
/// 面板窗口的玻璃底板,SwiftUI 内容放在其中
@MainActor
enum GlassBackdrop {
static func make(containing content: NSView, cornerRadius: CGFloat) -> NSView {
if #available(macOS 26.0, *) {
let glass = NSGlassEffectView()
glass.cornerRadius = cornerRadius
glass.style = .regular
glass.contentView = content
return glass
}
let effect = NSVisualEffectView()
effect.material = .popover
effect.blendingMode = .behindWindow
effect.state = .active
effect.wantsLayer = true
effect.layer?.cornerRadius = cornerRadius
effect.layer?.masksToBounds = true
content.frame = effect.bounds
content.autoresizingMask = [.width, .height]
effect.addSubview(content)
return effect
}
}
/// 设置窗口侧边栏的半透明背景
struct SidebarMaterial: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = .sidebar
view.blendingMode = .behindWindow
view.state = .followsWindowActiveState
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
@@ -0,0 +1,166 @@
import AppKit
import SwiftUI
/// 设计 token。界面里的颜色、字号、间距、圆角一律从这里取值。
public enum DS {}
// MARK: - 颜色
extension DS {
enum Palette {
// 品牌色(单个视图内不超过 3 种)
static let primary = Color.dynamic(light: 0x2563EB, dark: 0x3B82F6)
static let primaryHover = Color.dynamic(light: 0x1D4ED8, dark: 0x2563EB) // 加深约 10%
static let secondary = Color.dynamic(light: 0x0D9488, dark: 0x2DD4BF)
// 语义色
static let success = Color.dynamic(light: 0x16A34A, dark: 0x22C55E)
static let warning = Color.dynamic(light: 0xD97706, dark: 0xF59E0B)
static let error = Color.dynamic(light: 0xDC2626, dark: 0xF87171)
// 文字
static let textPrimary = Color.dynamic(light: 0x111827, dark: 0xF3F4F6)
static let textSecondary = Color.dynamic(light: 0x6B7280, dark: 0x9CA3AF)
static let textTertiary = Color.dynamic(light: 0x9CA3AF, dark: 0x6B7280)
static let onPrimary = Color.dynamic(light: 0xFFFFFF, dark: 0xFFFFFF)
// 中性色(背景为实色,截图与不支持玻璃效果时使用)
static let background = Color.dynamic(light: 0xF8F9FA, dark: 0x111315)
// 卡片与控件使用半透明色,叠在液态玻璃上仍能透出背景
static let surface = Color.dynamic(light: 0xFFFFFF, lightAlpha: 0.72, dark: 0x2A2D33, darkAlpha: 0.55)
static let surfaceHover = Color.dynamic(light: 0x000000, lightAlpha: 0.05, dark: 0xFFFFFF, darkAlpha: 0.07)
static let border = Color.dynamic(light: 0x000000, lightAlpha: 0.07, dark: 0xFFFFFF, darkAlpha: 0.09)
static let track = Color.dynamic(light: 0x000000, lightAlpha: 0.06, dark: 0xFFFFFF, darkAlpha: 0.10)
static let neutral300 = Color.dynamic(light: 0xD1D5DB, dark: 0x3F434A)
static let neutral400 = Color.dynamic(light: 0x9CA3AF, dark: 0x6B7280)
}
}
extension DS {
/// 网速配色:上传绿、下载蓝,面板与菜单栏保持一致
enum NetworkPalette {
static let upload = NSColor.dynamic(light: 0x16A34A, dark: 0x22C55E)
static let download = NSColor.dynamic(light: 0x2563EB, dark: 0x3B82F6)
}
}
extension Color {
static func dynamic(light: UInt32, lightAlpha: CGFloat = 1, dark: UInt32, darkAlpha: CGFloat = 1) -> Color {
Color(nsColor: .dynamic(light: light, lightAlpha: lightAlpha, dark: dark, darkAlpha: darkAlpha))
}
}
extension NSColor {
static func dynamic(light: UInt32, lightAlpha: CGFloat = 1, dark: UInt32, darkAlpha: CGFloat = 1) -> NSColor {
NSColor(name: nil) { appearance in
let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
return NSColor(hex: isDark ? dark : light, alpha: isDark ? darkAlpha : lightAlpha)
}
}
}
extension NSColor {
convenience init(hex: UInt32, alpha: CGFloat = 1) {
self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: alpha)
}
}
// MARK: - 字号 / 间距 / 圆角 / 尺寸
extension DS {
enum TextSize: CGFloat {
case xs = 12, sm = 14, base = 16, lg = 20, xl = 24, xxl = 32
}
enum Space {
static let s1: CGFloat = 4
static let s2: CGFloat = 8
static let s3: CGFloat = 12
static let s4: CGFloat = 16
static let s6: CGFloat = 24
static let s8: CGFloat = 32
static let s12: CGFloat = 48
static let s16: CGFloat = 64
}
/// 层级:控件 < 卡片 < 面板/窗口
enum Radius {
static let sm: CGFloat = 4
static let md: CGFloat = 8
static let lg: CGFloat = 12
static let xl: CGFloat = 16 // 玻璃面板
}
enum Size {
static let iconInline: CGFloat = 16
static let iconStandalone: CGFloat = 20
static let controlHeight: CGFloat = 32
static let segmentHeight: CGFloat = 24
static let switchWidth: CGFloat = 40
static let switchHeight: CGFloat = 24
static let switchKnob: CGFloat = 16
static let barHeight: CGFloat = 8
static let coreBarHeight: CGFloat = 32
static let chartHeight: CGFloat = 48
static let tileChart: CGFloat = 40
static let stroke: CGFloat = 1
static let chartLine: CGFloat = 1.5
static let panelWidth: CGFloat = 640
static let panelMinHeight: CGFloat = 320
static let panelGap: CGFloat = 4
static let tabsWidth: CGFloat = 280
/// 三列卡片的单列宽度:(面板宽 - 两侧内边距 - 两个列间距) / 3
static let tileWidth: CGFloat = (panelWidth - Space.s3 * 4) / 3
static let settingsWidth: CGFloat = 720
static let settingsHeight: CGFloat = 520
static let settingsSidebar: CGFloat = 192
static let valueColumn: CGFloat = 64
static let labelColumn: CGFloat = 48
}
enum Shadow {
struct Level {
let color: Color
let radius: CGFloat
let y: CGFloat
}
static let level1 = Level(color: .black.opacity(0.08), radius: 3, y: 1)
static let level2 = Level(color: .black.opacity(0.10), radius: 12, y: 4)
}
enum Motion {
static let quick = Animation.easeOut(duration: 0.15)
}
/// 温度阈值(°C),用于着色
enum Thermal {
static let warm: Double = 80
static let hot: Double = 95
static let scaleMax: Double = 110
}
}
// MARK: - 修饰器
extension View {
func dsFont(_ size: DS.TextSize, weight: Font.Weight = .regular) -> some View {
font(.system(size: size.rawValue, weight: weight))
}
}
extension Text {
func dsFont(_ size: DS.TextSize, weight: Font.Weight = .regular) -> Text {
font(.system(size: size.rawValue, weight: weight))
}
}
extension View {
func dsShadow(_ level: DS.Shadow.Level) -> some View {
shadow(color: level.color, radius: level.radius, x: 0, y: level.y)
}
}
@@ -0,0 +1,144 @@
import Metrics
import SwiftUI
struct KeepAwakePage: View {
@Environment(AppModel.self) private var model
var body: some View {
PageScroll {
HeroCard()
HStack(alignment: .top, spacing: DS.Space.s3) {
VStack(spacing: DS.Space.s3) {
ModeCard()
DurationCard()
}
.frame(maxWidth: .infinity)
LidCard().frame(maxWidth: .infinity)
}
.fixedSize(horizontal: false, vertical: true)
}
}
}
private struct HeroCard: View {
@Environment(AppModel.self) private var model
var body: some View {
let keepAwake = model.keepAwake
Card {
HStack(spacing: DS.Space.s3) {
RoundedRectangle(cornerRadius: DS.Radius.md)
.fill(keepAwake.isActive ? DS.Palette.primary : DS.Palette.track)
.frame(width: DS.Space.s12 - DS.Space.s2, height: DS.Space.s12 - DS.Space.s2)
.overlay {
Image(systemName: keepAwake.isActive ? "cup.and.saucer.fill" : "cup.and.saucer")
.font(.system(size: DS.TextSize.lg.rawValue, weight: .medium))
.foregroundStyle(keepAwake.isActive ? DS.Palette.onPrimary : DS.Palette.textSecondary)
}
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text("防休眠")
.dsFont(.lg, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
StatusLine()
}
Spacer()
DSToggle(isOn: Binding(get: { keepAwake.isActive },
set: { value in Task { await keepAwake.setActive(value) } }),
label: "防休眠")
}
if let notice = keepAwake.notice {
InfoBanner(icon: keepAwake.noticeIsError ? "exclamationmark.triangle.fill" : "info.circle.fill",
text: notice,
tone: keepAwake.noticeIsError ? .error : .primary)
}
}
}
}
private struct StatusLine: View {
@Environment(AppModel.self) private var model
var body: some View {
let keepAwake = model.keepAwake
TimelineView(.periodic(from: .now, by: 30)) { context in
Text(text(now: context.date))
.dsFont(.sm)
.foregroundStyle(keepAwake.isActive ? DS.Palette.primary : DS.Palette.textSecondary)
.monospacedDigit()
}
}
private func text(now: Date) -> String {
let keepAwake = model.keepAwake
guard keepAwake.isActive else { return "未开启,Mac 按系统设置休眠" }
var parts = [keepAwake.mode.title]
if keepAwake.lidClosedActive { parts.append("合盖运行") }
if let end = keepAwake.endDate {
let minutes = max(1, Int(ceil(end.timeIntervalSince(now) / 60)))
parts.append("剩余 \(Format.duration(minutes: minutes))")
} else {
parts.append("不限时")
}
return parts.joined(separator: " · ")
}
}
private struct ModeCard: View {
@Environment(AppModel.self) private var model
var body: some View {
Card {
Text("模式").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
ForEach(Array(KeepAwakeController.Mode.allCases.enumerated()), id: \.element) { index, mode in
if index > 0 { HairlineDivider() }
RadioRow(title: mode.title, subtitle: mode.subtitle, isSelected: model.keepAwake.mode == mode) {
model.keepAwake.setMode(mode)
}
}
}
}
}
private struct LidCard: View {
@Environment(AppModel.self) private var model
var body: some View {
let keepAwake = model.keepAwake
Card {
SettingRow(title: "合盖后继续运行",
subtitle: "合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。") {
DSToggle(isOn: Binding(get: { keepAwake.lidClosedRequested },
set: { value in Task { await keepAwake.setLidClosed(value) } }),
label: "合盖后继续运行")
.disabled(!model.helper.isReady)
}
if !model.helper.isReady {
HelperRequiredBanner(text: "合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。")
}
InfoBanner(icon: "exclamationmark.triangle",
text: "合盖运行时散热变差,请勿放入包中。使用电池且电量低于 \(model.settings.lidModeBatteryFloor)% 时会自动关闭。",
tone: .warning)
}
}
}
private struct DurationCard: View {
@Environment(AppModel.self) private var model
var body: some View {
Card {
Text("持续时间").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
SegmentedControl(selection: Binding(get: { model.keepAwake.duration },
set: { model.keepAwake.setDuration($0) }),
options: KeepAwakeController.Duration.allCases.map { ($0, $0.title) })
Text("到时后自动恢复系统默认的睡眠行为。")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
}
}
@@ -0,0 +1,556 @@
import AppKit
import Metrics
import SMC
import SwiftUI
struct OverviewPage: View {
@Environment(AppModel.self) private var model
var body: some View {
PageScroll {
HealthHeader()
EqualHeightRow {
CPUTile().frame(maxWidth: .infinity)
GPUTile().frame(maxWidth: .infinity)
MemoryTile().frame(maxWidth: .infinity)
}
EqualHeightRow {
DiskTile().frame(maxWidth: .infinity)
NetworkTile().frame(maxWidth: .infinity)
FanTile().frame(maxWidth: .infinity)
}
EqualHeightRow {
CoreLoadCard().frame(maxWidth: .infinity)
if model.store.battery != nil {
BatteryCard().frame(width: DS.Size.tileWidth)
}
}
EqualHeightRow {
TopProcessesCard().frame(maxWidth: .infinity)
QuickActionsCard().frame(width: DS.Size.tileWidth)
}
}
}
}
/// 一行等高卡片
private struct EqualHeightRow<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
HStack(alignment: .top, spacing: DS.Space.s3) { content }
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - 健康度与徽章
struct HealthReport {
let score: Int
let summary: String
let tone: Tone
@MainActor
init(store: MetricsStore) {
var issues: [(penalty: Int, text: String)] = []
if let cpu = store.cpu {
if cpu.total >= 0.9 { issues.append((20, "CPU 负载很高")) } else if cpu.total >= 0.75 { issues.append((10, "CPU 负载偏高")) }
}
switch store.memory?.pressure {
case .critical: issues.append((30, "内存压力严重"))
case .warning: issues.append((15, "内存压力偏高"))
default: break
}
if let hottest = store.sensors?.temperature(.cpu)?.maximum {
if hottest >= DS.Thermal.hot { issues.append((20, "CPU 温度过高")) } else if hottest >= 85 { issues.append((10, "CPU 温度偏高")) }
}
if let disk = store.disk {
if disk.usedFraction >= 0.95 { issues.append((15, "磁盘空间不足")) } else if disk.usedFraction >= 0.9 { issues.append((8, "磁盘空间偏紧")) }
}
if let health = store.battery?.health, health < 0.8 { issues.append((5, "电池健康度下降")) }
score = max(0, 100 - issues.reduce(0) { $0 + $1.penalty })
summary = issues.max { $0.penalty < $1.penalty }?.text ?? "各项指标正常"
tone = score >= 85 ? .success : score >= 60 ? .warning : .error
}
}
private struct HealthHeader: View {
@Environment(AppModel.self) private var model
var body: some View {
let store = model.store
let report = HealthReport(store: store)
VStack(alignment: .leading, spacing: DS.Space.s3) {
HStack(alignment: .center, spacing: DS.Space.s3) {
Image(systemName: report.tone == .success ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
.font(.system(size: DS.TextSize.xl.rawValue, weight: .semibold))
.foregroundStyle(report.tone.color)
Text(verbatim: "\(report.score)")
.dsFont(.xxl, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
Text(report.summary)
.dsFont(.base, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
Spacer()
}
FlowLayout(spacing: DS.Space.s2) {
Chip(text: chipName(store.topology.brand), icon: "apple.logo")
Chip(text: Format.bytes(ProcessInfo.processInfo.physicalMemory).replacingOccurrences(of: ".0 ", with: " "))
Chip(text: store.system.osVersion)
if let boot = store.system.bootDate {
Chip(text: "已运行 " + Format.uptime(since: boot))
}
Chip(text: shortModel(store.system.modelName))
}
}
.padding(.horizontal, DS.Space.s1)
}
/// “Apple M5 Max” → “M5 Max”,前面配 Apple 标志
private func chipName(_ brand: String) -> String {
brand.hasPrefix("Apple ") ? String(brand.dropFirst("Apple ".count)) : brand
}
/// “MacBook Pro (16-inch, M5 Max)” → “MacBook Pro 16 英寸”
private func shortModel(_ name: String) -> String {
guard let open = name.firstIndex(of: "(") else { return name }
let base = name[..<open].trimmingCharacters(in: .whitespaces)
let details = name[open...].dropFirst().split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
if let inch = details.first(where: { $0.hasSuffix("-inch") }) {
return "\(base) \(inch.dropLast("-inch".count)) 英寸"
}
return base
}
}
// MARK: - 指标卡片
private func loadLevel(_ value: Double) -> String {
value < 0.3 ? "低负载" : value < 0.7 ? "中等负载" : "高负载"
}
private func splitRate(_ text: String) -> (String, String) {
let parts = text.split(separator: " ", maxSplits: 1).map(String.init)
return (parts.first ?? text, parts.count > 1 ? parts[1] : "")
}
private struct CPUTile: View {
@Environment(AppModel.self) private var model
var body: some View {
let store = model.store
let total = store.cpu?.total ?? 0
let temperature = store.sensors?.temperature(.cpu)
let load = store.cpu?.loadAverage.first.map { $0.formatted(.number.precision(.fractionLength(1))) } ?? "—"
MetricTile(icon: "cpu", title: "CPU",
chip: temperature.map { Format.temperature($0.maximum, fahrenheit: model.settings.useFahrenheit) },
chipTone: temperature.map { Tone.forTemperature($0.maximum) } ?? .neutral,
value: store.cpu == nil ? "—" : "\(Int((total * 100).rounded()))", unit: "%") {
BarHistoryChart(values: store.cpuTotal.elements, capacity: 20, height: DS.Size.tileChart)
} footer: {
Text(verbatim: "\(loadLevel(total)) · 负载 \(load)/\(store.topology.logicalCores)")
}
}
}
private struct GPUTile: View {
@Environment(AppModel.self) private var model
var body: some View {
let store = model.store
let utilization = store.gpu?.utilization ?? 0
let temperature = store.sensors?.temperature(.gpu)
MetricTile(icon: "display", title: "GPU",
chip: temperature.map { Format.temperature($0.maximum, fahrenheit: model.settings.useFahrenheit) },
chipTone: temperature.map { Tone.forTemperature($0.maximum) } ?? .neutral,
value: store.gpu == nil ? "—" : "\(Int((utilization * 100).rounded()))", unit: "%") {
LineHistoryChart(values: store.gpuHistory.elements, capacity: 30, color: DS.Palette.secondary, height: DS.Size.tileChart)
} footer: {
Text(verbatim: [loadLevel(utilization), store.gpu?.coreCount.map { "\($0) 核" }].compactMap { $0 }.joined(separator: " · "))
}
}
}
private struct MemoryTile: View {
@Environment(AppModel.self) private var model
var body: some View {
let memory = model.store.memory
MetricTile(icon: "memorychip", title: "内存",
chip: memory.map { "压力 \($0.pressure.title)" },
chipTone: memory.map { tone($0.pressure) } ?? .neutral,
value: memory.map { "\(Int(($0.usedFraction * 100).rounded()))" } ?? "—", unit: "%") {
LineHistoryChart(values: model.store.memoryHistory.elements, capacity: 30, height: DS.Size.tileChart)
} footer: {
Text(verbatim: memory.map { "\(Format.bytes($0.used)) / \(Format.bytes($0.total))" } ?? "—")
}
}
private func tone(_ pressure: MemoryPressure) -> Tone {
switch pressure {
case .normal: .success
case .warning: .warning
case .critical: .error
}
}
}
private struct DiskTile: View {
@Environment(AppModel.self) private var model
var body: some View {
let disk = model.store.disk
MetricTile(icon: "internaldrive", title: "磁盘",
chip: disk.map { Format.bytes($0.total, base: .decimal).replacingOccurrences(of: ".0 ", with: " ") },
value: disk.map { "\(Int(($0.usedFraction * 100).rounded()))" } ?? "—", unit: "%") {
VStack {
Spacer()
ProgressTrack(fraction: disk?.usedFraction ?? 0,
color: (disk?.usedFraction ?? 0) > 0.9 ? DS.Palette.warning : DS.Palette.primary,
height: DS.Space.s3)
Spacer()
}
} footer: {
Text(verbatim: disk.map { "可用 \(Format.bytes($0.available, base: .decimal))" } ?? "—")
}
}
}
private struct NetworkTile: View {
@Environment(AppModel.self) private var model
var body: some View {
let store = model.store
let rate = store.network
let total = splitRate(Format.menuBarRate((rate?.downloadBytesPerSecond ?? 0) + (rate?.uploadBytesPerSecond ?? 0)))
MetricTile(icon: "network", title: "网络",
chip: store.networkInterface.map { shortInterface($0) },
value: rate == nil ? "—" : total.0, unit: rate == nil ? nil : total.1) {
DualLineChart(upload: store.uploadHistory.elements, download: store.downloadHistory.elements,
capacity: 30, height: DS.Size.tileChart)
} footer: {
HStack(spacing: DS.Space.s1) {
Image(systemName: "arrow.up").foregroundStyle(Color(nsColor: DS.NetworkPalette.upload))
Text(verbatim: rate.map { Format.menuBarRate($0.uploadBytesPerSecond) } ?? "—")
Image(systemName: "arrow.down").foregroundStyle(Color(nsColor: DS.NetworkPalette.download))
Text(verbatim: rate.map { Format.menuBarRate($0.downloadBytesPerSecond) } ?? "—")
}
.fontWeight(.medium)
}
}
private func shortInterface(_ info: NetworkInterfaceInfo) -> String {
info.displayName == "VPN 隧道" ? "VPN" : info.displayName
}
}
private struct FanTile: View {
@Environment(AppModel.self) private var model
var body: some View {
let fans = model.store.sensors?.fans ?? []
let fastest = model.store.fastestFan
let isManual = fans.contains(where: \.isManual)
// 以最高转速为 100%,比“最低到最高”的区间比例更直观
let average = fans.isEmpty ? 0 : fans.map { $0.maximum > 0 ? $0.current / $0.maximum : 0 }.reduce(0, +) / Double(fans.count)
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "fan").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("风扇").dsFont(.xs, weight: .semibold)
Spacer(minLength: DS.Space.s1)
if !fans.isEmpty { Chip(text: "转速 \(Format.percent(average))") }
}
.foregroundStyle(DS.Palette.textSecondary)
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) {
Text(verbatim: fastest.map { Int($0.current).formatted() } ?? "—")
.dsFont(.xl, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "RPM").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
}
Text(fanStatus(fans: fans, isManual: isManual))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.frame(height: DS.Size.tileChart / 2, alignment: .center)
HStack(spacing: DS.Space.s1) {
ForEach([FanController.Mode.automatic, .cooling, .maximum]) { mode in
ChipButton(title: mode.title, isSelected: model.fans.mode == mode) {
model.requestFanMode(mode)
}
}
}
.disabled(fans.isEmpty)
}
}
}
extension FanTile {
/// 固件只记录“手动 / 自动”,无法区分是谁设置的:OpenStats 未下发时即为其他程序(如 Stats、Mole)
fileprivate func fanStatus(fans: [FanState], isManual: Bool) -> String {
if fans.isEmpty { return "未检测到风扇" }
guard isManual else { return "由 macOS 调节" }
return model.fans.mode == .automatic ? "其他程序手动控制" : "OpenStats 控制中"
}
}
// MARK: - 核心负载
private struct CoreLoadCard: View {
@Environment(AppModel.self) private var model
var body: some View {
let cpu = model.store.cpu
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "square.grid.3x3.fill").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("核心负载").dsFont(.xs, weight: .semibold)
Spacer()
LegendItem(color: DS.Palette.primary, label: "用户", value: cpu.map { Format.percent($0.user) } ?? "—")
LegendItem(color: DS.Palette.secondary, label: "系统", value: cpu.map { Format.percent($0.system) } ?? "—")
}
.foregroundStyle(DS.Palette.textSecondary)
CoreClusterBars(topology: model.store.topology, perCore: cpu?.perCore ?? [], barHeight: DS.Space.s12 + DS.Space.s4)
}
}
}
// MARK: - 电池
private struct BatteryCard: View {
@Environment(AppModel.self) private var model
var body: some View {
if let battery = model.store.battery {
let tone: Tone = battery.level < 0.2 ? .error : battery.isCharging ? .primary : .success
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: battery.isCharging ? "battery.100.bolt" : "battery.75")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("电池").dsFont(.xs, weight: .semibold)
Spacer(minLength: DS.Space.s1)
if let health = battery.health {
Chip(text: "健康 \(Format.percent(health))", tone: health < 0.8 ? .warning : .neutral)
}
}
.foregroundStyle(DS.Palette.textSecondary)
HStack(alignment: .center, spacing: DS.Space.s2) {
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(verbatim: Format.percent(battery.level))
.dsFont(.xl, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
Text(stateText(battery))
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
if let watts = battery.adapterWatts, battery.isPluggedIn {
Label { Text(verbatim: "\(watts)W") } icon: { Image(systemName: "bolt.fill") }
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.warning)
}
}
Spacer(minLength: 0)
RingGauge(fraction: battery.level, color: tone.color, size: DS.Space.s12 + DS.Space.s2) {
Image(systemName: "laptopcomputer")
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.foregroundStyle(DS.Palette.textSecondary)
}
}
Text(verbatim: detailText(battery))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
}
}
}
private func stateText(_ battery: BatteryStatus) -> String {
if battery.isCharging { return "充电中" }
if battery.isPluggedIn { return battery.isFullyCharged ? "已充满" : "电源供电" }
return "电池供电"
}
private func detailText(_ battery: BatteryStatus) -> String {
var parts: [String] = []
if let minutes = battery.minutesRemaining {
parts.append(battery.isCharging ? "\(Format.duration(minutes: minutes))后充满" : "剩余 \(Format.duration(minutes: minutes))")
}
if let cycles = battery.cycleCount { parts.append("循环 \(cycles) 次") }
return parts.joined(separator: " · ")
}
}
// MARK: - 高占用进程
private struct TopProcessesCard: View {
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
var body: some View {
let processes = Array(model.store.processes.prefix(5))
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "chart.bar.fill").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("高占用进程").dsFont(.xs, weight: .semibold)
Spacer()
Text("CPU").dsFont(.xs, weight: .medium).frame(width: DS.Size.valueColumn, alignment: .trailing)
Text("内存").dsFont(.xs, weight: .medium).frame(width: DS.Size.valueColumn, alignment: .trailing)
Color.clear.frame(width: DS.Size.iconInline)
}
.foregroundStyle(DS.Palette.textSecondary)
if processes.isEmpty {
PlaceholderLine()
}
ForEach(processes) { process in
HStack(spacing: DS.Space.s2) {
AppIconCache.shared.image(for: process)
.resizable()
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
Text(process.displayName)
.dsFont(.sm, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
Text(verbatim: cpuText(process.cpu))
.dsFont(.sm, weight: .medium)
.foregroundStyle(cpuTone(process.cpu).color)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(verbatim: Format.bytes(process.memory))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
moreButton(process)
}
.monospacedDigit()
}
}
}
@ViewBuilder
private func moreButton(_ process: ProcessUsage) -> some View {
let icon = Image(systemName: "ellipsis")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .bold))
.foregroundStyle(DS.Palette.textTertiary)
.frame(width: DS.Size.iconInline)
if isSnapshot {
icon
} else {
Menu {
if let path = process.appBundlePath ?? process.executablePath {
Button("在访达中显示") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
Button("拷贝 PID \(String(process.pid))") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(process.pid), forType: .string)
}
Divider()
Button("查看全部进程") { model.settings.panelTab = .processes }
} label: {
icon
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: DS.Size.iconInline)
}
}
private func cpuText(_ value: Double) -> String {
"\((value * 100).formatted(.number.precision(.fractionLength(1))))%"
}
private func cpuTone(_ value: Double) -> Tone {
value >= 0.8 ? .error : value >= 0.5 ? .warning : .neutral
}
}
// MARK: - 快捷开关
private struct QuickActionsCard: View {
@Environment(AppModel.self) private var model
var body: some View {
let keepAwake = model.keepAwake
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "switch.2").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("快捷开关").dsFont(.xs, weight: .semibold)
}
.foregroundStyle(DS.Palette.textSecondary)
QuickAction(icon: "cup.and.saucer", activeIcon: "cup.and.saucer.fill", title: "防休眠",
detail: keepAwake.isActive ? "已开启" : "关闭", isActive: keepAwake.isActive) {
Task { await keepAwake.setActive(!keepAwake.isActive) }
}
QuickAction(icon: "laptopcomputer", activeIcon: "laptopcomputer", title: "合盖运行",
detail: keepAwake.lidClosedActive ? "已开启" : "关闭", isActive: keepAwake.lidClosedActive) {
model.requestLidMode(!keepAwake.lidClosedRequested)
}
QuickAction(icon: "fan", activeIcon: "fan.fill", title: "散热模式",
detail: model.fans.mode.title, isActive: model.fans.mode != .automatic) {
model.settings.panelTab = .thermal
}
}
}
}
private struct QuickAction: View {
let icon: String
let activeIcon: String
let title: String
let detail: String
let isActive: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
HStack(spacing: DS.Space.s2) {
Image(systemName: isActive ? activeIcon : icon)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.frame(width: DS.Size.iconStandalone)
Text(title).dsFont(.sm, weight: .medium).lineLimit(1)
Spacer(minLength: DS.Space.s1)
Text(detail).dsFont(.xs).foregroundStyle(isActive ? DS.Palette.primary : DS.Palette.textTertiary)
}
.foregroundStyle(isActive ? DS.Palette.primary : DS.Palette.textPrimary)
.padding(.horizontal, DS.Space.s2)
.frame(maxWidth: .infinity)
.frame(height: DS.Size.controlHeight)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.accessibilityAddTraits(isActive ? .isSelected : [])
}
private var background: Color {
if isActive { return DS.Palette.primary.opacity(0.12) }
return hovering ? DS.Palette.surfaceHover : DS.Palette.track
}
}
struct PlaceholderLine: View {
var body: some View {
RoundedRectangle(cornerRadius: DS.Radius.sm)
.fill(DS.Palette.track)
.frame(height: DS.Size.barHeight)
}
}
@@ -0,0 +1,112 @@
import Metrics
import SwiftUI
public struct PanelRootView: View {
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
public init() {}
public var body: some View {
VStack(spacing: 0) {
PanelHeader()
.padding(DS.Space.s3)
HairlineDivider()
Group {
switch model.settings.panelTab {
case .overview: OverviewPage()
case .processes: ProcessesPage()
case .thermal: ThermalPage()
case .keepAwake: KeepAwakePage()
}
}
.frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
}
.frame(width: DS.Size.panelWidth)
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.fixedSize(horizontal: false, vertical: isSnapshot)
.background {
// 实际面板由窗口的液态玻璃底板提供背景;平铺(测量 / 截图)模式使用实色
if isSnapshot {
RoundedRectangle(cornerRadius: DS.Radius.xl).fill(DS.Palette.background)
}
}
.overlay {
if isSnapshot {
RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)
}
}
}
}
private struct PanelHeader: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
HStack(spacing: DS.Space.s3) {
AppGlyph()
VStack(alignment: .leading, spacing: 0) {
Text("OpenStats")
.dsFont(.base, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Text("状态栏监控")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
.fixedSize()
Spacer(minLength: DS.Space.s3)
SegmentedControl(selection: $settings.panelTab,
options: PanelTab.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.tabsWidth)
Spacer(minLength: DS.Space.s3)
if model.keepAwake.isActive {
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.primary)
.help("防休眠已开启")
}
IconButton(systemName: "gearshape", help: "设置") { model.openSettings() }
IconButton(systemName: "power", help: "退出 OpenStats") { model.quit() }
}
}
}
struct AppGlyph: View {
var size: CGFloat = DS.Size.controlHeight
var body: some View {
RoundedRectangle(cornerRadius: DS.Radius.md)
.fill(DS.Palette.primary)
.frame(width: size, height: size)
.overlay {
PulseMark()
.stroke(DS.Palette.onPrimary,
style: StrokeStyle(lineWidth: size / DS.Space.s12 * DS.Space.s1, lineCap: .round, lineJoin: .round))
.padding(size * 0.22)
}
.accessibilityHidden(true)
}
}
/// 应用标志折线,与 scripts/make-icon.swift 中的点位一致
struct PulseMark: Shape {
static let points: [CGPoint] = [
CGPoint(x: 0.00, y: 0.56), CGPoint(x: 0.26, y: 0.56), CGPoint(x: 0.36, y: 0.30),
CGPoint(x: 0.50, y: 0.80), CGPoint(x: 0.62, y: 0.40), CGPoint(x: 0.70, y: 0.56),
CGPoint(x: 1.00, y: 0.56),
]
func path(in rect: CGRect) -> Path {
Path { path in
path.addLines(Self.points.map { CGPoint(x: rect.minX + $0.x * rect.width, y: rect.minY + $0.y * rect.height) })
}
}
}
@@ -0,0 +1,174 @@
import AppKit
import Metrics
import SwiftUI
struct ProcessesPage: View {
enum Sort: String, CaseIterable {
case cpu, memory
var title: String { self == .cpu ? "按 CPU" : "按内存" }
}
@Environment(AppModel.self) private var model
@State private var sort: Sort = .cpu
private let visibleCount = 15
var body: some View {
let processes = sorted(model.store.processes)
PageScroll {
HStack {
SegmentedControl(selection: $sort, options: Sort.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.tileWidth)
Spacer()
Text(verbatim: "占用最高的 \(visibleCount) 个")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
Card(padding: 0, spacing: 0) {
HStack(spacing: DS.Space.s3) {
Text("进程")
Spacer()
Text("CPU").frame(width: DS.Size.valueColumn, alignment: .trailing)
Text("内存").frame(width: DS.Size.valueColumn, alignment: .trailing)
}
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textTertiary)
.padding(.horizontal, DS.Space.s4)
.padding(.vertical, DS.Space.s2)
if processes.isEmpty {
HairlineDivider()
Text("正在读取进程…")
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity)
.padding(DS.Space.s6)
} else {
ForEach(processes.prefix(visibleCount)) { process in
HairlineDivider()
ProcessRow(process: process, emphasis: sort)
}
}
}
Text("仅列出当前用户有权限读取的进程,CPU 以单核满载为 100%。")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
}
private func sorted(_ processes: [ProcessUsage]) -> [ProcessUsage] {
switch sort {
case .cpu: processes
case .memory: processes.sorted { $0.memory > $1.memory }
}
}
}
private struct ProcessRow: View {
let process: ProcessUsage
let emphasis: ProcessesPage.Sort
@State private var hovering = false
@Environment(\.isSnapshot) private var isSnapshot
var body: some View {
HStack(spacing: DS.Space.s3) {
AppIconCache.shared.image(for: process)
.resizable()
.frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone)
VStack(alignment: .leading, spacing: 0) {
Text(title)
.dsFont(.sm)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Text(subtitle)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
}
Spacer(minLength: DS.Space.s2)
Text(cpuText)
.dsFont(.sm, weight: emphasis == .cpu ? .semibold : .regular)
.foregroundStyle(emphasis == .cpu ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(Format.bytes(process.memory))
.dsFont(.sm, weight: emphasis == .memory ? .semibold : .regular)
.foregroundStyle(emphasis == .memory ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
}
.monospacedDigit()
.padding(.horizontal, DS.Space.s4)
.padding(.vertical, DS.Space.s2)
.background(hovering ? DS.Palette.surfaceHover : .clear)
.onHover { hovering = $0 }
.contextMenu(isSnapshot ? nil : ContextMenu {
if let path = process.appBundlePath ?? process.executablePath {
Button("在访达中显示") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
Button("拷贝 PID") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(process.pid), forType: .string)
}
})
}
private var title: String { process.displayName }
private var subtitle: String {
let pid = "PID \(process.pid)"
guard let role = process.helperRole else { return pid }
return "\(role) · \(pid)"
}
private var cpuText: String {
let percent = process.cpu * 100
return percent < 0.05 ? "0%" : "\(percent.formatted(.number.precision(.fractionLength(1))))%"
}
}
/// 进程图标缓存:只保留 40px 小位图,避免每次刷新解码系统图标的全部分辨率
@MainActor
final class AppIconCache {
static let shared = AppIconCache()
private static let pixelSize: CGFloat = DS.Size.iconStandalone * 2
private static let limit = 128
private var cache: [String: CGImage] = [:]
func image(for process: ProcessUsage) -> Image {
let key = process.appBundlePath ?? "unix-executable"
if let cached = cache[key] { return Image(decorative: cached, scale: 2) }
let icon = process.appBundlePath.map { NSWorkspace.shared.icon(forFile: $0) }
?? NSWorkspace.shared.icon(for: .unixExecutable)
var rect = NSRect(x: 0, y: 0, width: Self.pixelSize, height: Self.pixelSize)
guard let cgImage = icon.cgImage(forProposedRect: &rect, context: nil, hints: nil) else {
return Image(systemName: "app")
}
if cache.count >= Self.limit { cache.removeAll() }
cache[key] = cgImage
return Image(decorative: cgImage, scale: 2)
}
}
extension ProcessUsage {
private var appName: String? {
appBundlePath.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
}
/// 应用的辅助进程显示为“主应用名”
var displayName: String {
guard let appName, name.hasPrefix(appName), name != appName else { return name }
return appName
}
/// 辅助进程的角色,例如 “Helper (Renderer)”
var helperRole: String? {
guard let appName, name.hasPrefix(appName), name != appName else { return nil }
let role = name.dropFirst(appName.count).trimmingCharacters(in: .whitespaces)
return role.isEmpty ? nil : role
}
}
@@ -0,0 +1,217 @@
import Metrics
import SMC
import SwiftUI
struct ThermalPage: View {
@Environment(AppModel.self) private var model
var body: some View {
PageScroll {
HStack(alignment: .top, spacing: DS.Space.s3) {
TemperatureCard().frame(maxWidth: .infinity)
FanCard().frame(maxWidth: .infinity)
}
.fixedSize(horizontal: false, vertical: true)
}
}
}
private struct TemperatureCard: View {
@Environment(AppModel.self) private var model
var body: some View {
let summaries = model.store.sensors?.temperatures ?? []
let sensorCount = summaries.reduce(0) { $0 + $1.sensorCount }
Card {
CardHeader(icon: "thermometer.medium", title: "温度",
detail: summaries.isEmpty ? "读取中" : "\(sensorCount) 个传感器")
if summaries.isEmpty {
PlaceholderLine()
} else {
ForEach(summaries) { summary in
TemperatureRow(summary: summary, fahrenheit: model.settings.useFahrenheit)
}
}
}
}
}
private struct TemperatureRow: View {
let summary: TemperatureSummary
let fahrenheit: Bool
var body: some View {
let tone = Tone.forTemperature(summary.maximum)
VStack(alignment: .leading, spacing: DS.Space.s1) {
HStack(spacing: DS.Space.s3) {
Text(summary.group.title)
.dsFont(.sm, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.frame(width: DS.Size.labelColumn, alignment: .leading)
ProgressTrack(fraction: summary.maximum / DS.Thermal.scaleMax, color: tone.color)
Text(Format.temperature(summary.maximum, fahrenheit: fahrenheit))
.dsFont(.sm, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
}
if summary.sensorCount > 1 {
Text("平均 \(Format.temperature(summary.average, fahrenheit: fahrenheit)) · 最高 \(Format.temperature(summary.maximum, fahrenheit: fahrenheit))")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.padding(.leading, DS.Size.labelColumn + DS.Space.s3)
}
}
}
}
private struct FanCard: View {
@Environment(AppModel.self) private var model
var body: some View {
let fans = model.store.sensors?.fans ?? []
let controller = model.fans
Card {
CardHeader(icon: "fan", title: "风扇") {
if !fans.isEmpty {
StatusBadge(text: fans.contains(where: \.isManual)
? (controller.mode == .automatic ? "其他程序控制" : "OpenStats 控制")
: "系统自动",
tone: fans.contains(where: \.isManual) ? .primary : .neutral)
}
}
if fans.isEmpty {
Text(model.store.sensors == nil ? "正在读取风扇…" : "此设备没有可调节的风扇")
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(fans) { fan in
FanRow(fan: fan, name: name(for: fan, count: fans.count))
}
HairlineDivider()
Text("调速模式")
.dsFont(.sm, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
SegmentedControl(selection: modeBinding, options: FanController.Mode.allCases.map { ($0, $0.title) })
.disabled(!model.helper.isReady || controller.isApplying)
if controller.mode == .custom {
CustomLevelControl(fans: fans)
}
if !model.helper.isReady {
HelperRequiredBanner(text: "调节风扇需要安装辅助工具,仅需管理员授权一次。")
}
if let notice = controller.notice {
InfoBanner(icon: controller.noticeIsError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill",
text: notice,
tone: controller.noticeIsError ? .error : .success)
}
Text(verbatim: "自定义模式下 CPU 达到 \(model.settings.fanSafetyTemperature)°C 会自动交还系统控制;退出应用时风扇恢复自动。")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var modeBinding: Binding<FanController.Mode> {
Binding(get: { model.fans.mode },
set: { mode in Task { await model.fans.select(mode) } })
}
private func name(for fan: FanState, count: Int) -> String {
guard count == 2 else { return "风扇 \(fan.id + 1)" }
return fan.id == 0 ? "左侧风扇" : "右侧风扇"
}
}
private struct FanRow: View {
let fan: FanState
let name: String
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.s1) {
HStack {
Text(name).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
Spacer()
Text(Format.rpm(fan.current))
.dsFont(.sm, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
ProgressTrack(fraction: fan.fraction, color: DS.Palette.secondary)
Text(verbatim: "最低 \(Int(fan.minimum)) · 最高 \(Int(fan.maximum)) · 目标 \(Int(fan.target)) RPM")
.dsFont(.xs)
.monospacedDigit()
.foregroundStyle(DS.Palette.textTertiary)
}
}
}
private struct CustomLevelControl: View {
@Environment(AppModel.self) private var model
let fans: [FanState]
var body: some View {
@Bindable var controller = model.fans
let reference = fans.first
let rpm = reference.map { model.fans.targetRPM(for: $0) } ?? 0
VStack(alignment: .leading, spacing: DS.Space.s2) {
HStack {
Text("目标转速").dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer()
Text("\(Format.percent(controller.customLevel)) · 约 \(Format.rpm(rpm))")
.dsFont(.xs, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
DSSlider(value: $controller.customLevel) {
Task { await model.fans.commitCustomLevel() }
}
HStack {
Text("安静").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Spacer()
Text("最强").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
}
struct HelperRequiredBanner: View {
@Environment(AppModel.self) private var model
let text: String
var body: some View {
InfoBanner(icon: "lock.shield", text: bannerText, tone: .warning) {
switch model.helper.status {
case .notInstalled:
Button("安装") { model.helper.install() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(model.helper.isWorking)
case .requiresApproval:
Button("去批准") { model.helper.openLoginItemsSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
case .enabled, .unavailable:
EmptyView()
}
}
}
private var bannerText: String {
switch model.helper.status {
case .requiresApproval: "请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。"
case .unavailable(let reason): reason
default: text
}
}
}
@@ -0,0 +1,421 @@
import AppKit
import HelperShared
import SwiftUI
enum SettingsSection: String, CaseIterable, Identifiable {
case general, menuBar, thermal, helper, about
var id: String { rawValue }
var title: String {
switch self {
case .general: "通用"
case .menuBar: "菜单栏"
case .thermal: "散热与防休眠"
case .helper: "辅助工具"
case .about: "关于"
}
}
var symbol: String {
switch self {
case .general: "gearshape"
case .menuBar: "menubar.rectangle"
case .thermal: "fan"
case .helper: "lock.shield"
case .about: "info.circle"
}
}
}
public struct SettingsView: View {
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
@State private var section: SettingsSection
public init() {
_section = State(initialValue: .general)
}
init(section: SettingsSection) {
_section = State(initialValue: section)
}
public var body: some View {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: DS.Space.s1) {
ForEach(SettingsSection.allCases) { item in
SidebarItem(section: item, isSelected: item == section) { section = item }
}
Spacer()
}
.padding(DS.Space.s3)
.frame(width: DS.Size.settingsSidebar)
.frame(maxHeight: .infinity)
.background {
if isSnapshot { DS.Palette.background } else { SidebarMaterial() }
}
Rectangle().fill(DS.Palette.border).frame(width: DS.Size.stroke)
SettingsPage(title: section.title) {
switch section {
case .general: GeneralSettings()
case .menuBar: MenuBarSettings()
case .thermal: ThermalSettings()
case .helper: HelperSettings()
case .about: AboutSettings()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(nsColor: .windowBackgroundColor))
}
.frame(width: DS.Size.settingsWidth, height: isSnapshot ? nil : DS.Size.settingsHeight)
.fixedSize(horizontal: false, vertical: isSnapshot)
.onAppear {
model.refreshLaunchAtLogin()
model.helper.refreshStatus()
}
}
}
private struct SidebarItem: View {
let section: SettingsSection
let isSelected: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
HStack(spacing: DS.Space.s2) {
Image(systemName: section.symbol)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.frame(width: DS.Size.iconStandalone)
Text(section.title).dsFont(.sm, weight: isSelected ? .semibold : .regular)
Spacer()
}
.foregroundStyle(isSelected ? DS.Palette.primary : DS.Palette.textPrimary)
.padding(.horizontal, DS.Space.s2)
.frame(height: DS.Size.controlHeight)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
}
private var background: Color {
if isSelected { return DS.Palette.primary.opacity(0.1) }
return hovering ? DS.Palette.surfaceHover : .clear
}
}
private struct SettingsPage<Content: View>: View {
let title: String
@ViewBuilder var content: Content
@Environment(\.isSnapshot) private var isSnapshot
var body: some View {
let stack = VStack(alignment: .leading, spacing: DS.Space.s4) {
Text(title).dsFont(.xl, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
content
}
.padding(DS.Space.s6)
.frame(maxWidth: .infinity, alignment: .leading)
if isSnapshot {
stack
} else {
ScrollView { stack }
}
}
}
private struct SettingsGroup<Content: View>: View {
var caption: String?
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.s2) {
if let caption {
Text(caption).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
}
VStack(alignment: .leading, spacing: 0) {
content
}
.background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke))
}
}
}
/// 分组内的一行,自带内边距与分隔线
private struct GroupRow<Content: View>: View {
var showsDivider = true
@ViewBuilder var content: Content
var body: some View {
VStack(spacing: 0) {
if showsDivider { HairlineDivider() }
content
.padding(.horizontal, DS.Space.s4)
.padding(.vertical, DS.Space.s3)
}
}
}
// MARK: - 通用
private struct GeneralSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
SettingsGroup {
GroupRow(showsDivider: false) {
SettingRow(title: "外观", subtitle: "面板与设置窗口的配色;菜单栏始终跟随系统") {
SegmentedControl(selection: $settings.appearance,
options: AppearanceMode.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.settingsSidebar + DS.Space.s12)
}
}
GroupRow {
SettingRow(title: "登录时启动", subtitle: model.launchAtLoginError ?? "开机后自动在菜单栏显示 OpenStats") {
DSToggle(isOn: Binding(get: { model.launchAtLoginEnabled },
set: { model.setLaunchAtLogin($0) }),
label: "登录时启动")
}
}
GroupRow {
SettingRow(title: "刷新频率", subtitle: "面板收起时菜单栏的采样间隔;展开面板时固定为 1 秒") {
SegmentedControl(selection: $settings.refreshSeconds,
options: AppSettings.refreshOptions.map { ($0, "\($0) 秒") })
.frame(width: DS.Size.settingsSidebar + DS.Space.s12)
}
}
GroupRow {
SettingRow(title: "温度单位") {
SegmentedControl(selection: $settings.useFahrenheit, options: [(false, "°C"), (true, "°F")])
.frame(width: DS.Size.settingsSidebar / 2 + DS.Space.s6)
}
}
}
}
}
// MARK: - 菜单栏
private struct MenuBarSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
MenuBarPreview()
SettingsGroup(caption: "显示项目") {
ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in
GroupRow(showsDivider: index > 0) {
VStack(alignment: .leading, spacing: DS.Space.s3) {
SettingRow(title: item.title, subtitle: item.subtitle, icon: item.symbol) {
DSToggle(isOn: Binding(get: { settings.isEnabled(item) },
set: { settings.setEnabled(item, $0) }),
label: item.title)
}
if item.supportsGaugeStyle, settings.isEnabled(item) {
SegmentedControl(selection: Binding(get: { settings.gaugeStyle(for: item) },
set: { settings.setGaugeStyle($0, for: item) }),
options: GaugeStyle.allCases.map { ($0, $0.title) })
.padding(.leading, DS.Size.iconStandalone + DS.Space.s3)
}
}
}
}
}
SettingsGroup(caption: "样式") {
GroupRow(showsDivider: false) {
SettingRow(title: "高负载时着色", subtitle: "CPU 超过 85% 时数值显示为红色,其余时间跟随菜单栏颜色") {
DSToggle(isOn: $settings.colorizeHighLoad, label: "高负载时着色")
}
}
}
}
}
private struct MenuBarPreview: View {
@Environment(AppModel.self) private var model
var body: some View {
let image = MenuBarRenderer.image(for: model)
VStack(spacing: DS.Space.s2) {
preview(image: image, dark: false)
preview(image: image, dark: true)
}
}
private func preview(image: NSImage, dark: Bool) -> some View {
let tinted = MenuBarRenderer.preview(image, dark: dark)
return HStack {
Spacer()
Image(nsImage: tinted)
Spacer()
}
.frame(height: DS.Size.controlHeight)
.background(dark ? Color(nsColor: NSColor(hex: 0x1F2937)) : Color(nsColor: NSColor(hex: 0xE5E7EB)),
in: RoundedRectangle(cornerRadius: DS.Radius.md))
.accessibilityLabel(dark ? "深色菜单栏预览" : "浅色菜单栏预览")
}
}
// MARK: - 散热与防休眠
private struct ThermalSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
SettingsGroup(caption: "风扇") {
GroupRow(showsDivider: false) {
SettingRow(title: "安全温度", subtitle: "自定义转速时,CPU 达到该温度自动恢复系统控制") {
SegmentedControl(selection: $settings.fanSafetyTemperature,
options: AppSettings.fanSafetyOptions.map { ($0, "\($0)°C") })
.frame(width: DS.Size.settingsSidebar + DS.Space.s12)
}
}
}
SettingsGroup(caption: "合盖运行") {
GroupRow(showsDivider: false) {
SettingRow(title: "电量下限", subtitle: "使用电池且电量低于该值时,自动关闭合盖运行") {
SegmentedControl(selection: $settings.lidModeBatteryFloor,
options: AppSettings.batteryFloorOptions.map { ($0, "\($0)%") })
.frame(width: DS.Size.settingsSidebar + DS.Space.s12)
}
}
}
}
}
// MARK: - 辅助工具
private struct HelperSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
let helper = model.helper
SettingsGroup {
GroupRow(showsDivider: false) {
SettingRow(title: "OpenStats 辅助工具", subtitle: "以系统权限运行的后台服务,只接受本应用的请求", icon: "lock.shield") {
StatusBadge(text: helper.status.title, tone: tone(helper.status))
}
}
GroupRow {
VStack(alignment: .leading, spacing: DS.Space.s2) {
Text("它只做三件事").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
CapabilityLine(text: "设置风扇目标转速,或恢复系统自动控制")
CapabilityLine(text: "开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep)")
CapabilityLine(text: "应用退出或断开连接时,自动恢复以上两项设置")
}
}
GroupRow {
HStack(spacing: DS.Space.s2) {
switch helper.status {
case .notInstalled:
Button("安装辅助工具") { helper.install() }
.buttonStyle(DSButtonStyle(kind: .primary))
case .requiresApproval:
Button("打开登录项设置") { helper.openLoginItemsSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
Button("卸载") { Task { await helper.uninstall() } }
.buttonStyle(DSButtonStyle(kind: .secondary))
case .enabled:
Button("卸载辅助工具") { Task { await helper.uninstall() } }
.buttonStyle(DSButtonStyle(kind: .secondary))
case .unavailable:
EmptyView()
}
Button("刷新状态") { helper.refreshStatus() }
.buttonStyle(DSButtonStyle(kind: .ghost))
Spacer()
}
.disabled(helper.isWorking)
}
}
if case .unavailable(let reason) = helper.status {
InfoBanner(icon: "exclamationmark.triangle.fill", text: reason, tone: .error)
}
if let error = helper.lastError {
InfoBanner(icon: "exclamationmark.triangle.fill", text: error, tone: .error)
}
if HelperConstants.teamIdentifier.isEmpty {
InfoBanner(icon: "info.circle", text: "当前为开发构建:辅助工具只校验应用标识。正式发布前需使用 Developer ID 签名并填写 Team ID。", tone: .neutral)
}
}
private func tone(_ status: HelperClient.Status) -> Tone {
switch status {
case .enabled: .success
case .requiresApproval: .warning
case .notInstalled: .neutral
case .unavailable: .error
}
}
}
private struct CapabilityLine: View {
let text: String
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
Image(systemName: "checkmark")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .bold))
.foregroundStyle(DS.Palette.success)
Text(text).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
}
}
}
// MARK: - 关于
private struct AboutSettings: View {
var body: some View {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "开发版"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
SettingsGroup {
GroupRow(showsDivider: false) {
HStack(spacing: DS.Space.s4) {
AppGlyph(size: DS.Space.s12)
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text("OpenStats").dsFont(.lg, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "版本 \(version)\(build.map { "(\($0))" } ?? "")")
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
}
Spacer()
}
}
GroupRow {
Text("轻量的 macOS 菜单栏系统监控:CPU、GPU、内存、网络、温度、风扇与防休眠。")
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
}
}
SettingsGroup(caption: "致谢") {
GroupRow(showsDivider: false) {
SettingRow(title: "exelban/stats", subtitle: "SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License") {
Button("查看") {
if let url = URL(string: "https://github.com/exelban/stats") { NSWorkspace.shared.open(url) }
}
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
}
}
}
@@ -0,0 +1,101 @@
import AppKit
import Metrics
import SMC
import SwiftUI
/// `OpenStats --snapshot <目录>`:用本机实时数据渲染各页面 PNG,用于设计走查
@MainActor
enum SnapshotRenderer {
static func run(outputDirectory: URL) async {
try? FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
let defaults = UserDefaults(suiteName: "OpenStats.snapshot") ?? .standard
let settings = AppSettings(defaults: defaults)
settings.menuBarItems = [.cpu, .gpu, .memory, .network, .temperature]
settings.setGaugeStyle(.ring, for: .gpu)
settings.setGaugeStyle(.pie, for: .memory)
let model = AppModel(settings: settings)
model.isPanelVisible = true
// 采集约 12 秒的真实数据,让历史曲线有内容
var demand = MetricsDemand()
demand.interval = .milliseconds(250)
demand.memory = true
demand.network = true
demand.gpu = true
demand.disk = true
demand.battery = true
demand.processes = true
demand.temperatures = Set(TemperatureGroup.allCases)
demand.fans = true
await model.hub.update(demand)
await model.hub.start { snapshot in model.handle(snapshot) }
try? await Task.sleep(for: .seconds(12))
await model.hub.stop()
for (appearanceName, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] {
guard let appearance = NSAppearance(named: appearanceName) else { continue }
NSApp.appearance = appearance
for tab in PanelTab.allCases {
settings.panelTab = tab
write(PanelRootView(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("panel-\(tab.rawValue)-\(suffix).png"))
}
for section in SettingsSection.allCases {
write(SettingsView(section: section), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("settings-\(section.rawValue)-\(suffix).png"))
}
}
let menuBar = MenuBarRenderer.image(for: model)
for dark in [false, true] {
let preview = MenuBarRenderer.preview(menuBar, dark: dark)
// 垫上菜单栏底色,便于查看
let padded = NSImage(size: NSSize(width: preview.size.width + DS.Space.s4, height: preview.size.height), flipped: false) { rect in
NSColor(hex: dark ? 0x1F2937 : 0xE5E7EB).setFill()
rect.fill()
preview.draw(in: NSRect(x: DS.Space.s2, y: 0, width: preview.size.width, height: preview.size.height))
return true
}
writePNG(padded, scale: 2, to: outputDirectory.appendingPathComponent("menubar-\(dark ? "dark" : "light").png"))
}
print("截图已输出到 \(outputDirectory.path)")
}
/// 用真实的 NSHostingView 放进离屏窗口截图,渲染路径与 App 内一致
private static func write<V: View>(_ view: V, model: AppModel, appearance: NSAppearance, to url: URL) {
let hosting = NSHostingView(rootView: view
.environment(model)
.environment(\.isSnapshot, true))
hosting.appearance = appearance
let size = hosting.fittingSize
hosting.frame = CGRect(origin: .zero, size: size)
let window = NSWindow(contentRect: CGRect(origin: CGPoint(x: -10_000, y: -10_000), size: size),
styleMask: .borderless, backing: .buffered, defer: false)
window.appearance = appearance
window.isReleasedWhenClosed = false
window.contentView = hosting
window.orderFrontRegardless()
hosting.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.3))
defer { window.orderOut(nil) }
guard let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) else { return }
hosting.cacheDisplay(in: hosting.bounds, to: rep)
try? rep.representation(using: .png, properties: [:])?.write(to: url)
}
private static func writePNG(_ image: NSImage, scale: CGFloat, to url: URL) {
let size = NSSize(width: image.size.width * scale, height: image.size.height * scale)
guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else { return }
bitmap.size = image.size
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
image.draw(in: NSRect(origin: .zero, size: image.size))
NSGraphicsContext.restoreGraphicsState()
try? bitmap.representation(using: .png, properties: [:])?.write(to: url)
}
}
@@ -0,0 +1,103 @@
import Foundation
import Metrics
import Observation
import ServiceManagement
import SMC
/// 应用状态总入口,注入到所有 SwiftUI 视图
@MainActor
@Observable
public final class AppModel {
public let settings: AppSettings
public let store: MetricsStore
public let helper: HelperClient
public let fans: FanController
public let keepAwake: KeepAwakeController
@ObservationIgnored public let hub = MetricsHub()
public var isPanelVisible = false
public private(set) var launchAtLoginEnabled = SMAppService.mainApp.status == .enabled
public private(set) var launchAtLoginError: String?
@ObservationIgnored var openSettings: () -> Void = {}
@ObservationIgnored var quit: () -> Void = {}
public init(settings: AppSettings = AppSettings()) {
let store = MetricsStore()
let helper = HelperClient()
self.settings = settings
self.store = store
self.helper = helper
fans = FanController(helper: helper, store: store, settings: settings)
keepAwake = KeepAwakeController(helper: helper, settings: settings)
}
/// 根据当前可见内容决定采集范围
var demand: MetricsDemand {
var demand = MetricsDemand()
let tab = settings.panelTab
let panel = isPanelVisible
let menu = settings.menuBarItems
demand.interval = panel ? .seconds(1) : .seconds(settings.refreshSeconds)
demand.memory = true
demand.network = true
demand.gpu = (panel && tab == .overview) || menu.contains(.gpu)
demand.disk = panel && tab == .overview
demand.battery = (panel && (tab == .overview || tab == .keepAwake)) || keepAwake.lidClosedActive
demand.processes = panel && (tab == .processes || tab == .overview)
var groups = Set<TemperatureGroup>()
if panel && tab == .overview { groups.formUnion([.cpu, .gpu]) }
if panel && tab == .thermal { groups.formUnion(TemperatureGroup.allCases) }
if menu.contains(.temperature) || fans.mode != .automatic { groups.insert(.cpu) }
demand.temperatures = groups
demand.fans = (panel && (tab == .thermal || tab == .overview)) || menu.contains(.fan) || fans.mode != .automatic
return demand
}
func refreshLaunchAtLogin() {
launchAtLoginEnabled = SMAppService.mainApp.status == .enabled
}
func setLaunchAtLogin(_ enabled: Bool) {
launchAtLoginError = nil
do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
} catch {
launchAtLoginError = error.localizedDescription
}
refreshLaunchAtLogin()
}
/// 概览页快捷切换风扇:未安装辅助工具时跳到散热页引导安装
func requestFanMode(_ mode: FanController.Mode) {
guard helper.isReady else {
settings.panelTab = .thermal
return
}
Task { await fans.select(mode) }
}
/// 概览页快捷开关合盖运行:同时开启防休眠
func requestLidMode(_ enabled: Bool) {
guard helper.isReady else {
settings.panelTab = .keepAwake
return
}
Task {
await keepAwake.setLidClosed(enabled)
if enabled, !keepAwake.isActive { await keepAwake.start() }
}
}
func handle(_ snapshot: MetricsSnapshot) {
store.apply(snapshot)
fans.evaluateSafety()
keepAwake.evaluateBattery(store.battery)
}
}
@@ -0,0 +1,198 @@
import Foundation
import Observation
public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case cpu, memory, network, gpu, temperature, fan
public var id: String { rawValue }
var title: String {
switch self {
case .cpu: "CPU"
case .memory: "内存"
case .network: "网速"
case .gpu: "GPU"
case .temperature: "CPU 温度"
case .fan: "风扇转速"
}
}
var subtitle: String {
switch self {
case .cpu: "总占用,可附带迷你负载图"
case .memory: "已用内存占比"
case .network: "两行显示:绿点上传、蓝点下载"
case .gpu: "GPU 占用"
case .temperature: "CPU 核心最高温度"
case .fan: "转速最高的风扇"
}
}
/// 菜单栏上方的小标签
var menuBarLabel: String {
switch self {
case .cpu: "CPU"
case .memory: "RAM"
case .network: "NET"
case .gpu: "GPU"
case .temperature: "TEMP"
case .fan: "FAN"
}
}
/// 百分比类指标可选择图形样式
var supportsGaugeStyle: Bool { self == .cpu || self == .gpu || self == .memory }
var symbol: String {
switch self {
case .cpu: "cpu"
case .memory: "memorychip"
case .network: "arrow.up.arrow.down"
case .gpu: "square.3.layers.3d"
case .temperature: "thermometer.medium"
case .fan: "fan"
}
}
}
public enum GaugeStyle: String, CaseIterable, Identifiable, Sendable {
case value, bars, ring, pie, barsAndValue
public var id: String { rawValue }
var title: String {
switch self {
case .value: "数字"
case .bars: "柱状图"
case .ring: "圆环"
case .pie: "饼图"
case .barsAndValue: "柱状+数字"
}
}
}
public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
case system, light, dark
public var id: String { rawValue }
var title: String {
switch self {
case .system: "跟随系统"
case .light: "浅色"
case .dark: "深色"
}
}
}
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case overview, processes, thermal, keepAwake
public var id: String { rawValue }
var title: String {
switch self {
case .overview: "概览"
case .processes: "进程"
case .thermal: "散热"
case .keepAwake: "防休眠"
}
}
}
/// UserDefaults 持久化的偏好设置
@MainActor
@Observable
public final class AppSettings {
@ObservationIgnored private let defaults: UserDefaults
public var menuBarItems: Set<MenuBarItem> {
didSet { defaults.set(menuBarItems.map(\.rawValue).sorted(), forKey: Keys.menuBarItems) }
}
/// CPU / GPU / 内存各自的菜单栏样式
public var gaugeStyles: [MenuBarItem: GaugeStyle] {
didSet {
defaults.set(Dictionary(uniqueKeysWithValues: gaugeStyles.map { ($0.key.rawValue, $0.value.rawValue) }),
forKey: Keys.gaugeStyles)
}
}
public var refreshSeconds: Int {
didSet { defaults.set(refreshSeconds, forKey: Keys.refreshSeconds) }
}
public var colorizeHighLoad: Bool {
didSet { defaults.set(colorizeHighLoad, forKey: Keys.colorizeHighLoad) }
}
public var useFahrenheit: Bool {
didSet { defaults.set(useFahrenheit, forKey: Keys.useFahrenheit) }
}
/// 电量低于该值时自动关闭“合盖运行”
public var lidModeBatteryFloor: Int {
didSet { defaults.set(lidModeBatteryFloor, forKey: Keys.lidModeBatteryFloor) }
}
/// 自定义风扇模式下 CPU 达到该温度时恢复系统控制
public var fanSafetyTemperature: Int {
didSet { defaults.set(fanSafetyTemperature, forKey: Keys.fanSafetyTemperature) }
}
public var panelTab: PanelTab {
didSet { defaults.set(panelTab.rawValue, forKey: Keys.panelTab) }
}
public var appearance: AppearanceMode {
didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) }
}
public static let refreshOptions = [1, 2, 3, 5]
public static let batteryFloorOptions = [10, 20, 30, 40]
public static let fanSafetyOptions = [85, 90, 95, 100]
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:))
menuBarItems = Set(items ?? [.cpu])
let storedStyles = defaults.dictionary(forKey: Keys.gaugeStyles) as? [String: String] ?? [:]
gaugeStyles = Dictionary(uniqueKeysWithValues: storedStyles.compactMap { key, value in
guard let item = MenuBarItem(rawValue: key), let style = GaugeStyle(rawValue: value) else { return nil }
return (item, style)
})
refreshSeconds = Self.refreshOptions.contains(defaults.integer(forKey: Keys.refreshSeconds))
? defaults.integer(forKey: Keys.refreshSeconds) : 2
colorizeHighLoad = defaults.bool(forKey: Keys.colorizeHighLoad)
useFahrenheit = defaults.bool(forKey: Keys.useFahrenheit)
lidModeBatteryFloor = Self.batteryFloorOptions.contains(defaults.integer(forKey: Keys.lidModeBatteryFloor))
? defaults.integer(forKey: Keys.lidModeBatteryFloor) : 20
fanSafetyTemperature = Self.fanSafetyOptions.contains(defaults.integer(forKey: Keys.fanSafetyTemperature))
? defaults.integer(forKey: Keys.fanSafetyTemperature) : 95
panelTab = defaults.string(forKey: Keys.panelTab).flatMap(PanelTab.init(rawValue:)) ?? .overview
appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system
}
/// 按固定顺序返回已启用的菜单栏项目
var orderedMenuBarItems: [MenuBarItem] {
MenuBarItem.allCases.filter(menuBarItems.contains)
}
func isEnabled(_ item: MenuBarItem) -> Bool { menuBarItems.contains(item) }
func gaugeStyle(for item: MenuBarItem) -> GaugeStyle {
gaugeStyles[item] ?? (item == .cpu ? .barsAndValue : .value)
}
func setGaugeStyle(_ style: GaugeStyle, for item: MenuBarItem) {
gaugeStyles[item] = style
}
func setEnabled(_ item: MenuBarItem, _ enabled: Bool) {
if enabled { menuBarItems.insert(item) } else { menuBarItems.remove(item) }
}
private enum Keys {
static let menuBarItems = "menuBarItems"
static let gaugeStyles = "gaugeStyles"
static let refreshSeconds = "refreshSeconds"
static let colorizeHighLoad = "colorizeHighLoad"
static let useFahrenheit = "useFahrenheit"
static let lidModeBatteryFloor = "lidModeBatteryFloor"
static let fanSafetyTemperature = "fanSafetyTemperature"
static let panelTab = "panelTab"
static let appearance = "appearance"
}
}
@@ -0,0 +1,117 @@
import Foundation
import Metrics
import Observation
import SMC
@MainActor
@Observable
public final class FanController {
public enum Mode: String, CaseIterable, Identifiable {
case automatic, cooling, maximum, custom
public var id: String { rawValue }
var title: String {
switch self {
case .automatic: "自动"
case .cooling: "降温"
case .maximum: "强冷"
case .custom: "自定义"
}
}
/// “降温”档位于最低与最高转速之间的 60%
static let coolingLevel = 0.6
}
public private(set) var mode: Mode = .automatic
/// 自定义档位:0 为最低转速,1 为最高转速
public var customLevel: Double = 0.5
public private(set) var isApplying = false
public private(set) var notice: String?
public private(set) var noticeIsError = false
@ObservationIgnored private let helper: HelperClient
@ObservationIgnored private let store: MetricsStore
@ObservationIgnored private let settings: AppSettings
init(helper: HelperClient, store: MetricsStore, settings: AppSettings) {
self.helper = helper
self.store = store
self.settings = settings
}
var hasFans: Bool { !(store.sensors?.fans.isEmpty ?? true) }
func select(_ newMode: Mode) async {
guard newMode != mode || newMode == .custom else { return }
let previous = mode
mode = newMode
if let error = await apply() {
mode = previous
show(error, isError: true)
} else {
clearNotice()
}
}
func commitCustomLevel() async {
guard mode == .custom else { return }
if let error = await apply() { show(error, isError: true) }
}
/// 连接恢复或唤醒后重新下发
func reapply() async {
guard mode != .automatic else { return }
if let error = await apply() { show(error, isError: true) }
}
/// 每次收到新指标时检查温度,自定义档位过低且 CPU 过热时交还系统控制
func evaluateSafety() {
guard mode == .custom, customLevel < 1,
let hottest = store.sensors?.temperature(.cpu)?.maximum,
hottest >= Double(settings.fanSafetyTemperature) else { return }
Task {
mode = .automatic
_ = await apply()
show("CPU 温度达到 \(Format.temperature(hottest, fahrenheit: settings.useFahrenheit)),已恢复系统自动控制", isError: false)
}
}
func targetRPM(for fan: FanState) -> Double {
switch mode {
case .automatic: fan.target
case .cooling: fan.minimum + (fan.maximum - fan.minimum) * Mode.coolingLevel
case .maximum: fan.maximum
case .custom: fan.minimum + (fan.maximum - fan.minimum) * customLevel
}
}
private func apply() async -> String? {
let fans = store.sensors?.fans ?? []
guard !fans.isEmpty else { return "未检测到可调节的风扇" }
isApplying = true
defer { isApplying = false }
if mode == .automatic {
return await helper.resetAllFans()
}
for fan in fans {
if let error = await helper.setFanTarget(fan: fan.id, rpm: targetRPM(for: fan)) {
_ = await helper.resetAllFans()
return error
}
}
return nil
}
private func show(_ message: String, isError: Bool) {
notice = message
noticeIsError = isError
}
private func clearNotice() {
notice = nil
noticeIsError = false
}
}
@@ -0,0 +1,173 @@
import Foundation
import HelperShared
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: "未安装"
case .requiresApproval: "等待批准"
case .enabled: "已启用"
case .unavailable: "不可用"
}
}
}
public private(set) var status: Status = .notInstalled
public private(set) var isWorking = false
public private(set) var lastError: String?
/// 连接中断后重新连上时回调,用于重新下发风扇 / 防休眠状态
@ObservationIgnored var onReconnect: (() -> Void)?
@ObservationIgnored private var connection: NSXPCConnection?
@ObservationIgnored private let service = SMAppService.daemon(plistName: HelperConstants.launchdPlistName)
public init() {}
public var isReady: Bool { status == .enabled }
public func refreshStatus() {
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("应用包内未找到辅助工具,请使用完整构建的 OpenStats.app")
@unknown default: status = .unavailable("未知状态")
}
}
public func install() {
isWorking = true
defer { isWorking = false }
lastError = nil
do {
try service.register()
} catch {
lastError = "安装失败:\(error.localizedDescription)"
}
refreshStatus()
if status == .requiresApproval {
SMAppService.openSystemSettingsLoginItems()
}
}
public func uninstall() async {
isWorking = true
defer { isWorking = false }
lastError = nil
connection?.invalidate()
connection = nil
do {
try await service.unregister()
} catch {
lastError = "卸载失败:\(error.localizedDescription)"
}
refreshStatus()
}
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) }
}
/// 退出应用时使用:同步恢复风扇与睡眠设置,每步最多等待 1 秒
func restoreDefaultsSynchronously() {
guard isReady else { return }
let connection = ensureConnection()
let steps: [(OpenStatsHelperProtocol, @escaping @Sendable (String?) -> Void) -> Void] = [
{ proxy, reply in proxy.resetAllFans(reply: reply) },
{ proxy, reply in proxy.setSleepDisabled(false, reply: reply) },
]
for step in steps {
let semaphore = DispatchSemaphore(value: 0)
guard let proxy = connection.remoteObjectProxyWithErrorHandler({ _ in semaphore.signal() })
as? OpenStatsHelperProtocol else { return }
step(proxy) { _ in semaphore.signal() }
_ = semaphore.wait(timeout: .now() + 1)
}
}
private func call(_ body: (OpenStatsHelperProtocol, @escaping @Sendable (String?) -> Void) -> Void) async -> String? {
refreshStatus()
guard isReady else { return "辅助工具未启用" }
let connection = ensureConnection()
return await withCheckedContinuation { (continuation: CheckedContinuation<String?, Never>) in
let once = ResumeOnce(continuation)
let proxy = connection.remoteObjectProxyWithErrorHandler { error in
once.resume("无法连接辅助工具:\(error.localizedDescription)")
} as? OpenStatsHelperProtocol
guard let proxy else {
once.resume("无法连接辅助工具")
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: OpenStatsHelperProtocol.self)
connection.interruptionHandler = { [weak self] in
Task { @MainActor in self?.onReconnect?() }
}
connection.invalidationHandler = { [weak self] in
Task { @MainActor in self?.connection = nil }
}
connection.resume()
self.connection = connection
return connection
}
}
/// XPC 的错误回调和正常回调可能都会触发,保证 continuation 只恢复一次
private final class ResumeOnce: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<String?, Never>?
init(_ continuation: CheckedContinuation<String?, Never>) {
self.continuation = continuation
}
func resume(_ value: String?) {
lock.lock()
let pending = continuation
continuation = nil
lock.unlock()
pending?.resume(returning: value)
}
}
@@ -0,0 +1,189 @@
import Foundation
import IOKit.pwr_mgt
import Metrics
import Observation
@MainActor
@Observable
public final class KeepAwakeController {
public enum Mode: String, CaseIterable, Identifiable {
case display, system
public var id: String { rawValue }
var title: String {
switch self {
case .display: "屏幕保持常亮"
case .system: "仅系统不休眠"
}
}
var subtitle: String {
switch self {
case .display: "屏幕和系统都不会因闲置而关闭或休眠"
case .system: "屏幕可按设置关闭,下载、编译等后台任务继续运行"
}
}
/// IOPMLib 中的 CFSTR 宏无法直接导入 Swift,这里使用其字符串值
var assertionType: String {
switch self {
case .display: "PreventUserIdleDisplaySleep"
case .system: "PreventUserIdleSystemSleep"
}
}
}
public enum Duration: Int, CaseIterable, Identifiable {
case minutes15 = 15, hour1 = 60, hours2 = 120, hours5 = 300, unlimited = 0
public var id: Int { rawValue }
var title: String {
switch self {
case .minutes15: "15 分钟"
case .hour1: "1 小时"
case .hours2: "2 小时"
case .hours5: "5 小时"
case .unlimited: "不限"
}
}
}
public private(set) var isActive = false
public private(set) var mode: Mode = .display
public private(set) var duration: Duration = .unlimited
public private(set) var endDate: Date?
/// 用户是否希望合盖后继续运行
public private(set) var lidClosedRequested = false
/// 辅助工具是否已实际关闭系统睡眠
public private(set) var lidClosedActive = false
public private(set) var notice: String?
public private(set) var noticeIsError = false
@ObservationIgnored private var assertionID: IOPMAssertionID = 0
@ObservationIgnored private var expiryTask: Task<Void, Never>?
@ObservationIgnored private let helper: HelperClient
@ObservationIgnored private let settings: AppSettings
init(helper: HelperClient, settings: AppSettings) {
self.helper = helper
self.settings = settings
}
func setActive(_ active: Bool) async {
active ? await start() : await stop(reason: nil)
}
func setMode(_ newMode: Mode) {
mode = newMode
if isActive { takeAssertion() }
}
func setDuration(_ newDuration: Duration) {
duration = newDuration
if isActive { scheduleExpiry() }
}
func setLidClosed(_ requested: Bool) async {
lidClosedRequested = requested
guard isActive else { return }
await applyLidMode()
}
func start() async {
isActive = true
notice = nil
takeAssertion()
scheduleExpiry()
if lidClosedRequested { await applyLidMode() }
}
func stop(reason: String?) async {
isActive = false
endDate = nil
expiryTask?.cancel()
releaseAssertion()
if lidClosedActive {
if let error = await helper.setSleepDisabled(false) {
show("恢复系统睡眠失败:\(error)", isError: true)
return
}
lidClosedActive = false
}
if let reason { show(reason, isError: false) }
}
/// 连接恢复后重新下发合盖设置
func reapply() async {
guard isActive, lidClosedRequested else { return }
await applyLidMode()
}
/// 电池电量保护:使用电池供电且电量低于下限时关闭合盖运行
func evaluateBattery(_ battery: BatteryStatus?) {
guard lidClosedActive, let battery, !battery.isPluggedIn,
battery.level * 100 < Double(settings.lidModeBatteryFloor) else { return }
Task {
lidClosedRequested = false
await applyLidMode()
show("电量低于 \(settings.lidModeBatteryFloor)%,已关闭合盖运行", isError: false)
}
}
/// 退出应用时释放电源断言(合盖设置由 HelperClient 同步恢复)
func releaseForTermination() {
releaseAssertion()
}
private func applyLidMode() async {
let wanted = isActive && lidClosedRequested
guard wanted != lidClosedActive else { return }
if let error = await helper.setSleepDisabled(wanted) {
lidClosedRequested = lidClosedActive
show(error, isError: true)
return
}
lidClosedActive = wanted
}
private func takeAssertion() {
releaseAssertion()
var id: IOPMAssertionID = 0
let result = IOPMAssertionCreateWithName(mode.assertionType as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
"OpenStats 防休眠" as CFString,
&id)
if result == kIOReturnSuccess {
assertionID = id
} else {
show("无法创建电源断言(\(result))", isError: true)
}
}
private func releaseAssertion() {
guard assertionID != 0 else { return }
IOPMAssertionRelease(assertionID)
assertionID = 0
}
private func scheduleExpiry() {
expiryTask?.cancel()
guard duration != .unlimited else {
endDate = nil
return
}
let end = Date().addingTimeInterval(TimeInterval(duration.rawValue * 60))
endDate = end
expiryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(end.timeIntervalSinceNow))
guard !Task.isCancelled else { return }
await self?.stop(reason: "已到设定时间,防休眠已关闭")
}
}
private func show(_ message: String, isError: Bool) {
notice = message
noticeIsError = isError
}
}
@@ -0,0 +1,80 @@
import Foundation
import Metrics
import Observation
import SMC
/// 主线程上的最新指标与短历史,只保留界面需要的数据
@MainActor
@Observable
public final class MetricsStore {
public static let historyCapacity = 60
public let topology = CPUSampler.topology()
public private(set) var system = SystemInfoReader.read()
public private(set) var cpu: CPULoad?
public private(set) var cpuUser = History<Double>(capacity: historyCapacity)
public private(set) var cpuSystem = History<Double>(capacity: historyCapacity)
public private(set) var cpuTotal = History<Double>(capacity: historyCapacity)
public private(set) var memory: MemoryUsage?
public private(set) var memoryHistory = History<Double>(capacity: historyCapacity)
public private(set) var network: NetworkRate?
public private(set) var downloadHistory = History<Double>(capacity: historyCapacity)
public private(set) var uploadHistory = History<Double>(capacity: historyCapacity)
public private(set) var networkInterface: NetworkInterfaceInfo?
public private(set) var gpu: GPUUsage?
public private(set) var gpuHistory = History<Double>(capacity: historyCapacity)
public private(set) var disk: DiskUsage?
public private(set) var battery: BatteryStatus?
public private(set) var processes: [ProcessUsage] = []
public private(set) var sensors: SensorReadings?
public private(set) var lastUpdate: Date?
public init() {}
public func apply(_ snapshot: MetricsSnapshot) {
lastUpdate = snapshot.date
if let cpu = snapshot.cpu {
self.cpu = cpu
cpuUser.append(cpu.user)
cpuSystem.append(cpu.system)
cpuTotal.append(cpu.total)
}
if let memory = snapshot.memory {
self.memory = memory
memoryHistory.append(memory.usedFraction)
}
if let network = snapshot.network {
self.network = network
downloadHistory.append(network.downloadBytesPerSecond)
uploadHistory.append(network.uploadBytesPerSecond)
}
if let interface = snapshot.networkInterface { networkInterface = interface }
if let gpu = snapshot.gpu {
self.gpu = gpu
gpuHistory.append(gpu.utilization)
}
if let disk = snapshot.disk { self.disk = disk }
if let battery = snapshot.battery { self.battery = battery }
if let processes = snapshot.processes { self.processes = processes }
if let sensors = snapshot.sensors { mergeSensors(sensors) }
}
/// 不同页面采集的温度分组不同,按组合并,避免切页时数据闪烁
private func mergeSensors(_ new: SensorReadings) {
guard let old = sensors else {
sensors = new
return
}
var byGroup = Dictionary(uniqueKeysWithValues: old.temperatures.map { ($0.group, $0) })
for summary in new.temperatures { byGroup[summary.group] = summary }
let ordered = TemperatureGroup.allCases.compactMap { byGroup[$0] }
sensors = SensorReadings(temperatures: ordered, fans: new.fans.isEmpty ? old.fans : new.fans)
}
var fastestFan: FanState? {
sensors?.fans.max { $0.current < $1.current }
}
}
@@ -0,0 +1,142 @@
//
// FanControl.swift
// OpenStats
//
// 风扇读写。Apple Silicon 解锁流程参考 exelban/stats (MIT):
// Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved.
//
import Foundation
public struct FanState: Sendable, Equatable, Identifiable {
public let id: Int
public var current: Double
public var minimum: Double
public var maximum: Double
public var target: Double
public var isManual: Bool
public init(id: Int, current: Double, minimum: Double, maximum: Double, target: Double, isManual: Bool) {
self.id = id
self.current = current
self.minimum = minimum
self.maximum = maximum
self.target = target
self.isManual = isManual
}
/// 当前转速在 min...max 区间的占比
public var fraction: Double {
guard maximum > minimum else { return 0 }
return min(1, max(0, (current - minimum) / (maximum - minimum)))
}
}
public final class FanControl {
private let smc: SMCConnection
private var lowercaseModeKey: Bool?
public init(smc: SMCConnection) {
self.smc = smc
}
public var fanCount: Int {
Int(smc.double(SMCKey("FNum")) ?? 0)
}
public func read() -> [FanState] {
(0..<fanCount).compactMap { id in
guard let current = smc.double(SMCKey("F\(id)Ac")) else { return nil }
let minimum = smc.double(SMCKey("F\(id)Mn")) ?? 0
let maximum = smc.double(SMCKey("F\(id)Mx")) ?? 0
let target = smc.double(SMCKey("F\(id)Tg")) ?? current
let mode = smc.double(modeKey(id)) ?? 0
return FanState(id: id, current: current, minimum: minimum, maximum: maximum,
target: target, isManual: mode == 1)
}
}
// MARK: 写入(需要 root)
/// 设为手动并指定目标转速;转速会被限制在固件给出的 min...max 之间。
public func setManual(fan id: Int, rpm: Double) throws {
let minimum = smc.double(SMCKey("F\(id)Mn")) ?? 0
let maximum = smc.double(SMCKey("F\(id)Mx")) ?? rpm
let clamped = min(max(rpm, minimum), maximum)
#if arch(arm64)
if smc.double(modeKey(id)) != 1 {
try unlockManualMode(fan: id)
}
try writeWithRetry(SMCKey("F\(id)Tg"), value: clamped)
#else
try smc.write(SMCKey("F\(id)Md"), value: 1)
try smc.write(SMCKey("F\(id)Tg"), value: clamped)
#endif
}
public func setAutomatic(fan id: Int) throws {
#if arch(arm64)
if smc.double(modeKey(id)) != 0 {
try writeWithRetry(modeKey(id), value: 0)
}
#else
try smc.write(SMCKey("F\(id)Md"), value: 0)
#endif
}
/// 全部风扇恢复系统自动控制
public func resetAll() throws {
var firstError: Error?
for id in 0..<fanCount {
do { try setAutomatic(fan: id) } catch { firstError = firstError ?? error }
}
#if arch(arm64)
// M1–M4 需要复位 Ftst,让 thermalmonitord 接管
let ftst = SMCKey("Ftst")
if smc.exists(ftst), smc.double(ftst) == 1 {
do { try writeWithRetry(ftst, value: 0) } catch { firstError = firstError ?? error }
}
#endif
if let firstError { throw firstError }
}
// MARK: 私有
private func modeKey(_ id: Int) -> SMCKey {
if lowercaseModeKey == nil {
lowercaseModeKey = smc.exists(SMCKey("F0md"))
}
return SMCKey(lowercaseModeKey == true ? "F\(id)md" : "F\(id)Md")
}
#if arch(arm64)
private func unlockManualMode(fan id: Int) throws {
// M5 及以后:直接写模式键即可
if (try? smc.write(modeKey(id), value: 1)) != nil { return }
// M1–M4:先写 Ftst=1,等待 thermalmonitord 让出控制权
let ftst = SMCKey("Ftst")
guard smc.exists(ftst) else { throw SMCError.firmwareRejected(modeKey(id), 0) }
if smc.double(ftst) != 1 {
try writeWithRetry(ftst, value: 1, attempts: 100)
Thread.sleep(forTimeInterval: 3)
}
try writeWithRetry(modeKey(id), value: 1, attempts: 300, delay: 0.1)
}
private func writeWithRetry(_ key: SMCKey, value: Double, attempts: Int = 10, delay: TimeInterval = 0.05) throws {
var lastError: Error?
for attempt in 0..<attempts {
do {
try smc.write(key, value: value)
return
} catch {
lastError = error
if attempt < attempts - 1 { Thread.sleep(forTimeInterval: delay) }
}
}
throw lastError ?? SMCError.firmwareRejected(key, 0)
}
#endif
}
@@ -0,0 +1,238 @@
//
// SMCConnection.swift
// OpenStats
//
// 与 AppleSMC 用户客户端通信。结构体布局与读写流程移植自 exelban/stats (MIT):
// Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved.
//
import Foundation
import IOKit
public struct SMCKey: Hashable, Sendable, CustomStringConvertible {
public let code: UInt32
public init(_ string: String) {
precondition(string.utf8.count == 4, "SMC key must be 4 chars")
code = string.utf8.reduce(0) { $0 << 8 | UInt32($1) }
}
init(code: UInt32) { self.code = code }
public var description: String {
let bytes = [24, 16, 8, 0].map { UInt8(truncatingIfNeeded: code >> $0) }
return String(decoding: bytes, as: UTF8.self)
}
}
public struct SMCKeyInfo: Sendable, Equatable {
public let size: UInt32
public let type: String
}
public enum SMCError: Error, Sendable, CustomStringConvertible {
case serviceNotFound
case openFailed(kern_return_t)
case callFailed(SMCKey, kern_return_t)
case firmwareRejected(SMCKey, UInt8)
case unsupportedType(SMCKey, String)
public var description: String {
switch self {
case .serviceNotFound: "未找到 AppleSMC 服务"
case .openFailed(let kr): "打开 SMC 失败 (\(Self.message(kr)))"
case .callFailed(let key, let kr): "SMC [\(key)] 调用失败 (\(Self.message(kr)))"
case .firmwareRejected(let key, let code): "SMC [\(key)] 被固件拒绝 (0x\(String(code, radix: 16)))"
case .unsupportedType(let key, let type): "SMC [\(key)] 不支持的数据类型 \(type)"
}
}
private static func message(_ kr: kern_return_t) -> String {
String(cString: mach_error_string(kr))
}
}
/// 与内核 `SMCParamStruct` 等长(80 字节),字段顺序不可调整。
struct SMCParam {
typealias Bytes = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)
struct Version { var major: UInt8 = 0, minor: UInt8 = 0, build: UInt8 = 0, reserved: UInt8 = 0, release: UInt16 = 0 }
struct PLimit { var version: UInt16 = 0, length: UInt16 = 0, cpu: UInt32 = 0, gpu: UInt32 = 0, mem: UInt32 = 0 }
struct KeyInfo { var dataSize: UInt32 = 0, dataType: UInt32 = 0, dataAttributes: UInt8 = 0 }
var key: UInt32 = 0
var vers = Version()
var pLimit = PLimit()
var keyInfo = KeyInfo()
var padding: UInt16 = 0
var result: UInt8 = 0
var status: UInt8 = 0
var data8: UInt8 = 0
var data32: UInt32 = 0
var bytes: Bytes = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
}
private enum SMCSelector: UInt8 {
case kernelIndex = 2
case readBytes = 5
case writeBytes = 6
case readIndex = 8
case readKeyInfo = 9
}
/// 非线程安全:请在单一 actor / 串行队列内使用。
public final class SMCConnection {
private var connection: io_connect_t = 0
private var infoCache: [SMCKey: SMCKeyInfo] = [:]
public init() throws {
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSMC"))
guard service != 0 else { throw SMCError.serviceNotFound }
defer { IOObjectRelease(service) }
let kr = IOServiceOpen(service, mach_task_self_, 0, &connection)
guard kr == KERN_SUCCESS else { throw SMCError.openFailed(kr) }
}
deinit {
IOServiceClose(connection)
}
// MARK: 读取
public func info(_ key: SMCKey) throws -> SMCKeyInfo {
if let cached = infoCache[key] { return cached }
var input = SMCParam()
input.key = key.code
input.data8 = SMCSelector.readKeyInfo.rawValue
let output = try call(key, &input)
let info = SMCKeyInfo(size: output.keyInfo.dataSize, type: SMCKey(code: output.keyInfo.dataType).description)
infoCache[key] = info
return info
}
public func bytes(_ key: SMCKey) throws -> (SMCKeyInfo, [UInt8]) {
let info = try info(key)
var input = SMCParam()
input.key = key.code
input.keyInfo.dataSize = info.size
input.data8 = SMCSelector.readBytes.rawValue
var output = try call(key, &input)
let count = min(Int(info.size), 32)
let bytes = withUnsafeBytes(of: &output.bytes) { Array($0.prefix(count)) }
return (info, bytes)
}
/// 读取数值型键,失败或类型不支持时返回 nil。
public func double(_ key: SMCKey) -> Double? {
guard let (info, bytes) = try? bytes(key) else { return nil }
return SMCDecoder.decode(type: info.type, bytes: bytes)
}
public func keyCount() -> Int {
Int(double(SMCKey("#KEY")) ?? 0)
}
public func key(at index: Int) -> SMCKey? {
var input = SMCParam()
input.data8 = SMCSelector.readIndex.rawValue
input.data32 = UInt32(index)
guard let output = try? call(SMCKey(code: 0), &input) else { return nil }
return SMCKey(code: output.key)
}
public func allKeys() -> [SMCKey] {
(0..<keyCount()).compactMap { key(at: $0) }
}
// MARK: 写入(需要 root)
public func write(_ key: SMCKey, bytes newBytes: [UInt8]) throws {
let info = try info(key)
var input = SMCParam()
input.key = key.code
input.data8 = SMCSelector.writeBytes.rawValue
input.keyInfo.dataSize = info.size
withUnsafeMutableBytes(of: &input.bytes) { buffer in
for (i, byte) in newBytes.prefix(min(Int(info.size), 32)).enumerated() { buffer[i] = byte }
}
let output = try call(key, &input)
// IOKit 返回成功时,SMC 固件仍可能拒绝写入
guard output.result == 0 else { throw SMCError.firmwareRejected(key, output.result) }
}
public func write(_ key: SMCKey, value: Double) throws {
let info = try info(key)
guard let encoded = SMCDecoder.encode(type: info.type, value: value) else {
throw SMCError.unsupportedType(key, info.type)
}
try write(key, bytes: encoded)
}
public func exists(_ key: SMCKey) -> Bool {
guard let info = try? info(key) else { return false }
return info.size > 0
}
private func call(_ key: SMCKey, _ input: inout SMCParam) throws -> SMCParam {
var output = SMCParam()
var outputSize = MemoryLayout<SMCParam>.stride
let kr = IOConnectCallStructMethod(connection, UInt32(SMCSelector.kernelIndex.rawValue),
&input, MemoryLayout<SMCParam>.stride, &output, &outputSize)
guard kr == KERN_SUCCESS else { throw SMCError.callFailed(key, kr) }
// result 0x84 表示键不存在
if output.result == 0x84 { throw SMCError.callFailed(key, kIOReturnNotFound) }
return output
}
}
public enum SMCDecoder {
public static func decode(type: String, bytes: [UInt8]) -> Double? {
func be16() -> Double? { bytes.count >= 2 ? Double(UInt16(bytes[0]) << 8 | UInt16(bytes[1])) : nil }
func signed16() -> Double? { bytes.count >= 2 ? Double(Int16(bitPattern: UInt16(bytes[0]) << 8 | UInt16(bytes[1]))) : nil }
switch type {
case "flt ":
guard bytes.count >= 4 else { return nil }
return Double(bytes.withUnsafeBytes { $0.loadUnaligned(as: Float32.self) })
case "ui8 ", "flag":
return bytes.first.map(Double.init)
case "ui16":
return be16()
case "ui32":
guard bytes.count >= 4 else { return nil }
return Double(bytes.prefix(4).reduce(UInt32(0)) { $0 << 8 | UInt32($1) })
case "fpe2":
return be16().map { $0 / 4 }
case "sp78":
return signed16().map { $0 / 256 }
case "sp87":
return signed16().map { $0 / 128 }
case "sp96":
return signed16().map { $0 / 64 }
case "spb4":
return signed16().map { $0 / 16 }
case "spf0":
return signed16()
default:
return nil
}
}
public static func encode(type: String, value: Double) -> [UInt8]? {
switch type {
case "flt ":
return withUnsafeBytes(of: Float32(value)) { Array($0) }
case "ui8 ", "flag":
return [UInt8(clamping: Int(value))]
case "fpe2":
let raw = UInt16(clamping: Int(value * 4))
return [UInt8(raw >> 8), UInt8(raw & 0xff)]
default:
return nil
}
}
}
@@ -0,0 +1,58 @@
import Foundation
public enum TemperatureGroup: String, Sendable, CaseIterable, Codable {
case cpu, gpu, memory, battery, palmRest
public var title: String {
switch self {
case .cpu: "CPU"
case .gpu: "GPU"
case .memory: "内存"
case .battery: "电池"
case .palmRest: "掌托"
}
}
}
/// 不按芯片型号硬编码键名:启动时枚举一次 SMC,按前缀归类温度键。
public enum TemperatureCatalog {
public static let plausibleRange: ClosedRange<Double> = 5...130
public static func group(for key: String) -> TemperatureGroup? {
guard key.utf8.count == 4, key.hasPrefix("T") else { return nil }
switch key {
case "Ts0P", "Ts1P": return .palmRest
default: break
}
switch String(key.prefix(2)) {
case "Tp", "Te": return .cpu // Apple Silicon CPU 核心
case "Tg": return .gpu // Apple Silicon GPU
case "Tm": return .memory
case "TB": return .battery
case "TC": return key.hasSuffix("P") || key.hasSuffix("D") ? .cpu : nil // Intel
case "TG": return key.hasSuffix("P") || key.hasSuffix("D") ? .gpu : nil // Intel
default: return nil
}
}
/// 每组最多读取的传感器数量。M5 Max 仅 GPU 就有 84 个键,全部每秒读取没有必要
public static let maxKeysPerGroup = 12
/// 返回每组可用的键(首次读数合理的才保留),数量过多时均匀抽样
public static func discover(using smc: SMCConnection) -> [TemperatureGroup: [SMCKey]] {
var result: [TemperatureGroup: [SMCKey]] = [:]
for key in smc.allKeys() {
guard let group = group(for: key.description),
let value = smc.double(key),
plausibleRange.contains(value) else { continue }
result[group, default: []].append(key)
}
return result.mapValues(sample)
}
static func sample(_ keys: [SMCKey]) -> [SMCKey] {
guard keys.count > maxKeysPerGroup else { return keys }
let step = Double(keys.count) / Double(maxKeysPerGroup)
return (0..<maxKeysPerGroup).map { keys[Int(Double($0) * step)] }
}
}
@@ -0,0 +1,135 @@
import Foundation
import Testing
@testable import Metrics
@testable import SMC
@Suite("SMC 编解码")
struct SMCDecoderTests {
@Test func keyRoundTrip() {
#expect(SMCKey("F0Ac").description == "F0Ac")
#expect(SMCKey("#KEY").code == 0x234B_4559)
}
@Test func decodesCommonTypes() {
let float = withUnsafeBytes(of: Float32(1464.5)) { Array($0) }
#expect(SMCDecoder.decode(type: "flt ", bytes: float) == 1464.5)
#expect(SMCDecoder.decode(type: "ui8 ", bytes: [2]) == 2)
#expect(SMCDecoder.decode(type: "ui16", bytes: [0x01, 0x02]) == 258)
#expect(SMCDecoder.decode(type: "sp78", bytes: [0x2A, 0x80]) == 42.5)
#expect(SMCDecoder.decode(type: "fpe2", bytes: [0x16, 0xE0]) == 1464)
#expect(SMCDecoder.decode(type: "????", bytes: [1, 2]) == nil)
}
@Test func encodeMatchesDecode() throws {
for type in ["flt ", "fpe2"] {
let bytes = try #require(SMCDecoder.encode(type: type, value: 2400))
#expect(SMCDecoder.decode(type: type, bytes: bytes) == 2400)
}
}
@Test func SMCStructMatchesKernelLayout() {
#expect(MemoryLayout<SMCParam>.stride == 80)
}
@Test func temperatureGrouping() {
#expect(TemperatureCatalog.group(for: "Tp0C") == .cpu)
#expect(TemperatureCatalog.group(for: "Te05") == .cpu)
#expect(TemperatureCatalog.group(for: "Tg1U") == .gpu)
#expect(TemperatureCatalog.group(for: "TB0T") == .battery)
#expect(TemperatureCatalog.group(for: "Ts0P") == .palmRest)
#expect(TemperatureCatalog.group(for: "TC0P") == .cpu)
#expect(TemperatureCatalog.group(for: "F0Ac") == nil)
#expect(TemperatureCatalog.group(for: "TaLP") == nil)
}
}
@Suite("格式化")
struct FormatTests {
@Test func bytes() {
#expect(Format.bytes(UInt64(512)) == "512 B")
#expect(Format.bytes(UInt64(68_719_476_736)) == "64.0 GB")
#expect(Format.bytes(UInt64(1_000_000_000_000), base: .decimal) == "1.0 TB")
#expect(Format.bytes(UInt64(412_000_000_000), base: .decimal) == "412 GB")
}
@Test func compactRate() {
#expect(Format.compactRate(0) == "0B")
#expect(Format.compactRate(692_486) == "676K")
#expect(Format.compactRate(3.5 * 1024 * 1024) == "3.5M")
#expect(Format.compactRate(42 * 1024 * 1024) == "42M")
}
@Test func menuBarRate() {
#expect(Format.menuBarRate(0) == "0 B/s")
#expect(Format.menuBarRate(512) == "512 B/s")
#expect(Format.menuBarRate(12 * 1024) == "12 KB/s")
#expect(Format.menuBarRate(154 * 1024) == "154 KB/s")
#expect(Format.menuBarRate(1000 * 1024) == "1.0 MB/s")
#expect(Format.menuBarRate(1.2 * 1024 * 1024) == "1.2 MB/s")
#expect(Format.menuBarRate(12 * 1024 * 1024) == "12 MB/s")
#expect(Format.menuBarRate(1.1 * 1024 * 1024 * 1024) == "1.1 GB/s")
}
@Test func percentAndDuration() {
#expect(Format.percent(0.257) == "26%")
#expect(Format.percent(1.4) == "100%")
#expect(Format.duration(minutes: 45) == "45 分钟")
#expect(Format.duration(minutes: 120) == "2 小时")
#expect(Format.duration(minutes: 90) == "1 小时 30 分钟")
#expect(Format.temperature(68.4) == "68°C")
#expect(Format.temperature(100, fahrenheit: true) == "212°F")
}
@Test func historyDropsOldest() {
var history = History<Int>(capacity: 3)
(1...5).forEach { history.append($0) }
#expect(history.elements == [3, 4, 5])
}
}
@Suite("本机采样", .serialized)
struct LiveSamplerTests {
@Test func cpuNeedsTwoSamples() async throws {
var sampler = CPUSampler()
#expect(sampler.sample() == nil)
try await Task.sleep(for: .milliseconds(200))
let sample = sampler.sample()
let load = try #require(sample)
#expect((0...1).contains(load.total))
#expect(load.perCore.count == CPUSampler.topology().logicalCores)
}
@Test func topologyCoversAllCores() {
let topology = CPUSampler.topology()
let indices = topology.clusters.flatMap(\.coreIndices).sorted()
#expect(indices == Array(0..<topology.logicalCores))
}
@Test func memoryIsWithinPhysicalLimits() throws {
let memory = try #require(MemorySampler().sample())
#expect(memory.total == ProcessInfo.processInfo.physicalMemory)
#expect(memory.used <= memory.total)
}
@Test func networkRateIsNonNegative() async throws {
var sampler = NetworkSampler()
_ = sampler.sample()
try await Task.sleep(for: .milliseconds(200))
let sample = sampler.sample()
let rate = try #require(sample)
#expect(rate.downloadBytesPerSecond >= 0)
#expect(rate.uploadBytesPerSecond >= 0)
}
@Test func processesIncludeCurrentProcess() {
var sampler = ProcessSampler()
let processes = sampler.sample(limit: .max)
#expect(processes.contains { $0.pid == getpid() })
}
@Test func diskHasCapacity() throws {
let disk = try #require(DiskSampler.sample())
#expect(disk.total > 0)
#expect(disk.available <= disk.total)
}
}
+156
View File
@@ -0,0 +1,156 @@
<p align="center">
<img src="App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png" width="112" alt="OpenStats 图标">
</p>
<h1 align="center">OpenStats</h1>
<p align="center">
<b>轻量、原生的 macOS 菜单栏系统监控</b><br>
CPU · GPU · 内存 · 网络 · 磁盘 · 电池 · 温度 · 风扇调速 · 防休眠<br>
<a href="https://getopenstats.com">getopenstats.com</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/macOS-14%2B-111827?logo=apple&logoColor=white" alt="macOS 14+">
<img src="https://img.shields.io/badge/Swift-6-F05138?logo=swift&logoColor=white" alt="Swift 6">
<img src="https://img.shields.io/badge/Apple%20Silicon-原生-2563EB" alt="Apple Silicon">
<img src="https://img.shields.io/badge/状态-开发中-D97706" alt="开发中">
</p>
<p align="center">
<img src="docs/images/overview-dark.png" width="49%" alt="概览(深色)">
<img src="docs/images/overview-light.png" width="49%" alt="概览(浅色)">
</p>
---
## 为什么做 OpenStats
市面上的菜单栏监控要么功能单一,要么常驻耗电。OpenStats 的目标很简单:
- **一眼看全**:点开菜单栏就是一整屏仪表盘,不用翻页、不用下拉。
- **足够省电**:面板收起时只采集菜单栏用到的指标,M5 Max 实测 **约 0.3% CPU、16 MB 内存**。
- **原生体验**:Swift 6 + AppKit + SwiftUI,macOS 26 起使用系统液态玻璃,跟随浅色 / 深色外观。
## 功能
### 菜单栏
<p><img src="docs/images/menubar-dark.png" width="600" alt="菜单栏"></p>
| 指标 | 显示方式 |
|---|---|
| CPU / GPU / 内存 | 数字、柱状图、圆环、饼图、柱状图 + 数字,逐项可选 |
| 网速 | 两行显示:绿点上传、蓝点下载,完整单位(KB/s、MB/s、GB/s) |
| CPU 温度 / 风扇转速 | 迷你数值 |
| 防休眠 | 开启时显示咖啡杯图标 |
高负载时可选数值变红提醒;数值等宽右对齐,刷新时菜单栏不抖动。
### 面板
- **概览仪表盘**:健康评分、芯片 / 内存 / 系统 / 运行时长徽章,CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇、各核心负载(超级核 / 性能核 / 能效核分组)、电池环形图、高占用进程、快捷开关。
- **进程**:按 CPU 或内存排序,辅助进程归并显示为所属应用。
- **散热**:CPU / GPU / 内存 / 电池 / 掌托温度;风扇实时转速与调速。
- **防休眠**:屏幕常亮、仅系统不休眠、**合盖后继续运行**,支持定时与低电量自动关闭。
<p align="center">
<img src="docs/images/thermal-dark.png" width="49%" alt="散热">
<img src="docs/images/keepawake-light.png" width="49%" alt="防休眠">
</p>
### 风扇调速
| 模式 | 行为 |
|---|---|
| 自动 | 交还 macOS 温控 |
| 降温 | 固定在最低到最高转速的 60% |
| 强冷 | 最高转速 |
| 自定义 | 滑块自由设定 |
安全机制:自定义模式下 CPU 达到设定温度(默认 95°C)自动交还系统控制;应用退出、崩溃或断开连接时风扇自动恢复。
### 设置
<p><img src="docs/images/settings-menubar-light.png" width="600" alt="设置"></p>
外观(跟随系统 / 浅色 / 深色)、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖模式电量下限。
## 技术实现
| 数据 | 来源 |
|---|---|
| CPU | `host_processor_info`,按 `hw.perflevelN` 区分核心类型 |
| 内存 | `host_statistics64`,口径与活动监视器一致;`kern.memorystatus_vm_pressure_level` |
| 网络 | `sysctl(NET_RT_IFLIST2)` 读取 64 位计数器(避免 `getifaddrs` 32 位回绕) |
| GPU | `IOAccelerator` 的 `PerformanceStatistics` |
| 温度 / 风扇 | AppleSMC;启动时枚举全部 SMC 键按前缀归类,**不硬编码芯片型号** |
| 进程 | `proc_pid_rusage`,CPU 时间与墙钟时间同用 mach 时间单位 |
| 电池 | `IOPowerSources` + `AppleSmartBattery` |
采集按界面需要分级:面板收起时只采菜单栏用到的指标;锁屏、屏幕休眠、系统睡眠时暂停。图表全部用 Canvas 自绘,面板关闭后立即销毁视图。
## 辅助工具与安全
风扇调速和“合盖后继续运行”需要系统权限,由 `SMAppService.daemon` 注册的辅助工具完成,首次使用时在“系统设置 › 通用 › 登录项”中批准一次。
- 只暴露固定的几个操作:设置风扇目标转速、恢复自动、切换 `pmset disablesleep`,**不提供任意命令执行**。
- 通过 XPC 通信,并校验调用方代码签名。
- 客户端断开时自动恢复风扇与睡眠设置;异常退出后下次开机也会恢复。
## 安装
尚未发布正式版本。发布后将提供经过 Developer ID 签名与 Apple 公证的 DMG。
## 从源码构建
需要 Xcode 16 及以上,以及 [XcodeGen](https://github.com/yonaskolb/XcodeGen):
```bash
brew install xcodegen
make run # 生成工程、编译并启动(Debug)
make run CONFIG=Release # Release 构建
make test # 单元测试
make snapshot # 用本机实时数据渲染各页面截图到 build/snapshots
make open # 生成并用 Xcode 打开工程
```
开发调试参数:
- `--snapshot <目录>`:输出面板、设置页、菜单栏的浅色 / 深色截图
- `--show-panel`:启动后展开并固定面板,用于测量面板打开时的资源占用
## 项目结构
```
App/ 应用入口与图标
Helper/ root 辅助工具及其 launchd 配置
Packages/OpenStatsKit/
Sources/SMC SMC 读写、风扇控制、温度传感器归类
Sources/Metrics 指标采集与分级调度(MetricsHub)
Sources/HelperShared XPC 协议
Sources/OpenStatsUI 设计 token、面板、设置窗口、菜单栏绘制
Tests/MetricsTests 单元测试
project.yml XcodeGen 工程描述
scripts/make-icon.swift 生成应用图标
docs/PLAN.md 技术方案与调研记录
```
## 路线图
- [x] 菜单栏多指标与多种显示样式
- [x] 概览仪表盘、进程、散热、防休眠
- [x] 液态玻璃面板与浅色 / 深色主题
- [x] 风扇调速与合盖运行(辅助工具)
- [ ] Developer ID 签名、公证与 DMG 发布
- [ ] Sparkle 自动更新
- [ ] 功耗、磁盘读写等更多指标
- [ ] 桌面小组件
## 致谢
SMC 通信与 Apple Silicon 风扇控制流程移植自 [exelban/stats](https://github.com/exelban/stats)(MIT License),详见 [ThirdPartyNotices.md](ThirdPartyNotices.md)。
## 许可证
待定。
+31
View File
@@ -0,0 +1,31 @@
# 第三方声明
OpenStats 的以下部分移植自 [exelban/stats](https://github.com/exelban/stats):
- `Packages/OpenStatsKit/Sources/SMC/SMCConnection.swift`:SMC 参数结构体布局与读写流程
- `Packages/OpenStatsKit/Sources/SMC/FanControl.swift`:Apple Silicon 风扇手动模式解锁流程
- 菜单栏迷你样式的字号与基线参数
```
MIT License
Copyright (c) 2019 Serhiy Mytrovtsiy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
+404
View File
@@ -0,0 +1,404 @@
# OpenStats 方案:macOS 菜单栏监控 + 系统清理
> 调研日期:2026-09-13 | 开发机:macOS 27.0 / Xcode 27 / Swift 6.4 / Apple Silicon
## ⚠️ 范围调整(2026-09-13,已实现)
以下原始方案中的**清理功能已取消**,第 5 节及相关里程碑仅作存档。当前实现的范围:
| 模块 | 状态 |
|---|---|
| 菜单栏:CPU / GPU / 内存(数字、柱状图、圆环、饼图、柱状+数字五种样式)、网速两行、温度、风扇 | ✅ |
| 面板:概览仪表盘(健康评分、三列指标卡、核心负载、电池、高占用进程、快捷开关)、进程、散热、防休眠 | ✅ |
| 液态玻璃面板(macOS 26+),浅色 / 深色 / 跟随系统 | ✅ |
| 风扇调速(自动 / 降温 / 强冷 / 自定义),CPU 过热自动交还系统 | ✅ 需安装辅助工具 |
| 防休眠:屏幕常亮、仅系统不休眠、合盖后继续运行(电量保护、定时) | ✅ 合盖模式需辅助工具 |
| 温度传感器:启动时枚举 SMC 键按前缀归类,不硬编码芯片型号 | ✅ |
| Developer ID 签名、公证、Sparkle 自动更新 | ⏳ 下一阶段 |
工程结构与运行方式见根目录 [README.md](../README.md)。
---
## 0. 结论
| 决策 | 选择 | 理由 |
|---|---|---|
| 语言 / UI | Swift 6 + AppKit(`NSStatusItem`)+ SwiftUI(面板、设置页) | 菜单栏图标自绘需要 AppKit,面板用 SwiftUI 开发效率高 |
| 最低系统 | macOS 14 Sonoma | 可用 `@Observable`、`SMAppService`、`Task.sleep(for:tolerance:)` |
| 监控层 | 参考并部分移植 **exelban/stats**(MIT) | 许可证允许,保留版权声明即可 |
| 清理层 | 参考 **tw93/Mole** 的**规则与安全模型**,用 Swift **自己重写** | Mole 是 **GPL-3.0**,不能直接复制代码进非 GPL 项目(详见 §1) |
| 沙盒 | **不沙盒**,不上 Mac App Store | 清理要访问 `~/Library` 各处,温度传感器要走 IOKit/SMC,沙盒内做不到 |
| 分发 | Developer ID 签名 + 公证(notarize)+ Sparkle 2 自动更新 + Homebrew Cask | Stats 与 Mole 都是这条路 |
| 工程组织 | Xcode App 工程 + 本地 Swift Package(多 target) | 采集、清理、UI 分离,便于单测 |
---
## 1. 参考项目核查(有几处需要纠正)
| 项目 | 核查结果 | 能怎么用 |
|---|---|---|
| [exelban/stats](https://github.com/exelban/stats) | MIT,Swift,约 4.2 万 Star,2026-09 仍在更新 | ✅ 可移植代码,需在 `LICENSE`/致谢页保留 `Copyright © Serhiy Mytrovtsiy`。注意作者声明“开源但不接受贡献” |
| [tw93/Mole](https://github.com/tw93/Mole) | **GPL-3.0**(不是 MIT),Shell + Go,约 6.7 万 Star;另有 `TRADEMARK.md` 商标声明;它的 GUI 版“Mole for Mac”是闭源商业产品 | ⚠️ 不能把它的脚本/Go 代码搬进 MIT 或闭源项目;可以参考思路、清理目录清单、安全设计(这些是事实和设计,不是代码表达)。不能用 “Mole” 名称 |
| mhaeuser/Battery-Toolkit | BSD-3-Clause,Swift | ✅ 参考特权辅助进程(Privileged Helper)+ XPC 的规范写法 |
| Sam-Spencer/bar-charts | **GitHub 上不存在**(API 返回 404) | ❌ 忽略。图表直接用系统自带 Swift Charts 即可 |
| 补充:vladkens/macmon | MIT,Rust | ✅ Apple Silicon 下无需 sudo 读功耗 / GPU 的 `IOReport` 用法参考 |
**关于 Mole 的两条路**
- **A(推荐):项目用 MIT,清理模块自己用 Swift 实现。** 只把 Mole 当“需求说明书”:看它清哪些目录、有哪些保护规则、踩过哪些坑(`SECURITY_AUDIT.md` 很有价值),然后自己写。
- B:项目整体采用 GPL-3.0,就可以直接移植 Mole 的逻辑,甚至打包调用 `mo` CLI。代价是整个 App 必须开源为 GPL,以后无法闭源或商业授权;而且调用 Shell 脚本在 GUI 里做进度、取消、错误处理都很别扭。
---
## 2. 功能范围
### MVP(第 1 阶段)
- 菜单栏:CPU 百分比 + 迷你折线(可选显示内存、网速)
- 弹出面板:CPU 总量 / 用户态 / 内核态、每核心(区分 P 核 / E 核)、Top 5 进程;内存(App / 联动 / 压缩 / 内存压力);实时网速
- 清理(仅用户态,无需管理员密码):扫描 → 分类预览 → 勾选 → **移到废纸篓** → 记录日志
- 设置:刷新频率、菜单栏显示项、开机自启(`SMAppService.mainApp`)
### V1(第 2 阶段)
- GPU 使用率、温度、功耗、风扇转速、电池健康度、磁盘读写与剩余空间
- 清理扩展:开发者缓存(Xcode / npm / pnpm / Homebrew…)、项目构建产物(`node_modules`、`target`)、安装包(`.dmg/.pkg`)、大文件查找
- 应用卸载(连带清理偏好设置、缓存、LaunchAgent)
- 白名单、历史记录、通知(CPU 持续过高、磁盘将满)
### V2(第 3 阶段)
- 需要 root 的操作:刷新 DNS、系统日志清理、Time Machine 本地快照 → 通过 `SMAppService.daemon` 安装特权辅助进程 + XPC
- 磁盘占用可视化(类似 `mo analyze`,树图)
- 桌面小组件(WidgetKit)
---
## 3. 总体架构
```
┌──────────────────────────── OpenStats.app(主进程,非沙盒)────────────────────────────┐
│ │
│ UI 层(@MainActor) │
│ ├─ StatusItemController NSStatusItem,自绘 NSImage(数字 + 迷你图),不用 SwiftUI 重建 │
│ ├─ PopoverPanel NSPanel/NSPopover 承载 SwiftUI:监控页 / 清理页 / 设置入口 │
│ └─ SettingsWindow SwiftUI Settings │
│ ▲ @Observable 状态(只存“最新值 + 环形缓冲历史”) │
│ │ │
│ 调度层(actor MetricsHub) │
│ ├─ 分级:menuBar 档(2s,只跑 CPU 总量/网速) popover 档(1s,全量 + 进程列表) │
│ ├─ 暂停:锁屏 / 屏幕休眠 / 系统睡眠时停止;低电量模式下降频 │
│ └─ Samplers:CPU / Memory / Network / GPU / Sensors / Battery / Disk / Process │
│ │
│ 清理层(actor CleanEngine) │
│ ├─ RuleCatalog 清理规则(数据化,Swift 静态表或 JSON) │
│ ├─ Scanner 并发扫描计算大小(只读,可取消) │
│ ├─ SafetyGuard 路径校验 / 保护名单 / 符号链接 / 运行中应用检测 / 白名单 │
│ ├─ Executor trashItem(默认)或 removeItem(用户显式选择) │
│ └─ OperationLog ~/Library/Logs/OpenStats/operations.jsonl │
│ │
└──────────────────────────────────────┬────────────────────────────────────────────────┘
│ XPC(NSXPCConnection,V2 才需要)
┌────────────▼─────────────┐
│ PrivilegedHelper(root) │ SMAppService.daemon 注册
│ 只接受白名单内的固定操作 │ 校验调用方签名(Team ID)
└──────────────────────────┘
```
### 工程目录
```
openstats/
├─ OpenStats.xcodeproj
├─ App/ # App target:AppDelegate、StatusItem、窗口管理
├─ Packages/OpenStatsKit/ # 本地 Swift Package
│ ├─ Sources/
│ │ ├─ Metrics/ # 各 Sampler + MetricsHub(纯逻辑,可单测)
│ │ ├─ SMCBridge/ # C/ObjC 桥接:SMC、IOHIDEventSystemClient、IOReport
│ │ ├─ Cleaner/ # RuleCatalog、Scanner、SafetyGuard、Executor
│ │ ├─ DesignSystem/ # 颜色 / 字号 / 间距 token
│ │ └─ Features/ # SwiftUI 页面:MonitorView、CleanView、SettingsView
│ └─ Tests/
├─ Helper/ # V2:特权辅助进程
├─ ThirdPartyNotices.md # Stats 的 MIT 版权声明
└─ docs/
```
---
## 4. 监控层:每个指标从哪里读
| 指标 | API | 要点 / 坑 |
|---|---|---|
| CPU 总量 & 每核 | `host_processor_info(PROCESSOR_CPU_LOAD_INFO)`,两次采样取差值 | 返回的数组是内核分配的,**每次必须 `vm_deallocate`**,否则持续泄漏;计数器用 `&-` 防回绕 |
| P 核 / E 核划分 | `sysctl hw.perflevel0.logicalcpu`(P)/ `hw.perflevel1.logicalcpu`(E) | 核心编号顺序:Apple Silicon 上 E 核通常在前,按 perflevel 数量切分 |
| 内存 | `host_statistics64(HOST_VM_INFO64)` | 与活动监视器同口径:App = `internal - purgeable`,已用 = App + wired + compressed;页大小用 `host_page_size()`(Apple Silicon 为 16K) |
| 内存压力 | `sysctl kern.memorystatus_vm_pressure_level`(1/2/4) | 需要事件时可叠加 `DispatchSource.makeMemoryPressureSource` |
| 网速 | `sysctl(NET_RT_IFLIST2)` → `if_msghdr2.ifm_data`(`if_data64`) | ⚠️ 纠正原文:`getifaddrs` 里的 `if_data.ifi_ibytes` 是 **32 位**,流量超过 4 GiB 会回绕,应改用 64 位计数器 |
| 进程列表 | `proc_listallpids` + `proc_pid_rusage(RUSAGE_INFO_V4)` / `proc_pidinfo(PROC_PIDTASKINFO)` | 只在面板展开时跑;CPU% 同样靠两次 `ri_user_time + ri_system_time` 差值 |
| GPU 使用率 | `IOServiceMatching("IOAccelerator")` → `PerformanceStatistics["Device Utilization %"]` | Stats 的 `Modules/GPU/reader.swift` 可直接参考 |
| 温度(Apple Silicon) | `IOHIDEventSystemClient` 私有 API(`kIOHIDEventTypeTemperature`)+ SMC 键 | 私有 API,需 ObjC/C 桥接头;参考 Stats `Modules/Sensors/reader.m` |
| 功耗 / GPU 频率 | `IOReport`(`libIOReport.dylib`,“Energy Model”、“GPU Stats” 通道) | 无需 sudo;参考 macmon |
| 风扇 | SMC 键 `FNum`、`F0Ac` | 读取无需 root;**写入(调速)需要 root helper**,建议不做或放到最后 |
| 电池 | `IOPSCopyPowerSourcesInfo` + `IOServiceMatching("AppleSmartBattery")` | 循环次数、设计容量、当前最大容量 |
| 磁盘空间 | `URLResourceValues.volumeAvailableCapacityForImportantUsage` | 比 `statfs` 更接近 Finder 显示(含可清除空间) |
| 磁盘读写 | `IOBlockStorageDriver` 的 `Statistics` 字典 | 两次采样取差值 |
### 功耗预算(硬指标)
| 场景 | 目标 |
|---|---|
| 面板关闭,只显示 CPU | 平均 CPU < 0.3%,常驻内存 < 40 MB |
| 面板展开,全量 | 平均 CPU < 2% |
| 锁屏 / 屏幕休眠 | 采样完全停止 |
实现手段:
1. **分级采样**:面板关闭时只跑 CPU 总量和网速;进程列表、温度、GPU 仅面板展开时启用(温度传感器是 Stats 里最耗电的模块)。
2. **定时器合并**:`Task.sleep(for:tolerance:)` 给 0.5s 容差,让系统合并唤醒。
3. **菜单栏自绘**:每次刷新只重画一张小 `NSImage` 赋给 `statusItem.button.image`,不在菜单栏里挂 SwiftUI 视图(后者每秒重建开销大)。
4. **历史数据用固定大小环形缓冲**(例如 60 个点),不做持久化数据库。
5. 监听 `NSWorkspace.screensDidSleepNotification` / `willSleepNotification` / `com.apple.screenIsLocked` 暂停;`ProcessInfo.isLowPowerModeEnabled` 时降到 5s。
6. 用 Instruments 的 **Energy Log / Time Profiler** 与活动监视器“能耗影响”列做回归测试。
---
## 5. 清理层设计(参考 Mole,Swift 重写)
### 5.1 流程
```
扫描(只读、并发、可取消)→ 分类展示大小 → 用户勾选 → 二次确认 → SafetyGuard 逐项再校验
→ 移到废纸篓(默认)/ 永久删除(需用户显式开启)→ 写操作日志 → 显示释放空间
```
所有删除只走 **一个出口函数**(对应 Mole 的 `safe_remove`),便于审计和测试。
### 5.2 规则数据化
```swift
struct CleanRule: Identifiable, Sendable {
enum Risk: Sendable { case safe, review } // safe 默认勾选,review 默认不勾选
enum Requirement: Sendable { case none, fullDiskAccess, admin }
let id: String // "user.caches"
let category: Category // .system / .browser / .developer / .apps / .downloads
let title: LocalizedStringResource
let paths: [PathPattern] // ~/Library/Caches/*、~/Downloads/*.crdownload
let minAge: Duration? // 如 7 天内修改过的跳过
let skipIfAppRunning: [String] // bundle id,运行中则跳过
let risk: Risk
let requirement: Requirement
let postAction: PostAction? // 例如调用 `xcrun simctl delete unavailable`
}
```
### 5.3 首批规则(参考 Mole 实际清理的目录整理)
| 分类 | 目录 / 操作 | 风险 | 备注 |
|---|---|---|---|
| 系统 | `~/Library/Caches/*` | safe | **所属应用正在运行则跳过**(Mole 踩过坑:删除正在使用的 SQLite 缓存会让进程写满磁盘) |
| 系统 | `~/Library/Logs/*`、`~/Library/Application Support/CrashReporter` | safe | |
| 系统 | `~/.Trash` | review | 调用 Finder 清空更稳妥 |
| 浏览器 | Chrome / Edge / Brave / Arc / Firefox 的 **Cache、Code Cache、GPUCache** 子目录 | safe | ❌ 绝不碰 Cookies、History、Login Data;浏览器运行中跳过 |
| 浏览器 | Chromium 系旧版本目录(`…/Versions/<旧版本>`) | safe | |
| 下载 | `~/Downloads/*.crdownload`、`*.part`、`*.download` | safe | 用 `lsof` 或文件修改时间确认未在下载 |
| 下载 | `~/Downloads/*.dmg / *.pkg / *.xip` | review | 列出让用户选 |
| 邮件 | `~/Library/Containers/com.apple.mail/Data/Library/Mail Downloads` | safe | 需要完全磁盘访问权限 |
| 开发 | `~/Library/Developer/Xcode/DerivedData` | safe | |
| 开发 | `~/Library/Developer/Xcode/Archives`、iOS DeviceSupport | review | |
| 开发 | 模拟器:`xcrun simctl delete unavailable` | safe | 调用命令而不是直接删目录 |
| 开发 | npm `~/.npm/_cacache`、pnpm `~/Library/pnpm/store`、yarn、pip `~/Library/Caches/pip`、Go `go clean -modcache`、Homebrew `brew cleanup` | safe | 优先调用各工具自带清理命令 |
| 开发 | Maven `~/.m2/repository`、Gradle `~/.gradle/caches`、NuGet、conda pkgs | review | 删除后需重新下载 |
| 项目产物 | 用户指定目录下的 `node_modules`、`target`、`build`、`dist`、`.next` | review | 7 天内修改过的标记为“最近使用”;避免父子目录重复删除 |
| 云盘 | Dropbox / Google Drive / OneDrive 缓存 | review | 客户端运行中跳过 |
| 备份 | `~/Library/Application Support/MobileSync/Backup` | review | **只提示,不默认勾选** |
### 5.4 SafetyGuard 规则(照搬 Mole 的安全边界思路)
1. 拒绝:空路径、相对路径、含 `..` 路径段、含控制字符的路径。
2. 保护前缀永不删除:`/`、`/System`、`/bin`、`/sbin`、`/usr`、`/etc`、`/var`、`/private`、`/Library/Extensions`;仅放行 `/private/tmp`、`/private/var/folders` 等少数子路径。
3. **符号链接**:解析后的真实路径也要过一遍拒绝规则,而且解析结果只能“更严格”不能“放行”;父目录是符号链接也要检查。
4. 保护类别:钥匙串、密码管理器、VPN/代理工具(Clash、Tailscale、WireGuard…)、AI 工具数据(Claude、ChatGPT、Cursor、Ollama)、浏览器 Cookie/历史、iCloud `Mobile Documents`、`~/Library/Messages`、Apple 自带 Group Containers、用户自己的 `~/Library/LaunchAgents/*.plist`。
5. 执行前再次校验(扫描和删除之间可能过了几分钟,应用可能已启动)。
6. 判断不了就**跳过**,不扩大删除范围。
### 5.5 权限
- **完全磁盘访问(FDA)**:尝试读取受 TCC 保护的文件(如 `~/Library/Safari/Bookmarks.plist`)判断是否已授权;未授权时引导打开 `x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`。没有 FDA 时对应规则显示为“需要授权”,不报错。
- **管理员权限**(V2):`SMAppService.daemon(plistName:)` 注册 helper,XPC 协议只暴露固定的枚举操作(如 `.flushDNS`、`.cleanSystemLogs`),**绝不暴露“删除任意路径”接口**;helper 端校验调用方代码签名。
---
## 6. 技术选型清单
| 用途 | 选择 | 备注 |
|---|---|---|
| 菜单栏 | `NSStatusItem` + 自绘 `NSImage` | 不用 `MenuBarExtra`:label 不能自由绘图、点击行为难控制 |
| 弹出面板 | `NSPanel`(非激活、点击外部关闭)+ `NSHostingView` | 比 `NSPopover` 动画和定位更可控;想省事可先用 `NSPopover` |
| 状态管理 | Observation(`@Observable`) | |
| 并发 | Swift 6 严格并发,采样和清理放 `actor` | UI 更新 `@MainActor` |
| 图表 | Swift Charts | 面板内的历史曲线;菜单栏迷你图用 Core Graphics 自绘 |
| 设置存储 | `UserDefaults` + `@AppStorage` | |
| 开机自启 | `SMAppService.mainApp.register()` | 不需要单独的 LaunchAtLogin helper |
| 自动更新 | Sparkle 2 | 通过 SPM 引入 |
| 快捷键 | sindresorhus/KeyboardShortcuts(MIT) | 可选 |
| 图标 | SF Symbols(唯一图标集) | 16pt 行内 / 20pt 独立 |
| 日志 | `os.Logger` + 清理操作 JSONL 文件 | |
| 测试 | Swift Testing;清理用临时目录做沙箱测试 | SafetyGuard 必须覆盖符号链接、`..`、保护路径等用例 |
| CI | GitHub Actions `macos-latest`:构建、测试、签名、公证、生成 DMG | 用 `xcrun notarytool` |
### 界面设计 token(与你的全局设计规范一致)
- 颜色:`primary` / `secondary` / `neutral` 灰阶 / `success` / `warning` / `error`,定义在 Asset Catalog 并提供深色变体;CPU 负载颜色:< 60% 中性色,60–85% `warning`,> 85% `error`
- 字号:12 / 14 / 16 / 20 / 24 / 32;间距:4 / 8 / 12 / 16 / 24 / 32
- 圆角:统一 token(如 `radius.sm = 6`、`radius.md = 10`、`radius.lg = 14`)区分卡片 / 面板
- 卡片只用边框或阴影之一;不用渐变、不用毛玻璃(系统面板自带材质除外)
---
## 7. 关键代码骨架
完整可运行版本见 [prototype/Samplers.swift](prototype/Samplers.swift)(本机实测输出:`CPU total 25.7% … cores=18`、`MEM used 43775MB / 65536MB … pressure=1`、`NET rx 692486 B/s`,进程常驻内存约 6.6 MB)。
### 7.1 应用入口与菜单栏
```swift
import AppKit
import SwiftUI
@main
struct OpenStatsApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene { Settings { SettingsView() } }
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private let store = MetricsStore() // @Observable,UI 读取
private let hub = MetricsHub()
private var statusItem: NSStatusItem!
private var panel: StatusPanel!
func applicationDidFinishLaunching(_ note: Notification) {
NSApp.setActivationPolicy(.accessory) // 不显示 Dock 图标(也可在 Info.plist 设 LSUIElement)
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.target = self
statusItem.button?.action = #selector(togglePanel)
panel = StatusPanel(rootView: MonitorView().environment(store))
panel.onVisibilityChange = { [hub] visible in
Task { await hub.setTier(visible ? .popover : .menuBar) }
}
Task {
await hub.start { [weak self] cpu, mem, net in
guard let self else { return }
store.apply(cpu: cpu, memory: mem, network: net)
statusItem.button?.image = MenuBarRenderer.image(cpu: store.cpuHistory, network: net)
}
}
}
@objc private func togglePanel() {
guard let button = statusItem.button else { return }
panel.toggle(relativeTo: button)
}
}
```
### 7.2 CPU 采样(节选)
```swift
struct CPUSampler: Sampler {
private var prevTicks: [[UInt32]] = []
mutating func sample() -> CPULoad? {
var cpuCount: natural_t = 0
var info: processor_info_array_t?
var infoCount: mach_msg_type_number_t = 0
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
&cpuCount, &info, &infoCount) == KERN_SUCCESS,
let info else { return nil }
defer { // 内核分配的内存必须归还
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
vm_size_t(Int(infoCount) * MemoryLayout<integer_t>.stride))
}
// …读取每核 user/system/nice/idle,与上一次做差值…
}
}
```
### 7.3 调度器(分级采样)
```swift
actor MetricsHub {
private var cpu = CPUSampler()
private var mem = MemorySampler()
private var net = NetworkSampler()
private var tier: SampleTier = .menuBar
private var loop: Task<Void, Never>?
func setTier(_ t: SampleTier) { tier = t }
func start(onUpdate: @escaping @MainActor @Sendable (CPULoad?, MemoryUsage?, NetRate?) -> Void) {
loop?.cancel()
loop = Task {
while !Task.isCancelled {
let c = cpu.sample(), n = net.sample()
let m = tier == .popover ? mem.sample() : nil // 面板关闭时不读细节
await onUpdate(c, m, n)
try? await Task.sleep(for: tier == .popover ? .seconds(1) : .seconds(2),
tolerance: .milliseconds(500))
}
}
}
}
```
### 7.4 清理执行出口
```swift
actor CleanEngine {
func execute(_ items: [CleanItem], mode: DeleteMode) async -> CleanReport {
var report = CleanReport()
for item in items {
do {
try SafetyGuard.validate(item.url) // 执行前再校验一次
if try await RunningAppProbe.isInUse(item) { report.skipped(item, .inUse); continue }
switch mode {
case .trash: try FileManager.default.trashItem(at: item.url, resultingItemURL: nil)
case .delete: try FileManager.default.removeItem(at: item.url)
}
report.cleaned(item)
} catch {
report.failed(item, error)
}
OperationLog.append(item, result: report.last)
}
return report
}
}
```
---
## 8. 里程碑
| 周 | 交付 |
|---|---|
| 第 1 周 | 工程搭建(App + 本地 Package)、`NSStatusItem` 自绘 CPU、`MetricsHub` 分级调度、锁屏暂停 |
| 第 2 周 | 弹出面板:CPU 每核(P/E)、内存、网速、Top 进程;Swift Charts 历史曲线;设置页、开机自启 |
| 第 3 周 | 清理引擎:`SafetyGuard` + 单测、规则表(系统 / 浏览器 / 下载 / Xcode)、扫描与预览 UI、废纸篓执行、操作日志 |
| 第 4 周 | FDA 权限引导、白名单、Energy Log 功耗回归、签名 + 公证 + Sparkle + DMG 打包,发布 0.1 |
| 第 5–6 周 | V1:GPU / 温度 / 功耗(IOHID + IOReport 桥接)、电池、磁盘、开发者缓存与项目产物清理、应用卸载 |
| 之后 | V2:特权 helper(DNS、系统日志)、磁盘占用可视化、小组件 |
---
## 9. 风险
| 风险 | 应对 |
|---|---|
| 误删用户数据 | 默认只移到废纸篓;review 类默认不勾选;单一删除出口 + 单测;操作日志可追溯 |
| 私有 API(IOHID 温度、IOReport)随系统更新失效 | 封装在 `SMCBridge` 内,读取失败时隐藏对应模块而不是崩溃;每个 macOS beta 回归 |
| 自身耗电过高 | §4 功耗预算作为发布门槛,CI 外手动跑 Energy Log |
| 许可证 | Stats 代码保留 MIT 声明并写入 `ThirdPartyNotices.md`;Mole 只参考不复制 |
| 公证被拒 | 开启 Hardened Runtime;私有框架用 `dlopen` 动态加载;不申请不必要的 entitlement |
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

+83
View File
@@ -0,0 +1,83 @@
name: OpenStats
options:
bundleIdPrefix: com.openstats
deploymentTarget:
macOS: "14.0"
developmentLanguage: zh-Hans
createIntermediateGroups: true
groupSortPosition: top
settings:
base:
SWIFT_VERSION: "6.0"
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
MACOSX_DEPLOYMENT_TARGET: "14.0"
# 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md)
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "-"
DEVELOPMENT_TEAM: ""
ENABLE_HARDENED_RUNTIME: YES
DEAD_CODE_STRIPPING: YES
ENABLE_USER_SCRIPT_SANDBOXING: YES
packages:
OpenStatsKit:
path: Packages/OpenStatsKit
targets:
OpenStats:
type: application
platform: macOS
sources:
- path: App
- path: Helper/com.openstats.helper.plist
buildPhase:
copyFiles:
destination: wrapper
subpath: Contents/Library/LaunchDaemons
info:
path: App/Info.plist
properties:
CFBundleName: OpenStats
CFBundleDisplayName: OpenStats
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
CFBundleDevelopmentRegion: zh-Hans
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
LSUIElement: true
LSApplicationCategoryType: public.app-category.utilities
NSHumanReadableCopyright: © 2026 OpenStats
settings:
base:
PRODUCT_NAME: OpenStats
PRODUCT_BUNDLE_IDENTIFIER: com.openstats.app
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies:
- package: OpenStatsKit
product: OpenStatsUI
- target: OpenStatsHelper
embed: true
copy:
destination: executables
OpenStatsHelper:
type: tool
platform: macOS
sources:
- path: Helper
excludes:
- "*.plist"
settings:
base:
PRODUCT_NAME: OpenStatsHelper
PRODUCT_BUNDLE_IDENTIFIER: com.openstats.helper
GENERATE_INFOPLIST_FILE: YES
CREATE_INFOPLIST_SECTION_IN_BINARY: YES
SKIP_INSTALL: YES
dependencies:
- package: OpenStatsKit
product: SMC
- package: OpenStatsKit
product: HelperShared
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env swift
//
// 生成 AppIcon.appiconset:纯色圆角底 + 白色脉搏折线(不使用 SF Symbols,避免许可问题)
// 用法:swift scripts/make-icon.swift App/Assets.xcassets/AppIcon.appiconset
//
import AppKit
let output = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "App/Assets.xcassets/AppIcon.appiconset")
try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
let primary = NSColor(srgbRed: 0x25 / 255.0, green: 0x63 / 255.0, blue: 0xEB / 255.0, alpha: 1)
/// 与界面中 PulseMark 的折线点一致(单位坐标,原点在左上)
let pulse: [CGPoint] = [
CGPoint(x: 0.00, y: 0.56), CGPoint(x: 0.26, y: 0.56), CGPoint(x: 0.36, y: 0.30),
CGPoint(x: 0.50, y: 0.80), CGPoint(x: 0.62, y: 0.40), CGPoint(x: 0.70, y: 0.56),
CGPoint(x: 1.00, y: 0.56),
]
func render(pixels: Int) -> Data {
let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
let size = CGFloat(pixels)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
// macOS 图标栅格:1024 画布内 824 的圆角矩形
let inset = size * 100 / 1024
let body = NSRect(x: inset, y: inset, width: size - inset * 2, height: size - inset * 2)
primary.setFill()
NSBezierPath(roundedRect: body, xRadius: size * 185 / 1024, yRadius: size * 185 / 1024).fill()
let markInset = body.width * 0.18
let mark = body.insetBy(dx: markInset, dy: markInset)
let path = NSBezierPath()
for (index, point) in pulse.enumerated() {
let p = NSPoint(x: mark.minX + point.x * mark.width, y: mark.maxY - point.y * mark.height)
index == 0 ? path.move(to: p) : path.line(to: p)
}
path.lineWidth = size * 64 / 1024
path.lineCapStyle = .round
path.lineJoinStyle = .round
NSColor.white.setStroke()
path.stroke()
NSGraphicsContext.restoreGraphicsState()
return rep.representation(using: .png, properties: [:])!
}
var images: [[String: String]] = []
for points in [16, 32, 128, 256, 512] {
for scale in [1, 2] {
let name = "icon_\(points)x\(points)\(scale == 2 ? "@2x" : "").png"
try render(pixels: points * scale).write(to: output.appendingPathComponent(name))
images.append(["idiom": "mac", "size": "\(points)x\(points)", "scale": "\(scale)x", "filename": name])
}
}
let contents: [String: Any] = ["images": images, "info": ["author": "xcode", "version": 1]]
let json = try JSONSerialization.data(withJSONObject: contents, options: [.prettyPrinted, .sortedKeys])
try json.write(to: output.appendingPathComponent("Contents.json"))
print("已生成 \(images.count) 张图标到 \(output.path)")