CPU 与内存详情改为 OpenStats 自己的设计:热力图、水位条、压力走势与按应用汇总

- CPU:占用、状态、30 秒变化与温度余量;核心热力图;核心分工与最忙核心;排队程度(每核平均负载与趋势);按应用汇总
- 内存:还可用多少与压力走势条;内存水位条;压缩省下的内存、压缩比与交换区实时读写;按应用汇总
- 数据层新增各核心占用历史、内存压力历史、压缩前大小与交换区换入换出累计量;进程采样返回全部可读进程
- 去掉仿 Stats 的圆环与仪表组件
This commit is contained in:
gentpan committed 2026-09-13 06:13:32 +05:00
1 parent 50d04ebda8
commit 3caf690987
12 files changed
+455 -251

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 162 KiB

+3 -3
View File
@@ -18,10 +18,10 @@
- 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。
- DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。
- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。
- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
@@ -28,7 +28,9 @@ public struct MemorySampler {
var swap = xsw_usage()
var swapSize = MemoryLayout<xsw_usage>.size
let swapUsed = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0) == 0 ? swap.xsu_used : 0
let hasSwap = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0) == 0
let swapUsed = hasSwap ? swap.xsu_used : 0
let swapTotal = hasSwap ? swap.xsu_total : 0
let level = Sysctl.int("kern.memorystatus_vm_pressure_level") ?? 1
return MemoryUsage(total: total,
@@ -37,6 +39,10 @@ public struct MemorySampler {
compressed: UInt64(stats.compressor_page_count) * pageSize,
cached: (UInt64(stats.external_page_count) + purgeable) * pageSize,
swapUsed: swapUsed,
pressure: MemoryPressure(rawValue: level) ?? .normal)
swapTotal: swapTotal,
pressure: MemoryPressure(rawValue: level) ?? .normal,
uncompressed: stats.total_uncompressed_pages_in_compressor * pageSize,
swapInBytes: stats.swapins * pageSize,
swapOutBytes: stats.swapouts * pageSize)
}
}
@@ -40,10 +40,21 @@ public struct MemoryUsage: Sendable, Equatable {
public var compressed: UInt64
public var cached: UInt64
public var swapUsed: UInt64
public var swapTotal: UInt64
public var pressure: MemoryPressure
/// 被压缩的内容原本的大小;与 compressed 之差就是压缩省下的内存
public var uncompressed: UInt64
/// 开机以来从交换区换入 / 换出的累计字节数,用相邻两次采样算速率
public var swapInBytes: UInt64
public var swapOutBytes: UInt64
public var used: UInt64 { app + wired + compressed }
public var usedFraction: Double { total == 0 ? 0 : min(1, Double(used) / Double(total)) }
public var available: UInt64 { total > used ? total - used : 0 }
/// 空闲 = 总量 - 已用 - 缓存文件
public var free: UInt64 { total > used + cached ? total - used - cached : 0 }
public var compressionSavings: UInt64 { uncompressed > compressed ? uncompressed - compressed : 0 }
public var compressionRatio: Double? { compressed > 0 && uncompressed > 0 ? Double(uncompressed) / Double(compressed) : nil }
}
public struct NetworkInterfaceInfo: Sendable, Equatable {
@@ -20,7 +20,8 @@ public struct ProcessSampler {
public init() {}
/// 返回按 CPU 排序的进程;无权限读取的进程(通常属于 root)会被跳过。
public mutating func sample(limit: Int = 50) -> [ProcessUsage] {
/// 默认返回全部可读进程:按应用汇总内存时,CPU 很低但内存很大的进程也要算进去
public mutating func sample(limit: Int = .max) -> [ProcessUsage] {
let capacity = Int(proc_listallpids(nil, 0)) + 64
guard capacity > 64 else { return [] }
var pids = [pid_t](repeating: 0, count: capacity)
@@ -213,82 +213,134 @@ struct CoreClusterBars: View {
}
}
/// 分段圆环:各段首尾相接(内存的 App / 联动 / 压缩),剩余为底槽
struct SegmentedRing<Center: View>: View {
let segments: [(fraction: Double, color: Color)]
var lineWidth: CGFloat = DS.Space.s2
var size: CGFloat = DS.Space.s16
@ViewBuilder var center: Center
/// 核心热力图:每行一个核心(按类型分组,高性能档在上),每列一次采样,最新在右。
/// 颜色越深越忙,超过 85% 用警告色
struct CoreHeatmap: View {
let topology: CPUTopology
let history: [[Double]]
var columns = 40
var rowHeight: CGFloat = DS.Space.s1 - DS.Size.stroke
private static let gap = DS.Size.stroke
private static let groupGap = DS.Space.s1
var body: some View {
ZStack {
Circle().stroke(DS.Palette.track, lineWidth: lineWidth)
ForEach(Array(ranges.enumerated()), id: \.offset) { _, range in
Circle()
.trim(from: range.start, to: range.end)
.stroke(range.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
.rotationEffect(.degrees(-90))
let clusters = topology.clusters.sorted { $0.id < $1.id }
let height = clusters.reduce(CGFloat(0)) { $0 + CGFloat($1.coreIndices.count) * (rowHeight + Self.gap) }
+ CGFloat(max(0, clusters.count - 1)) * Self.groupGap
HStack(alignment: .top, spacing: DS.Space.s2) {
VStack(alignment: .leading, spacing: Self.groupGap) {
ForEach(clusters) { cluster in
Text(verbatim: cluster.name)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
.frame(height: CGFloat(cluster.coreIndices.count) * (rowHeight + Self.gap), alignment: .center)
}
}
center
.fixedSize()
Canvas { context, size in
let recent = Array(history.suffix(columns))
let cellWidth = (size.width - Self.gap * CGFloat(columns - 1)) / CGFloat(columns)
let offset = columns - recent.count
var y: CGFloat = 0
for cluster in clusters {
for core in cluster.coreIndices.reversed() {
for column in 0..<columns {
let rect = CGRect(x: CGFloat(column) * (cellWidth + Self.gap), y: y, width: cellWidth, height: rowHeight)
let value = column >= offset ? recent[column - offset][safe: core] : nil
context.fill(Path(rect), with: .color(Self.color(value)))
}
y += rowHeight + Self.gap
}
y += Self.groupGap
}
}
.frame(height: height)
}
.padding(lineWidth / 2)
.frame(width: size, height: size)
.accessibilityElement(children: .ignore)
.accessibilityLabel("核心热力图")
}
private var ranges: [(start: Double, end: Double, color: Color)] {
var start = 0.0
return segments.map { segment in
let end = min(1, start + max(0, segment.fraction))
defer { start = end }
return (start, end, segment.color)
static func color(_ value: Double?) -> Color {
guard let value else { return DS.Palette.track }
if value >= 0.85 { return DS.Palette.warning }
return DS.Palette.primary.opacity(0.08 + 0.92 * min(1, max(0, value)))
}
}
/// 热力图图例:低 → 高
struct HeatLegend: View {
var body: some View {
HStack(spacing: DS.Space.s1) {
Text("闲").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
ForEach([0.05, 0.3, 0.55, 0.8, 0.9], id: \.self) { value in
RoundedRectangle(cornerRadius: DS.Radius.sm / 2)
.fill(CoreHeatmap.color(value))
.frame(width: DS.Space.s2, height: DS.Space.s2)
}
Text("忙").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
/// 内存压力仪表:半圆弧分正常、偏高、严重三段,指针指向当前档位
struct PressureGauge: View {
let pressure: MemoryPressure?
var width: CGFloat = DS.Space.s16
/// 压力走势条:每次采样一段,绿色正常、橙色偏高、红色严重
struct PressureStrip: View {
let history: [MemoryPressure]
var capacity = MetricsStore.historyCapacity
var body: some View {
VStack(spacing: DS.Space.s1) {
Canvas { context, size in
let lineWidth = DS.Space.s2 - DS.Space.s1 / 2
let radius = min(size.width / 2, size.height) - lineWidth / 2
let center = CGPoint(x: size.width / 2, y: size.height - DS.Space.s1)
// 角度从左(0°)经过顶部到右(180°)
func point(_ degrees: Double, _ r: CGFloat) -> CGPoint {
let radians = degrees * .pi / 180
return CGPoint(x: center.x - r * cos(radians), y: center.y - r * sin(radians))
Canvas { context, size in
let width = size.width / CGFloat(capacity)
let offset = capacity - min(capacity, history.count)
let recent = history.suffix(capacity)
context.clip(to: Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: size.height / 2))
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(DS.Palette.track))
for (index, pressure) in recent.enumerated() {
let color: Color = switch pressure {
case .normal: DS.Palette.success
case .warning: DS.Palette.warning
case .critical: DS.Palette.error
}
let bands: [(Double, Double, Color)] = [(0, 58, DS.Palette.success), (62, 118, DS.Palette.warning), (122, 180, DS.Palette.error)]
for (start, end, color) in bands {
var arc = Path()
arc.move(to: point(start, radius))
for step in stride(from: start, through: end, by: 2) { arc.addLine(to: point(step, radius)) }
context.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
}
let angle: Double = switch pressure {
case .warning: 90
case .critical: 150
default: 30
}
var needle = Path()
needle.move(to: center)
needle.addLine(to: point(angle, radius * 0.72))
context.stroke(needle, with: .color(DS.Palette.textPrimary),
style: StrokeStyle(lineWidth: DS.Size.chartLine * 2, lineCap: .round))
let dot = DS.Space.s1 + DS.Space.s1 / 2
context.fill(Path(ellipseIn: CGRect(x: center.x - dot / 2, y: center.y - dot / 2, width: dot, height: dot)),
with: .color(DS.Palette.textPrimary))
// 多画 1pt 盖住相邻段之间的缝
let rect = CGRect(x: CGFloat(offset + index) * width, y: 0, width: width + DS.Size.stroke, height: size.height)
context.fill(Path(rect), with: .color(color))
}
.frame(width: width, height: width / 2 + DS.Space.s1)
Text(pressure.map { "压力\($0.title)" } ?? "—")
.dsFont(.xs, weight: .semibold)
.foregroundStyle(DS.Palette.textSecondary)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("内存压力 \(pressure?.title ?? "未知")")
.frame(height: DS.Space.s2)
.accessibilityElement()
.accessibilityLabel("最近的内存压力走势")
}
}
/// 横向水位条:各段按大小依次排开,段与段之间留 1pt 缝
struct WaterlineBar: View {
let segments: [(value: Double, color: Color)]
let total: Double
var height: CGFloat = DS.Space.s4 + DS.Space.s1
var body: some View {
Canvas { context, size in
context.clip(to: Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: DS.Radius.sm))
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(DS.Palette.track))
var x: CGFloat = 0
for segment in segments where segment.value > 0 {
let width = size.width * CGFloat(segment.value / max(total, 1))
context.fill(Path(CGRect(x: x, y: 0, width: max(0, width - DS.Size.stroke), height: size.height)),
with: .color(segment.color))
x += width
}
}
.frame(height: height)
.accessibilityHidden(true)
}
}
extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -27,107 +27,131 @@ struct CPUPopover: View {
let settings = model.settings
let store = model.store
let cpu = store.cpu
let temperature = store.sensors?.temperature(.cpu)
let load = cpu?.loadAverage.first
let cores = Double(max(1, store.topology.logicalCores))
// 三个圆环:温度、总占用、1 分钟负载(按逻辑核心数归一)
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
LabeledRing(title: "温度", fraction: (temperature?.maximum ?? 0) / DS.Thermal.scaleMax,
color: temperature.map { Tone.forTemperature($0.maximum).color } ?? DS.Palette.primary,
value: temperature.map { "\(Int($0.maximum.rounded()))°" } ?? "—")
.frame(maxWidth: .infinity)
LabeledRing(title: "占用", fraction: cpu?.total ?? 0, color: loadTone(cpu?.total ?? 0).color,
value: cpu.map { Format.percent($0.total) } ?? "—", large: true)
.frame(maxWidth: .infinity)
LabeledRing(title: "负载", fraction: (load ?? 0) / cores, color: loadTone((load ?? 0) / cores).color,
value: load.map { $0.formatted(.number.precision(.fractionLength(2))) } ?? "—")
.frame(maxWidth: .infinity)
}
}
CPUHero()
ForEach(MenuBarItem.cpu.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .cpuHistory:
SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) {
LineHistoryChart(values: store.cpuTotal.elements, height: detailChartHeight(isDetailPage))
case .cpuHeatmap:
SectionCard(title: section.title, trailing: { HeatLegend() }) {
CoreHeatmap(topology: store.topology, history: store.coreHistory.elements,
columns: isDetailPage ? 60 : 36,
rowHeight: isDetailPage ? DS.Space.s2 : DS.Space.s1 - DS.Size.stroke)
Text("每行一个核心,每列一次采样,最新的在右边")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
case .cpuCores:
case .cpuClusters:
SectionCard(title: section.title) {
CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? [],
barHeight: isDetailPage ? DS.Space.s16 : DS.Space.s12, byCluster: true)
}
case .cpuDetails:
SectionCard(title: section.title) {
LegendRow(color: DS.Palette.primary, label: "用户", value: cpu.map { Format.percent($0.user) } ?? "—")
LegendRow(color: DS.Palette.secondary, label: "系统", value: cpu.map { Format.percent($0.system) } ?? "—")
LegendRow(color: DS.Palette.track, label: "空闲", value: cpu.map { Format.percent(max(0, 1 - $0.total)) } ?? "—")
ForEach(store.topology.clusters) { cluster in
LegendRow(color: DS.Palette.cluster(cluster.id), label: "\(cluster.name)平均",
value: CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []).map { Format.percent($0) } ?? "—")
let average = CoreClusterBars.average(of: cluster, perCore: cpu?.perCore ?? []) ?? 0
ShareRow(label: "\(cluster.name) · \(cluster.coreIndices.count) 核", value: Format.percent(average),
fraction: average, color: DS.Palette.cluster(cluster.id))
}
InfoRow(label: "处理器", text: "\(store.topology.brand) · \(store.topology.logicalCores) 核")
if let boot = store.system.bootDate {
InfoRow(label: "已运行", text: Format.uptime(since: boot))
if let busiest = busiestCore(store.topology, cpu?.perCore ?? []) {
InfoRow(label: "最忙的核心", text: busiest)
}
}
case .cpuLoadAverage:
SectionCard(title: section.title, trailing: { Text(verbatim: "\(store.topology.logicalCores) 核") }) {
SectionCard(title: section.title, trailing: { Text(loadTrend(cpu?.loadAverage ?? [])) }) {
let averages = cpu?.loadAverage ?? []
ForEach(Array(["1 分钟", "5 分钟", "15 分钟"].enumerated()), id: \.offset) { index, label in
let value = index < averages.count ? averages[index] : nil
VStack(spacing: DS.Space.s1) {
InfoRow(label: label, text: value.map { $0.formatted(.number.precision(.fractionLength(2))) } ?? "—")
ProgressTrack(fraction: (value ?? 0) / cores, color: loadTone((value ?? 0) / cores).color, height: DS.Space.s1)
}
let value = averages[safe: index] ?? 0
ShareRow(label: label,
value: "每核 \((value / cores).formatted(.number.precision(.fractionLength(2))))",
fraction: value / cores, color: loadTone(value / cores).color)
}
Text("平均负载除以核心数:小于 1 表示任务不用排队,大于 1 表示有任务在等 CPU")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
case .cpuProcesses:
case .cpuApps:
SectionCard(title: section.title, trailing: { Text("CPU") }) {
let processes = store.processes
ProcessList(count: processes.count, rowCount: 8) { index in
let process = processes[index]
HStack(spacing: DS.Space.s2) {
ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName)
Text(verbatim: "\((process.cpu * 100).formatted(.number.precision(.fractionLength(1))))%")
.dsFont(.xs, weight: .medium)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
}
.explainable(.init(process))
}
AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.cpu > $1.cpu },
rowCount: isDetailPage ? 10 : 6, metric: .cpu)
}
default:
EmptyView()
}
}
}
private func busiestCore(_ topology: CPUTopology, _ perCore: [Double]) -> String? {
guard let (index, value) = perCore.enumerated().max(by: { $0.element < $1.element }).map({ ($0.offset, $0.element) }),
let cluster = topology.clusters.first(where: { $0.coreIndices.contains(index) }) else { return nil }
return "\(cluster.name) #\(index + 1) · \(Format.percent(value))"
}
private func loadTrend(_ averages: [Double]) -> String {
guard averages.count >= 3, averages[2] > 0 else { return "" }
let change = averages[0] / averages[2]
return change > 1.15 ? "负载在上升" : change < 0.85 ? "负载在下降" : "负载平稳"
}
}
/// 圆环 + 中间数值 + 下方标题
private struct LabeledRing: View {
let title: String
let fraction: Double
let color: Color
let value: String
var large = false
/// CPU 顶部:大号占用、状态、与 30 秒前相比的变化、温度余量,下面是走势线和用户 / 系统 / 空闲构成
private struct CPUHero: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(spacing: DS.Space.s1) {
RingGauge(fraction: fraction, color: color,
lineWidth: large ? DS.Space.s2 : DS.Space.s1 + DS.Space.s1 / 2,
size: large ? DS.Space.s16 + DS.Space.s2 : DS.Space.s12 + DS.Space.s1) {
Text(verbatim: value)
.dsFont(large ? .base : .xs, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
.minimumScaleFactor(0.7)
.lineLimit(1)
let store = model.store
let cpu = store.cpu
let total = cpu?.total ?? 0
let history = store.cpuTotal.elements
let temperature = store.sensors?.temperature(.cpu)
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
HeroValue(value: cpu.map { "\(Int(($0.total * 100).rounded()))" } ?? "—", unit: "%", size: .xxl)
VStack(alignment: .leading, spacing: DS.Space.s1) {
StatusBadge(text: status(total), tone: total >= 0.85 ? .error : total >= 0.6 ? .warning : .success)
Text(verbatim: trend(history))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
Spacer(minLength: DS.Space.s2)
if let temperature {
VStack(alignment: .trailing, spacing: 0) {
Text("温度余量").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Text(verbatim: "\(Int(max(0, 100 - temperature.maximum).rounded()))°C")
.dsFont(.base, weight: .semibold)
.monospacedDigit()
.foregroundStyle(Tone.forTemperature(temperature.maximum).color)
Text(verbatim: "当前 \(Format.temperature(temperature.maximum, fahrenheit: model.settings.useFahrenheit))")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
.help("离 100°C 还差多少度;越接近 0,越可能因过热降频")
}
}
LineHistoryChart(values: history, height: DS.Space.s8)
if let cpu {
WaterlineBar(segments: [(cpu.user, DS.Palette.primary), (cpu.system, DS.Palette.secondary)],
total: 1, height: DS.Space.s2)
HStack(spacing: DS.Space.s3) {
LegendItem(color: DS.Palette.primary, label: "用户", value: Format.percent(cpu.user))
LegendItem(color: DS.Palette.secondary, label: "系统", value: Format.percent(cpu.system))
LegendItem(color: DS.Palette.track, label: "空闲", value: Format.percent(max(0, 1 - cpu.total)))
}
}
Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
private func status(_ total: Double) -> String {
total >= 0.85 ? "满载" : total >= 0.6 ? "繁忙" : total >= 0.25 ? "适中" : "空闲"
}
/// 与大约 30 秒前(第 15 个采样之前)相比的变化
private func trend(_ history: [Double]) -> String {
guard history.count > 15, let now = history.last else { return "正在收集走势…" }
let before = history[history.count - 16]
let delta = Int(((now - before) * 100).rounded())
if abs(delta) < 3 { return "与 30 秒前持平" }
return delta > 0 ? "比 30 秒前高 \(delta)%" : "比 30 秒前低 \(-delta)%"
}
}
// MARK: - 内存
@@ -141,73 +165,208 @@ struct MemoryPopover: View {
let store = model.store
let memory = store.memory
// 左边内存压力仪表,右边按 App / 联动 / 压缩分段的占用圆环
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
PressureGauge(pressure: memory?.pressure)
.frame(maxWidth: .infinity)
SegmentedRing(segments: memory.map(segments) ?? [], size: DS.Space.s16 + DS.Space.s2) {
VStack(spacing: 0) {
Text(verbatim: memory.map { Format.percent($0.usedFraction) } ?? "—")
.dsFont(.base, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: memory.map { Format.bytes($0.total) } ?? "")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
}
.frame(maxWidth: .infinity)
}
}
MemoryHero()
ForEach(MenuBarItem.memory.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in
switch section {
case .memoryHistory:
SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) {
LineHistoryChart(values: store.memoryHistory.elements, height: detailChartHeight(isDetailPage))
}
case .memoryBreakdown:
SectionCard(title: section.title) {
case .memoryWaterline:
SectionCard(title: section.title, trailing: { Text(verbatim: memory.map { Format.bytes($0.total) } ?? "") }) {
if let memory {
InfoRow(label: "已用", text: "\(Format.bytes(memory.used)) / \(Format.bytes(memory.total))")
MemoryBar(memory: memory)
LegendRow(color: DS.Palette.primary, label: "App 内存", value: Format.bytes(memory.app))
LegendRow(color: DS.Palette.secondary, label: "联动内存", value: Format.bytes(memory.wired))
LegendRow(color: DS.Palette.warning, label: "被压缩", value: Format.bytes(memory.compressed))
LegendRow(color: DS.Palette.track, label: "可用",
value: Format.bytes(memory.total > memory.used ? memory.total - memory.used : 0))
LegendRow(color: DS.Palette.neutral300, label: "缓存文件", value: Format.bytes(memory.cached))
InfoRow(label: "交换区", text: Format.bytes(memory.swapUsed))
let parts: [(String, UInt64, Color)] = [
("App", memory.app, DS.Palette.primary),
("联动", memory.wired, DS.Palette.secondary),
("压缩", memory.compressed, DS.Palette.warning),
("缓存", memory.cached, DS.Palette.primarySoft),
("空闲", memory.free, DS.Palette.track),
]
WaterlineBar(segments: parts.map { (Double($0.1), $0.2) }, total: Double(memory.total))
LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible())], spacing: DS.Space.s2) {
ForEach(parts, id: \.0) { part in
HStack(spacing: DS.Space.s1) {
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(part.2)
.frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
Text(part.0).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer(minLength: DS.Space.s1)
Text(verbatim: Format.bytes(part.1)).dsFont(.xs, weight: .medium).monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
}
}
}
PurgeMemoryRow()
}
case .memoryProcesses:
SectionCard(title: section.title, trailing: { Text("内存") }) {
let processes = store.processes.sorted { $0.memory > $1.memory }
ProcessList(count: processes.count, rowCount: 8) { index in
let process = processes[index]
HStack(spacing: DS.Space.s2) {
ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName)
Text(verbatim: Format.bytes(process.memory))
.dsFont(.xs, weight: .medium)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
case .memoryCompression:
SectionCard(title: section.title) {
if let memory {
InfoRow(label: "压缩为你省下") {
Text(verbatim: Format.bytes(memory.compressionSavings)).foregroundStyle(DS.Palette.success)
}
InfoRow(label: "压缩比", text: memory.compressionRatio.map { "\($0.formatted(.number.precision(.fractionLength(1))))×" } ?? "—")
InfoRow(label: "交换区", text: memory.swapTotal > 0
? "\(Format.bytes(memory.swapUsed)) / \(Format.bytes(memory.swapTotal))" : "未使用")
InfoRow(label: "换入 / 换出", text: store.swapRate.map { "\(Format.menuBarRate($0.swapIn)) / \(Format.menuBarRate($0.swapOut))" } ?? "—")
if let rate = store.swapRate, rate.swapOut > 1024 * 1024 {
InfoBanner(icon: "exclamationmark.triangle.fill", text: "系统正在把内存写到磁盘,可能会变慢。可以关掉占用大的应用。", tone: .warning)
}
.explainable(.init(process))
}
}
case .memoryApps:
SectionCard(title: section.title, trailing: { Text("内存") }) {
AppUsageList(apps: AppUsage.group(store.processes).sorted { $0.memory > $1.memory },
rowCount: isDetailPage ? 10 : 6, metric: .memory, total: memory?.used)
}
default:
EmptyView()
}
}
}
}
private func segments(_ memory: MemoryUsage) -> [(fraction: Double, color: Color)] {
let total = max(1, Double(memory.total))
return [(Double(memory.app) / total, DS.Palette.primary),
(Double(memory.wired) / total, DS.Palette.secondary),
(Double(memory.compressed) / total, DS.Palette.warning)]
/// 内存顶部:可用多少、压力状态、最近一分钟的压力走势
private struct MemoryHero: View {
@Environment(AppModel.self) private var model
var body: some View {
let store = model.store
let memory = store.memory
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(alignment: .center, spacing: DS.Space.s3) {
VStack(alignment: .leading, spacing: 0) {
Text("还可用").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
HeroValue(value: memory.map { availableNumber($0.available) } ?? "—", unit: "GB", size: .xxl)
}
Spacer(minLength: DS.Space.s2)
VStack(alignment: .trailing, spacing: DS.Space.s1) {
StatusBadge(text: "压力\(memory?.pressure.title ?? "—")", tone: tone(memory?.pressure))
Text(verbatim: memory.map { "已用 \(Format.bytes($0.used)) · \(Format.percent($0.usedFraction))" } ?? "")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
}
PressureStrip(history: store.pressureHistory.elements)
HStack {
Text("压力走势").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
Spacer()
Text("最近 60 秒").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
private func availableNumber(_ bytes: UInt64) -> String {
(Double(bytes) / 1_073_741_824).formatted(.number.precision(.fractionLength(1)))
}
private func tone(_ pressure: MemoryPressure?) -> Tone {
switch pressure {
case .normal: .success
case .warning: .warning
case .critical: .error
case nil: .neutral
}
}
}
// MARK: - 按应用汇总
/// 同一个应用的主进程与辅助进程合并计算
struct AppUsage: Identifiable {
let id: String
let name: String
let bundlePath: String?
var cpu: Double
var memory: UInt64
var processCount: Int
/// 占用最高的那个进程,用于图标与 Apple 智能解释
var representative: ProcessUsage
@MainActor
static func group(_ processes: [ProcessUsage]) -> [AppUsage] {
var groups: [String: AppUsage] = [:]
for process in processes {
let key = process.appBundlePath ?? "pid-\(process.pid)"
if var group = groups[key] {
group.cpu += process.cpu
group.memory += process.memory
group.processCount += 1
if process.memory > group.representative.memory { group.representative = process }
groups[key] = group
} else {
let name = process.appBundlePath.map { AppNameCache.shared.name(forBundle: $0) } ?? process.displayName
groups[key] = AppUsage(id: key, name: name, bundlePath: process.appBundlePath,
cpu: process.cpu, memory: process.memory, processCount: 1, representative: process)
}
}
return Array(groups.values)
}
}
private struct AppUsageList: View {
enum Metric { case cpu, memory }
let apps: [AppUsage]
let rowCount: Int
let metric: Metric
var total: UInt64?
var body: some View {
let visible = Array(apps.prefix(rowCount))
// 横条以列表里最大的一项为满格,比较谁占得多
let peak = visible.map { metric == .cpu ? $0.cpu : Double($0.memory) }.max() ?? 1
if visible.isEmpty {
Text("正在统计…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(visible) { app in
let value = metric == .cpu ? app.cpu : Double(app.memory)
VStack(spacing: DS.Space.s1) {
HStack(spacing: DS.Space.s2) {
AppIconCache.shared.image(bundlePath: app.bundlePath)
.resizable()
.frame(width: DS.Size.iconInline, height: DS.Size.iconInline)
Text(verbatim: app.name)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
if app.processCount > 1 {
Text(verbatim: "\(app.processCount) 个进程").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
Spacer(minLength: DS.Space.s2)
Text(verbatim: valueText(app))
.dsFont(.xs, weight: .medium)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
}
ProgressTrack(fraction: peak > 0 ? value / peak : 0, color: DS.Palette.primary, height: DS.Space.s1)
.padding(.leading, DS.Size.iconInline + DS.Space.s2)
}
.explainable(.init(app.representative))
}
}
private func valueText(_ app: AppUsage) -> String {
switch metric {
case .cpu:
return "\((app.cpu * 100).formatted(.number.precision(.fractionLength(1))))%"
case .memory:
guard let total, total > 0 else { return Format.bytes(app.memory) }
return "\(Format.bytes(app.memory)) · \(Format.percent(Double(app.memory) / Double(total)))"
}
}
}
/// 标签 + 数值 + 横条
private struct ShareRow: View {
let label: String
let value: String
let fraction: Double
let color: Color
var body: some View {
VStack(spacing: DS.Space.s1) {
InfoRow(label: label, text: value)
ProgressTrack(fraction: fraction, color: color, height: DS.Space.s1 + DS.Space.s1 / 2)
}
}
}
@@ -238,49 +397,8 @@ private struct ExplainableRow<Content: View>: View {
}
}
private struct LegendRow: View {
let color: Color
let label: String
let value: String
var body: some View {
HStack(spacing: DS.Space.s2) {
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(color)
.frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer(minLength: DS.Space.s2)
Text(verbatim: value).dsFont(.xs, weight: .medium).monospacedDigit().foregroundStyle(DS.Palette.textPrimary)
}
}
}
/// App / 联动 / 压缩三段堆叠条,剩余部分为可用
private struct MemoryBar: View {
let memory: MemoryUsage
var body: some View {
GeometryReader { proxy in
let total = max(1, Double(memory.total))
let parts: [(UInt64, Color)] = [(memory.app, DS.Palette.primary), (memory.wired, DS.Palette.secondary),
(memory.compressed, DS.Palette.warning)]
HStack(spacing: DS.Size.stroke) {
ForEach(Array(parts.enumerated()), id: \.offset) { _, part in
Rectangle().fill(part.1)
.frame(width: max(0, proxy.size.width * Double(part.0) / total - DS.Size.stroke))
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
.background(DS.Palette.track)
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm))
}
.frame(height: DS.Space.s3)
}
}
private struct PurgeMemoryRow: View {
@Environment(AppModel.self) private var model
@Environment(\.isDetailPage) private var isDetailPage
var body: some View {
let maintenance = model.maintenance
@@ -55,8 +55,8 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
/// 点击该项弹出的详情里可以显示的内容,按显示顺序排列
var popoverSections: [PopoverSection] {
switch self {
case .cpu: [.cpuHistory, .cpuCores, .cpuDetails, .cpuLoadAverage, .cpuProcesses]
case .memory: [.memoryHistory, .memoryBreakdown, .memoryProcesses]
case .cpu: [.cpuHeatmap, .cpuClusters, .cpuLoadAverage, .cpuApps]
case .memory: [.memoryWaterline, .memoryCompression, .memoryApps]
case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses]
case .gpu: [.gpuHistory, .gpuDetails]
case .temperature: [.thermalSensors, .thermalFans]
@@ -81,8 +81,8 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
/// 详情弹窗里可以单独隐藏的区块
public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
case cpuHistory, cpuCores, cpuDetails, cpuLoadAverage, cpuProcesses
case memoryHistory, memoryBreakdown, memoryProcesses
case cpuHeatmap, cpuClusters, cpuLoadAverage, cpuApps
case memoryWaterline, memoryCompression, memoryApps
case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses
case gpuHistory, gpuDetails
case thermalSensors, thermalFans
@@ -91,13 +91,13 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
var title: String {
switch self {
case .cpuHistory: "负载历史"
case .cpuCores: "核心负载"
case .cpuDetails: "详细信息"
case .cpuLoadAverage: "平均负载"
case .cpuProcesses, .memoryProcesses, .networkProcesses: "高占用进程"
case .memoryHistory: "使用历史"
case .memoryBreakdown: "内存构成"
case .cpuHeatmap: "核心热力图"
case .cpuClusters: "核心分工"
case .cpuLoadAverage: "排队程度"
case .cpuApps, .memoryApps: "按应用汇总"
case .networkProcesses: "高占用进程"
case .memoryWaterline: "内存水位"
case .memoryCompression: "压缩与交换"
case .networkHistory: "流量历史"
case .networkProbe: "连接探测"
case .networkInterface: "接口"
@@ -14,9 +14,15 @@ public final class MetricsStore {
public private(set) var cpu: CPULoad?
public private(set) var cpuTotal = History<Double>(capacity: historyCapacity)
/// 每次采样各核心的占用,用于核心热力图
public private(set) var coreHistory = History<[Double]>(capacity: historyCapacity)
public private(set) var memory: MemoryUsage?
public private(set) var memoryHistory = History<Double>(capacity: historyCapacity)
public private(set) var pressureHistory = History<MemoryPressure>(capacity: historyCapacity)
/// 交换区换入 / 换出速率(字节 / 秒)
public private(set) var swapRate: (swapIn: Double, swapOut: Double)?
@ObservationIgnored private var lastSwap: (date: Date, swapIn: UInt64, swapOut: UInt64)?
public private(set) var network: NetworkRate?
public private(set) var downloadHistory = History<Double>(capacity: historyCapacity)
public private(set) var uploadHistory = History<Double>(capacity: historyCapacity)
@@ -38,10 +44,18 @@ public final class MetricsStore {
if let cpu = snapshot.cpu {
self.cpu = cpu
cpuTotal.append(cpu.total)
coreHistory.append(cpu.perCore)
}
if let memory = snapshot.memory {
self.memory = memory
memoryHistory.append(memory.usedFraction)
pressureHistory.append(memory.pressure)
if let last = lastSwap, snapshot.date > last.date {
let seconds = snapshot.date.timeIntervalSince(last.date)
swapRate = (Double(memory.swapInBytes >= last.swapIn ? memory.swapInBytes - last.swapIn : 0) / seconds,
Double(memory.swapOutBytes >= last.swapOut ? memory.swapOutBytes - last.swapOut : 0) / seconds)
}
lastSwap = (snapshot.date, memory.swapInBytes, memory.swapOutBytes)
}
if let network = snapshot.network {
self.network = network
+7 -5
View File
@@ -62,10 +62,10 @@ Not released yet · **38** changes in development · [full changelog](CHANGELOG.
- 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。
- DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。
- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。
- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
@@ -139,8 +139,10 @@ Not released yet · **38** changes in development · [full changelog](CHANGELOG.
**Detail popovers** — each metric gets its own menu-bar item; click it for a narrow popover.
Choose which sections each popover shows in Settings. `Esc` closes it.
- **CPU.** Load history, per-core load grouped by core type, processor info, busiest processes.
- **Memory.** Usage history, breakdown with one-click purge, busiest processes.
- **CPU.** Usage with a status and 30-second change, thermal headroom; a per-core heatmap; load by core type;
queueing (load average per core, rising or falling); usage summed by app.
- **Memory.** What is still available with a pressure timeline; a waterline bar; memory saved by compression and
live swap I/O; usage summed by app.
- **Network.** Traffic history; a connection-probe grid (latency, jitter, loss); interface,
Wi-Fi signal, VPN / proxy; local and public IPv4 / IPv6 with a flag, region and ASN; DNS flush
and one-click switching to Cloudflare, Google, Tencent, Alibaba Cloud or manual servers;
+5 -5
View File
@@ -60,10 +60,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
- 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。
- DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。
- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。
- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
@@ -134,8 +134,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
<p align="center"><img src="Assets/readme/menubar-dark.png" width="600" alt="菜单栏"></p>
**详情弹窗**——每个指标一个菜单栏图标,点击弹出该项的窄详情,显示哪些区块可在设置里勾选;按 `Esc` 关闭。
- **CPU**:负载历史、按超级核 / 性能核 / 能效核分组的核心负载、处理器信息、高占用进程。
- **内存**:使用历史、内存构成与一键释放、高占用进程。
- **CPU**:占用与状态、与 30 秒前的变化、温度余量;核心热力图;核心分工;排队程度(每核平均负载与趋势);按应用汇总。
- **内存**:还可用多少与压力走势;内存水位条;压缩省下的内存与交换区读写;按应用汇总。
- **网络**:流量历史;连接探测格子(延迟、抖动、丢包);接口、Wi-Fi 信号、VPN / 代理;本地与公网 IPv4 / IPv6,
归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。
- **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。