From 63c37b0e22d5f0d9e013e5e24309455b3967b466 Mon Sep 17 00:00:00 2001 From: gentpan Date: Sun, 13 Sep 2026 06:26:43 +0500 Subject: [PATCH] =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E7=AE=A1=E7=90=86=E5=99=A8?= =?UTF-8?q?=EF=BC=9A=E5=8C=85=E6=8B=AC=E7=B3=BB=E7=BB=9F=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E6=90=9C=E7=B4=A2=E3=80=81=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E3=80=81=E6=8E=92=E5=BA=8F=E3=80=81=E6=8C=89=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=B9=B6=E7=BB=93=E6=9D=9F=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=EF=BC=9B=E5=BF=AB=E6=8D=B7=E5=BC=80=E5=85=B3=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 系统与其他用户的进程通过系统自带的 ps 读取 CPU、内存与 CPU 时间,自己的进程另有线程、唤醒次数与磁盘读写 - 列标题点击排序;搜索名称、PID 或用户;全部 / 我的 / 系统;按进程或按应用 - 选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 禁止结束) - 底部汇总用户 / 系统 / 空闲与全系统进程数、线程数;窗口变窄时自动隐藏次要列 - 仪表盘快捷开关改为开关样式 --- CHANGELOG.md | 3 + .../Sources/Metrics/Formatting.swift | 9 + .../Sources/Metrics/MetricsHub.swift | 7 +- .../OpenStatsKit/Sources/Metrics/Models.swift | 44 +- .../Sources/Metrics/ProcessSampler.swift | 152 ++++- .../OpenStatsUI/Panel/OverviewPage.swift | 71 +-- .../OpenStatsUI/Panel/ProcessesPage.swift | 581 +++++++++++++++--- .../Sources/OpenStatsUI/State/AppModel.swift | 1 + .../OpenStatsUI/State/MetricsStore.swift | 2 + .../Tests/MetricsTests/MetricsTests.swift | 35 +- README.md | 7 +- README.zh-CN.md | 7 +- web/index.html | 4 +- 13 files changed, 764 insertions(+), 159 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d3660..d87ad75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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,两者都可自由调整大小;滚动条改为浮层样式,只在滚动时出现。 diff --git a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift index ddaf9cc..5bd7055 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift @@ -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) diff --git a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift index b6f19b5..dd0ce01 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift @@ -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 = [] 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) } diff --git a/Packages/OpenStatsKit/Sources/Metrics/Models.swift b/Packages/OpenStatsKit/Sources/Metrics/Models.swift index 56354aa..2afb28e 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Models.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Models.swift @@ -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.size / MemoryLayout.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() {} diff --git a/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift index 4cb9282..0280e91 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/ProcessSampler.swift @@ -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() 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.size)) == Int32(MemoryLayout.size) { + usage.threads = Int(task.pti_threadnum) + } + var bsd = proc_bsdinfo() + if proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsd, Int32(MemoryLayout.size)) == Int32(MemoryLayout.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) -> [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 { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift index d05e6ae..61f16af 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift @@ -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) } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift index 2715986..00bc8cc 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift @@ -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 = ["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 } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 17b8393..ac3e642 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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() if window && tab == .overview { groups.formUnion([.cpu, .gpu]) } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift index f0dce27..e5a9470 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift @@ -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) } } diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift index cc217c0..d31a9e6 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift @@ -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) + } +} diff --git a/README.md b/README.md index 629ef62..61d02cb 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -Not released yet · **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)
-2026-09-13 · Unreleased · 27 added · 7 style · 6 fixed +2026-09-13 · Unreleased · 28 added · 9 style · 6 fixed **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;每种风格内部只用一套字号,指标之间留出更宽的间距。 diff --git a/README.zh-CN.md b/README.zh-CN.md index 254282e..c41fd9a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ -尚未发布正式版本 · 开发中 **40** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +尚未发布正式版本 · 开发中 **43** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
-2026-09-13 · 未发布 · 新增 27 · 样式 7 · 修复 6 +2026-09-13 · 未发布 · 新增 28 · 样式 9 · 修复 6 **新增** @@ -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;每种风格内部只用一套字号,指标之间留出更宽的间距。 diff --git a/web/index.html b/web/index.html index a2c520d..634392b 100644 --- a/web/index.html +++ b/web/index.html @@ -347,7 +347,7 @@
未发布
-

新增 27 · 样式 7 · 修复 6

+

新增 28 · 样式 9 · 修复 6

  • 新增菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。
  • 新增菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。
  • @@ -358,7 +358,7 @@
  • 新增网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。
  • 新增DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
- 还有 32 项 → + 还有 35 项 →