commit 4741d40e1b506467b6be4395e963f58de5e87654 Author: gentpan Date: Sun Sep 13 02:44:05 2026 +0500 OpenStats:macOS 菜单栏系统监控初始版本 - 菜单栏:CPU/GPU/内存多样式、网速两行、温度、风扇 - 面板:概览仪表盘、进程、散热、防休眠,液态玻璃与浅色/深色主题 - 采集层:CPU/内存/网络/GPU/磁盘/电池/进程/SMC 温度与风扇,按界面分级采样 - root 辅助工具:风扇调速、合盖不睡眠,断开时自动恢复 - XcodeGen 工程、单元测试、截图模式 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51abb5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +build/ +*.xcodeproj/ +xcuserdata/ +.build/ +.swiftpm/ +Packages/OpenStatsKit/Package.resolved diff --git a/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..5ec5b29 --- /dev/null +++ b/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} \ No newline at end of file diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..4cb0ecd Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..e04611f Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..1378b97 Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..191f659 Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..e04611f Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..490e741 Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..191f659 Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..5f6df3e Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..490e741 Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..e25626a Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/App/Assets.xcassets/Contents.json b/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Info.plist b/App/Info.plist new file mode 100644 index 0000000..ea23e1f --- /dev/null +++ b/App/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + zh-Hans + CFBundleDisplayName + OpenStats + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OpenStats + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHumanReadableCopyright + © 2026 OpenStats + + diff --git a/App/main.swift b/App/main.swift new file mode 100644 index 0000000..85c36f9 --- /dev/null +++ b/App/main.swift @@ -0,0 +1,3 @@ +import OpenStatsUI + +OpenStatsApplication.main() diff --git a/Helper/com.openstats.helper.plist b/Helper/com.openstats.helper.plist new file mode 100644 index 0000000..db6278e --- /dev/null +++ b/Helper/com.openstats.helper.plist @@ -0,0 +1,22 @@ + + + + + Label + com.openstats.helper + BundleProgram + Contents/MacOS/OpenStatsHelper + MachServices + + com.openstats.helper + + + AssociatedBundleIdentifiers + + com.openstats.app + + + RunAtLoad + + + diff --git a/Helper/main.swift b/Helper/main.swift new file mode 100644 index 0000000..f3b70f6 --- /dev/null +++ b/Helper/main.swift @@ -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 = [] + private var fans: FanControl? + private var manualFans: Set = [] + 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.. 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.. 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() diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f531151 --- /dev/null +++ b/Makefile @@ -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 diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift new file mode 100644 index 0000000..5d0b277 --- /dev/null +++ b/Packages/OpenStatsKit/Package.swift @@ -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] +) diff --git a/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift new file mode 100644 index 0000000..41c4d0a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift @@ -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) +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/CPUSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/CPUSampler.swift new file mode 100644 index 0000000..6e39010 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/CPUSampler.swift @@ -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.. 0 else { continue } + let raw = Sysctl.string("hw.perflevel\(level).name") ?? "" + clusters.append(CPUCluster(id: level, name: localizedClusterName(raw), coreIndices: Array(offset.. 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.stride)) + } + + let states = Int(CPU_STATE_MAX) + var ticks: [[UInt32]] = [] + ticks.reserveCapacity(Int(cpuCount)) + for core in 0.. 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))) : []) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift new file mode 100644 index 0000000..a855d91 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift @@ -0,0 +1,110 @@ +import Foundation + +public struct History: 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) 分钟" + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/HardwareSamplers.swift b/Packages/OpenStatsKit/Sources/Metrics/HardwareSamplers.swift new file mode 100644 index 0000000..a7dfd83 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/HardwareSamplers.swift @@ -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 = [.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.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) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/MemorySampler.swift b/Packages/OpenStatsKit/Sources/Metrics/MemorySampler.swift new file mode 100644 index 0000000..eeb959e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/MemorySampler.swift @@ -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.stride / MemoryLayout.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.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) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift new file mode 100644 index 0000000..1ff4515 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift @@ -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 = [] + 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? + 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 + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/Models.swift b/Packages/OpenStatsKit/Sources/Metrics/Models.swift new file mode 100644 index 0000000..b906189 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/Models.swift @@ -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() {} +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/NetworkSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/NetworkSampler.swift new file mode 100644 index 0000000..7fba118 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/NetworkSampler.swift @@ -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.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.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) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift new file mode 100644 index 0000000..2d85050 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift @@ -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.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[.. = 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() ?? []) : []) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/SysctlHelpers.swift b/Packages/OpenStatsKit/Sources/Metrics/SysctlHelpers.swift new file mode 100644 index 0000000..84a31ee --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/SysctlHelpers.swift @@ -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.size + guard sysctlbyname(name, &value, &size, nil, 0) == 0 else { return nil } + // 32 位值只写入低 4 字节 + return size == MemoryLayout.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) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift new file mode 100644 index 0000000..21b24a8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -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() + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift new file mode 100644 index 0000000..ce40fe3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift @@ -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)" + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift new file mode 100644 index 0000000..f07ad29 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift @@ -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: @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 + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift new file mode 100644 index 0000000..ee69edb --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift @@ -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..= 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: 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("各核心负载") + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift new file mode 100644 index 0000000..530d5c2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift @@ -0,0 +1,654 @@ +import Metrics +import SwiftUI + +/// ImageRenderer 无法渲染 ScrollView 等 AppKit 承载的控件,截图模式下改用平铺布局。 +extension EnvironmentValues { + @Entry var isSnapshot = false +} + +// MARK: - 卡片 + +struct Card: 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: 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: 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: 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: 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: 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: 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) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift new file mode 100644 index 0000000..4b92e5e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift @@ -0,0 +1,54 @@ +import AppKit +import SwiftUI + +// 液态玻璃:macOS 26 起使用系统 Liquid Glass,更早的系统退回毛玻璃材质。 +// 截图模式无法捕获窗口背后的模糊,统一改用实色。 + +extension View { + @ViewBuilder + func dsGlass(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) {} +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift new file mode 100644 index 0000000..3ffa84f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -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) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/KeepAwakePage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/KeepAwakePage.swift new file mode 100644 index 0000000..d73c957 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/KeepAwakePage.swift @@ -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) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift new file mode 100644 index 0000000..2e87fbf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift @@ -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: 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[.. 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) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift new file mode 100644 index 0000000..0dcb9f5 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift @@ -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) }) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift new file mode 100644 index 0000000..1a6eee7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift @@ -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 + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift new file mode 100644 index 0000000..272a5dc --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift @@ -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 { + 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 + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift new file mode 100644 index 0000000..f60f254 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift @@ -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: 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: 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: 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)) + } + } + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift new file mode 100644 index 0000000..1f1dc7f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -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(_ 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) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift new file mode 100644 index 0000000..59a4a43 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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() + 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) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift new file mode 100644 index 0000000..3bdef70 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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 { + 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" + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift new file mode 100644 index 0000000..3818611 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift @@ -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 + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift new file mode 100644 index 0000000..d119baa --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift @@ -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) 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? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(_ value: String?) { + lock.lock() + let pending = continuation + continuation = nil + lock.unlock() + pending?.resume(returning: value) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/KeepAwakeController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/KeepAwakeController.swift new file mode 100644 index 0000000..10522d0 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/KeepAwakeController.swift @@ -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? + @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 + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift new file mode 100644 index 0000000..57f5a9e --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift @@ -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(capacity: historyCapacity) + public private(set) var cpuSystem = History(capacity: historyCapacity) + public private(set) var cpuTotal = History(capacity: historyCapacity) + + public private(set) var memory: MemoryUsage? + public private(set) var memoryHistory = History(capacity: historyCapacity) + public private(set) var network: NetworkRate? + public private(set) var downloadHistory = History(capacity: historyCapacity) + public private(set) var uploadHistory = History(capacity: historyCapacity) + public private(set) var networkInterface: NetworkInterfaceInfo? + + public private(set) var gpu: GPUUsage? + public private(set) var gpuHistory = History(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 } + } +} diff --git a/Packages/OpenStatsKit/Sources/SMC/FanControl.swift b/Packages/OpenStatsKit/Sources/SMC/FanControl.swift new file mode 100644 index 0000000..b70b391 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/SMC/FanControl.swift @@ -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.. 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..> $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.. 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.stride + let kr = IOConnectCallStructMethod(connection, UInt32(SMCSelector.kernelIndex.rawValue), + &input, MemoryLayout.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 + } + } +} diff --git a/Packages/OpenStatsKit/Sources/SMC/TemperatureCatalog.swift b/Packages/OpenStatsKit/Sources/SMC/TemperatureCatalog.swift new file mode 100644 index 0000000..5c0c1bb --- /dev/null +++ b/Packages/OpenStatsKit/Sources/SMC/TemperatureCatalog.swift @@ -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 = 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...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(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..= 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) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..981dd54 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +

