Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 307 additions & 0 deletions Kit/process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ public class ProcessView: NSStackView {

private var imageView: NSImageView = NSImageView()
private var killView: NSButton = NSButton()
private var treeView: NSButton = NSButton()
private var labelView: LabelField = {
let view = LabelField()
view.cell?.truncatesLastVisibleLine = true
Expand All @@ -139,6 +140,7 @@ public class ProcessView: NSStackView {
}
self.imageView = NSImageView(frame: rect)
self.killView = NSButton(frame: rect)
self.treeView = NSButton(frame: rect)

super.init(frame: NSRect(x: 0, y: 0, width: size.width, height: size.height))

Expand Down Expand Up @@ -169,9 +171,31 @@ public class ProcessView: NSStackView {
return view
}()

self.treeView.bezelStyle = .regularSquare
self.treeView.translatesAutoresizingMaskIntoConstraints = false
self.treeView.imageScaling = .scaleNone
if #available(macOS 11.0, *) {
self.treeView.image = NSImage(systemSymbolName: "list.triangle", accessibilityDescription: localizedString("Show process tree"))?
.withSymbolConfiguration(.init(pointSize: 10, weight: .medium))
}
self.treeView.contentTintColor = .lightGray
self.treeView.isBordered = false
self.treeView.action = #selector(self.showProcessTree)
self.treeView.target = self
self.treeView.toolTip = localizedString("Show process tree")
self.treeView.focusRingType = .none
self.treeView.isHidden = true

let treeBox: NSView = {
let view = NSView()
view.addSubview(self.treeView)
return view
}()

self.addArrangedSubview(imageBox)
self.addArrangedSubview(self.labelView)
self.valuesViews(n).forEach{ self.addArrangedSubview($0) }
self.addArrangedSubview(treeBox)

self.addTrackingArea(NSTrackingArea(
rect: NSRect(x: 0, y: 0, width: self.frame.width, height: self.frame.height),
Expand All @@ -184,6 +208,8 @@ public class ProcessView: NSStackView {
imageBox.widthAnchor.constraint(equalToConstant: self.bounds.height),
imageBox.heightAnchor.constraint(equalToConstant: self.bounds.height),
self.labelView.heightAnchor.constraint(equalToConstant: 16),
treeBox.widthAnchor.constraint(equalToConstant: self.bounds.height),
treeBox.heightAnchor.constraint(equalToConstant: self.bounds.height),
self.widthAnchor.constraint(equalToConstant: self.bounds.width),
self.heightAnchor.constraint(equalToConstant: self.bounds.height)
])
Expand Down Expand Up @@ -213,6 +239,7 @@ public class ProcessView: NSStackView {
if self.lock {
self.imageView.isHidden = true
self.killView.isHidden = false
self.treeView.isHidden = false
return
}
self.layer?.backgroundColor = .init(gray: 0.01, alpha: 0.05)
Expand All @@ -222,6 +249,7 @@ public class ProcessView: NSStackView {
if self.lock {
self.imageView.isHidden = false
self.killView.isHidden = true
self.treeView.isHidden = true
return
}
self.layer?.backgroundColor = .none
Expand All @@ -231,6 +259,11 @@ public class ProcessView: NSStackView {
self.setLock(!self.lock)
}

@objc private func showProcessTree() {
guard let pid = self.pid else { return }
ProcessTreePanel(pid: pid, processName: self.labelView.stringValue).show()
}

fileprivate func set(_ process: Process_p, _ values: [String]) {
if self.lock && process.pid != self.pid { return }

Expand All @@ -255,10 +288,12 @@ public class ProcessView: NSStackView {
if self.lock {
self.imageView.isHidden = true
self.killView.isHidden = false
self.treeView.isHidden = false
self.layer?.backgroundColor = .init(gray: 0.01, alpha: 0.1)
} else {
self.imageView.isHidden = false
self.killView.isHidden = true
self.treeView.isHidden = true
self.layer?.backgroundColor = .none
}
}
Expand All @@ -271,3 +306,275 @@ public class ProcessView: NSStackView {
}
}
}

// MARK: - Process Tree

private struct ProcessTreeNode {
let pid: Int
let ppid: Int
let name: String
var children: [ProcessTreeNode]
}

public class ProcessTreePanel: NSPanel {
private let targetPid: Int
private let processName: String

public init(pid: Int, processName: String) {
self.targetPid = pid
self.processName = processName
super.init(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 400),
styleMask: [.hudWindow, .utilityWindow, .titled, .closable, .resizable],
backing: .buffered, defer: false
)
self.isFloatingPanel = true
self.isMovableByWindowBackground = true
self.level = .floating
self.title = "\(localizedString("Process tree")): \(processName) (pid \(pid))"
self.minSize = NSSize(width: 320, height: 200)
}

public func show() {
let scrollView = NSScrollView(frame: self.contentRect(forFrameRect: self.frame))
scrollView.hasVerticalScroller = true
scrollView.autoresizingMask = [.width, .height]

let textView = NSTextView()
textView.isEditable = false
textView.isSelectable = true
textView.autoresizingMask = [.width]
textView.backgroundColor = .clear
textView.textColor = .white
textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
textView.textContainerInset = NSSize(width: 10, height: 10)
textView.string = localizedString("Loading...")

scrollView.documentView = textView
self.contentView = scrollView

self.makeKeyAndOrderFront(nil)
self.center()

DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
let tree = self.buildTreeString()
DispatchQueue.main.async {
textView.string = tree
}
}
}

private func runShell(_ args: String) -> String {
let task = Process()
task.launchPath = "/bin/sh"
task.arguments = ["-c", args]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = FileHandle.nullDevice
task.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
return String(data: data, encoding: .utf8) ?? ""
}

private func buildTreeString() -> String {
let raw = self.runShell("ps -axo pid=,ppid=,%cpu=,rss=,comm=")
let rawArgs = self.runShell("ps -axo pid=,args=")
let lines = raw.split(separator: "\n")

// Parse args by pid for enrichment
var argsMap: [Int: String] = [:]
for line in rawArgs.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
let parts = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
guard parts.count >= 2, let pid = Int(parts[0]) else { continue }
argsMap[pid] = String(parts[1])
}

struct ProcInfo {
let pid: Int
let ppid: Int
let name: String
let cpu: String
let memKB: Int
let args: String
}

var allProcesses: [ProcInfo] = []
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
let parts = trimmed.split(separator: " ", maxSplits: 4, omittingEmptySubsequences: true)
guard parts.count >= 5,
let pid = Int(parts[0]),
let ppid = Int(parts[1]) else { continue }
let cpu = String(parts[2])
let memKB = Int(parts[3]) ?? 0
let name = String(parts[4])
let args = argsMap[pid] ?? name
allProcesses.append(ProcInfo(pid: pid, ppid: ppid, name: name, cpu: cpu, memKB: memKB, args: args))
}

func formatMem(_ kb: Int) -> String {
if kb >= 1_048_576 { return String(format: "%.1f GB", Double(kb) / 1_048_576.0) }
if kb >= 1024 { return String(format: "%.1f MB", Double(kb) / 1024.0) }
return "\(kb) KB"
}

func shortName(_ proc: ProcInfo) -> String {
(proc.name as NSString).lastPathComponent
}

func detail(_ proc: ProcInfo) -> String {
// Extract useful info from args (e.g. --type=renderer for Chrome/Electron)
var extra = ""
if let typeRange = proc.args.range(of: #"--type=(\S+)"#, options: .regularExpression) {
extra = " (" + proc.args[typeRange].replacingOccurrences(of: "--type=", with: "") + ")"
}
return "\(shortName(proc))\(extra) [\(proc.pid)] cpu: \(proc.cpu)% mem: \(formatMem(proc.memKB))"
}

// Build ancestor chain from target up to pid 0/1
var ancestors: [ProcInfo] = []
var currentPid = self.targetPid
while currentPid > 1 {
guard let proc = allProcesses.first(where: { $0.pid == currentPid }) else { break }
ancestors.append(proc)
currentPid = proc.ppid
}
if let root = allProcesses.first(where: { $0.pid == currentPid }) {
ancestors.append(root)
}
ancestors.reverse()

// Collect direct children of target
let children = allProcesses.filter { $0.ppid == self.targetPid }

// Compute total tree memory
func treeMemKB(_ pid: Int) -> Int {
let self_mem = allProcesses.first(where: { $0.pid == pid })?.memKB ?? 0
let childMem = allProcesses.filter({ $0.ppid == pid }).reduce(0) { $0 + treeMemKB($1.pid) }
return self_mem + childMem
}
let totalMem = treeMemKB(self.targetPid)

var result = ""

// Summary line
if let target = allProcesses.first(where: { $0.pid == self.targetPid }) {
result += "\(shortName(target)) [pid \(target.pid)]\n"
result += "CPU: \(target.cpu)% Memory: \(formatMem(target.memKB))"
if !children.isEmpty {
result += " Total (with children): \(formatMem(totalMem))"
}
result += "\n\n"
}

// Ancestor chain
result += "\(localizedString("Process tree")):\n\n"
for (i, ancestor) in ancestors.enumerated() {
let indent = String(repeating: " ", count: i)
let marker = ancestor.pid == self.targetPid ? "▶ " : " "
result += "\(indent)\(marker)\(detail(ancestor))\n"
}

// Children under the target
if !children.isEmpty {
let childIndent = String(repeating: " ", count: ancestors.count)
for (i, child) in children.enumerated() {
let connector = i == children.count - 1 ? "└─" : "├─"
result += "\(childIndent)\(connector) \(detail(child))\n"

// Grandchildren (one level deep)
let grandchildren = allProcesses.filter { $0.ppid == child.pid }
for (j, gc) in grandchildren.enumerated() {
let gcPrefix = i == children.count - 1 ? " " : "│ "
let gcConnector = j == grandchildren.count - 1 ? "└─" : "├─"
result += "\(childIndent)\(gcPrefix)\(gcConnector) \(detail(gc))\n"
}
}
}

if children.isEmpty && ancestors.last?.pid == self.targetPid {
result += "\n\(localizedString("No child processes"))\n"
}

// Safari tab enrichment
let isSafari = self.processName.lowercased().contains("safari") ||
allProcesses.first(where: { $0.pid == self.targetPid })?.name.contains("Safari") == true ||
allProcesses.first(where: { $0.pid == self.targetPid })?.name.contains("WebContent") == true
if isSafari {
// List WebContent processes (each represents a tab/extension)
let webContentProcs = allProcesses
.filter { $0.name.contains("WebContent") }
.sorted(by: { $0.memKB > $1.memKB })
if !webContentProcs.isEmpty {
result += "\nWebContent processes (\(webContentProcs.count)):\n\n"
for proc in webContentProcs {
result += " [\(proc.pid)] mem: \(formatMem(proc.memKB)) cpu: \(proc.cpu)%\n"
}
}

let tabs = self.fetchSafariTabs()
if !tabs.isEmpty {
result += "\n\(localizedString("Open Safari tabs")) (\(tabs.count)):\n\n"
for (i, tab) in tabs.enumerated() {
result += " \(i + 1). \(tab)\n"
}
if !webContentProcs.isEmpty {
result += "\n Note: tabs are listed by window order; WebContent processes\n are sorted by memory (highest first) to help correlate.\n"
}
}
}

return result
}

