From 1390eda46872f5346b4936f74574dd689b06de2e Mon Sep 17 00:00:00 2001 From: hariel1985 Date: Thu, 2 Jul 2026 20:08:15 +0200 Subject: [PATCH] Increment 2: deep per-process data + process inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- TopManager.xcodeproj/project.pbxproj | 4 + TopManager/Models/ProcessInfo.swift | 36 +++- TopManager/Services/ProcessMonitor.swift | 122 ++++++++++- .../Views/Processes/ProcessDetailView.swift | 192 ++++++++++++------ TopManager/Views/Processes/ProcessView.swift | 104 ++++++++-- TopManagerTests/ProcessDataTests.swift | 95 +++++++++ 6 files changed, 464 insertions(+), 89 deletions(-) create mode 100644 TopManagerTests/ProcessDataTests.swift diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index f4dd3f8..e0ebb1c 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -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 = ""; }; 3010F605712E09175D9D8878 /* NetworkInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInfo.swift; sourceTree = ""; }; 301352EB5B362C732CE1A246 /* FormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingTests.swift; sourceTree = ""; }; + 332290DABD5326C87258723C /* ProcessDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDataTests.swift; sourceTree = ""; }; 334573CEB47D60570A40B55D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 33BCE156CB39EDADFFC938F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; @@ -210,6 +212,7 @@ children = ( 301352EB5B362C732CE1A246 /* FormattingTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, + 332290DABD5326C87258723C /* ProcessDataTests.swift */, ); path = TopManagerTests; sourceTree = ""; @@ -329,6 +332,7 @@ files = ( 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, + 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TopManager/Models/ProcessInfo.swift b/TopManager/Models/ProcessInfo.swift index 789775b..8d23e6d 100644 --- a/TopManager/Models/ProcessInfo.swift +++ b/TopManager/Models/ProcessInfo.swift @@ -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 } } diff --git a/TopManager/Services/ProcessMonitor.swift b/TopManager/Services/ProcessMonitor.swift index 4d2c064..44740eb 100644 --- a/TopManager/Services/ProcessMonitor.swift +++ b/TopManager/Services/ProcessMonitor.swift @@ -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() private var noIconPids: Set = [] // 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.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.. [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.stride +} diff --git a/TopManager/Views/Processes/ProcessDetailView.swift b/TopManager/Views/Processes/ProcessDetailView.swift index b439935..7eccb1e 100644 --- a/TopManager/Views/Processes/ProcessDetailView.swift +++ b/TopManager/Views/Processes/ProcessDetailView.swift @@ -1,63 +1,138 @@ 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 - HStack(spacing: 12) { - if let icon = process.icon { - Image(nsImage: icon) - .resizable() - .frame(width: 48, height: 48) - } else { - Image(systemName: "app.dashed") - .font(.system(size: 48)) - } + header - VStack(alignment: .leading) { - Text(process.name) - .font(.title2) - .fontWeight(.semibold) + Divider() - Text("PID: \(process.pid)") - .foregroundColor(.secondary) - } - - Spacer() - - VStack(alignment: .trailing) { - HStack { - Image(systemName: process.state.symbol) - Text(process.state.rawValue) - } - .foregroundColor(stateColor(process.state)) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + liveStats + pathSection + detailsSection } } Divider() - - // Details grid - LazyVGrid(columns: [ - GridItem(.flexible()), - GridItem(.flexible()) - ], alignment: .leading, spacing: 12) { - DetailRow(label: "CPU Usage", value: String(format: "%.1f%%", process.cpuUsage)) - DetailRow(label: "Memory", value: formatBytes(process.memoryUsage)) - DetailRow(label: "Threads", value: "\(process.threadCount)") - DetailRow(label: "User", value: process.user) - DetailRow(label: "Parent PID", value: "\(process.parentPid)") - - if let startTime = process.startTime { - DetailRow(label: "Started", value: formatDate(startTime)) - } - } - - Spacer() + 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: 44, height: 44) + } else { + Image(systemName: "app.dashed").font(.system(size: 40)).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() + HStack(spacing: 4) { + Image(systemName: process.state.symbol) + Text(process.state.rawValue) + } + .foregroundColor(stateColor(process.state)) + } + } + + 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: "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 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) } diff --git a/TopManager/Views/Processes/ProcessView.swift b/TopManager/Views/Processes/ProcessView.swift index 29cc38a..8f86b05 100644 --- a/TopManager/Views/Processes/ProcessView.swift +++ b/TopManager/Views/Processes/ProcessView.swift @@ -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,23 +96,22 @@ struct ProcessView: View { // Process table Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) { - TableColumn("") { process in - if let icon = process.icon { - Image(nsImage: icon) - .resizable() - .frame(width: 16, height: 16) - } else { - Image(systemName: "app.dashed") - .frame(width: 16, height: 16) + TableColumn("Name", value: \.name) { process in + HStack(spacing: 6) { + if let icon = process.icon { + Image(nsImage: icon) + .resizable() + .frame(width: 16, height: 16) + } else { + Image(systemName: "app.dashed") + .frame(width: 16, height: 16) + .foregroundColor(.secondary) + } + Text(process.name) + .lineLimit(1) } } - .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) } } diff --git a/TopManagerTests/ProcessDataTests.swift b/TopManagerTests/ProcessDataTests.swift new file mode 100644 index 0000000..ee2d482 --- /dev/null +++ b/TopManagerTests/ProcessDataTests.swift @@ -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)) + } +}