diff --git a/Assets/readme/cleaner-light.png b/Assets/readme/cleaner-light.png index ec0105c..ecc8bd9 100644 Binary files a/Assets/readme/cleaner-light.png and b/Assets/readme/cleaner-light.png differ diff --git a/Assets/readme/keepawake-light.png b/Assets/readme/keepawake-light.png index 251600d..221fcb9 100644 Binary files a/Assets/readme/keepawake-light.png and b/Assets/readme/keepawake-light.png differ diff --git a/Assets/readme/menubar-dark.png b/Assets/readme/menubar-dark.png index 47e2d38..9a7fdc5 100644 Binary files a/Assets/readme/menubar-dark.png and b/Assets/readme/menubar-dark.png differ diff --git a/Assets/readme/overview-dark.png b/Assets/readme/overview-dark.png index 2f09402..98dfa61 100644 Binary files a/Assets/readme/overview-dark.png and b/Assets/readme/overview-dark.png differ diff --git a/Assets/readme/overview-light.png b/Assets/readme/overview-light.png index 3f570ad..83e39c9 100644 Binary files a/Assets/readme/overview-light.png and b/Assets/readme/overview-light.png differ diff --git a/Assets/readme/popover-cpu-light.png b/Assets/readme/popover-cpu-light.png new file mode 100644 index 0000000..6f4ebca Binary files /dev/null and b/Assets/readme/popover-cpu-light.png differ diff --git a/Assets/readme/popover-memory-dark.png b/Assets/readme/popover-memory-dark.png new file mode 100644 index 0000000..4d2f44e Binary files /dev/null and b/Assets/readme/popover-memory-dark.png differ diff --git a/Assets/readme/popover-network-light.png b/Assets/readme/popover-network-light.png new file mode 100644 index 0000000..2758223 Binary files /dev/null and b/Assets/readme/popover-network-light.png differ diff --git a/Assets/readme/settings-menubar-light.png b/Assets/readme/settings-menubar-light.png deleted file mode 100644 index 778ab4c..0000000 Binary files a/Assets/readme/settings-menubar-light.png and /dev/null differ diff --git a/Assets/readme/thermal-dark.png b/Assets/readme/thermal-dark.png index b3725f3..fd66a63 100644 Binary files a/Assets/readme/thermal-dark.png and b/Assets/readme/thermal-dark.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1100135..a8c4ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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、内存、电池和掌托温度。 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index f163f72..d6ba016 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift index 324e264..9e58c8a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift @@ -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)") - .dsFont(.xs) - .foregroundStyle(DS.Palette.textTertiary) - .lineLimit(1) + 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: 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 ?? "未知")") + } } /// 上下镜像的流量图:上半部分上传、下半部分下载,各自按自己的峰值缩放 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index 51284de..24e1246 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -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 + } + } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index d3cab8b..2e0c477 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift index e9547fc..d05e6ae 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift @@ -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: { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift index 20ba6ce..2715986 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift @@ -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)” diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift index ecf2602..e415d77 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/MetricPopovers.swift @@ -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 struct LabeledRing: View { + let title: String + let fraction: Double + let color: Color + let value: String + var large = false - private func loadAverage(_ values: [Double]) -> String { - values.prefix(3).map { $0.formatted(.number.precision(.fractionLength(2))) }.joined(separator: " · ") + 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) { - Text(verbatim: memory.map { Format.percent($0.usedFraction) } ?? "—") - .dsFont(.sm, weight: .semibold) - .monospacedDigit() - .foregroundStyle(DS.Palette.textPrimary) + HStack(alignment: .center, spacing: DS.Space.s3) { + PressureGauge(pressure: memory?.pressure) + .frame(maxWidth: .infinity) + SegmentedRing(segments: memory.map(segments) ?? [], size: DS.Space.s16 + DS.Space.s2) { + VStack(spacing: 0) { + Text(verbatim: memory.map { Format.percent($0.usedFraction) } ?? "—") + .dsFont(.base, weight: .semibold) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + Text(verbatim: memory.map { Format.bytes($0.total) } ?? "") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } } - 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,16 +203,41 @@ 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: 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 { let color: Color let label: String diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift index 15cef7d..de066b2 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift @@ -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)) } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 7605223..17b8393 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 9048fb8..7681f3c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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: "内存构成" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ProcessExplainer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ProcessExplainer.swift new file mode 100644 index 0000000..80e062f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ProcessExplainer.swift @@ -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? + + /// 系统版本与框架是否支持;是否真正可用(设备、开关、模型下载)在运行时再判断 + 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) + } +} diff --git a/README.md b/README.md index 989fa6a..9072fef 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -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)
-2026-09-13 · Unreleased · 23 added · 5 style · 6 fixed +2026-09-13 · Unreleased · 27 added · 5 style · 6 fixed **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

- Overview, dark - Overview, light + Main window dashboard, dark + Main window dashboard, light

## 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. +

+ CPU popover + Network popover + Memory popover, dark +

+ **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.

- Thermal + Temperature & fans Keep awake

@@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 7296e09..89cf2ff 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ -尚未发布正式版本 · 开发中 **34** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +尚未发布正式版本 · 开发中 **38** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
-2026-09-13 · 未发布 · 新增 23 · 样式 5 · 修复 6 +2026-09-13 · 未发布 · 新增 27 · 样式 5 · 修复 6 **新增** @@ -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:/ ## 界面一览

- 概览(深色) - 概览(浅色) + 主窗口仪表盘(深色) + 主窗口仪表盘(浅色)

## 数据显示在哪里 @@ -136,13 +140,22 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ 归属地国旗、ASN;DNS 一键刷新,一键切换 Cloudflare、Google、腾讯、阿里云或手动填写;各进程流量。 - **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。 +

+ CPU 详情弹窗 + 网络详情弹窗 + 内存详情弹窗(深色) +

+ **主窗口**——左侧边栏切换仪表盘、CPU、GPU、内存、网络、温度与风扇、进程、防休眠、清理,宽高都可调整。 仪表盘有健康评分、芯片与系统徽章、三列指标卡片、核心负载、电池、高占用进程和快捷开关;清理见[清理](#清理)。 +**用 Apple 智能解释进程**——看不懂的进程右键「用 Apple 智能解释」,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出。 +不接入第三方 AI,不联网;需要 macOS 26 并开启 Apple 智能,回答可能不准确,结束进程前请自行确认。 + 浅色为白底蓝色、深色为黑底蓝色,窗口与弹窗统一外观,可一键切换或跟随系统。

- 散热 + 温度与风扇 防休眠

@@ -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 宏插件)以及 diff --git a/web/index.html b/web/index.html index d364e44..47a86c3 100644 --- a/web/index.html +++ b/web/index.html @@ -347,7 +347,7 @@
未发布
-

新增 23 · 样式 5 · 修复 6

+

新增 27 · 样式 5 · 修复 6

  • 新增菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。
  • 新增菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。
  • @@ -358,7 +358,7 @@
  • 新增网络详情:上下镜像的流量历史;连接探测格子(每 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 可能被接管。
- 还有 26 项 → + 还有 30 项 →