CPU 与内存详情改用圆环、仪表与分色柱形图,新增用 Apple 智能解释进程

- CPU:温度 / 占用 / 负载三个圆环,负载历史面积图,核心按类型分色,各类核心平均占用与 1 / 5 / 15 分钟平均负载
- 内存:压力仪表与按 App / 联动 / 压缩分段的圆环,补充已用、可用与交换区
- 进程右键或进程页星形按钮调用系统自带的本机大模型解释进程,附带路径、所属应用与签名方,不联网
- 进程显示本地化应用名,详情弹窗进程列表 8 行;修复截图模式下短页面卡片被拉高
- 更新 README 截图与说明,更新日志同步到官网
This commit is contained in:
gentpan committed 2026-09-13 06:01:41 +05:00
1 parent 99e970fa8f
commit 50d04ebda8
25 files changed
+581 -75

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 261 KiB

+4
View File
@@ -18,6 +18,10 @@
- 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。 - 网络详情:上下镜像的流量历史;连接探测格子(每 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 可能被接管。 - DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。 - 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。
- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。
- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
@@ -53,6 +53,16 @@ public final class AppController: NSObject, NSApplicationDelegate, NSWindowDeleg
if arguments.contains("--show-window") { if arguments.contains("--show-window") {
mainWindow.show(tab: nil) mainWindow.show(tab: nil)
} }
// 开发调试:--explain-process <进程名> 打开进程页并用 Apple 智能解释该进程
if let index = arguments.firstIndex(of: "--explain-process"), let name = arguments.dropFirst(index + 1).first {
mainWindow.show(tab: .processes)
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
guard let self else { return }
let processes = self.model.store.processes
guard let process = processes.first(where: { $0.name.localizedCaseInsensitiveContains(name) }) ?? processes.first else { return }
self.model.explainProcess(.init(process))
}
}
let model = self.model let model = self.model
Task { [weak self] in Task { [weak self] in
@@ -141,6 +141,13 @@ struct CoreClusterBars: View {
let topology: CPUTopology let topology: CPUTopology
let perCore: [Double] let perCore: [Double]
var barHeight: CGFloat = DS.Size.coreBarHeight var barHeight: CGFloat = DS.Size.coreBarHeight
/// 按核心类型着色(各类核心的平均占用显示在详细信息里)
var byCluster = false
static func average(of cluster: CPUCluster, perCore: [Double]) -> Double? {
let values = cluster.coreIndices.compactMap { $0 < perCore.count ? perCore[$0] : nil }
return values.isEmpty ? nil : values.reduce(0, +) / Double(values.count)
}
private static let barGap = DS.Space.s1 / 2 private static let barGap = DS.Space.s1 / 2
private static let groupGap = DS.Space.s3 private static let groupGap = DS.Space.s3
@@ -165,8 +172,8 @@ struct CoreClusterBars: View {
context.fill(Path(roundedRect: track, cornerRadius: DS.Radius.sm / 2), with: .color(DS.Palette.track)) context.fill(Path(roundedRect: track, cornerRadius: DS.Radius.sm / 2), with: .color(DS.Palette.track))
let fillHeight = size.height * value let fillHeight = size.height * value
let fill = CGRect(x: x, y: size.height - fillHeight, width: barWidth, height: fillHeight) let fill = CGRect(x: x, y: size.height - fillHeight, width: barWidth, height: fillHeight)
context.fill(Path(roundedRect: fill, cornerRadius: DS.Radius.sm / 2), let color = byCluster ? DS.Palette.cluster(cluster.id) : value >= 0.8 ? DS.Palette.warning : DS.Palette.primary
with: .color(value >= 0.8 ? DS.Palette.warning : DS.Palette.primary)) context.fill(Path(roundedRect: fill, cornerRadius: DS.Radius.sm / 2), with: .color(color))
x += barWidth + Self.barGap x += barWidth + Self.barGap
} }
x += Self.groupGap - Self.barGap x += Self.groupGap - Self.barGap
@@ -180,10 +187,17 @@ struct CoreClusterBars: View {
HStack(spacing: Self.groupGap) { HStack(spacing: Self.groupGap) {
ForEach(clusters) { cluster in ForEach(clusters) { cluster in
let count = CGFloat(cluster.coreIndices.count) let count = CGFloat(cluster.coreIndices.count)
Text(verbatim: "\(cluster.name) · \(cluster.coreIndices.count)") HStack(spacing: DS.Space.s1) {
.dsFont(.xs) if byCluster {
.foregroundStyle(DS.Palette.textTertiary) RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(DS.Palette.cluster(cluster.id))
.lineLimit(1) .frame(width: DS.Size.barHeight, height: DS.Size.barHeight)
}
Text(verbatim: label(cluster))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
.lineLimit(1)
}
.frame(width: count * barWidth + (count - 1) * Self.barGap, alignment: .leading) .frame(width: count * barWidth + (count - 1) * Self.barGap, alignment: .leading)
} }
} }
@@ -193,6 +207,89 @@ struct CoreClusterBars: View {
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel("各核心负载") .accessibilityLabel("各核心负载")
} }
private func label(_ cluster: CPUCluster) -> String {
"\(cluster.name) · \(cluster.coreIndices.count)"
}
}
/// 分段圆环:各段首尾相接(内存的 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
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))
}
center
}
.padding(lineWidth / 2)
.frame(width: size, height: size)
}
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)
}
}
}
/// 内存压力仪表:半圆弧分正常、偏高、严重三段,指针指向当前档位
struct PressureGauge: View {
let pressure: MemoryPressure?
var width: CGFloat = DS.Space.s16
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))
}
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))
}
.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 ?? "未知")")
}
} }
/// 上下镜像的流量图:上半部分上传、下半部分下载,各自按自己的峰值缩放 /// 上下镜像的流量图:上半部分上传、下半部分下载,各自按自己的峰值缩放
@@ -34,6 +34,17 @@ extension DS {
static let border = Color.dynamic(light: 0x000000, lightAlpha: 0.07, dark: 0xFFFFFF, darkAlpha: 0.09) 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 track = Color.dynamic(light: 0x000000, lightAlpha: 0.06, dark: 0xFFFFFF, darkAlpha: 0.10)
static let neutral300 = Color.dynamic(light: 0xD1D5DB, dark: 0x3F434A) static let neutral300 = Color.dynamic(light: 0xD1D5DB, dark: 0x3F434A)
/// 浅一档的品牌蓝,用来区分同一图表里的第二类数据(如性能核)
static let primarySoft = Color.dynamic(light: 0x93C5FD, dark: 0x60A5FA)
/// 核心类型配色:最高性能档为品牌蓝,其次浅蓝,能效核灰色
static func cluster(_ id: Int) -> Color {
switch id {
case 0: primary
case 1: primarySoft
default: neutral300
}
}
} }
} }
@@ -35,6 +35,8 @@ public struct MainWindowView: View {
} }
} }
.frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
// 截图时页面取自身高度:侧边栏更高时,多出的高度不能分给页面里可伸展的卡片
.fixedSize(horizontal: false, vertical: isSnapshot)
} }
.frame(minWidth: DS.Size.panelWidth, maxWidth: .infinity) .frame(minWidth: DS.Size.panelWidth, maxWidth: .infinity)
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top) .frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
@@ -478,6 +478,9 @@ private struct TopProcessesCard: View {
NSPasteboard.general.clearContents() NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(process.pid), forType: .string) NSPasteboard.general.setString(String(process.pid), forType: .string)
} }
if ProcessExplainer.isSupported {
Button("用 Apple 智能解释") { model.explainProcess(.init(process)) }
}
Divider() Divider()
Button("查看全部进程") { model.settings.panelTab = .processes } Button("查看全部进程") { model.settings.panelTab = .processes }
} label: { } label: {
@@ -17,6 +17,9 @@ struct ProcessesPage: View {
let processes = sorted(model.store.processes) let processes = sorted(model.store.processes)
PageScroll { PageScroll {
if model.explainer.subject != nil {
ProcessExplanationCard()
}
HStack { HStack {
SegmentedControl(selection: $sort, options: Sort.allCases.map { ($0, $0.title) }) SegmentedControl(selection: $sort, options: Sort.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.tileWidth) .frame(width: DS.Size.tileWidth)
@@ -72,6 +75,7 @@ private struct ProcessRow: View {
let emphasis: ProcessesPage.Sort let emphasis: ProcessesPage.Sort
@State private var hovering = false @State private var hovering = false
@Environment(\.isSnapshot) private var isSnapshot @Environment(\.isSnapshot) private var isSnapshot
@Environment(AppModel.self) private var model
var body: some View { var body: some View {
HStack(spacing: DS.Space.s3) { HStack(spacing: DS.Space.s3) {
@@ -97,6 +101,12 @@ private struct ProcessRow: View {
.dsFont(.sm, weight: emphasis == .memory ? .semibold : .regular) .dsFont(.sm, weight: emphasis == .memory ? .semibold : .regular)
.foregroundStyle(emphasis == .memory ? DS.Palette.textPrimary : DS.Palette.textSecondary) .foregroundStyle(emphasis == .memory ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.frame(width: DS.Size.valueColumn, alignment: .trailing) .frame(width: DS.Size.valueColumn, alignment: .trailing)
if ProcessExplainer.isSupported {
MiniIconButton(systemName: "sparkles", help: "用 Apple 智能解释这个进程") {
model.explainProcess(.init(process))
}
.opacity(hovering || isSnapshot ? 1 : 0)
}
} }
.monospacedDigit() .monospacedDigit()
.padding(.horizontal, DS.Space.s4) .padding(.horizontal, DS.Space.s4)
@@ -113,6 +123,10 @@ private struct ProcessRow: View {
NSPasteboard.general.clearContents() NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(process.pid), forType: .string) NSPasteboard.general.setString(String(process.pid), forType: .string)
} }
if ProcessExplainer.isSupported {
Divider()
Button("用 Apple 智能解释") { model.explainProcess(.init(process)) }
}
}) })
} }
@@ -130,6 +144,57 @@ private struct ProcessRow: View {
} }
} }
/// Apple 智能对某个进程的解释,流式显示
private struct ProcessExplanationCard: View {
@Environment(AppModel.self) private var model
var body: some View {
let explainer = model.explainer
if let subject = explainer.subject {
Card(padding: DS.Space.s4, spacing: DS.Space.s3) {
HStack(spacing: DS.Space.s2) {
Image(systemName: "sparkles")
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.primary)
Text("Apple 智能解释").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "\(subject.displayName) · PID \(subject.pid)")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
if explainer.phase == .done {
MiniIconButton(systemName: "arrow.clockwise", help: "重新生成") { explainer.explain(subject) }
}
MiniIconButton(systemName: "xmark", help: "关闭") { explainer.dismiss() }
}
switch explainer.phase {
case .failed(let message):
InfoBanner(icon: "exclamationmark.triangle.fill", text: message, tone: .warning)
default:
if explainer.text.isEmpty {
HStack(spacing: DS.Space.s2) {
ProgressView().controlSize(.small)
Text("正在本机生成…").dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
}
} else {
Text(explainer.text)
.dsFont(.sm)
.foregroundStyle(DS.Palette.textPrimary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
Text("由 Apple 智能在这台 Mac 上生成,不联网;内容可能不准确,结束进程前请自行确认。")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
/// 进程图标缓存:只保留 40px 小位图,避免每次刷新解码系统图标的全部分辨率 /// 进程图标缓存:只保留 40px 小位图,避免每次刷新解码系统图标的全部分辨率
@MainActor @MainActor
final class AppIconCache { final class AppIconCache {
@@ -158,15 +223,39 @@ final class AppIconCache {
} }
} }
/// 应用的本地化显示名(“WeChat.app”显示为“微信”),按包路径缓存
final class AppNameCache: @unchecked Sendable {
static let shared = AppNameCache()
private let lock = NSLock()
private var names: [String: String] = [:]
func name(forBundle path: String) -> String {
lock.lock()
defer { lock.unlock() }
if let cached = names[path] { return cached }
var name = FileManager.default.displayName(atPath: path)
if name.hasSuffix(".app") { name = String(name.dropLast(4)) }
if names.count > 256 { names.removeAll() }
names[path] = name
return name
}
}
extension NetworkProcessUsage {
var localizedName: String {
appBundlePath.map { AppNameCache.shared.name(forBundle: $0) } ?? name
}
}
extension ProcessUsage { extension ProcessUsage {
private var appName: String? { private var appName: String? {
appBundlePath.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent } appBundlePath.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
} }
/// 应用的辅助进程显示为“主应用名” /// 应用本身及其辅助进程都显示为应用的本地化名称
var displayName: String { var displayName: String {
guard let appName, name.hasPrefix(appName), name != appName else { return name } guard let appName, let appBundlePath, name.hasPrefix(appName) else { return name }
return appName return AppNameCache.shared.name(forBundle: appBundlePath)
} }
/// 辅助进程的角色,例如 “Helper (Renderer)” /// 辅助进程的角色,例如 “Helper (Renderer)”
@@ -28,26 +28,22 @@ struct CPUPopover: View {
let store = model.store let store = model.store
let cpu = store.cpu let cpu = store.cpu
let temperature = store.sensors?.temperature(.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) { Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) { HStack(alignment: .center, spacing: DS.Space.s3) {
RingGauge(fraction: cpu?.total ?? 0, color: loadTone(cpu?.total ?? 0).color, size: heroGauge) { LabeledRing(title: "温度", fraction: (temperature?.maximum ?? 0) / DS.Thermal.scaleMax,
Text(verbatim: cpu.map { Format.percent($0.total) } ?? "—") color: temperature.map { Tone.forTemperature($0.maximum).color } ?? DS.Palette.primary,
.dsFont(.sm, weight: .semibold) value: temperature.map { "\(Int($0.maximum.rounded()))°" } ?? "—")
.monospacedDigit() .frame(maxWidth: .infinity)
.foregroundStyle(DS.Palette.textPrimary) LabeledRing(title: "占用", fraction: cpu?.total ?? 0, color: loadTone(cpu?.total ?? 0).color,
} value: cpu.map { Format.percent($0.total) } ?? "—", large: true)
VStack(alignment: .leading, spacing: DS.Space.s1) { .frame(maxWidth: .infinity)
LegendItem(color: DS.Palette.primary, label: "用户", value: cpu.map { Format.percent($0.user) } ?? "—") LabeledRing(title: "负载", fraction: (load ?? 0) / cores, color: loadTone((load ?? 0) / cores).color,
LegendItem(color: DS.Palette.secondary, label: "系统", value: cpu.map { Format.percent($0.system) } ?? "—") value: load.map { $0.formatted(.number.precision(.fractionLength(2))) } ?? "—")
LegendItem(color: DS.Palette.neutral300, label: "空闲", .frame(maxWidth: .infinity)
value: cpu.map { Format.percent(max(0, 1 - $0.total)) } ?? "—")
}
Spacer(minLength: 0)
if let temperature {
Chip(text: Format.temperature(temperature.maximum, fahrenheit: settings.useFahrenheit),
icon: "thermometer.medium", tone: Tone.forTemperature(temperature.maximum))
}
} }
} }
@@ -55,25 +51,42 @@ struct CPUPopover: View {
switch section { switch section {
case .cpuHistory: case .cpuHistory:
SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) { SectionCard(title: section.title, trailing: { Text("最近 60 秒") }) {
BarHistoryChart(values: store.cpuTotal.elements, capacity: isDetailPage ? 60 : 40, height: detailChartHeight(isDetailPage)) LineHistoryChart(values: store.cpuTotal.elements, height: detailChartHeight(isDetailPage))
} }
case .cpuCores: case .cpuCores:
SectionCard(title: section.title) { SectionCard(title: section.title) {
CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? []) CoreClusterBars(topology: store.topology, perCore: cpu?.perCore ?? [],
barHeight: isDetailPage ? DS.Space.s16 : DS.Space.s12, byCluster: true)
} }
case .cpuDetails: case .cpuDetails:
SectionCard(title: section.title) { SectionCard(title: section.title) {
InfoRow(label: "处理器", text: store.topology.brand) LegendRow(color: DS.Palette.primary, label: "用户", value: cpu.map { Format.percent($0.user) } ?? "—")
InfoRow(label: "核心", text: coreSummary(store.topology)) LegendRow(color: DS.Palette.secondary, label: "系统", value: cpu.map { Format.percent($0.system) } ?? "—")
InfoRow(label: "负载平均", text: cpu.map { loadAverage($0.loadAverage) } ?? "—") 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) } ?? "—")
}
InfoRow(label: "处理器", text: "\(store.topology.brand) · \(store.topology.logicalCores) 核")
if let boot = store.system.bootDate { if let boot = store.system.bootDate {
InfoRow(label: "已运行", text: Format.uptime(since: boot)) InfoRow(label: "已运行", text: Format.uptime(since: boot))
} }
} }
case .cpuLoadAverage:
SectionCard(title: section.title, trailing: { Text(verbatim: "\(store.topology.logicalCores) 核") }) {
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)
}
}
}
case .cpuProcesses: case .cpuProcesses:
SectionCard(title: section.title, trailing: { Text("CPU") }) { SectionCard(title: section.title, trailing: { Text("CPU") }) {
let processes = store.processes let processes = store.processes
ProcessList(count: processes.count) { index in ProcessList(count: processes.count, rowCount: 8) { index in
let process = processes[index] let process = processes[index]
HStack(spacing: DS.Space.s2) { HStack(spacing: DS.Space.s2) {
ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName) ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName)
@@ -82,6 +95,7 @@ struct CPUPopover: View {
.monospacedDigit() .monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary) .foregroundStyle(DS.Palette.textSecondary)
} }
.explainable(.init(process))
} }
} }
default: default:
@@ -89,14 +103,30 @@ struct CPUPopover: View {
} }
} }
} }
}
private func coreSummary(_ topology: CPUTopology) -> String { /// 圆环 + 中间数值 + 下方标题
let clusters = topology.clusters.map { "\($0.coreIndices.count) \($0.name)" }.joined(separator: " + ") private struct LabeledRing: View {
return clusters.isEmpty ? "\(topology.logicalCores) 核" : "\(topology.logicalCores) 核(\(clusters))" let title: String
} let fraction: Double
let color: Color
let value: String
var large = false
private func loadAverage(_ values: [Double]) -> String { var body: some View {
values.prefix(3).map { $0.formatted(.number.precision(.fractionLength(2))) }.joined(separator: " · ") 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)
}
Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
} }
} }
@@ -111,19 +141,23 @@ struct MemoryPopover: View {
let store = model.store let store = model.store
let memory = store.memory let memory = store.memory
// 左边内存压力仪表,右边按 App / 联动 / 压缩分段的占用圆环
Card(padding: DS.Space.s3, spacing: DS.Space.s2) { Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) { HStack(alignment: .center, spacing: DS.Space.s3) {
RingGauge(fraction: memory?.usedFraction ?? 0, color: pressureTone(memory?.pressure).color, size: heroGauge) { PressureGauge(pressure: memory?.pressure)
Text(verbatim: memory.map { Format.percent($0.usedFraction) } ?? "—") .frame(maxWidth: .infinity)
.dsFont(.sm, weight: .semibold) SegmentedRing(segments: memory.map(segments) ?? [], size: DS.Space.s16 + DS.Space.s2) {
.monospacedDigit() VStack(spacing: 0) {
.foregroundStyle(DS.Palette.textPrimary) 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)
}
} }
VStack(alignment: .leading, spacing: DS.Space.s1) { .frame(maxWidth: .infinity)
HeroValue(value: memory.map { Format.bytes($0.used) } ?? "—", unit: memory.map { "/ \(Format.bytes($0.total))" }, size: .lg)
StatusBadge(text: "内存压力 \(memory?.pressure.title ?? "—")", tone: pressureTone(memory?.pressure))
}
Spacer(minLength: 0)
} }
} }
@@ -136,19 +170,22 @@ struct MemoryPopover: View {
case .memoryBreakdown: case .memoryBreakdown:
SectionCard(title: section.title) { SectionCard(title: section.title) {
if let memory { if let memory {
InfoRow(label: "已用", text: "\(Format.bytes(memory.used)) / \(Format.bytes(memory.total))")
MemoryBar(memory: memory) MemoryBar(memory: memory)
LegendRow(color: DS.Palette.primary, label: "App 内存", value: Format.bytes(memory.app)) 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.secondary, label: "联动内存", value: Format.bytes(memory.wired))
LegendRow(color: DS.Palette.warning, label: "被压缩", value: Format.bytes(memory.compressed)) 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)) LegendRow(color: DS.Palette.neutral300, label: "缓存文件", value: Format.bytes(memory.cached))
LegendRow(color: DS.Palette.track, label: "交换空间", value: Format.bytes(memory.swapUsed)) InfoRow(label: "交换区", text: Format.bytes(memory.swapUsed))
} }
PurgeMemoryRow() PurgeMemoryRow()
} }
case .memoryProcesses: case .memoryProcesses:
SectionCard(title: section.title, trailing: { Text("内存") }) { SectionCard(title: section.title, trailing: { Text("内存") }) {
let processes = store.processes.sorted { $0.memory > $1.memory } let processes = store.processes.sorted { $0.memory > $1.memory }
ProcessList(count: processes.count) { index in ProcessList(count: processes.count, rowCount: 8) { index in
let process = processes[index] let process = processes[index]
HStack(spacing: DS.Space.s2) { HStack(spacing: DS.Space.s2) {
ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName) ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName)
@@ -157,6 +194,7 @@ struct MemoryPopover: View {
.monospacedDigit() .monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary) .foregroundStyle(DS.Palette.textSecondary)
} }
.explainable(.init(process))
} }
} }
default: default:
@@ -165,16 +203,41 @@ struct MemoryPopover: View {
} }
} }
private func pressureTone(_ pressure: MemoryPressure?) -> Tone { private func segments(_ memory: MemoryUsage) -> [(fraction: Double, color: Color)] {
switch pressure { let total = max(1, Double(memory.total))
case .normal: .success return [(Double(memory.app) / total, DS.Palette.primary),
case .warning: .warning (Double(memory.wired) / total, DS.Palette.secondary),
case .critical: .error (Double(memory.compressed) / total, DS.Palette.warning)]
case nil: .neutral }
}
extension View {
/// 进程行右键菜单:用 Apple 智能解释
@ViewBuilder
func explainable(_ subject: ProcessExplainer.Subject) -> some View {
if ProcessExplainer.isSupported {
ExplainableRow(subject: subject) { self }
} else {
self
} }
} }
} }
private struct ExplainableRow<Content: View>: View {
let subject: ProcessExplainer.Subject
@ViewBuilder var content: Content
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
var body: some View {
content
.contentShape(Rectangle())
.contextMenu(isSnapshot ? nil : ContextMenu {
Button("用 Apple 智能解释") { model.explainProcess(subject) }
})
}
}
private struct LegendRow: View { private struct LegendRow: View {
let color: Color let color: Color
let label: String let label: String
@@ -341,10 +341,10 @@ private struct NetworkProcessesSection: View {
Text("上传").frame(width: DS.Size.valueColumn, alignment: .trailing) Text("上传").frame(width: DS.Size.valueColumn, alignment: .trailing)
} }
}) { }) {
ProcessList(count: processes.count, emptyText: "正在统计各进程流量…") { index in ProcessList(count: processes.count, rowCount: 8, emptyText: "正在统计各进程流量…") { index in
let process = processes[index] let process = processes[index]
HStack(spacing: 0) { HStack(spacing: 0) {
ProcessNameLabel(icon: AppIconCache.shared.image(bundlePath: process.appBundlePath), name: process.name) ProcessNameLabel(icon: AppIconCache.shared.image(bundlePath: process.appBundlePath), name: process.localizedName)
Text(verbatim: Format.menuBarRate(process.download)) Text(verbatim: Format.menuBarRate(process.download))
.frame(width: DS.Size.valueColumn, alignment: .trailing) .frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(verbatim: Format.menuBarRate(process.upload)) Text(verbatim: Format.menuBarRate(process.upload))
@@ -353,6 +353,7 @@ private struct NetworkProcessesSection: View {
.dsFont(.xs) .dsFont(.xs)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary) .foregroundStyle(DS.Palette.textSecondary)
.explainable(.init(process))
} }
} }
} }
@@ -16,6 +16,7 @@ public final class AppModel {
public let cleaner: CleanerController public let cleaner: CleanerController
public let maintenance: MaintenanceController public let maintenance: MaintenanceController
public let network: NetworkController public let network: NetworkController
public let explainer = ProcessExplainer()
@ObservationIgnored public let hub = MetricsHub() @ObservationIgnored public let hub = MetricsHub()
public var isMainWindowVisible = false public var isMainWindowVisible = false
@@ -121,6 +122,13 @@ public final class AppModel {
} }
} }
/// 用 Apple 智能解释进程:结果显示在主窗口的进程页
func explainProcess(_ subject: ProcessExplainer.Subject) {
settings.panelTab = .processes
if !isMainWindowVisible { openMainWindow(.processes) }
explainer.explain(subject)
}
func handle(_ snapshot: MetricsSnapshot) { func handle(_ snapshot: MetricsSnapshot) {
store.apply(snapshot) store.apply(snapshot)
fans.evaluateSafety() fans.evaluateSafety()
@@ -55,7 +55,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
/// 点击该项弹出的详情里可以显示的内容,按显示顺序排列 /// 点击该项弹出的详情里可以显示的内容,按显示顺序排列
var popoverSections: [PopoverSection] { var popoverSections: [PopoverSection] {
switch self { switch self {
case .cpu: [.cpuHistory, .cpuCores, .cpuDetails, .cpuProcesses] case .cpu: [.cpuHistory, .cpuCores, .cpuDetails, .cpuLoadAverage, .cpuProcesses]
case .memory: [.memoryHistory, .memoryBreakdown, .memoryProcesses] case .memory: [.memoryHistory, .memoryBreakdown, .memoryProcesses]
case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses] case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses]
case .gpu: [.gpuHistory, .gpuDetails] case .gpu: [.gpuHistory, .gpuDetails]
@@ -81,7 +81,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
/// 详情弹窗里可以单独隐藏的区块 /// 详情弹窗里可以单独隐藏的区块
public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
case cpuHistory, cpuCores, cpuDetails, cpuProcesses case cpuHistory, cpuCores, cpuDetails, cpuLoadAverage, cpuProcesses
case memoryHistory, memoryBreakdown, memoryProcesses case memoryHistory, memoryBreakdown, memoryProcesses
case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses case networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses
case gpuHistory, gpuDetails case gpuHistory, gpuDetails
@@ -93,7 +93,8 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
switch self { switch self {
case .cpuHistory: "负载历史" case .cpuHistory: "负载历史"
case .cpuCores: "核心负载" case .cpuCores: "核心负载"
case .cpuDetails: "处理器信息" case .cpuDetails: "详细信息"
case .cpuLoadAverage: "平均负载"
case .cpuProcesses, .memoryProcesses, .networkProcesses: "高占用进程" case .cpuProcesses, .memoryProcesses, .networkProcesses: "高占用进程"
case .memoryHistory: "使用历史" case .memoryHistory: "使用历史"
case .memoryBreakdown: "内存构成" case .memoryBreakdown: "内存构成"
@@ -0,0 +1,186 @@
import Foundation
import Metrics
import Observation
import Security
#if canImport(FoundationModels)
import FoundationModels
#endif
/// 用 Apple 智能(系统自带的本机大模型)解释一个进程是做什么的。
/// 全程在本机运行,不联网;需要 macOS 26 且开启了 Apple 智能。
@MainActor
@Observable
public final class ProcessExplainer {
public struct Subject: Equatable, Sendable {
let pid: Int32
let name: String
let displayName: String
let executablePath: String?
let appBundlePath: String?
let cpu: Double?
let memory: UInt64?
let networkRate: Double?
}
public enum Phase: Equatable {
case idle
case running
case done
case failed(String)
}
public private(set) var subject: Subject?
public private(set) var text = ""
public private(set) var phase: Phase = .idle
@ObservationIgnored private var task: Task<Void, Never>?
/// 系统版本与框架是否支持;是否真正可用(设备、开关、模型下载)在运行时再判断
static var isSupported: Bool {
#if canImport(FoundationModels)
if #available(macOS 26.0, *) { return true }
#endif
return false
}
func explain(_ subject: Subject) {
task?.cancel()
self.subject = subject
text = ""
phase = .running
task = Task { [weak self] in
let facts = await Task.detached { Self.facts(for: subject) }.value
guard let self, !Task.isCancelled else { return }
await self.run(facts: facts)
}
}
func dismiss() {
task?.cancel()
task = nil
subject = nil
text = ""
phase = .idle
}
private func run(facts: String) async {
#if canImport(FoundationModels)
if #available(macOS 26.0, *) {
let model = SystemLanguageModel.default
if case .unavailable(let reason) = model.availability {
phase = .failed(Self.message(for: reason))
return
}
let session = LanguageModelSession(instructions: Self.instructions)
do {
for try await snapshot in session.streamResponse(to: facts) {
guard !Task.isCancelled else { return }
text = snapshot.content
}
phase = .done
} catch {
guard !Task.isCancelled else { return }
phase = .failed("Apple 智能没有给出回答:\(error.localizedDescription)")
}
return
}
#endif
phase = .failed("需要 macOS 26 及以上,并在系统设置中开启 Apple 智能。")
}
private static let instructions = """
你是熟悉 macOS 的系统工程师。用户会给出 Mac 上一个进程的事实信息(名称、路径、所属应用、签名方、资源占用)。
请只根据这些事实和你确定知道的知识,用简体中文回答三点,每点一两句:
1. 它是什么:属于哪个应用或系统组件,负责什么。
2. 占用是否正常:结合给出的 CPU、内存数值判断。
3. 能否退出:直接退出会有什么影响。
不确定时明确说“不确定”,不要编造功能或来源。不使用 Markdown 标题,总共不超过 180 字。
"""
#if canImport(FoundationModels)
@available(macOS 26.0, *)
private static func message(for reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
switch reason {
case .deviceNotEligible: "这台 Mac 不支持 Apple 智能。"
case .appleIntelligenceNotEnabled: "请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。"
case .modelNotReady: "Apple 智能模型还在下载或准备中,请稍后再试。"
@unknown default: "Apple 智能暂时不可用。"
}
}
#endif
// MARK: 事实
/// 交给模型的事实:路径位置、所属应用、签名方都能明显减少模型胡猜
nonisolated static func facts(for subject: Subject) -> String {
var lines = ["进程名:\(subject.name)"]
if subject.displayName != subject.name { lines.append("显示名称:\(subject.displayName)") }
let path = subject.executablePath ?? executablePath(pid: subject.pid)
if let path {
lines.append("可执行文件:\(path)")
lines.append("位置:\(location(of: path))")
if let signer = signer(of: path) { lines.append("签名方:\(signer)") }
}
if let bundlePath = subject.appBundlePath, let bundle = Bundle(path: bundlePath) {
let name = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
?? URL(fileURLWithPath: bundlePath).deletingPathExtension().lastPathComponent
lines.append("所属应用:\(name)(\(bundle.bundleIdentifier ?? "无 Bundle ID"))")
}
if let cpu = subject.cpu { lines.append("CPU:\(Format.percent(cpu))(以单核满载为 100%)") }
if let memory = subject.memory { lines.append("内存:\(Format.bytes(memory))") }
if let rate = subject.networkRate { lines.append("网络:\(Format.menuBarRate(rate))") }
return lines.joined(separator: "\n")
}
nonisolated private static func location(of path: String) -> String {
if path.hasPrefix("/System/") || path.hasPrefix("/usr/") || path.hasPrefix("/sbin/") || path.hasPrefix("/bin/") {
return "macOS 系统目录(系统组件)"
}
if path.hasPrefix("/Applications/") || path.contains("/Applications/") { return "应用程序文件夹" }
if path.hasPrefix("/Library/") { return "系统级资源库(第三方驱动、辅助工具或后台服务)" }
if path.contains("/Library/") { return "用户资源库" }
return "其他位置"
}
nonisolated private static func signer(of path: String) -> String? {
var code: SecStaticCode?
guard SecStaticCodeCreateWithPath(URL(fileURLWithPath: path) as CFURL, [], &code) == errSecSuccess, let code else { return nil }
var requirement: SecRequirement?
if SecRequirementCreateWithString("anchor apple" as CFString, [], &requirement) == errSecSuccess, let requirement,
SecStaticCodeCheckValidity(code, [], requirement) == errSecSuccess {
return "Apple(系统自带)"
}
var information: CFDictionary?
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
let dictionary = information as? [String: Any] else { return nil }
if let team = dictionary[kSecCodeInfoTeamIdentifier as String] as? String {
let certificates = dictionary[kSecCodeInfoCertificates as String] as? [SecCertificate]
let summary = certificates?.first.flatMap { SecCertificateCopySubjectSummary($0) as String? }
return [summary, "团队 \(team)"].compactMap { $0 }.joined(separator: ",")
}
return "未签名或临时签名"
}
nonisolated private static func executablePath(pid: Int32) -> String? {
var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN))
guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil }
return String(cString: buffer)
}
}
extension ProcessExplainer.Subject {
@MainActor
init(_ process: ProcessUsage) {
self.init(pid: process.pid, name: process.name, displayName: process.displayName,
executablePath: process.executablePath, appBundlePath: process.appBundlePath,
cpu: process.cpu, memory: process.memory, networkRate: nil)
}
@MainActor
init(_ process: NetworkProcessUsage) {
self.init(pid: process.pid, name: process.name, displayName: process.localizedName,
executablePath: nil, appBundlePath: process.appBundlePath,
cpu: nil, memory: nil, networkRate: process.download + process.upload)
}
}
+21 -5
View File
@@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese.
<!-- changelog:start --> <!-- changelog:start -->
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. --> <!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
Not released yet · **34** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) Not released yet · **38** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
<details open> <details open>
<summary><b>2026-09-13</b> · Unreleased · 23 added · 5 style · 6 fixed</summary> <summary><b>2026-09-13</b> · Unreleased · 27 added · 5 style · 6 fixed</summary>
**Added** **Added**
@@ -62,6 +62,10 @@ Not released yet · **34** changes in development · [full changelog](CHANGELOG.
- 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。 - 网络详情:上下镜像的流量历史;连接探测格子(每 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 可能被接管。 - DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。 - 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。
- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。
- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
@@ -116,8 +120,8 @@ Not released yet · **34** changes in development · [full changelog](CHANGELOG.
## At a glance ## At a glance
<p align="center"> <p align="center">
<img src="Assets/readme/overview-dark.png" width="49%" alt="Overview, dark"> <img src="Assets/readme/overview-dark.png" width="49%" alt="Main window dashboard, dark">
<img src="Assets/readme/overview-light.png" width="49%" alt="Overview, light"> <img src="Assets/readme/overview-light.png" width="49%" alt="Main window dashboard, light">
</p> </p>
## Where the numbers show ## Where the numbers show
@@ -143,14 +147,24 @@ Choose which sections each popover shows in Settings. `Esc` closes it.
per-process traffic. per-process traffic.
- **GPU**, **Temperature & fans.** History, sensor groups, fan speeds and quick modes. - **GPU**, **Temperature & fans.** History, sensor groups, fan speeds and quick modes.
<p align="center">
<img src="Assets/readme/popover-cpu-light.png" width="32%" alt="CPU popover">
<img src="Assets/readme/popover-network-light.png" width="32%" alt="Network popover">
<img src="Assets/readme/popover-memory-dark.png" width="32%" alt="Memory popover, dark">
</p>
**Main window** — a sidebar with Dashboard, CPU, GPU, Memory, Network, Temperature & fans, **Main window** — a sidebar with Dashboard, CPU, GPU, Memory, Network, Temperature & fans,
Processes, Keep awake and Clean; resizable in both directions. See [Cleanup](#cleanup). Processes, Keep awake and Clean; resizable in both directions. See [Cleanup](#cleanup).
**Ask Apple Intelligence about a process** — right-click a process you don't recognise and the on-device
model explains what it is, whether its usage looks normal and whether it is safe to quit. No third-party AI and
no network; requires macOS 26 with Apple Intelligence turned on. Answers can be wrong — check before you quit anything.
White and blue in light mode, black and blue in dark mode — one look across windows and popovers, White and blue in light mode, black and blue in dark mode — one look across windows and popovers,
switched with one click or following the system. switched with one click or following the system.
<p align="center"> <p align="center">
<img src="Assets/readme/thermal-dark.png" width="49%" alt="Thermal"> <img src="Assets/readme/thermal-dark.png" width="49%" alt="Temperature & fans">
<img src="Assets/readme/keepawake-light.png" width="49%" alt="Keep awake"> <img src="Assets/readme/keepawake-light.png" width="49%" alt="Keep awake">
</p> </p>
@@ -206,6 +220,8 @@ Settings → Network:
Tencent or your router) every 1, 2 or 5 seconds, only while the network item is in the menu bar Tencent or your router) every 1, 2 or 5 seconds, only while the network item is in the menu bar
or network details are open. or network details are open.
Process explanations run entirely on device through Apple Intelligence; process details never leave the Mac.
## Build & run ## Build & run
Requires macOS 14+, **Xcode 26 or later** (CommandLineTools Requires macOS 14+, **Xcode 26 or later** (CommandLineTools
+20 -5
View File
@@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
<!-- changelog:start --> <!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 --> <!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
尚未发布正式版本 · 开发中 **34** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) 尚未发布正式版本 · 开发中 **38** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
<details open> <details open>
<summary><b>2026-09-13</b> · 未发布 · 新增 23 · 样式 5 · 修复 6</summary> <summary><b>2026-09-13</b> · 未发布 · 新增 27 · 样式 5 · 修复 6</summary>
**新增** **新增**
@@ -60,6 +60,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
- 网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。 - 网络详情:上下镜像的流量历史;连接探测格子(每 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 可能被接管。 - DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。 - 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。
- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。
- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 - 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 - CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 - 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
@@ -114,8 +118,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
## 界面一览 ## 界面一览
<p align="center"> <p align="center">
<img src="Assets/readme/overview-dark.png" width="49%" alt="概览(深色)"> <img src="Assets/readme/overview-dark.png" width="49%" alt="主窗口仪表盘(深色)">
<img src="Assets/readme/overview-light.png" width="49%" alt="概览(浅色)"> <img src="Assets/readme/overview-light.png" width="49%" alt="主窗口仪表盘(浅色)">
</p> </p>
## 数据显示在哪里 ## 数据显示在哪里
@@ -136,13 +140,22 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。 归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。
- **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。 - **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。
<p align="center">
<img src="Assets/readme/popover-cpu-light.png" width="32%" alt="CPU 详情弹窗">
<img src="Assets/readme/popover-network-light.png" width="32%" alt="网络详情弹窗">
<img src="Assets/readme/popover-memory-dark.png" width="32%" alt="内存详情弹窗(深色)">
</p>
**主窗口**——左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理,宽高都可调整。 **主窗口**——左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理,宽高都可调整。
仪表盘有健康评分、芯片与系统徽章、三列指标卡片、核心负载、电池、高占用进程和快捷开关;清理见[清理](#清理)。 仪表盘有健康评分、芯片与系统徽章、三列指标卡片、核心负载、电池、高占用进程和快捷开关;清理见[清理](#清理)。
**用 Apple 智能解释进程**——看不懂的进程右键「用 Apple 智能解释」,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出。
不接入第三方 AI,不联网;需要 macOS 26 并开启 Apple 智能,回答可能不准确,结束进程前请自行确认。
浅色为白底蓝色、深色为黑底蓝色,窗口与弹窗统一外观,可一键切换或跟随系统。 浅色为白底蓝色、深色为黑底蓝色,窗口与弹窗统一外观,可一键切换或跟随系统。
<p align="center"> <p align="center">
<img src="Assets/readme/thermal-dark.png" width="49%" alt="散热"> <img src="Assets/readme/thermal-dark.png" width="49%" alt="温度与风扇">
<img src="Assets/readme/keepawake-light.png" width="49%" alt="防休眠"> <img src="Assets/readme/keepawake-light.png" width="49%" alt="防休眠">
</p> </p>
@@ -185,6 +198,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
- **公网 IP**:打开网络详情时向 `ipinfo.io` 请求一次公网地址、归属地与 ASN(IPv6 与回退使用 Cloudflare `1.1.1.1` / ipify),10 分钟内不重复请求。 - **公网 IP**:打开网络详情时向 `ipinfo.io` 请求一次公网地址、归属地与 ASN(IPv6 与回退使用 Cloudflare `1.1.1.1` / ipify),10 分钟内不重复请求。
- **连接探测**:每 1、2 或 5 秒向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。 - **连接探测**:每 1、2 或 5 秒向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。
用 Apple 智能解释进程完全在本机完成,进程信息不会离开这台 Mac。
## 构建与运行 ## 构建与运行
需要 macOS 14+、**Xcode 26 或更高版本**(仅有 CommandLineTools 时缺少 SwiftUI 宏插件)以及 需要 macOS 14+、**Xcode 26 或更高版本**(仅有 CommandLineTools 时缺少 SwiftUI 宏插件)以及
+2 -2
View File
@@ -347,7 +347,7 @@
<div class="log"> <div class="log">
<article class="log__day"> <article class="log__day">
<div class="log__head"><time datetime="2026-09-13">2026-09-13</time><span class="log__tag">未发布</span></div> <div class="log__head"><time datetime="2026-09-13">2026-09-13</time><span class="log__tag">未发布</span></div>
<p class="log__counts">新增 23 · 样式 5 · 修复 6</p> <p class="log__counts">新增 27 · 样式 5 · 修复 6</p>
<ul class="log__list"> <ul class="log__list">
<li><span class="log__kind">新增</span>菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。</li> <li><span class="log__kind">新增</span>菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。</li>
<li><span class="log__kind">新增</span>菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。</li> <li><span class="log__kind">新增</span>菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。</li>
@@ -358,7 +358,7 @@
<li><span class="log__kind">新增</span>网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。</li> <li><span class="log__kind">新增</span>网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。</li>
<li><span class="log__kind">新增</span>DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。</li> <li><span class="log__kind">新增</span>DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。</li>
</ul> </ul>
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 26 项 →</a> <a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 30 项 →</a>
</article> </article>
</div> </div>
<!-- changelog:end --> <!-- changelog:end -->