private func fetchSafariTabs() -> [String] {
let script = """
tell application "System Events"
if (name of processes) contains "Safari" then
tell application "Safari"
set tabList to ""
repeat with w in windows
repeat with t in tabs of w
set tabList to tabList & (name of t) & "|||" & (URL of t) & linefeed
end repeat
end repeat
return tabList
end tell
end if
end tell
"""
let task = Process()
task.launchPath = "/usr/bin/osascript"
task.arguments = ["-e", script]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = FileHandle.nullDevice
task.launch()

// Timeout: kill if it takes too long
let deadline = DispatchTime.now() + .seconds(3)
DispatchQueue.global().asyncAfter(deadline: deadline) {
if task.isRunning { task.terminate() }
}

let data = pipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()

guard let output = String(data: data, encoding: .utf8), !output.isEmpty else { return [] }

return output.split(separator: "\n").compactMap { line in
let parts = String(line).components(separatedBy: "|||")
guard let title = parts.first, !title.isEmpty else { return nil }
let url = parts.count > 1 ? parts[1] : ""
if url.isEmpty { return String(title) }
// Show just the domain from the URL
if let urlObj = URL(string: url), let host = urlObj.host {
return "\(title) (\(host))"
}
return String(title)
}
}
}
4 changes: 4 additions & 0 deletions Stats/Supporting Files/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@
"No options to configure for the popup in this module" = "No options to configure for the popup in this module";
"Process" = "Process";
"Kill process" = "Kill process";
"Show process tree" = "Show process tree";
"Process tree" = "Process tree";
"No child processes" = "No child processes";
"Open Safari tabs" = "Open Safari tabs";
"Keyboard shortcut" = "Keyboard shortcut";
"Listening..." = "Listening...";

Expand Down