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:
hariel1985
2026-07-02 20:08:15 +02:00
szülő 222638fc98
commit 1390eda468
6 fájl változott, egészen pontosan 464 új sor hozzáadva és 89 régi sor törölve

Fájl megtekintése

@@ -14,6 +14,7 @@
1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; }; 1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; };
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; }; 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; };
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.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 */; }; 341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; };
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; }; 35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; };
4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
@@ -210,6 +212,7 @@
children = ( children = (
301352EB5B362C732CE1A246 /* FormattingTests.swift */, 301352EB5B362C732CE1A246 /* FormattingTests.swift */,
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
332290DABD5326C87258723C /* ProcessDataTests.swift */,
); );
path = TopManagerTests; path = TopManagerTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -329,6 +332,7 @@
files = ( files = (
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */, 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */,
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

Fájl megtekintése

@@ -14,9 +14,17 @@ struct ProcessItem: Identifiable, Hashable {
let icon: NSImage? let icon: NSImage?
let parentPid: pid_t let parentPid: pid_t
let startTime: Date? 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 iconPlaceholder: String { "" }
var diskTotalRate: Double { diskReadRate + diskWriteRate }
init( init(
pid: pid_t, pid: pid_t,
name: String, name: String,
@@ -28,7 +36,13 @@ struct ProcessItem: Identifiable, Hashable {
state: ProcessState, state: ProcessState,
icon: NSImage?, icon: NSImage?,
parentPid: pid_t, 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.id = pid
self.pid = pid self.pid = pid
@@ -42,6 +56,12 @@ struct ProcessItem: Identifiable, Hashable {
self.icon = icon self.icon = icon
self.parentPid = parentPid self.parentPid = parentPid
self.startTime = startTime 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) { func hash(into hasher: inout Hasher) {
@@ -54,7 +74,19 @@ struct ProcessItem: Identifiable, Hashable {
lhs.cpuUsageTotal == rhs.cpuUsageTotal && lhs.cpuUsageTotal == rhs.cpuUsageTotal &&
lhs.memoryUsage == rhs.memoryUsage && lhs.memoryUsage == rhs.memoryUsage &&
lhs.threadCount == rhs.threadCount && 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
} }
} }

Fájl megtekintése

@@ -4,10 +4,12 @@ import AppKit
final class ProcessMonitor { final class ProcessMonitor {
private var previousCPUTimes: [pid_t: (user: UInt64, system: UInt64, timestamp: Date)] = [:] 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 var lastKnownCPU: [pid_t: Double] = [:] // Cache last known CPU usage
private let iconCache = NSCache<NSNumber, NSImage>() private let iconCache = NSCache<NSNumber, NSImage>()
private var noIconPids: Set<pid_t> = [] // Cache for PIDs with no icon private var noIconPids: Set<pid_t> = [] // Cache for PIDs with no icon
private var nameCache: [pid_t: String] = [:] private var nameCache: [pid_t: String] = [:]
private var pathCache: [pid_t: String] = [:] // Full executable path
private var userCache: [uid_t: String] = [:] private var userCache: [uid_t: String] = [:]
private let timebaseInfo: mach_timebase_info_data_t private let timebaseInfo: mach_timebase_info_data_t
private var refreshCounter = 0 private var refreshCounter = 0
@@ -48,8 +50,10 @@ final class ProcessMonitor {
let stalePids = Set(nameCache.keys).subtracting(currentPids) let stalePids = Set(nameCache.keys).subtracting(currentPids)
for pid in stalePids { for pid in stalePids {
nameCache.removeValue(forKey: pid) nameCache.removeValue(forKey: pid)
pathCache.removeValue(forKey: pid)
iconCache.removeObject(forKey: NSNumber(value: pid)) iconCache.removeObject(forKey: NSNumber(value: pid))
previousCPUTimes.removeValue(forKey: pid) previousCPUTimes.removeValue(forKey: pid)
previousDiskIO.removeValue(forKey: pid)
noIconPids.remove(pid) noIconPids.remove(pid)
lastKnownCPU.removeValue(forKey: pid) lastKnownCPU.removeValue(forKey: pid)
} }
@@ -80,6 +84,11 @@ final class ProcessMonitor {
let memoryUsage: Int64 let memoryUsage: Int64
let threadCount: Int32 let threadCount: Int32
let cpuUsage: Double 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 { if needsDetailedInfo {
var taskInfo = proc_taskinfo() var taskInfo = proc_taskinfo()
@@ -96,6 +105,17 @@ final class ProcessMonitor {
userTime: rusageData.userTime, userTime: rusageData.userTime,
systemTime: rusageData.systemTime 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 { } else {
memoryUsage = 0 memoryUsage = 0
threadCount = 0 threadCount = 0
@@ -126,7 +146,13 @@ final class ProcessMonitor {
state: state, state: state,
icon: icon, icon: icon,
parentPid: parentPid, 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 // This protects against edge cases where buffer might not be null-terminated
let pathData = Data(bytes: pathBuffer, count: pathLength) let pathData = Data(bytes: pathBuffer, count: pathLength)
if let path = String(data: pathData, encoding: .utf8) { if let path = String(data: pathData, encoding: .utf8) {
pathCache[pid] = path
name = (path as NSString).lastPathComponent name = (path as NSString).lastPathComponent
} else { } else {
name = "Process \(pid)" name = "Process \(pid)"
@@ -203,7 +230,16 @@ final class ProcessMonitor {
return name 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() var rusage = rusage_info_v4()
let result = withUnsafeMutablePointer(to: &rusage) { ptr -> Int32 in let result = withUnsafeMutablePointer(to: &rusage) { ptr -> Int32 in
ptr.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { rusagePtr in ptr.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { rusagePtr in
@@ -212,25 +248,45 @@ final class ProcessMonitor {
} }
if result == 0 { if result == 0 {
return ( return RusageData(
memory: Int64(rusage.ri_phys_footprint), memory: Int64(rusage.ri_phys_footprint),
userTime: rusage.ri_user_time, 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() var taskInfo = proc_taskinfo()
let taskInfoSize = Int32(MemoryLayout<proc_taskinfo>.size) let taskInfoSize = Int32(MemoryLayout<proc_taskinfo>.size)
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize { if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
return ( return RusageData(
memory: Int64(taskInfo.pti_resident_size), memory: Int64(taskInfo.pti_resident_size),
userTime: taskInfo.pti_total_user, userTime: taskInfo.pti_total_user,
systemTime: taskInfo.pti_total_system 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? { private func fetchBasicProcessInfo(pid: pid_t) -> ProcessItem? {
@@ -373,3 +429,55 @@ final class ProcessMonitor {
previousCPUTimes.removeAll() 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
}

Fájl megtekintése

@@ -1,63 +1,138 @@
import SwiftUI import SwiftUI
import AppKit
struct ProcessDetailView: View { /// Deep-dive inspector for a single process, presented as a sheet. Shows live
let process: ProcessItem /// 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 { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Header 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))
}
VStack(alignment: .leading) { Divider()
Text(process.name)
.font(.title2)
.fontWeight(.semibold)
Text("PID: \(process.pid)") ScrollView {
.foregroundColor(.secondary) VStack(alignment: .leading, spacing: 16) {
} liveStats
pathSection
Spacer() detailsSection
VStack(alignment: .trailing) {
HStack {
Image(systemName: process.state.symbol)
Text(process.state.rawValue)
}
.foregroundColor(stateColor(process.state))
} }
} }
Divider() Divider()
footer
// Details grid }
LazyVGrid(columns: [ .padding(20)
GridItem(.flexible()), .frame(width: 460, height: 560)
GridItem(.flexible()) .task(id: initialProcess.pid) {
], alignment: .leading, spacing: 12) { let pid = initialProcess.pid
DetailRow(label: "CPU Usage", value: String(format: "%.1f%%", process.cpuUsage)) let result = await Task.detached(priority: .userInitiated) {
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage)) (args: fetchProcessArguments(pid: pid), files: fetchOpenFileCount(pid: pid))
DetailRow(label: "Threads", value: "\(process.threadCount)") }.value
DetailRow(label: "User", value: process.user) arguments = result.args
DetailRow(label: "Parent PID", value: "\(process.parentPid)") openFiles = result.files
loadedDetails = true
if let startTime = process.startTime { }
DetailRow(label: "Started", value: formatDate(startTime)) }
}
} private var header: some View {
HStack(spacing: 12) {
Spacer() 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 { private func stateColor(_ state: ProcessState) -> Color {
@@ -95,17 +170,14 @@ struct DetailRow: View {
} }
#Preview { #Preview {
ProcessDetailView(process: ProcessItem( ProcessInspectorSheet(initialProcess: ProcessItem(
pid: 1234, pid: 1234, name: "Safari", user: "ariel",
name: "Safari", cpuUsage: 12.5, cpuUsageTotal: 1.56,
user: "ariel", memoryUsage: 512 * 1024 * 1024, threadCount: 42,
cpuUsage: 12.5, state: .running, icon: nil, parentPid: 1, startTime: Date(),
cpuUsageTotal: 1.56, diskReadRate: 2048, diskWriteRate: 1024,
memoryUsage: 512 * 1024 * 1024, diskReadBytes: 900_000, diskWriteBytes: 400_000,
threadCount: 42, energyImpact: 14.2, executablePath: "/Applications/Safari.app/Contents/MacOS/Safari"
state: .running,
icon: nil,
parentPid: 1,
startTime: Date()
)) ))
.environmentObject(SystemMonitor.shared)
} }

Fájl megtekintése

@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
enum ProcessSortColumn: String { 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 { struct ProcessView: View {
@@ -30,6 +30,9 @@ struct ProcessView: View {
@State private var errorTitle = "" @State private var errorTitle = ""
@State private var errorMessage = "" @State private var errorMessage = ""
// Deep-dive inspector
@State private var inspectorProcess: ProcessItem?
// Protected system processes that cannot be force-killed via UI // Protected system processes that cannot be force-killed via UI
private static let protectedProcesses = ["kernel_task", "launchd", "WindowServer", "loginwindow"] private static let protectedProcesses = ["kernel_task", "launchd", "WindowServer", "loginwindow"]
@@ -63,6 +66,10 @@ struct ProcessView: View {
comparison = lhs.user.localizedCaseInsensitiveCompare(rhs.user) comparison = lhs.user.localizedCaseInsensitiveCompare(rhs.user)
case .state: case .state:
comparison = lhs.state.rawValue.compare(rhs.state.rawValue) 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 // Primary sort
@@ -89,23 +96,22 @@ struct ProcessView: View {
// Process table // Process table
Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) { Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) {
TableColumn("") { process in TableColumn("Name", value: \.name) { process in
if let icon = process.icon { HStack(spacing: 6) {
Image(nsImage: icon) if let icon = process.icon {
.resizable() Image(nsImage: icon)
.frame(width: 16, height: 16) .resizable()
} else { .frame(width: 16, height: 16)
Image(systemName: "app.dashed") } else {
.frame(width: 16, height: 16) Image(systemName: "app.dashed")
.frame(width: 16, height: 16)
.foregroundColor(.secondary)
}
Text(process.name)
.lineLimit(1)
} }
} }
.width(24) .width(min: 170, ideal: 220)
TableColumn("Name", value: \.name) { process in
Text(process.name)
.lineLimit(1)
}
.width(min: 150, ideal: 200)
TableColumn("PID", value: \.pid) { process in TableColumn("PID", value: \.pid) { process in
Text("\(process.pid)") Text("\(process.pid)")
@@ -127,12 +133,29 @@ struct ProcessView: View {
} }
.width(70) .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 TableColumn("Memory", value: \.memoryUsage) { process in
Text(formatBytes(process.memoryUsage)) Text(formatBytes(process.memoryUsage))
.monospacedDigit() .monospacedDigit()
} }
.width(80) .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 TableColumn("Threads", value: \.threadCount) { process in
Text("\(process.threadCount)") Text("\(process.threadCount)")
.monospacedDigit() .monospacedDigit()
@@ -157,6 +180,10 @@ struct ProcessView: View {
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in .contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
if let pid = selection.first, if let pid = selection.first,
let process = monitor.processes.first(where: { $0.pid == pid }) { let process = monitor.processes.first(where: { $0.pid == pid }) {
Button("Get Info (⌘I)") {
inspectorProcess = process
}
Divider()
Button("Terminate (⌫)") { Button("Terminate (⌫)") {
initiateTerminate() initiateTerminate()
} }
@@ -177,7 +204,11 @@ struct ProcessView: View {
} }
} }
} primaryAction: { selection in } 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 { .onDeleteCommand {
initiateTerminate() initiateTerminate()
@@ -211,6 +242,10 @@ struct ProcessView: View {
sortColumn = .cpuTotal sortColumn = .cpuTotal
} else if keyPathString.contains("cpuUsage") { } else if keyPathString.contains("cpuUsage") {
sortColumn = .cpu sortColumn = .cpu
} else if keyPathString.contains("energyImpact") {
sortColumn = .energy
} else if keyPathString.contains("diskTotalRate") {
sortColumn = .disk
} else if keyPathString.contains("memoryUsage") { } else if keyPathString.contains("memoryUsage") {
sortColumn = .memory sortColumn = .memory
} else if keyPathString.contains("name") { } else if keyPathString.contains("name") {
@@ -229,7 +264,10 @@ struct ProcessView: View {
} }
.background( .background(
KeyboardShortcutHandler( KeyboardShortcutHandler(
onCommandBackspace: { initiateForceKill() } onCommandBackspace: { initiateForceKill() },
onCommandI: {
if let process = selectedProcessItem { inspectorProcess = process }
}
) )
) )
.sheet(isPresented: $showTerminateConfirm) { .sheet(isPresented: $showTerminateConfirm) {
@@ -268,6 +306,10 @@ struct ProcessView: View {
} message: { } message: {
Text(errorMessage) Text(errorMessage)
} }
.sheet(item: $inspectorProcess) { process in
ProcessInspectorSheet(initialProcess: process)
.environmentObject(monitor)
}
} }
private func initiateTerminate() { private func initiateTerminate() {
@@ -350,6 +392,17 @@ struct ProcessView: View {
return .primary 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 { private func stateColor(_ state: ProcessState) -> Color {
switch state { switch state {
case .running: return .green case .running: return .green
@@ -441,28 +494,39 @@ struct ConfirmationSheet: View {
struct KeyboardShortcutHandler: NSViewRepresentable { struct KeyboardShortcutHandler: NSViewRepresentable {
let onCommandBackspace: () -> Void let onCommandBackspace: () -> Void
var onCommandI: (() -> Void)? = nil
func makeNSView(context: Context) -> NSView { func makeNSView(context: Context) -> NSView {
let view = KeyCaptureView() let view = KeyCaptureView()
view.onCommandBackspace = onCommandBackspace view.onCommandBackspace = onCommandBackspace
view.onCommandI = onCommandI
return view return view
} }
func updateNSView(_ nsView: NSView, context: Context) { func updateNSView(_ nsView: NSView, context: Context) {
if let view = nsView as? KeyCaptureView { if let view = nsView as? KeyCaptureView {
view.onCommandBackspace = onCommandBackspace view.onCommandBackspace = onCommandBackspace
view.onCommandI = onCommandI
} }
} }
class KeyCaptureView: NSView { class KeyCaptureView: NSView {
var onCommandBackspace: (() -> Void)? var onCommandBackspace: (() -> Void)?
var onCommandI: (() -> Void)?
override func performKeyEquivalent(with event: NSEvent) -> Bool { override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Backspace = keyCode 51 guard event.modifierFlags.contains(.command) else {
if event.keyCode == 51 && event.modifierFlags.contains(.command) { return super.performKeyEquivalent(with: event)
}
// Backspace = keyCode 51, "i" = keyCode 34
if event.keyCode == 51 {
onCommandBackspace?() onCommandBackspace?()
return true return true
} }
if event.keyCode == 34 {
onCommandI?()
return true
}
return super.performKeyEquivalent(with: event) return super.performKeyEquivalent(with: event)
} }
} }

Fájl megtekintése

@@ -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))
}
}