CPU 与内存详情改用圆环、仪表与分色柱形图,新增用 Apple 智能解释进程
- CPU:温度 / 占用 / 负载三个圆环,负载历史面积图,核心按类型分色,各类核心平均占用与 1 / 5 / 15 分钟平均负载 - 内存:压力仪表与按 App / 联动 / 压缩分段的圆环,补充已用、可用与交换区 - 进程右键或进程页星形按钮调用系统自带的本机大模型解释进程,附带路径、所属应用与签名方,不联网 - 进程显示本地化应用名,详情弹窗进程列表 8 行;修复截图模式下短页面卡片被拉高 - 更新 README 截图与说明,更新日志同步到官网
No files matched your search
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 261 KiB |
@@ -18,6 +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 / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
|
||||
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
|
||||
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
|
||||
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
|
||||
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
|
||||
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
|
||||
|
||||
@@ -53,6 +53,16 @@ public final class AppController: NSObject, NSApplicationDelegate, NSWindowDeleg
|
||||
if arguments.contains("--show-window") {
|
||||
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
|
||||
Task { [weak self] in
|
||||
|
||||
@@ -141,6 +141,13 @@ struct CoreClusterBars: View {
|
||||
let topology: CPUTopology
|
||||
let perCore: [Double]
|
||||
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 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))
|
||||
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))
|
||||
let color = byCluster ? DS.Palette.cluster(cluster.id) : 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 += Self.groupGap - Self.barGap
|
||||
@@ -180,10 +187,17 @@ struct CoreClusterBars: View {
|
||||
HStack(spacing: Self.groupGap) {
|
||||
ForEach(clusters) { cluster in
|
||||
let count = CGFloat(cluster.coreIndices.count)
|
||||
Text(verbatim: "\(cluster.name) · \(cluster.coreIndices.count)")
|
||||
HStack(spacing: DS.Space.s1) {
|
||||
if byCluster {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.sm / 2).fill(DS.Palette.cluster(cluster.id))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -193,6 +207,89 @@ struct CoreClusterBars: View {
|
||||
.accessibilityElement(children: .ignore)
|
||||
.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 track = Color.dynamic(light: 0x000000, lightAlpha: 0.06, dark: 0xFFFFFF, darkAlpha: 0.10)
|
||||
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)
|
||||
// 截图时页面取自身高度:侧边栏更高时,多出的高度不能分给页面里可伸展的卡片
|
||||
.fixedSize(horizontal: false, vertical: isSnapshot)
|
||||
}
|
||||
.frame(minWidth: DS.Size.panelWidth, maxWidth: .infinity)
|
||||
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
|
||||
|
||||
@@ -478,6 +478,9 @@ private struct TopProcessesCard: View {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(String(process.pid), forType: .string)
|
||||
}
|
||||
if ProcessExplainer.isSupported {
|
||||
Button("用 Apple 智能解释") { model.explainProcess(.init(process)) }
|
||||
}
|
||||
Divider()
|
||||
Button("查看全部进程") { model.settings.panelTab = .processes }
|
||||
} label: {
|
||||
|
||||
@@ -17,6 +17,9 @@ struct ProcessesPage: View {
|
||||
let processes = sorted(model.store.processes)
|
||||
|
||||
PageScroll {
|
||||
if model.explainer.subject != nil {
|
||||
ProcessExplanationCard()
|
||||
}
|
||||
HStack {
|
||||
SegmentedControl(selection: $sort, options: Sort.allCases.map { ($0, $0.title) })
|
||||
.frame(width: DS.Size.tileWidth)
|
||||
@@ -72,6 +75,7 @@ private struct ProcessRow: View {
|
||||
let emphasis: ProcessesPage.Sort
|
||||
@State private var hovering = false
|
||||
@Environment(\.isSnapshot) private var isSnapshot
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
@@ -97,6 +101,12 @@ private struct ProcessRow: View {
|
||||
.dsFont(.sm, weight: emphasis == .memory ? .semibold : .regular)
|
||||
.foregroundStyle(emphasis == .memory ? DS.Palette.textPrimary : DS.Palette.textSecondary)
|
||||
.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()
|
||||
.padding(.horizontal, DS.Space.s4)
|
||||
@@ -113,6 +123,10 @@ private struct ProcessRow: View {
|
||||
NSPasteboard.general.clearContents()
|
||||
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 小位图,避免每次刷新解码系统图标的全部分辨率
|
||||
@MainActor
|
||||
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 {
|
||||
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
|
||||
guard let appName, let appBundlePath, name.hasPrefix(appName) else { return name }
|
||||
return AppNameCache.shared.name(forBundle: appBundlePath)
|
||||
}
|
||||
|
||||
/// 辅助进程的角色,例如 “Helper (Renderer)”
|
||||
|
||||
@@ -28,26 +28,22 @@ struct CPUPopover: View {
|
||||
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(spacing: DS.Space.s3) {
|
||||
RingGauge(fraction: cpu?.total ?? 0, color: loadTone(cpu?.total ?? 0).color, size: heroGauge) {
|
||||
Text(verbatim: cpu.map { Format.percent($0.total) } ?? "—")
|
||||
.dsFont(.sm, weight: .semibold)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
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) } ?? "—")
|
||||
LegendItem(color: DS.Palette.neutral300, label: "空闲",
|
||||
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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,25 +51,42 @@ struct CPUPopover: View {
|
||||
switch section {
|
||||
case .cpuHistory:
|
||||
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:
|
||||
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:
|
||||
SectionCard(title: section.title) {
|
||||
InfoRow(label: "处理器", text: store.topology.brand)
|
||||
InfoRow(label: "核心", text: coreSummary(store.topology))
|
||||
InfoRow(label: "负载平均", text: cpu.map { loadAverage($0.loadAverage) } ?? "—")
|
||||
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) } ?? "—")
|
||||
}
|
||||
InfoRow(label: "处理器", text: "\(store.topology.brand) · \(store.topology.logicalCores) 核")
|
||||
if let boot = store.system.bootDate {
|
||||
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:
|
||||
SectionCard(title: section.title, trailing: { Text("CPU") }) {
|
||||
let processes = store.processes
|
||||
ProcessList(count: processes.count) { index in
|
||||
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)
|
||||
@@ -82,6 +95,7 @@ struct CPUPopover: View {
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
.explainable(.init(process))
|
||||
}
|
||||
}
|
||||
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: " + ")
|
||||
return clusters.isEmpty ? "\(topology.logicalCores) 核" : "\(topology.logicalCores) 核(\(clusters))"
|
||||
}
|
||||
|
||||
private func loadAverage(_ values: [Double]) -> String {
|
||||
values.prefix(3).map { $0.formatted(.number.precision(.fractionLength(2))) }.joined(separator: " · ")
|
||||
/// 圆环 + 中间数值 + 下方标题
|
||||
private struct LabeledRing: View {
|
||||
let title: String
|
||||
let fraction: Double
|
||||
let color: Color
|
||||
let value: String
|
||||
var large = false
|
||||
|
||||
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)
|
||||
}
|
||||
Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +141,23 @@ struct MemoryPopover: View {
|
||||
let store = model.store
|
||||
let memory = store.memory
|
||||
|
||||
// 左边内存压力仪表,右边按 App / 联动 / 压缩分段的占用圆环
|
||||
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
RingGauge(fraction: memory?.usedFraction ?? 0, color: pressureTone(memory?.pressure).color, size: heroGauge) {
|
||||
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(.sm, weight: .semibold)
|
||||
.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) {
|
||||
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)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,19 +170,22 @@ struct MemoryPopover: View {
|
||||
case .memoryBreakdown:
|
||||
SectionCard(title: section.title) {
|
||||
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))
|
||||
LegendRow(color: DS.Palette.track, label: "交换空间", value: Format.bytes(memory.swapUsed))
|
||||
InfoRow(label: "交换区", text: Format.bytes(memory.swapUsed))
|
||||
}
|
||||
PurgeMemoryRow()
|
||||
}
|
||||
case .memoryProcesses:
|
||||
SectionCard(title: section.title, trailing: { Text("内存") }) {
|
||||
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]
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName)
|
||||
@@ -157,6 +194,7 @@ struct MemoryPopover: View {
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
.explainable(.init(process))
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -165,14 +203,39 @@ struct MemoryPopover: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func pressureTone(_ pressure: MemoryPressure?) -> Tone {
|
||||
switch pressure {
|
||||
case .normal: .success
|
||||
case .warning: .warning
|
||||
case .critical: .error
|
||||
case nil: .neutral
|
||||
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)]
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -341,10 +341,10 @@ private struct NetworkProcessesSection: View {
|
||||
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]
|
||||
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))
|
||||
.frame(width: DS.Size.valueColumn, alignment: .trailing)
|
||||
Text(verbatim: Format.menuBarRate(process.upload))
|
||||
@@ -353,6 +353,7 @@ private struct NetworkProcessesSection: View {
|
||||
.dsFont(.xs)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.explainable(.init(process))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public final class AppModel {
|
||||
public let cleaner: CleanerController
|
||||
public let maintenance: MaintenanceController
|
||||
public let network: NetworkController
|
||||
public let explainer = ProcessExplainer()
|
||||
@ObservationIgnored public let hub = MetricsHub()
|
||||
|
||||
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) {
|
||||
store.apply(snapshot)
|
||||
fans.evaluateSafety()
|
||||
|
||||
@@ -55,7 +55,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
|
||||
/// 点击该项弹出的详情里可以显示的内容,按显示顺序排列
|
||||
var popoverSections: [PopoverSection] {
|
||||
switch self {
|
||||
case .cpu: [.cpuHistory, .cpuCores, .cpuDetails, .cpuProcesses]
|
||||
case .cpu: [.cpuHistory, .cpuCores, .cpuDetails, .cpuLoadAverage, .cpuProcesses]
|
||||
case .memory: [.memoryHistory, .memoryBreakdown, .memoryProcesses]
|
||||
case .network: [.networkHistory, .networkProbe, .networkInterface, .networkAddresses, .networkDNS, .networkProcesses]
|
||||
case .gpu: [.gpuHistory, .gpuDetails]
|
||||
@@ -81,7 +81,7 @@ public enum MenuBarItem: 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 networkHistory, networkProbe, networkInterface, networkAddresses, networkDNS, networkProcesses
|
||||
case gpuHistory, gpuDetails
|
||||
@@ -93,7 +93,8 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
|
||||
switch self {
|
||||
case .cpuHistory: "负载历史"
|
||||
case .cpuCores: "核心负载"
|
||||
case .cpuDetails: "处理器信息"
|
||||
case .cpuDetails: "详细信息"
|
||||
case .cpuLoadAverage: "平均负载"
|
||||
case .cpuProcesses, .memoryProcesses, .networkProcesses: "高占用进程"
|
||||
case .memoryHistory: "使用历史"
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese.
|
||||
<!-- changelog:start -->
|
||||
<!-- 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>
|
||||
<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**
|
||||
|
||||
@@ -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 与网络运营方;各进程上传下载速率。
|
||||
- DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
|
||||
- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。
|
||||
- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。
|
||||
- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
|
||||
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
|
||||
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
|
||||
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
|
||||
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
|
||||
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
|
||||
@@ -116,8 +120,8 @@ Not released yet · **34** changes in development · [full changelog](CHANGELOG.
|
||||
## At a glance
|
||||
|
||||
<p align="center">
|
||||
<img src="Assets/readme/overview-dark.png" width="49%" alt="Overview, dark">
|
||||
<img src="Assets/readme/overview-light.png" width="49%" alt="Overview, light">
|
||||
<img src="Assets/readme/overview-dark.png" width="49%" alt="Main window dashboard, dark">
|
||||
<img src="Assets/readme/overview-light.png" width="49%" alt="Main window dashboard, light">
|
||||
</p>
|
||||
|
||||
## Where the numbers show
|
||||
@@ -143,14 +147,24 @@ Choose which sections each popover shows in Settings. `Esc` closes it.
|
||||
per-process traffic.
|
||||
- **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,
|
||||
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,
|
||||
switched with one click or following the system.
|
||||
|
||||
<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">
|
||||
</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
|
||||
or network details are open.
|
||||
|
||||
Process explanations run entirely on device through Apple Intelligence; process details never leave the Mac.
|
||||
|
||||
## Build & run
|
||||
|
||||
Requires macOS 14+, **Xcode 26 or later** (CommandLineTools
|
||||
|
||||
@@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
|
||||
<!-- changelog:start -->
|
||||
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
|
||||
|
||||
尚未发布正式版本 · 开发中 **34** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
|
||||
尚未发布正式版本 · 开发中 **38** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
|
||||
|
||||
<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 与网络运营方;各进程上传下载速率。
|
||||
- DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
|
||||
- 主窗口:左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理;指标页右上角直接开关该项的菜单栏显示;宽度与高度都可调整,卡片按比例放大。打开主窗口时应用出现在程序坞中,关闭后回到仅菜单栏运行。
|
||||
- CPU 详情:顶部温度、占用、负载三个圆环;负载历史面积图;核心负载按超级核 / 性能核分色;详细信息列出用户、系统、空闲与各类核心平均占用;新增 1 / 5 / 15 分钟平均负载。
|
||||
- 内存详情:内存压力仪表(正常、偏高、严重三段加指针)与按 App / 联动 / 压缩分段的占用圆环;内存构成补充已用、可用与交换区。
|
||||
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
|
||||
- 进程显示应用的本地化名称(如“微信”),详情弹窗的高占用进程列表增加到 8 行。
|
||||
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
|
||||
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
|
||||
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
|
||||
@@ -114,8 +118,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
|
||||
## 界面一览
|
||||
|
||||
<p align="center">
|
||||
<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-dark.png" width="49%" alt="主窗口仪表盘(深色)">
|
||||
<img src="Assets/readme/overview-light.png" width="49%" alt="主窗口仪表盘(浅色)">
|
||||
</p>
|
||||
|
||||
## 数据显示在哪里
|
||||
@@ -136,13 +140,22 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
|
||||
归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。
|
||||
- **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、内存、网络、温度与风扇、进程、防休眠、清理,宽高都可调整。
|
||||
仪表盘有健康评分、芯片与系统徽章、三列指标卡片、核心负载、电池、高占用进程和快捷开关;清理见[清理](#清理)。
|
||||
|
||||
**用 Apple 智能解释进程**——看不懂的进程右键「用 Apple 智能解释」,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出。
|
||||
不接入第三方 AI,不联网;需要 macOS 26 并开启 Apple 智能,回答可能不准确,结束进程前请自行确认。
|
||||
|
||||
浅色为白底蓝色、深色为黑底蓝色,窗口与弹窗统一外观,可一键切换或跟随系统。
|
||||
|
||||
<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="防休眠">
|
||||
</p>
|
||||
|
||||
@@ -185,6 +198,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
|
||||
- **公网 IP**:打开网络详情时向 `ipinfo.io` 请求一次公网地址、归属地与 ASN(IPv6 与回退使用 Cloudflare `1.1.1.1` / ipify),10 分钟内不重复请求。
|
||||
- **连接探测**:每 1、2 或 5 秒向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。
|
||||
|
||||
用 Apple 智能解释进程完全在本机完成,进程信息不会离开这台 Mac。
|
||||
|
||||
## 构建与运行
|
||||
|
||||
需要 macOS 14+、**Xcode 26 或更高版本**(仅有 CommandLineTools 时缺少 SwiftUI 宏插件)以及
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
<div class="log">
|
||||
<article class="log__day">
|
||||
<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">
|
||||
<li><span class="log__kind">新增</span>菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。</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>DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。</li>
|
||||
</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>
|
||||
</div>
|
||||
<!-- changelog:end -->
|
||||
|
||||