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 */; };
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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