进程管理器:包括系统进程,可搜索、筛选、排序、按应用合并并结束进程;快捷开关改为开关样式
- 系统与其他用户的进程通过系统自带的 ps 读取 CPU、内存与 CPU 时间,自己的进程另有线程、唤醒次数与磁盘读写 - 列标题点击排序;搜索名称、PID 或用户;全部 / 我的 / 系统;按进程或按应用 - 选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 禁止结束) - 底部汇总用户 / 系统 / 空闲与全系统进程数、线程数;窗口变窄时自动隐藏次要列 - 仪表盘快捷开关改为开关样式
This commit is contained in:
1 parent
958d566870
commit
63c37b0e22
13 files changed
+764
-159
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 -->
|
||||
|
||||
Reference in new issue
Block a user