+ OpenStats 图标 +

+ +

OpenStats

+ +

+ 轻量、原生的 macOS 菜单栏系统监控
+ CPU · GPU · 内存 · 网络 · 磁盘 · 电池 · 温度 · 风扇调速 · 防休眠
+ getopenstats.com +

+ +

+ macOS 14+ + Swift 6 + Apple Silicon + 开发中 +

+ +

+ 概览(深色) + 概览(浅色) +

+ +--- + +## 为什么做 OpenStats + +市面上的菜单栏监控要么功能单一,要么常驻耗电。OpenStats 的目标很简单: + +- **一眼看全**:点开菜单栏就是一整屏仪表盘,不用翻页、不用下拉。 +- **足够省电**:面板收起时只采集菜单栏用到的指标,M5 Max 实测 **约 0.3% CPU、16 MB 内存**。 +- **原生体验**:Swift 6 + AppKit + SwiftUI,macOS 26 起使用系统液态玻璃,跟随浅色 / 深色外观。 + +## 功能 + +### 菜单栏 + +

菜单栏

+ +| 指标 | 显示方式 | +|---|---| +| CPU / GPU / 内存 | 数字、柱状图、圆环、饼图、柱状图 + 数字,逐项可选 | +| 网速 | 两行显示:绿点上传、蓝点下载,完整单位(KB/s、MB/s、GB/s) | +| CPU 温度 / 风扇转速 | 迷你数值 | +| 防休眠 | 开启时显示咖啡杯图标 | + +高负载时可选数值变红提醒;数值等宽右对齐,刷新时菜单栏不抖动。 + +### 面板 + +- **概览仪表盘**:健康评分、芯片 / 内存 / 系统 / 运行时长徽章,CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇、各核心负载(超级核 / 性能核 / 能效核分组)、电池环形图、高占用进程、快捷开关。 +- **进程**:按 CPU 或内存排序,辅助进程归并显示为所属应用。 +- **散热**:CPU / GPU / 内存 / 电池 / 掌托温度;风扇实时转速与调速。 +- **防休眠**:屏幕常亮、仅系统不休眠、**合盖后继续运行**,支持定时与低电量自动关闭。 + +

