Increment 2: deep per-process data + process inspector
- ProcessItem: disk read/write rate + cumulative bytes, energy-impact score, executable path; equality now tracks energy/disk so the table refreshes - ProcessMonitor: disk I/O + idle-wakeup rates from rusage_info_v4; full path capture; on-demand KERN_PROCARGS2 args + PROC_PIDLISTFDS open-file count - EnergyModel heuristic + pure ProcArgs parser (both unit-tested) - ProcessView: sortable Energy + Disk I/O columns (icon folded into Name to stay within SwiftUI's 10-column limit); Get Info (double-click / ⌘I) opens inspector - ProcessInspectorSheet: live stats, path, args, open files, Reveal/Copy Path - 10 new tests (32 total green); smoke: stable under CPU+disk load Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; };
|
||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; };
|
||||
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; };
|
||||
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332290DABD5326C87258723C /* ProcessDataTests.swift */; };
|
||||
341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; };
|
||||
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; };
|
||||
4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; };
|
||||
@@ -64,6 +65,7 @@
|
||||
295232B4C746511757DCF8FE /* ProcessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessMonitor.swift; sourceTree = "<group>"; };
|
||||
3010F605712E09175D9D8878 /* NetworkInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInfo.swift; sourceTree = "<group>"; };
|
||||
301352EB5B362C732CE1A246 /* FormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingTests.swift; sourceTree = "<group>"; };
|
||||
332290DABD5326C87258723C /* ProcessDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDataTests.swift; sourceTree = "<group>"; };
|
||||
334573CEB47D60570A40B55D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
33BCE156CB39EDADFFC938F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
||||
@@ -210,6 +212,7 @@
|
||||
children = (
|
||||
301352EB5B362C732CE1A246 /* FormattingTests.swift */,
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
||||
332290DABD5326C87258723C /* ProcessDataTests.swift */,
|
||||
);
|
||||
path = TopManagerTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -329,6 +332,7 @@
|
||||
files = (
|
||||
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */,
|
||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
|
||||
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -14,9 +14,17 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
let icon: NSImage?
|
||||
let parentPid: pid_t
|
||||
let startTime: Date?
|
||||
let diskReadRate: Double // bytes/sec read from disk
|
||||
let diskWriteRate: Double // bytes/sec written to disk
|
||||
let diskReadBytes: UInt64 // cumulative bytes read
|
||||
let diskWriteBytes: UInt64 // cumulative bytes written
|
||||
let energyImpact: Double // heuristic energy-impact score (proxy for Activity Monitor's)
|
||||
let executablePath: String?
|
||||
|
||||
var iconPlaceholder: String { "" }
|
||||
|
||||
var diskTotalRate: Double { diskReadRate + diskWriteRate }
|
||||
|
||||
init(
|
||||
pid: pid_t,
|
||||
name: String,
|
||||
@@ -28,7 +36,13 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
state: ProcessState,
|
||||
icon: NSImage?,
|
||||
parentPid: pid_t,
|
||||
startTime: Date?
|
||||
startTime: Date?,
|
||||
diskReadRate: Double = 0,
|
||||
diskWriteRate: Double = 0,
|
||||
diskReadBytes: UInt64 = 0,
|
||||
diskWriteBytes: UInt64 = 0,
|
||||
energyImpact: Double = 0,
|
||||
executablePath: String? = nil
|
||||
) {
|
||||
self.id = pid
|
||||
self.pid = pid
|
||||
@@ -42,6 +56,12 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
self.icon = icon
|
||||
self.parentPid = parentPid
|
||||
self.startTime = startTime
|
||||
self.diskReadRate = diskReadRate
|
||||
self.diskWriteRate = diskWriteRate
|
||||
self.diskReadBytes = diskReadBytes
|
||||
self.diskWriteBytes = diskWriteBytes
|
||||
self.energyImpact = energyImpact
|
||||
self.executablePath = executablePath
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
@@ -54,7 +74,19 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
lhs.cpuUsageTotal == rhs.cpuUsageTotal &&
|
||||
lhs.memoryUsage == rhs.memoryUsage &&
|
||||
lhs.threadCount == rhs.threadCount &&
|
||||
lhs.state == rhs.state
|
||||
lhs.state == rhs.state &&
|
||||
lhs.diskReadRate == rhs.diskReadRate &&
|
||||
lhs.diskWriteRate == rhs.diskWriteRate &&
|
||||
lhs.energyImpact == rhs.energyImpact
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic "energy impact" proxy. This is *not* Apple's exact private formula;
|
||||
/// it approximates it: sustained CPU dominates, with a smaller penalty for idle
|
||||
/// (timer) wakeups, which correlate with power draw even at low CPU.
|
||||
enum EnergyModel {
|
||||
static func impact(cpuPercent: Double, idleWakeupsPerSec: Double) -> Double {
|
||||
max(0, cpuPercent) + max(0, idleWakeupsPerSec) * 0.045
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import AppKit
|
||||
|
||||
final class ProcessMonitor {
|
||||
private var previousCPUTimes: [pid_t: (user: UInt64, system: UInt64, timestamp: Date)] = [:]
|
||||
private var previousDiskIO: [pid_t: (read: UInt64, write: UInt64, idle: UInt64, timestamp: Date)] = [:]
|
||||
private var lastKnownCPU: [pid_t: Double] = [:] // Cache last known CPU usage
|
||||
private let iconCache = NSCache<NSNumber, NSImage>()
|
||||
private var noIconPids: Set<pid_t> = [] // Cache for PIDs with no icon
|
||||
private var nameCache: [pid_t: String] = [:]
|
||||
private var pathCache: [pid_t: String] = [:] // Full executable path
|
||||
private var userCache: [uid_t: String] = [:]
|
||||
private let timebaseInfo: mach_timebase_info_data_t
|
||||
private var refreshCounter = 0
|
||||
@@ -48,8 +50,10 @@ final class ProcessMonitor {
|
||||
let stalePids = Set(nameCache.keys).subtracting(currentPids)
|
||||
for pid in stalePids {
|
||||
nameCache.removeValue(forKey: pid)
|
||||
pathCache.removeValue(forKey: pid)
|
||||
iconCache.removeObject(forKey: NSNumber(value: pid))
|
||||
previousCPUTimes.removeValue(forKey: pid)
|
||||
previousDiskIO.removeValue(forKey: pid)
|
||||
noIconPids.remove(pid)
|
||||
lastKnownCPU.removeValue(forKey: pid)
|
||||
}
|
||||
@@ -80,6 +84,11 @@ final class ProcessMonitor {
|
||||
let memoryUsage: Int64
|
||||
let threadCount: Int32
|
||||
let cpuUsage: Double
|
||||
var diskReadRate: Double = 0
|
||||
var diskWriteRate: Double = 0
|
||||
var diskReadBytes: UInt64 = 0
|
||||
var diskWriteBytes: UInt64 = 0
|
||||
var energyImpact: Double = 0
|
||||
|
||||
if needsDetailedInfo {
|
||||
var taskInfo = proc_taskinfo()
|
||||
@@ -96,6 +105,17 @@ final class ProcessMonitor {
|
||||
userTime: rusageData.userTime,
|
||||
systemTime: rusageData.systemTime
|
||||
)
|
||||
let extra = computeExtraRates(
|
||||
pid: pid,
|
||||
diskRead: rusageData.diskRead,
|
||||
diskWrite: rusageData.diskWrite,
|
||||
idleWakeups: rusageData.idleWakeups
|
||||
)
|
||||
diskReadRate = extra.readRate
|
||||
diskWriteRate = extra.writeRate
|
||||
diskReadBytes = rusageData.diskRead
|
||||
diskWriteBytes = rusageData.diskWrite
|
||||
energyImpact = EnergyModel.impact(cpuPercent: cpuUsage, idleWakeupsPerSec: extra.idleWakeupsPerSec)
|
||||
} else {
|
||||
memoryUsage = 0
|
||||
threadCount = 0
|
||||
@@ -126,7 +146,13 @@ final class ProcessMonitor {
|
||||
state: state,
|
||||
icon: icon,
|
||||
parentPid: parentPid,
|
||||
startTime: startTime
|
||||
startTime: startTime,
|
||||
diskReadRate: diskReadRate,
|
||||
diskWriteRate: diskWriteRate,
|
||||
diskReadBytes: diskReadBytes,
|
||||
diskWriteBytes: diskWriteBytes,
|
||||
energyImpact: energyImpact,
|
||||
executablePath: pathCache[pid]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,6 +176,7 @@ final class ProcessMonitor {
|
||||
// This protects against edge cases where buffer might not be null-terminated
|
||||
let pathData = Data(bytes: pathBuffer, count: pathLength)
|
||||
if let path = String(data: pathData, encoding: .utf8) {
|
||||
pathCache[pid] = path
|
||||
name = (path as NSString).lastPathComponent
|
||||
} else {
|
||||
name = "Process \(pid)"
|
||||
@@ -203,7 +230,16 @@ final class ProcessMonitor {
|
||||
return name
|
||||
}
|
||||
|
||||
private func fetchRusageData(pid: pid_t) -> (memory: Int64, userTime: UInt64, systemTime: UInt64) {
|
||||
private struct RusageData {
|
||||
var memory: Int64 = 0
|
||||
var userTime: UInt64 = 0
|
||||
var systemTime: UInt64 = 0
|
||||
var diskRead: UInt64 = 0
|
||||
var diskWrite: UInt64 = 0
|
||||
var idleWakeups: UInt64 = 0
|
||||
}
|
||||
|
||||
private func fetchRusageData(pid: pid_t) -> RusageData {
|
||||
var rusage = rusage_info_v4()
|
||||
let result = withUnsafeMutablePointer(to: &rusage) { ptr -> Int32 in
|
||||
ptr.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { rusagePtr in
|
||||
@@ -212,25 +248,45 @@ final class ProcessMonitor {
|
||||
}
|
||||
|
||||
if result == 0 {
|
||||
return (
|
||||
return RusageData(
|
||||
memory: Int64(rusage.ri_phys_footprint),
|
||||
userTime: rusage.ri_user_time,
|
||||
systemTime: rusage.ri_system_time
|
||||
systemTime: rusage.ri_system_time,
|
||||
diskRead: rusage.ri_diskio_bytesread,
|
||||
diskWrite: rusage.ri_diskio_byteswritten,
|
||||
idleWakeups: rusage.ri_pkg_idle_wkups
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to proc_taskinfo if rusage fails
|
||||
// Fallback to proc_taskinfo if rusage fails (no disk/wakeup data available)
|
||||
var taskInfo = proc_taskinfo()
|
||||
let taskInfoSize = Int32(MemoryLayout<proc_taskinfo>.size)
|
||||
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
|
||||
return (
|
||||
return RusageData(
|
||||
memory: Int64(taskInfo.pti_resident_size),
|
||||
userTime: taskInfo.pti_total_user,
|
||||
systemTime: taskInfo.pti_total_system
|
||||
)
|
||||
}
|
||||
|
||||
return (memory: 0, userTime: 0, systemTime: 0)
|
||||
return RusageData()
|
||||
}
|
||||
|
||||
/// Computes per-second disk read/write and idle-wakeup rates from cumulative
|
||||
/// counters, using the previous snapshot. Mirrors the CPU delta approach.
|
||||
private func computeExtraRates(pid: pid_t, diskRead: UInt64, diskWrite: UInt64, idleWakeups: UInt64)
|
||||
-> (readRate: Double, writeRate: Double, idleWakeupsPerSec: Double) {
|
||||
let now = Date()
|
||||
defer { previousDiskIO[pid] = (diskRead, diskWrite, idleWakeups, now) }
|
||||
|
||||
guard let prev = previousDiskIO[pid] else { return (0, 0, 0) }
|
||||
let dt = now.timeIntervalSince(prev.timestamp)
|
||||
guard dt > 0 else { return (0, 0, 0) }
|
||||
|
||||
let readRate = diskRead >= prev.read ? Double(diskRead - prev.read) / dt : 0
|
||||
let writeRate = diskWrite >= prev.write ? Double(diskWrite - prev.write) / dt : 0
|
||||
let idleRate = idleWakeups >= prev.idle ? Double(idleWakeups - prev.idle) / dt : 0
|
||||
return (readRate, writeRate, idleRate)
|
||||
}
|
||||
|
||||
private func fetchBasicProcessInfo(pid: pid_t) -> ProcessItem? {
|
||||
@@ -373,3 +429,55 @@ final class ProcessMonitor {
|
||||
previousCPUTimes.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-demand deep inspection (called only when the inspector is open)
|
||||
|
||||
/// Pure parser for a KERN_PROCARGS2 buffer, split out so it is unit-testable
|
||||
/// without a live process. Layout: [Int32 argc][exec_path\0][\0 padding][argv…][envp…]
|
||||
enum ProcArgs {
|
||||
static func parse(_ bytes: [UInt8]) -> (path: String, args: [String])? {
|
||||
guard bytes.count > 4 else { return nil }
|
||||
// argc: first 4 bytes, host little-endian (arm64/x86_64)
|
||||
let argc = Int(bytes[0]) | (Int(bytes[1]) << 8) | (Int(bytes[2]) << 16) | (Int(bytes[3]) << 24)
|
||||
guard argc >= 0 else { return nil }
|
||||
|
||||
var i = 4
|
||||
let pathStart = i
|
||||
while i < bytes.count && bytes[i] != 0 { i += 1 }
|
||||
let path = String(decoding: bytes[pathStart..<i], as: UTF8.self)
|
||||
|
||||
// Skip the NUL padding between exec_path and argv[0]
|
||||
while i < bytes.count && bytes[i] == 0 { i += 1 }
|
||||
|
||||
var args: [String] = []
|
||||
var consumed = 0
|
||||
while consumed < argc && i < bytes.count {
|
||||
let start = i
|
||||
while i < bytes.count && bytes[i] != 0 { i += 1 }
|
||||
args.append(String(decoding: bytes[start..<i], as: UTF8.self))
|
||||
i += 1 // skip the NUL terminator
|
||||
consumed += 1
|
||||
}
|
||||
return (path, args)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the launch arguments for a process (works for the current user's
|
||||
/// processes without elevated privileges; returns [] when denied/unavailable).
|
||||
func fetchProcessArguments(pid: pid_t) -> [String] {
|
||||
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid]
|
||||
var size = 0
|
||||
guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > 0 else { return [] }
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: size)
|
||||
guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return [] }
|
||||
// sysctl may shrink `size`; only parse what was actually written.
|
||||
return ProcArgs.parse(Array(buffer.prefix(size)))?.args ?? []
|
||||
}
|
||||
|
||||
/// Counts open file descriptors for a process (files, sockets, pipes, …).
|
||||
func fetchOpenFileCount(pid: pid_t) -> Int? {
|
||||
let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
|
||||
guard bufferSize > 0 else { return nil }
|
||||
return Int(bufferSize) / MemoryLayout<proc_fdinfo>.stride
|
||||
}
|
||||
|
||||
@@ -1,34 +1,67 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct ProcessDetailView: View {
|
||||
let process: ProcessItem
|
||||
/// Deep-dive inspector for a single process, presented as a sheet. Shows live
|
||||
/// stats (looked up from the monitor by PID) plus on-demand details that are too
|
||||
/// expensive to compute for every row every cycle: full path, launch arguments,
|
||||
/// and open-file-descriptor count.
|
||||
struct ProcessInspectorSheet: View {
|
||||
let initialProcess: ProcessItem
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var arguments: [String] = []
|
||||
@State private var openFiles: Int?
|
||||
@State private var loadedDetails = false
|
||||
|
||||
/// Prefer the latest live snapshot for this PID; fall back to the one we opened with.
|
||||
private var process: ProcessItem {
|
||||
monitor.processes.first { $0.pid == initialProcess.pid } ?? initialProcess
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
header
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
liveStats
|
||||
pathSection
|
||||
detailsSection
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 460, height: 560)
|
||||
.task(id: initialProcess.pid) {
|
||||
let pid = initialProcess.pid
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
(args: fetchProcessArguments(pid: pid), files: fetchOpenFileCount(pid: pid))
|
||||
}.value
|
||||
arguments = result.args
|
||||
openFiles = result.files
|
||||
loadedDetails = true
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
Image(nsImage: icon).resizable().frame(width: 44, height: 44)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.font(.system(size: 48))
|
||||
Image(systemName: "app.dashed").font(.system(size: 40)).foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(process.name)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("PID: \(process.pid)")
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(process.name).font(.title2).fontWeight(.semibold).lineLimit(1)
|
||||
Text("PID \(process.pid) • \(process.user)").foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: process.state.symbol)
|
||||
Text(process.state.rawValue)
|
||||
}
|
||||
@@ -36,28 +69,70 @@ struct ProcessDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Details grid
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], alignment: .leading, spacing: 12) {
|
||||
DetailRow(label: "CPU Usage", value: String(format: "%.1f%%", process.cpuUsage))
|
||||
private var liveStats: some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())],
|
||||
alignment: .leading, spacing: 12) {
|
||||
DetailRow(label: "CPU (core)", value: String(format: "%.1f%%", process.cpuUsage))
|
||||
DetailRow(label: "CPU (total)", value: String(format: "%.2f%%", process.cpuUsageTotal))
|
||||
DetailRow(label: "Energy", value: String(format: "%.1f", process.energyImpact))
|
||||
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage))
|
||||
DetailRow(label: "Threads", value: "\(process.threadCount)")
|
||||
DetailRow(label: "User", value: process.user)
|
||||
DetailRow(label: "Open files", value: openFiles.map(String.init) ?? "…")
|
||||
DetailRow(label: "Disk read/s", value: process.diskReadRate > 0 ? formatBytesPerSecond(process.diskReadRate) : "—")
|
||||
DetailRow(label: "Disk write/s", value: process.diskWriteRate > 0 ? formatBytesPerSecond(process.diskWriteRate) : "—")
|
||||
DetailRow(label: "Disk total", value: formatBytes(process.diskReadBytes + process.diskWriteBytes))
|
||||
DetailRow(label: "Parent PID", value: "\(process.parentPid)")
|
||||
|
||||
if let startTime = process.startTime {
|
||||
DetailRow(label: "Started", value: formatDate(startTime))
|
||||
if let start = process.startTime {
|
||||
DetailRow(label: "Started", value: formatDate(start))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var pathSection: some View {
|
||||
if let path = process.executablePath {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Path").font(.caption).foregroundColor(.secondary)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(3)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var detailsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Launch arguments").font(.caption).foregroundColor(.secondary)
|
||||
if !loadedDetails {
|
||||
Text("Loading…").font(.caption).foregroundColor(.secondary)
|
||||
} else if arguments.isEmpty {
|
||||
Text("Unavailable (process may be owned by another user)")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
} else {
|
||||
Text(arguments.joined(separator: " "))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
if let path = process.executablePath {
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
|
||||
} label: { Label("Reveal in Finder", systemImage: "folder") }
|
||||
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(path, forType: .string)
|
||||
} label: { Label("Copy Path", systemImage: "doc.on.doc") }
|
||||
}
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
|
||||
private func stateColor(_ state: ProcessState) -> Color {
|
||||
@@ -95,17 +170,14 @@ struct DetailRow: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProcessDetailView(process: ProcessItem(
|
||||
pid: 1234,
|
||||
name: "Safari",
|
||||
user: "ariel",
|
||||
cpuUsage: 12.5,
|
||||
cpuUsageTotal: 1.56,
|
||||
memoryUsage: 512 * 1024 * 1024,
|
||||
threadCount: 42,
|
||||
state: .running,
|
||||
icon: nil,
|
||||
parentPid: 1,
|
||||
startTime: Date()
|
||||
ProcessInspectorSheet(initialProcess: ProcessItem(
|
||||
pid: 1234, name: "Safari", user: "ariel",
|
||||
cpuUsage: 12.5, cpuUsageTotal: 1.56,
|
||||
memoryUsage: 512 * 1024 * 1024, threadCount: 42,
|
||||
state: .running, icon: nil, parentPid: 1, startTime: Date(),
|
||||
diskReadRate: 2048, diskWriteRate: 1024,
|
||||
diskReadBytes: 900_000, diskWriteBytes: 400_000,
|
||||
energyImpact: 14.2, executablePath: "/Applications/Safari.app/Contents/MacOS/Safari"
|
||||
))
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ProcessSortColumn: String {
|
||||
case name, pid, cpu, cpuTotal, memory, threads, user, state
|
||||
case name, pid, cpu, cpuTotal, memory, threads, user, state, energy, disk
|
||||
}
|
||||
|
||||
struct ProcessView: View {
|
||||
@@ -30,6 +30,9 @@ struct ProcessView: View {
|
||||
@State private var errorTitle = ""
|
||||
@State private var errorMessage = ""
|
||||
|
||||
// Deep-dive inspector
|
||||
@State private var inspectorProcess: ProcessItem?
|
||||
|
||||
// Protected system processes that cannot be force-killed via UI
|
||||
private static let protectedProcesses = ["kernel_task", "launchd", "WindowServer", "loginwindow"]
|
||||
|
||||
@@ -63,6 +66,10 @@ struct ProcessView: View {
|
||||
comparison = lhs.user.localizedCaseInsensitiveCompare(rhs.user)
|
||||
case .state:
|
||||
comparison = lhs.state.rawValue.compare(rhs.state.rawValue)
|
||||
case .energy:
|
||||
comparison = lhs.energyImpact < rhs.energyImpact ? .orderedAscending : (lhs.energyImpact > rhs.energyImpact ? .orderedDescending : .orderedSame)
|
||||
case .disk:
|
||||
comparison = lhs.diskTotalRate < rhs.diskTotalRate ? .orderedAscending : (lhs.diskTotalRate > rhs.diskTotalRate ? .orderedDescending : .orderedSame)
|
||||
}
|
||||
|
||||
// Primary sort
|
||||
@@ -89,7 +96,8 @@ struct ProcessView: View {
|
||||
|
||||
// Process table
|
||||
Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) {
|
||||
TableColumn("") { process in
|
||||
TableColumn("Name", value: \.name) { process in
|
||||
HStack(spacing: 6) {
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
@@ -97,15 +105,13 @@ struct ProcessView: View {
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.width(24)
|
||||
|
||||
TableColumn("Name", value: \.name) { process in
|
||||
Text(process.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.width(min: 150, ideal: 200)
|
||||
}
|
||||
.width(min: 170, ideal: 220)
|
||||
|
||||
TableColumn("PID", value: \.pid) { process in
|
||||
Text("\(process.pid)")
|
||||
@@ -127,12 +133,29 @@ struct ProcessView: View {
|
||||
}
|
||||
.width(70)
|
||||
|
||||
TableColumn("Energy", value: \.energyImpact) { process in
|
||||
Text(String(format: "%.1f", process.energyImpact))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(energyColor(process.energyImpact))
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("Disk I/O", value: \.diskTotalRate) { process in
|
||||
if process.diskTotalRate > 0 {
|
||||
Text(formatBytesPerSecond(process.diskTotalRate))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text("—").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.width(90)
|
||||
|
||||
TableColumn("Threads", value: \.threadCount) { process in
|
||||
Text("\(process.threadCount)")
|
||||
.monospacedDigit()
|
||||
@@ -157,6 +180,10 @@ struct ProcessView: View {
|
||||
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
|
||||
if let pid = selection.first,
|
||||
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||
Button("Get Info (⌘I)") {
|
||||
inspectorProcess = process
|
||||
}
|
||||
Divider()
|
||||
Button("Terminate (⌫)") {
|
||||
initiateTerminate()
|
||||
}
|
||||
@@ -177,7 +204,11 @@ struct ProcessView: View {
|
||||
}
|
||||
}
|
||||
} primaryAction: { selection in
|
||||
// Double-click action
|
||||
// Double-click opens the deep-dive inspector
|
||||
if let pid = selection.first,
|
||||
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||
inspectorProcess = process
|
||||
}
|
||||
}
|
||||
.onDeleteCommand {
|
||||
initiateTerminate()
|
||||
@@ -211,6 +242,10 @@ struct ProcessView: View {
|
||||
sortColumn = .cpuTotal
|
||||
} else if keyPathString.contains("cpuUsage") {
|
||||
sortColumn = .cpu
|
||||
} else if keyPathString.contains("energyImpact") {
|
||||
sortColumn = .energy
|
||||
} else if keyPathString.contains("diskTotalRate") {
|
||||
sortColumn = .disk
|
||||
} else if keyPathString.contains("memoryUsage") {
|
||||
sortColumn = .memory
|
||||
} else if keyPathString.contains("name") {
|
||||
@@ -229,7 +264,10 @@ struct ProcessView: View {
|
||||
}
|
||||
.background(
|
||||
KeyboardShortcutHandler(
|
||||
onCommandBackspace: { initiateForceKill() }
|
||||
onCommandBackspace: { initiateForceKill() },
|
||||
onCommandI: {
|
||||
if let process = selectedProcessItem { inspectorProcess = process }
|
||||
}
|
||||
)
|
||||
)
|
||||
.sheet(isPresented: $showTerminateConfirm) {
|
||||
@@ -268,6 +306,10 @@ struct ProcessView: View {
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
.sheet(item: $inspectorProcess) { process in
|
||||
ProcessInspectorSheet(initialProcess: process)
|
||||
.environmentObject(monitor)
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateTerminate() {
|
||||
@@ -350,6 +392,17 @@ struct ProcessView: View {
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func energyColor(_ impact: Double) -> Color {
|
||||
if impact > 50 {
|
||||
return .red
|
||||
} else if impact > 20 {
|
||||
return .orange
|
||||
} else if impact > 8 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func stateColor(_ state: ProcessState) -> Color {
|
||||
switch state {
|
||||
case .running: return .green
|
||||
@@ -441,28 +494,39 @@ struct ConfirmationSheet: View {
|
||||
|
||||
struct KeyboardShortcutHandler: NSViewRepresentable {
|
||||
let onCommandBackspace: () -> Void
|
||||
var onCommandI: (() -> Void)? = nil
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = KeyCaptureView()
|
||||
view.onCommandBackspace = onCommandBackspace
|
||||
view.onCommandI = onCommandI
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
if let view = nsView as? KeyCaptureView {
|
||||
view.onCommandBackspace = onCommandBackspace
|
||||
view.onCommandI = onCommandI
|
||||
}
|
||||
}
|
||||
|
||||
class KeyCaptureView: NSView {
|
||||
var onCommandBackspace: (() -> Void)?
|
||||
var onCommandI: (() -> Void)?
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
// Backspace = keyCode 51
|
||||
if event.keyCode == 51 && event.modifierFlags.contains(.command) {
|
||||
guard event.modifierFlags.contains(.command) else {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
// Backspace = keyCode 51, "i" = keyCode 34
|
||||
if event.keyCode == 51 {
|
||||
onCommandBackspace?()
|
||||
return true
|
||||
}
|
||||
if event.keyCode == 34 {
|
||||
onCommandI?()
|
||||
return true
|
||||
}
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
95
TopManagerTests/ProcessDataTests.swift
Normal file
95
TopManagerTests/ProcessDataTests.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class ProcessDataTests: XCTestCase {
|
||||
|
||||
// MARK: - KERN_PROCARGS2 parsing
|
||||
|
||||
private func buildProcArgsBuffer(argc: Int32, path: String, args: [String], env: [String]) -> [UInt8] {
|
||||
var bytes: [UInt8] = [
|
||||
UInt8(argc & 0xff),
|
||||
UInt8((argc >> 8) & 0xff),
|
||||
UInt8((argc >> 16) & 0xff),
|
||||
UInt8((argc >> 24) & 0xff)
|
||||
]
|
||||
bytes += Array(path.utf8) + [0]
|
||||
bytes += [0, 0] // NUL padding before argv[0]
|
||||
for a in args { bytes += Array(a.utf8) + [0] }
|
||||
for e in env { bytes += Array(e.utf8) + [0] }
|
||||
return bytes
|
||||
}
|
||||
|
||||
func testProcArgsParseExtractsPathAndArgs() {
|
||||
let buffer = buildProcArgsBuffer(argc: 2, path: "/bin/foo",
|
||||
args: ["/bin/foo", "--flag"],
|
||||
env: ["PATH=/usr/bin"])
|
||||
let parsed = ProcArgs.parse(buffer)
|
||||
XCTAssertEqual(parsed?.path, "/bin/foo")
|
||||
XCTAssertEqual(parsed?.args, ["/bin/foo", "--flag"])
|
||||
}
|
||||
|
||||
func testProcArgsParseStopsAtArgcAndIgnoresEnv() {
|
||||
// argc=1 → only the first arg is returned, env is not leaked into args
|
||||
let buffer = buildProcArgsBuffer(argc: 1, path: "/usr/bin/x",
|
||||
args: ["/usr/bin/x"],
|
||||
env: ["SECRET=shh"])
|
||||
let parsed = ProcArgs.parse(buffer)
|
||||
XCTAssertEqual(parsed?.args, ["/usr/bin/x"])
|
||||
XCTAssertFalse(parsed?.args.contains { $0.contains("SECRET") } ?? true)
|
||||
}
|
||||
|
||||
func testProcArgsParseZeroArgc() {
|
||||
let buffer = buildProcArgsBuffer(argc: 0, path: "/bin/x", args: [], env: [])
|
||||
let parsed = ProcArgs.parse(buffer)
|
||||
XCTAssertEqual(parsed?.path, "/bin/x")
|
||||
XCTAssertEqual(parsed?.args, [])
|
||||
}
|
||||
|
||||
func testProcArgsParseTooShortReturnsNil() {
|
||||
XCTAssertNil(ProcArgs.parse([1, 2, 3]))
|
||||
}
|
||||
|
||||
// MARK: - Energy model
|
||||
|
||||
func testEnergyImpactMonotonicInCPU() {
|
||||
XCTAssertGreaterThan(
|
||||
EnergyModel.impact(cpuPercent: 50, idleWakeupsPerSec: 0),
|
||||
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 0)
|
||||
)
|
||||
}
|
||||
|
||||
func testEnergyImpactMonotonicInWakeups() {
|
||||
XCTAssertGreaterThan(
|
||||
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 500),
|
||||
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 0)
|
||||
)
|
||||
}
|
||||
|
||||
func testEnergyImpactClampsNegativeInputsToZero() {
|
||||
XCTAssertEqual(EnergyModel.impact(cpuPercent: -5, idleWakeupsPerSec: -100), 0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testEnergyImpactEqualsCPUWhenNoWakeups() {
|
||||
XCTAssertEqual(EnergyModel.impact(cpuPercent: 33, idleWakeupsPerSec: 0), 33, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
// MARK: - ProcessItem derived + equality
|
||||
|
||||
func testDiskTotalRateSumsReadAndWrite() {
|
||||
let p = ProcessItem(pid: 1, name: "a", user: "u", cpuUsage: 0, cpuUsageTotal: 0,
|
||||
memoryUsage: 0, threadCount: 0, state: .running, icon: nil,
|
||||
parentPid: 0, startTime: nil, diskReadRate: 100, diskWriteRate: 50)
|
||||
XCTAssertEqual(p.diskTotalRate, 150)
|
||||
}
|
||||
|
||||
func testEqualityDetectsEnergyChange() {
|
||||
func make(energy: Double) -> ProcessItem {
|
||||
ProcessItem(pid: 1, name: "a", user: "u", cpuUsage: 1, cpuUsageTotal: 1,
|
||||
memoryUsage: 1, threadCount: 1, state: .running, icon: nil,
|
||||
parentPid: 0, startTime: nil, energyImpact: energy)
|
||||
}
|
||||
// Table refresh relies on == seeing energy/disk changes.
|
||||
XCTAssertNotEqual(make(energy: 5), make(energy: 9))
|
||||
XCTAssertEqual(make(energy: 5), make(energy: 5))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user