OpenStats:macOS 菜单栏系统监控初始版本
- 菜单栏:CPU/GPU/内存多样式、网速两行、温度、风扇 - 面板:概览仪表盘、进程、散热、防休眠,液态玻璃与浅色/深色主题 - 采集层:CPU/内存/网络/GPU/磁盘/电池/进程/SMC 温度与风扇,按界面分级采样 - root 辅助工具:风扇调速、合盖不睡眠,断开时自动恢复 - XcodeGen 工程、单元测试、截图模式
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 433 B |
|
After Width: | Height: | Size: 767 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 767 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
import OpenStatsUI
|
||||
|
||||
OpenStatsApplication.main()
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
## 许可证
|
||||
|
||||
待定。
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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 |
|
||||
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 207 KiB |
@@ -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
|
||||
@@ -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)")
|
||||