+ 散热 + 防休眠 +

+ +### 风扇调速 + +| 模式 | 行为 | +|---|---| +| 自动 | 交还 macOS 温控 | +| 降温 | 固定在最低到最高转速的 60% | +| 强冷 | 最高转速 | +| 自定义 | 滑块自由设定 | + +安全机制:自定义模式下 CPU 达到设定温度(默认 95°C)自动交还系统控制;应用退出、崩溃或断开连接时风扇自动恢复。 + +### 设置 + +

设置

+ +外观(跟随系统 / 浅色 / 深色)、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖模式电量下限。 + +## 技术实现 + +| 数据 | 来源 | +|---|---| +| 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)。 + +## 许可证 + +待定。 diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md new file mode 100644 index 0000000..8a92838 --- /dev/null +++ b/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. +``` diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..5132912 --- /dev/null +++ b/docs/PLAN.md @@ -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.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? + + 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 | diff --git a/docs/images/keepawake-light.png b/docs/images/keepawake-light.png new file mode 100644 index 0000000..82d2474 Binary files /dev/null and b/docs/images/keepawake-light.png differ diff --git a/docs/images/menubar-dark.png b/docs/images/menubar-dark.png new file mode 100644 index 0000000..180f27a Binary files /dev/null and b/docs/images/menubar-dark.png differ diff --git a/docs/images/overview-dark.png b/docs/images/overview-dark.png new file mode 100644 index 0000000..16753b5 Binary files /dev/null and b/docs/images/overview-dark.png differ diff --git a/docs/images/overview-light.png b/docs/images/overview-light.png new file mode 100644 index 0000000..1c80f6a Binary files /dev/null and b/docs/images/overview-light.png differ diff --git a/docs/images/settings-menubar-light.png b/docs/images/settings-menubar-light.png new file mode 100644 index 0000000..b6a7c88 Binary files /dev/null and b/docs/images/settings-menubar-light.png differ diff --git a/docs/images/thermal-dark.png b/docs/images/thermal-dark.png new file mode 100644 index 0000000..cf43011 Binary files /dev/null and b/docs/images/thermal-dark.png differ diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..cfef4de --- /dev/null +++ b/project.yml @@ -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 diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift new file mode 100644 index 0000000..45c72ac --- /dev/null +++ b/scripts/make-icon.swift @@ -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)")