进程管理器:包括系统进程,可搜索、筛选、排序、按应用合并并结束进程;快捷开关改为开关样式

- 系统与其他用户的进程通过系统自带的 ps 读取 CPU、内存与 CPU 时间,自己的进程另有线程、唤醒次数与磁盘读写
- 列标题点击排序;搜索名称、PID 或用户;全部 / 我的 / 系统;按进程或按应用
- 选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 禁止结束)
- 底部汇总用户 / 系统 / 空闲与全系统进程数、线程数;窗口变窄时自动隐藏次要列
- 仪表盘快捷开关改为开关样式
This commit is contained in:
gentpan committed 2026-09-13 06:26:43 +05:00
1 parent 958d566870
commit 63c37b0e22
13 files changed
+764 -159

No files matched your search

+3
View File
@@ -21,6 +21,7 @@
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
@@ -39,6 +40,8 @@
#### 样式
- 仪表盘“快捷开关”改为开关样式:防休眠、合盖运行、散热模式各一行,右侧开关,下面一行小字说明当前状态。
- 进程表按窗口宽度自动隐藏次要列,窄窗口里名称列不会被挤没。
- 内存详情的“释放内存”移到标题栏,执行中与完成后在按钮上显示进度和结果;主窗口内存页顶栏同样提供。
- 主窗口默认 1072×720、设置窗口默认 920×720,两者都可自由调整大小;滚动条改为浮层样式,只在滚动时出现。
@@ -79,6 +79,15 @@ public enum Format {
}
}
/// CPU 时间:不到 1 分钟显示“12.34 秒”,不到 1 小时“12:34”,更长“1:02:03”
public static func cpuTime(_ seconds: Double) -> String {
let total = max(0, seconds)
if total < 60 { return "\(total.formatted(.number.precision(.fractionLength(2)))) 秒" }
let whole = Int(total)
let hours = whole / 3600, minutes = whole % 3600 / 60, secs = whole % 60
return hours > 0 ? String(format: "%d:%02d:%02d", hours, minutes, secs) : String(format: "%d:%02d", minutes, secs)
}
public static func uptime(since date: Date, now: Date = Date()) -> String {
let minutes = max(0, Int(now.timeIntervalSince(date) / 60))
let days = minutes / (60 * 24)
@@ -10,6 +10,8 @@ public struct MetricsDemand: Sendable, Equatable {
public var disk = false
public var battery = false
public var processes = false
/// 进程管理器打开时:连同其他用户与系统的进程一起采集
public var systemProcesses = false
public var temperatures: Set<TemperatureGroup> = []
public var fans = false
@@ -124,7 +126,10 @@ public actor MetricsHub {
snapshot.battery = BatterySampler.sample()
lastBattery = now
}
if demand.processes { snapshot.processes = processes.sample() }
if demand.processes || demand.systemProcesses {
snapshot.processes = processes.sample(includeSystem: demand.systemProcesses)
snapshot.systemCounts = SystemCounts.read()
}
if !demand.temperatures.isEmpty || demand.fans {
snapshot.sensors = sensors.sample(groups: demand.temperatures, includeFans: demand.fans)
}
@@ -1,3 +1,4 @@
import Darwin
import Foundation
import SMC
@@ -103,7 +104,47 @@ public struct ProcessUsage: Sendable, Equatable, Identifiable {
public let executablePath: String?
public let appBundlePath: String?
public var cpu: Double // 以单核为 100%,与活动监视器一致
public var memory: UInt64 // phys_footprint
public var memory: UInt64 // 自己的进程为 phys_footprint;其他用户的进程为常驻内存
public var uid: UInt32 = 0
public var userName: String = ""
/// 累计 CPU 时间(秒)
public var cpuTime: Double = 0
/// 以下只有当前用户自己的进程能读到
public var threads: Int?
public var idleWakeups: Double? // 每秒
public var diskRead: Double? // 字节 / 秒
public var diskWrite: Double?
/// 是否属于当前用户:可以读取详细数据,也可以结束
public var isOwned: Bool = true
public init(pid: Int32, name: String, executablePath: String?, appBundlePath: String?, cpu: Double, memory: UInt64) {
self.pid = pid
self.name = name
self.executablePath = executablePath
self.appBundlePath = appBundlePath
self.cpu = cpu
self.memory = memory
}
}
/// 全系统的进程数与线程数(包括无权读取详情的系统进程)
public struct SystemCounts: Sendable, Equatable {
public var processes: Int
public var threads: Int
public static func read() -> SystemCounts? {
var set: processor_set_name_t = 0
guard processor_set_default(mach_host_self(), &set) == KERN_SUCCESS else { return nil }
var load = processor_set_load_info()
var count = mach_msg_type_number_t(MemoryLayout<processor_set_load_info>.size / MemoryLayout<integer_t>.size)
let result = withUnsafeMutablePointer(to: &load) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
processor_set_statistics(set, PROCESSOR_SET_LOAD_INFO, $0, &count)
}
}
guard result == KERN_SUCCESS else { return nil }
return SystemCounts(processes: Int(load.task_count), threads: Int(load.thread_count))
}
}
public struct TemperatureSummary: Sendable, Equatable, Identifiable {
@@ -144,6 +185,7 @@ public struct MetricsSnapshot: Sendable {
public var battery: BatteryStatus?
public var gpu: GPUUsage?
public var processes: [ProcessUsage]?
public var systemCounts: SystemCounts?
public var sensors: SensorReadings?
public init() {}
@@ -13,15 +13,32 @@ public struct ProcessSampler {
let bundle: String?
}
private var previousCPU: [Identity: UInt64] = [:]
private struct Counters {
var cpuTime: UInt64
var idleWakeups: UInt64
var diskRead: UInt64
var diskWrite: UInt64
}
private var previous: [Identity: Counters] = [:]
private var previousTime: UInt64 = 0
private var metaCache: [Identity: Meta] = [:]
/// 其他用户进程的累计 CPU 时间(秒),来自 ps
private var previousSystem: [Int32: Double] = [:]
private var previousSystemDate: Date?
private var userNames: [UInt32: String] = [:]
private static let timebase: Double = {
var info = mach_timebase_info_data_t()
mach_timebase_info(&info)
return Double(info.numer) / Double(info.denom)
}()
public init() {}
/// 返回按 CPU 排序的进程;无权限读取的进程(通常属于 root)会被跳过。
/// 默认返回全部可读进程:按应用汇总内存时,CPU 很低但内存很大的进程也要算进去
public mutating func sample(limit: Int = .max) -> [ProcessUsage] {
/// 返回按 CPU 排序的进程。当前用户自己的进程读 proc_pid_rusage,数据最全;
/// `includeSystem` 为真时再用 /bin/ps(系统自带、有 root 权限)补上其他用户与系统的进程,只有 CPU、内存与 CPU 时间。
public mutating func sample(includeSystem: Bool = false) -> [ProcessUsage] {
let capacity = Int(proc_listallpids(nil, 0)) + 64
guard capacity > 64 else { return [] }
var pids = [pid_t](repeating: 0, count: capacity)
@@ -31,10 +48,12 @@ public struct ProcessSampler {
// CPU 时间与墙钟时间都用 mach 绝对时间单位,比值无需换算时基
let now = mach_absolute_time()
let elapsed = previousTime > 0 ? now - previousTime : 0
let seconds = Double(elapsed) * Self.timebase / 1_000_000_000
var cpuTimes: [Identity: UInt64] = [:]
var counters: [Identity: Counters] = [:]
var liveMeta: [Identity: Meta] = [:]
var results: [ProcessUsage] = []
var owned = Set<Int32>()
results.reserveCapacity(count)
for pid in pids.prefix(count) where pid > 0 {
@@ -45,28 +64,133 @@ public struct ProcessSampler {
}
}
guard status == 0 else { continue }
owned.insert(pid)
let identity = Identity(pid: pid, startTime: info.ri_proc_start_abstime)
let cpuTime = info.ri_user_time + info.ri_system_time
cpuTimes[identity] = cpuTime
let current = Counters(cpuTime: info.ri_user_time + info.ri_system_time, idleWakeups: info.ri_interrupt_wkups,
diskRead: info.ri_diskio_bytesread, diskWrite: info.ri_diskio_byteswritten)
counters[identity] = current
let meta = metaCache[identity] ?? Self.meta(for: pid)
liveMeta[identity] = meta
var cpu = 0.0
if elapsed > 0, let before = previousCPU[identity], cpuTime >= before {
cpu = Double(cpuTime - before) / Double(elapsed)
var usage = ProcessUsage(pid: pid, name: meta.name, executablePath: meta.path,
appBundlePath: meta.bundle, cpu: 0, memory: info.ri_phys_footprint)
usage.cpuTime = Double(current.cpuTime) * Self.timebase / 1_000_000_000
if elapsed > 0, let before = previous[identity] {
if current.cpuTime >= before.cpuTime { usage.cpu = Double(current.cpuTime - before.cpuTime) / Double(elapsed) }
if seconds > 0 {
usage.idleWakeups = Double(current.idleWakeups &- before.idleWakeups) / seconds
usage.diskRead = Double(current.diskRead >= before.diskRead ? current.diskRead - before.diskRead : 0) / seconds
usage.diskWrite = Double(current.diskWrite >= before.diskWrite ? current.diskWrite - before.diskWrite : 0) / seconds
}
}
results.append(ProcessUsage(pid: pid, name: meta.name, executablePath: meta.path,
appBundlePath: meta.bundle, cpu: cpu, memory: info.ri_phys_footprint))
var task = proc_taskinfo()
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &task, Int32(MemoryLayout<proc_taskinfo>.size)) == Int32(MemoryLayout<proc_taskinfo>.size) {
usage.threads = Int(task.pti_threadnum)
}
var bsd = proc_bsdinfo()
if proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsd, Int32(MemoryLayout<proc_bsdinfo>.size)) == Int32(MemoryLayout<proc_bsdinfo>.size) {
usage.uid = bsd.pbi_uid
} else {
usage.uid = getuid()
}
usage.userName = userName(usage.uid)
results.append(usage)
}
previousCPU = cpuTimes
previous = counters
previousTime = now
metaCache = liveMeta
if includeSystem {
results += systemProcesses(excluding: owned)
} else {
previousSystem = [:]
previousSystemDate = nil
}
results.sort { $0.cpu == $1.cpu ? $0.memory > $1.memory : $0.cpu > $1.cpu }
return Array(results.prefix(limit))
return results
}
// MARK: 系统进程
private mutating func systemProcesses(excluding owned: Set<Int32>) -> [ProcessUsage] {
guard let output = Self.runPS() else { return [] }
let now = Date()
let interval = previousSystemDate.map { now.timeIntervalSince($0) } ?? 0
var times: [Int32: Double] = [:]
var results: [ProcessUsage] = []
for entry in Self.parsePS(output) where !owned.contains(entry.pid) {
times[entry.pid] = entry.cpuTime
let name = URL(fileURLWithPath: entry.command).lastPathComponent
var usage = ProcessUsage(pid: entry.pid, name: name.isEmpty ? entry.command : name,
executablePath: entry.command.hasPrefix("/") ? entry.command : nil,
appBundlePath: nil, cpu: 0, memory: entry.residentBytes)
if interval > 0, let before = previousSystem[entry.pid], entry.cpuTime >= before {
usage.cpu = (entry.cpuTime - before) / interval
}
usage.cpuTime = entry.cpuTime
usage.uid = entry.uid
usage.userName = userName(entry.uid)
usage.isOwned = false
results.append(usage)
}
previousSystem = times
previousSystemDate = now
return results
}
struct PSEntry: Equatable {
let pid: Int32
let uid: UInt32
let cpuTime: Double
let residentBytes: UInt64
let command: String
}
/// 解析 `ps -axo pid=,uid=,time=,rss=,comm=`;命令路径里可能有空格,取前四列之后的全部内容
static func parsePS(_ output: String) -> [PSEntry] {
output.split(whereSeparator: \.isNewline).compactMap { line in
let fields = line.split(separator: " ", maxSplits: 4, omittingEmptySubsequences: true)
guard fields.count == 5, let pid = Int32(fields[0]), let uid = UInt32(fields[1]),
let time = parseCPUTime(String(fields[2])), let rss = UInt64(fields[3]) else { return nil }
return PSEntry(pid: pid, uid: uid, cpuTime: time, residentBytes: rss * 1024,
command: fields[4].trimmingCharacters(in: .whitespaces))
}
}
/// ps 的 CPU 时间格式为 “分:秒.百分秒”,时间长时可能带小时 “时:分:秒.百分秒”
static func parseCPUTime(_ text: String) -> Double? {
let parts = text.split(separator: ":").map { Double($0) }
guard !parts.isEmpty, parts.allSatisfy({ $0 != nil }) else { return nil }
return parts.compactMap { $0 }.reduce(0) { $0 * 60 + $1 }
}
private static func runPS() -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/ps")
process.arguments = ["-axo", "pid=,uid=,time=,rss=,comm="]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
return nil
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return process.terminationStatus == 0 ? String(decoding: data, as: UTF8.self) : nil
}
private mutating func userName(_ uid: UInt32) -> String {
if let cached = userNames[uid] { return cached }
let name = getpwuid(uid).flatMap { String(cString: $0.pointee.pw_name) } ?? "\(uid)"
userNames[uid] = name
return name
}
static func meta(for pid: pid_t) -> Meta {
@@ -508,63 +508,54 @@ private struct QuickActionsCard: View {
var body: some View {
let keepAwake = model.keepAwake
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
let fans = model.fans
Card(padding: DS.Space.s3, spacing: 0) {
HStack(spacing: DS.Space.s1) {
Image(systemName: "switch.2").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text("快捷开关").dsFont(.xs, weight: .semibold)
}
.foregroundStyle(DS.Palette.textSecondary)
.padding(.bottom, DS.Space.s1)
QuickAction(icon: "cup.and.saucer", activeIcon: "cup.and.saucer.fill", title: "防休眠",
detail: keepAwake.isActive ? "已开启" : "关闭", isActive: keepAwake.isActive) {
Task { await keepAwake.setActive(!keepAwake.isActive) }
}
QuickAction(icon: "laptopcomputer", activeIcon: "laptopcomputer", title: "合盖运行",
detail: keepAwake.lidClosedActive ? "已开启" : "关闭", isActive: keepAwake.lidClosedActive) {
model.requestLidMode(!keepAwake.lidClosedRequested)
}
QuickAction(icon: "fan", activeIcon: "fan.fill", title: "散热模式",
detail: model.fans.mode.title, isActive: model.fans.mode != .automatic) {
model.settings.panelTab = .thermal
}
QuickToggleRow(icon: "cup.and.saucer", title: "防休眠",
detail: keepAwake.isActive ? "保持唤醒" : "按系统休眠",
isOn: Binding(get: { keepAwake.isActive },
set: { value in Task { await keepAwake.setActive(value) } }))
HairlineDivider()
QuickToggleRow(icon: "laptopcomputer", title: "合盖运行",
detail: keepAwake.lidClosedActive ? "继续运行" : "合盖睡眠",
isOn: Binding(get: { keepAwake.lidClosedRequested },
set: { model.requestLidMode($0) }))
HairlineDivider()
QuickToggleRow(icon: "fan", title: "散热模式",
detail: fans.mode == .automatic ? "系统调节" : "\(fans.mode.title)中",
isOn: Binding(get: { fans.mode != .automatic },
set: { model.requestFanMode($0 ? .cooling : .automatic) }))
}
}
}
private struct QuickAction: View {
/// 快捷开关的一行:图标、名称与当前状态,右侧开关
private struct QuickToggleRow: View {
let icon: String
let activeIcon: String
let title: String
let detail: String
let isActive: Bool
let action: () -> Void
@State private var hovering = false
@Binding var isOn: Bool
var body: some View {
Button(action: action) {
HStack(spacing: DS.Space.s2) {
Image(systemName: isActive ? activeIcon : icon)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.frame(width: DS.Size.iconStandalone)
Text(title).dsFont(.sm, weight: .medium).lineLimit(1)
Spacer(minLength: DS.Space.s1)
Text(detail).dsFont(.xs).foregroundStyle(isActive ? DS.Palette.primary : DS.Palette.textTertiary)
HStack(spacing: DS.Space.s2) {
Image(systemName: icon)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.foregroundStyle(isOn ? DS.Palette.primary : DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
VStack(alignment: .leading, spacing: 0) {
Text(title).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
Text(detail).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1)
}
.foregroundStyle(isActive ? DS.Palette.primary : DS.Palette.textPrimary)
.padding(.horizontal, DS.Space.s2)
.frame(maxWidth: .infinity)
.frame(height: DS.Size.controlHeight)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.contentShape(Rectangle())
Spacer(minLength: DS.Space.s2)
DSToggle(isOn: $isOn, label: title)
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.accessibilityAddTraits(isActive ? .isSelected : [])
}
private var background: Color {
if isActive { return DS.Palette.primary.opacity(0.12) }
return hovering ? DS.Palette.surfaceHover : DS.Palette.track
.padding(.vertical, DS.Space.s2)
}
}
@@ -2,145 +2,536 @@ import AppKit
import Metrics
import SwiftUI
/// 进程管理器:包括系统进程,可搜索、筛选、按列排序、按应用合并,选中后查看详情或结束进程
struct ProcessesPage: View {
enum Sort: String, CaseIterable {
case cpu, memory
enum Scope: String, CaseIterable {
case all, mine, system
var title: String { self == .cpu ? "按 CPU" : "按内存" }
var title: String {
switch self {
case .all: "全部"
case .mine: "我的"
case .system: "系统"
}
}
}
enum Grouping: String, CaseIterable {
case processes, apps
var title: String { self == .processes ? "按进程" : "按应用" }
}
@Environment(AppModel.self) private var model
@State private var sort: Sort = .cpu
private let visibleCount = 15
@Environment(\.isSnapshot) private var isSnapshot
@State private var search = ""
@State private var scope: Scope = .all
@State private var grouping: Grouping = .processes
@State private var sortColumn: ProcessColumn = .cpu
@State private var ascending = false
@State private var selection: String?
@State private var pendingQuit: ProcessRowModel?
@State private var actionError: String?
@State private var tableWidth: CGFloat = DS.Size.panelWidth
var body: some View {
let processes = sorted(model.store.processes)
let rows = visibleRows()
let selected = rows.first { $0.id == selection }
PageScroll {
VStack(alignment: .leading, spacing: DS.Space.s3) {
if model.explainer.subject != nil {
ProcessExplanationCard()
}
HStack {
SegmentedControl(selection: $sort, options: Sort.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.tileWidth)
Spacer()
Text(verbatim: "占用最高的 \(visibleCount) 个")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
toolbar
let columns = ProcessColumn.visible(forWidth: tableWidth)
Card(padding: 0, spacing: 0) {
HStack(spacing: DS.Space.s3) {
Text("进程")
Spacer()
Text("CPU").frame(width: DS.Size.valueColumn, alignment: .trailing)
Text("内存").frame(width: DS.Size.valueColumn, alignment: .trailing)
ProcessHeader(columns: columns, sortColumn: sortColumn, ascending: ascending) { column in
if column == sortColumn { ascending.toggle() } else { sortColumn = column; ascending = column.ascendingByDefault }
}
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textTertiary)
.padding(.horizontal, DS.Space.s4)
.padding(.vertical, DS.Space.s2)
if processes.isEmpty {
HairlineDivider()
Text("正在读取进程…")
HairlineDivider()
if rows.isEmpty {
Text(model.store.processes.isEmpty ? "正在读取进程…" : "没有匹配的进程")
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity)
.padding(DS.Space.s6)
} else {
ForEach(processes.prefix(visibleCount)) { process in
HairlineDivider()
ProcessRow(process: process, emphasis: sort)
} else if isSnapshot {
ForEach(rows.prefix(16)) { row in
ProcessTableRow(row: row, columns: columns, isSelected: false, sortColumn: sortColumn)
}
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(rows) { row in
ProcessTableRow(row: row, columns: columns, isSelected: row.id == selection, sortColumn: sortColumn)
.onTapGesture { selection = row.id }
.contextMenu { menu(for: row) }
}
}
.overlayScrollers()
}
.frame(maxHeight: .infinity)
}
}
.frame(maxHeight: isSnapshot ? nil : .infinity)
.background(GeometryReader { proxy in
Color.clear.preference(key: TableWidthKey.self, value: proxy.size.width)
})
.onPreferenceChange(TableWidthKey.self) { tableWidth = $0 }
Text("仅列出当前用户有权限读取的进程,CPU 以单核满载为 100%。")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
if let selected {
ProcessInspector(row: selected, error: actionError,
onQuit: { pendingQuit = selected },
onExplain: { model.explainProcess(.init(selected.representative)) })
}
summary(count: rows.count)
}
.padding(DS.Space.s3)
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.confirmationDialog(pendingQuit.map { "结束“\($0.title)”?" } ?? "", isPresented: Binding(get: { pendingQuit != nil }, set: { if !$0 { pendingQuit = nil } }),
titleVisibility: .visible, presenting: pendingQuit) { row in
Button("退出") { quit(row, force: false) }
Button("强制退出", role: .destructive) { quit(row, force: true) }
Button("取消", role: .cancel) {}
} message: { row in
Text(row.count > 1
? "会结束这个应用的 \(row.count) 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。"
: "未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。")
}
}
private func sorted(_ processes: [ProcessUsage]) -> [ProcessUsage] {
switch sort {
case .cpu: processes
case .memory: processes.sorted { $0.memory > $1.memory }
// MARK: 工具栏与汇总
private var toolbar: some View {
HStack(spacing: DS.Space.s3) {
HStack(spacing: DS.Space.s2) {
Image(systemName: "magnifyingglass")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.textTertiary)
TextField("搜索名称、PID 或用户", text: $search)
.textFieldStyle(.plain)
.dsFont(.sm)
if !search.isEmpty {
MiniIconButton(systemName: "xmark.circle.fill", help: "清除搜索") { search = "" }
}
}
.padding(.horizontal, DS.Space.s2)
.frame(height: DS.Size.controlHeight)
.frame(maxWidth: DS.Size.settingsSidebar + DS.Space.s16)
.background(DS.Palette.elevated, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.stroke))
SegmentedControl(selection: $scope, options: Scope.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.settingsSidebar)
SegmentedControl(selection: $grouping, options: Grouping.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.settingsSidebar - DS.Space.s8)
Spacer(minLength: 0)
}
}
private func summary(count: Int) -> some View {
let cpu = model.store.cpu
let counts = model.store.systemCounts
return HStack(spacing: DS.Space.s4) {
if let cpu {
LegendItem(color: DS.Palette.primary, label: "用户", value: Format.percent(cpu.user))
LegendItem(color: DS.Palette.secondary, label: "系统", value: Format.percent(cpu.system))
LegendItem(color: DS.Palette.track, label: "空闲", value: Format.percent(max(0, 1 - cpu.total)))
}
Spacer(minLength: DS.Space.s2)
if let counts {
Text(verbatim: "进程 \(counts.processes.formatted()) · 线程 \(counts.threads.formatted())")
.dsFont(.xs)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
}
Text(verbatim: "显示 \(count) 项").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
.padding(.horizontal, DS.Space.s1)
}
@ViewBuilder
private func menu(for row: ProcessRowModel) -> some View {
if ProcessExplainer.isSupported {
Button("用 Apple 智能解释") { model.explainProcess(.init(row.representative)) }
Divider()
}
if let path = row.bundlePath ?? row.representative.executablePath {
Button("在访达中显示") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) }
}
if let pid = row.pid {
Button("拷贝 PID") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(pid), forType: .string)
}
}
Divider()
Button(row.count > 1 ? "退出应用…" : "结束进程…") { selection = row.id; pendingQuit = row }
.disabled(row.quitBlockedReason != nil)
}
// MARK: 数据
private func visibleRows() -> [ProcessRowModel] {
let me = getuid()
let filtered = model.store.processes.filter { process in
switch scope {
case .all: true
case .mine: process.uid == me
case .system: process.uid != me
}
}
var rows = grouping == .processes
? filtered.map(ProcessRowModel.init(process:))
: ProcessRowModel.grouped(filtered)
let query = search.trimmingCharacters(in: .whitespaces)
if !query.isEmpty {
rows = rows.filter { $0.matches(query) }
}
return rows.sorted { lhs, rhs in
let ordered = sortColumn.isOrdered(lhs, rhs)
return ascending ? ordered : sortColumn.isOrdered(rhs, lhs)
}
}
private func quit(_ row: ProcessRowModel, force: Bool) {
actionError = ProcessActions.terminate(row, force: force)
if actionError == nil { selection = nil }
}
}
// MARK: - 行模型
struct ProcessRowModel: Identifiable {
let id: String
let title: String
let subtitle: String
let bundlePath: String?
let cpu: Double
let cpuTime: Double
let memory: UInt64
let threads: Int?
let wakeups: Double?
let disk: (read: Double, write: Double)?
let user: String
let pid: Int32?
let count: Int
let isOwned: Bool
let representative: ProcessUsage
let processes: [ProcessUsage]
@MainActor
init(process: ProcessUsage) {
id = "pid-\(process.pid)"
title = process.displayName
subtitle = [process.helperRole, "PID \(process.pid)"].compactMap { $0 }.joined(separator: " · ")
bundlePath = process.appBundlePath
cpu = process.cpu
cpuTime = process.cpuTime
memory = process.memory
threads = process.threads
wakeups = process.idleWakeups
disk = process.diskRead.map { ($0, process.diskWrite ?? 0) }
user = process.userName
pid = process.pid
count = 1
isOwned = process.isOwned
representative = process
processes = [process]
}
private init(group: [ProcessUsage], key: String, title: String) {
let representative = group.max { $0.memory < $1.memory } ?? group[0]
id = "app-\(key)"
self.title = title
subtitle = group.count > 1 ? "\(group.count) 个进程" : "PID \(representative.pid)"
bundlePath = representative.appBundlePath
cpu = group.reduce(0) { $0 + $1.cpu }
cpuTime = group.reduce(0) { $0 + $1.cpuTime }
memory = group.reduce(0) { $0 + $1.memory }
let threadCounts = group.compactMap(\.threads)
threads = threadCounts.isEmpty ? nil : threadCounts.reduce(0, +)
let wakeupCounts = group.compactMap(\.idleWakeups)
wakeups = wakeupCounts.isEmpty ? nil : wakeupCounts.reduce(0, +)
let reads = group.compactMap(\.diskRead)
disk = reads.isEmpty ? nil : (reads.reduce(0, +), group.compactMap(\.diskWrite).reduce(0, +))
user = representative.userName
pid = group.count == 1 ? representative.pid : nil
count = group.count
isOwned = group.allSatisfy(\.isOwned)
self.representative = representative
processes = group
}
/// 同一应用的主进程与辅助进程合并;不属于应用的进程各自一行
@MainActor
static func grouped(_ processes: [ProcessUsage]) -> [ProcessRowModel] {
var buckets: [String: [ProcessUsage]] = [:]
for process in processes {
buckets[process.appBundlePath ?? "pid-\(process.pid)", default: []].append(process)
}
return buckets.map { key, group in
if let bundle = group[0].appBundlePath {
return ProcessRowModel(group: group, key: key, title: AppNameCache.shared.name(forBundle: bundle))
}
return ProcessRowModel(process: group[0])
}
}
func matches(_ query: String) -> Bool {
title.localizedCaseInsensitiveContains(query) || user.localizedCaseInsensitiveContains(query)
|| processes.contains { $0.name.localizedCaseInsensitiveContains(query) || String($0.pid) == query }
}
/// 不能从这里结束的原因;nil 表示可以结束
var quitBlockedReason: String? {
if !isOwned { return "系统或其他用户的进程,OpenStats 不提供结束" }
if processes.contains(where: { $0.pid == getpid() }) { return "请从菜单退出 OpenStats" }
if processes.contains(where: { ProcessActions.protectedNames.contains($0.name) }) { return "结束它会注销当前用户,已禁止" }
return nil
}
}
private struct TableWidthKey: PreferenceKey {
static let defaultValue: CGFloat = DS.Size.panelWidth
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
enum ProcessColumn: CaseIterable {
case name, cpu, cpuTime, memory, threads, wakeups, disk, user
/// 窗口变窄时先隐藏 CPU 时间与唤醒,再窄只保留 CPU、内存与用户,保证名称列至少有 200pt
static func visible(forWidth width: CGFloat) -> [ProcessColumn] {
let tiers: [[ProcessColumn]] = [allCases, [.name, .cpu, .memory, .threads, .disk, .user], [.name, .cpu, .memory, .user]]
return tiers.first { tier in
let fixed = tier.compactMap(\.width).reduce(0, +) + DS.Space.s2 * CGFloat(tier.count - 1) + DS.Space.s4 * 2
return width - fixed >= DS.Space.s16 * 3 + DS.Space.s1 * 2
} ?? tiers[2]
}
var title: String {
switch self {
case .name: "进程"
case .cpu: "CPU"
case .cpuTime: "CPU 时间"
case .memory: "内存"
case .threads: "线程"
case .wakeups: "唤醒"
case .disk: "磁盘"
case .user: "用户"
}
}
var width: CGFloat? {
switch self {
case .name: nil
case .cpu, .memory, .user: DS.Size.valueColumn
case .cpuTime, .disk: DS.Size.valueColumn + DS.Space.s4
case .threads, .wakeups: DS.Size.labelColumn
}
}
var help: String {
switch self {
case .cpu: "以单核满载为 100%"
case .cpuTime: "进程启动以来累计占用的 CPU 时间"
case .memory: "自己的进程为实际占用内存,系统进程为常驻内存"
case .threads: "线程数(只能读取自己的进程)"
case .wakeups: "每秒让 CPU 从空闲中唤醒的次数,越高越耗电"
case .disk: "每秒读写磁盘的字节数"
default: ""
}
}
var ascendingByDefault: Bool { self == .name || self == .user }
func isOrdered(_ lhs: ProcessRowModel, _ rhs: ProcessRowModel) -> Bool {
switch self {
case .name: lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending
case .cpu: lhs.cpu < rhs.cpu
case .cpuTime: lhs.cpuTime < rhs.cpuTime
case .memory: lhs.memory < rhs.memory
case .threads: (lhs.threads ?? -1) < (rhs.threads ?? -1)
case .wakeups: (lhs.wakeups ?? -1) < (rhs.wakeups ?? -1)
case .disk: (lhs.disk.map { $0.read + $0.write } ?? -1) < (rhs.disk.map { $0.read + $0.write } ?? -1)
case .user: lhs.user.localizedStandardCompare(rhs.user) == .orderedAscending
}
}
}
private struct ProcessRow: View {
let process: ProcessUsage
let emphasis: ProcessesPage.Sort
@State private var hovering = false
@Environment(\.isSnapshot) private var isSnapshot
@Environment(AppModel.self) private var model
// MARK: - 表格
private struct ProcessHeader: View {
let columns: [ProcessColumn]
let sortColumn: ProcessColumn
let ascending: Bool
let onSort: (ProcessColumn) -> Void
var body: some View {
HStack(spacing: DS.Space.s3) {
AppIconCache.shared.image(for: process)
.resizable()
.frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone)
VStack(alignment: .leading, spacing: 0) {
Text(title)
.dsFont(.sm)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Text(subtitle)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
}
Spacer(minLength: DS.Space.s2)
Text(cpuText)
.dsFont(.sm, weight: emphasis == .cpu ? .semibold : .regular)
.foregroundStyle(emphasis == .cpu ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(Format.bytes(process.memory))
.dsFont(.sm, weight: emphasis == .memory ? .semibold : .regular)
.foregroundStyle(emphasis == .memory ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
if ProcessExplainer.isSupported {
MiniIconButton(systemName: "sparkles", help: "用 Apple 智能解释这个进程") {
model.explainProcess(.init(process))
HStack(spacing: DS.Space.s2) {
ForEach(columns, id: \.self) { column in
Button { onSort(column) } label: {
HStack(spacing: DS.Space.s1 / 2) {
if column != .name { Spacer(minLength: 0) }
Text(column.title).lineLimit(1)
if column == sortColumn {
Image(systemName: ascending ? "chevron.up" : "chevron.down")
.font(.system(size: DS.TextSize.xs.rawValue - DS.Space.s1, weight: .bold))
}
if column == .name { Spacer(minLength: 0) }
}
.foregroundStyle(column == sortColumn ? DS.Palette.primary : DS.Palette.textTertiary)
.contentShape(Rectangle())
}
.opacity(hovering || isSnapshot ? 1 : 0)
.buttonStyle(.plain)
.help(column.help)
.frame(width: column.width)
.frame(maxWidth: column.width == nil ? .infinity : nil)
.padding(.leading, column == .name ? DS.Size.iconStandalone + DS.Space.s3 : 0)
}
}
.dsFont(.xs, weight: .medium)
.padding(.horizontal, DS.Space.s4)
.frame(height: DS.Size.controlHeight)
}
}
private struct ProcessTableRow: View {
let row: ProcessRowModel
let columns: [ProcessColumn]
let isSelected: Bool
let sortColumn: ProcessColumn
@State private var hovering = false
var body: some View {
HStack(spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) {
AppIconCache.shared.image(bundlePath: row.bundlePath)
.resizable()
.frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone)
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: row.title).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
Text(verbatim: row.subtitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
ForEach(columns.filter { $0 != .name }, id: \.self) { column in
cell(column, text(for: column))
}
}
.monospacedDigit()
.padding(.horizontal, DS.Space.s4)
.padding(.vertical, DS.Space.s2)
.background(hovering ? DS.Palette.surfaceHover : .clear)
.padding(.vertical, DS.Space.s1 + DS.Space.s1 / 2)
.background(isSelected ? DS.Palette.primary.opacity(0.14) : hovering ? DS.Palette.surfaceHover : .clear)
.contentShape(Rectangle())
.onHover { hovering = $0 }
.contextMenu(isSnapshot ? nil : ContextMenu {
if let path = process.appBundlePath ?? process.executablePath {
Button("在访达中显示") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
private func text(for column: ProcessColumn) -> String {
switch column {
case .name: row.title
case .cpu: row.cpu < 0.0005 ? "0%" : "\((row.cpu * 100).formatted(.number.precision(.fractionLength(1))))%"
case .cpuTime: Format.cpuTime(row.cpuTime)
case .memory: Format.bytes(row.memory)
case .threads: row.threads.map(String.init) ?? "—"
case .wakeups: row.wakeups.map { String(Int($0.rounded())) } ?? "—"
case .disk: row.disk.map { Format.menuBarRate($0.read + $0.write) } ?? "—"
case .user: row.user
}
}
private func cell(_ column: ProcessColumn, _ text: String) -> some View {
Text(verbatim: text)
.dsFont(.xs, weight: column == sortColumn ? .semibold : .regular)
.foregroundStyle(column == sortColumn ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.lineLimit(1)
.frame(width: column.width, alignment: .trailing)
}
}
/// 选中进程后的详情条:路径、PID、用户、线程、磁盘读写,以及解释与结束
private struct ProcessInspector: View {
let row: ProcessRowModel
let error: String?
let onQuit: () -> Void
let onExplain: () -> Void
var body: some View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s3) {
AppIconCache.shared.image(bundlePath: row.bundlePath)
.resizable()
.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(verbatim: row.title).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
Text(verbatim: row.bundlePath ?? row.representative.executablePath ?? "路径不可读")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
Text(verbatim: details)
.dsFont(.xs)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
}
Spacer(minLength: DS.Space.s3)
if ProcessExplainer.isSupported {
Button { onExplain() } label: { Label("Apple 智能解释", systemImage: "sparkles") }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
if let path = row.bundlePath ?? row.representative.executablePath {
IconButton(systemName: "folder", help: "在访达中显示") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
Button(row.count > 1 ? "退出应用…" : "结束进程…") { onQuit() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(row.quitBlockedReason != nil)
.help(row.quitBlockedReason ?? "")
}
Button("拷贝 PID") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(process.pid), forType: .string)
if let error {
Text(error).dsFont(.xs).foregroundStyle(DS.Palette.error)
} else if let reason = row.quitBlockedReason {
Text(reason).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
if ProcessExplainer.isSupported {
Divider()
Button("用 Apple 智能解释") { model.explainProcess(.init(process)) }
}
})
}
.frame(maxHeight: nil)
.fixedSize(horizontal: false, vertical: true)
}
private var title: String { process.displayName }
private var subtitle: String {
let pid = "PID \(process.pid)"
guard let role = process.helperRole else { return pid }
return "\(role) · \(pid)"
private var details: String {
var parts = [row.pid.map { "PID \($0)" } ?? "\(row.count) 个进程", "用户 \(row.user)"]
if let threads = row.threads { parts.append("线程 \(threads)") }
parts.append("CPU 时间 \(Format.cpuTime(row.cpuTime))")
if let disk = row.disk { parts.append("读 \(Format.menuBarRate(disk.read)) · 写 \(Format.menuBarRate(disk.write))") }
return parts.joined(separator: " · ")
}
}
private var cpuText: String {
let percent = process.cpu * 100
return percent < 0.05 ? "0%" : "\(percent.formatted(.number.precision(.fractionLength(1))))%"
// MARK: - 结束进程
enum ProcessActions {
/// 结束会注销当前用户或导致系统异常的进程
static let protectedNames: Set<String> = ["loginwindow", "WindowServer", "launchd", "kernel_task"]
/// 返回错误描述,nil 表示已发出结束请求
@MainActor
static func terminate(_ row: ProcessRowModel, force: Bool) -> String? {
if let reason = row.quitBlockedReason { return reason }
// 应用优先按应用退出:辅助进程会随主进程一起结束,也给应用保存的机会
if let bundle = row.bundlePath,
let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleURL?.path == bundle }) {
let sent = force ? app.forceTerminate() : app.terminate()
return sent ? nil : "应用拒绝退出,可以试试强制退出"
}
for process in row.processes where kill(process.pid, force ? SIGKILL : SIGTERM) != 0 {
return "结束 \(process.name) 失败:\(String(cString: strerror(errno)))"
}
return nil
}
}
@@ -59,6 +59,7 @@ public final class AppModel {
demand.disk = window && (tab == .overview || tab == .cleaner)
demand.battery = (window && (tab == .overview || tab == .keepAwake)) || keepAwake.lidClosedActive
demand.processes = (window && [.processes, .overview].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory)
demand.systemProcesses = window && tab == .processes
var groups = Set<TemperatureGroup>()
if window && tab == .overview { groups.formUnion([.cpu, .gpu]) }
@@ -34,6 +34,7 @@ public final class MetricsStore {
/// 启动时读取一次,确保面板首次打开时布局(是否有电池卡片)就已确定
public private(set) var battery: BatteryStatus? = BatterySampler.sample()
public private(set) var processes: [ProcessUsage] = []
public private(set) var systemCounts: SystemCounts?
public private(set) var sensors: SensorReadings?
public private(set) var lastUpdate: Date?
@@ -70,6 +71,7 @@ public final class MetricsStore {
if let disk = snapshot.disk { self.disk = disk }
if let battery = snapshot.battery { self.battery = battery }
if let processes = snapshot.processes { self.processes = processes }
if let counts = snapshot.systemCounts { systemCounts = counts }
if let sensors = snapshot.sensors { mergeSensors(sensors) }
}
@@ -116,8 +116,10 @@ struct LiveSamplerTests {
@Test func processesIncludeCurrentProcess() {
var sampler = ProcessSampler()
let processes = sampler.sample(limit: .max)
#expect(processes.contains { $0.pid == getpid() })
let processes = sampler.sample(includeSystem: true)
#expect(processes.contains { $0.pid == getpid() && $0.isOwned && $0.threads != nil })
// 系统进程来自 ps:launchd(PID 1)属于 root
#expect(processes.contains { $0.pid == 1 && !$0.isOwned && $0.userName == "root" })
}
@Test func diskHasCapacity() throws {
@@ -193,3 +195,32 @@ struct LiveSamplerTests {
#expect(result?.organization == "Cloudflare")
}
}
@Suite struct ProcessParsingTests {
@Test func parsesPSOutput() {
let output = """
1 0 99:12.26 28144 /sbin/launchd
412 88 712:02.63 378656 /System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer
913 501 0:00.51 9120 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
broken
"""
let entries = ProcessSampler.parsePS(output)
#expect(entries.count == 3)
#expect(entries[0].pid == 1 && entries[0].uid == 0)
#expect(entries[1].cpuTime == 712 * 60 + 2.63)
#expect(entries[1].residentBytes == 378_656 * 1024)
#expect(entries[2].command == "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
}
@Test func parsesCPUTimeWithHours() {
#expect(ProcessSampler.parseCPUTime("1:02:03.50") == 3723.5)
#expect(ProcessSampler.parseCPUTime("0:00.51") == 0.51)
#expect(ProcessSampler.parseCPUTime("abc") == nil)
}
@Test func readsSystemCounts() throws {
let counts = try #require(SystemCounts.read())
#expect(counts.processes > 0)
#expect(counts.threads >= counts.processes)
}
}
+5 -2
View File
@@ -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 · **40** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
Not released yet · **43** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
<details open>
<summary><b>2026-09-13</b> · Unreleased · 27 added · 7 style · 6 fixed</summary>
<summary><b>2026-09-13</b> · Unreleased · 28 added · 9 style · 6 fixed</summary>
**Added**
@@ -65,6 +65,7 @@ Not released yet · **40** changes in development · [full changelog](CHANGELOG.
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
@@ -83,6 +84,8 @@ Not released yet · **40** changes in development · [full changelog](CHANGELOG.
**Style**
- 仪表盘“快捷开关”改为开关样式:防休眠、合盖运行、散热模式各一行,右侧开关,下面一行小字说明当前状态。
- 进程表按窗口宽度自动隐藏次要列,窄窗口里名称列不会被挤没。
- 内存详情的“释放内存”移到标题栏,执行中与完成后在按钮上显示进度和结果;主窗口内存页顶栏同样提供。
- 主窗口默认 1072×720、设置窗口默认 920×720,两者都可自由调整大小;滚动条改为浮层样式,只在滚动时出现。
- 菜单栏字号缩小并统一:两行布局为 7pt 标签加 10pt 数值,单行布局 11pt,网速两行 9pt;每种风格内部只用一套字号,指标之间留出更宽的间距。
+5 -2
View File
@@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
<!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
尚未发布正式版本 · 开发中 **40** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
尚未发布正式版本 · 开发中 **43** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
<details open>
<summary><b>2026-09-13</b> · 未发布 · 新增 27 · 样式 7 · 修复 6</summary>
<summary><b>2026-09-13</b> · 未发布 · 新增 28 · 样式 9 · 修复 6</summary>
**新增**
@@ -63,6 +63,7 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
@@ -81,6 +82,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
**样式**
- 仪表盘“快捷开关”改为开关样式:防休眠、合盖运行、散热模式各一行,右侧开关,下面一行小字说明当前状态。
- 进程表按窗口宽度自动隐藏次要列,窄窗口里名称列不会被挤没。
- 内存详情的“释放内存”移到标题栏,执行中与完成后在按钮上显示进度和结果;主窗口内存页顶栏同样提供。
- 主窗口默认 1072×720、设置窗口默认 920×720,两者都可自由调整大小;滚动条改为浮层样式,只在滚动时出现。
- 菜单栏字号缩小并统一:两行布局为 7pt 标签加 10pt 数值,单行布局 11pt,网速两行 9pt;每种风格内部只用一套字号,指标之间留出更宽的间距。
+2 -2
View File
@@ -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">新增 27 · 样式 7 · 修复 6</p>
<p class="log__counts">新增 28 · 样式 9 · 修复 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">还有 32 项 →</a>
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 35 项 →</a>
</article>
</div>
<!-- changelog:end -->