import AppKit import SwiftUI private enum ProcessSort: String, CaseIterable { case name = "Name" case cpu = "CPU" case memory = "Memory" case disk = "Disk" case network = "Network" case pid = "PID" } private enum ResourceDisplayMode: String, CaseIterable { case values = "Values" case percentages = "Percentages" } private let processNumericTextInset: CGFloat = 7 private enum ProcessColumn: String, CaseIterable, Codable { case name, pid, status, cpu, memory, disk, network var defaultWidth: CGFloat { switch self { case .name: 300 case .pid: 70 case .status: 80 case .cpu: 86 case .memory: 110 case .disk, .network: 88 } } var minimumWidth: CGFloat { switch self { case .name: 160 case .pid: 54 case .status: 68 case .cpu: 64 case .memory: 86 case .disk, .network: 76 } } var maximumWidth: CGFloat { self == .name ? 720 : 260 } static var defaultWidths: [ProcessColumn: CGFloat] { Dictionary(uniqueKeysWithValues: allCases.map { ($0, $0.defaultWidth) }) } } private enum ProcessCategory: String, CaseIterable, Identifiable { case apps = "Apps" case background = "Background processes" case system = "macOS processes" var id: String { rawValue } } struct ProcessesView: View { @Environment(SystemMonitor.self) private var monitor let searchText: String let onShowDetails: (ProcessRecord) -> Void @State private var selectedPID: Int32? @State private var selectedGroupID: String? @State private var expandedGroupIDs: Set = [] @State private var collapsedCategories: Set = [] @AppStorage("processSortColumn") private var sort: ProcessSort = .cpu @AppStorage("processSortAscending") private var ascending = false @AppStorage("processGroupByType") private var groupByType = true @AppStorage("processResourceDisplayMode") private var resourceDisplayMode: ResourceDisplayMode = .values @AppStorage("processColumnWidths") private var columnWidthsStorage = "" @State private var columnWidths = ProcessColumn.defaultWidths @State private var groupsFrozenForResize: [ProcessGroup]? @State private var isResizingColumn = false @State private var terminationRequest: TerminationRequest? @State private var groupTerminationRequest: ProcessGroup? @State private var inspectedProcess: ProcessRecord? private var grouped: [ProcessGroup] { ProcessGrouping.groups(from: monitor.processes, searchText: searchText).sorted { lhs, rhs in let ordering: ComparisonResult switch sort { case .name: ordering = lhs.name.localizedCaseInsensitiveCompare(rhs.name) case .cpu: ordering = lhs.cpu == rhs.cpu ? .orderedSame : (lhs.cpu < rhs.cpu ? .orderedAscending : .orderedDescending) case .memory: ordering = lhs.residentBytes == rhs.residentBytes ? .orderedSame : (lhs.residentBytes < rhs.residentBytes ? .orderedAscending : .orderedDescending) case .disk: let left = lhs.activity(using: monitor.processActivity).diskTotal let right = rhs.activity(using: monitor.processActivity).diskTotal ordering = left == right ? .orderedSame : (left < right ? .orderedAscending : .orderedDescending) case .network: let left = lhs.activity(using: monitor.processActivity).networkTotal let right = rhs.activity(using: monitor.processActivity).networkTotal ordering = left == right ? .orderedSame : (left < right ? .orderedAscending : .orderedDescending) case .pid: ordering = lhs.primary.pid == rhs.primary.pid ? .orderedSame : (lhs.primary.pid < rhs.primary.pid ? .orderedAscending : .orderedDescending) } if ordering == .orderedSame { return lhs.primary.pid < rhs.primary.pid } return ascending ? ordering == .orderedAscending : ordering == .orderedDescending } } private var displayedGroups: [ProcessGroup] { groupsFrozenForResize ?? grouped } var body: some View { VStack(spacing: 0) { PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") { if monitor.isPaused { Label("Paused", systemImage: "pause.fill") .foregroundStyle(.orange) } } ResourceSummaryBar(snapshot: monitor.snapshot) ScrollView(.horizontal) { VStack(spacing: 0) { ProcessTableHeader( sort: $sort, ascending: $ascending, groupByType: $groupByType, resourceDisplayMode: $resourceDisplayMode, columnWidths: $columnWidths, onResizeStarted: { isResizingColumn = true if groupsFrozenForResize == nil { groupsFrozenForResize = grouped } }, onResizeEnded: { persistColumnWidths() isResizingColumn = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { if !isResizingColumn { groupsFrozenForResize = nil } } } ) ScrollView { LazyVStack(spacing: 0) { if groupByType { ForEach(ProcessCategory.allCases) { category in let categoryGroups = displayedGroups.filter { $0.category == category } if !categoryGroups.isEmpty { ProcessCategoryHeader( category: category, count: categoryGroups.count, isExpanded: !collapsedCategories.contains(category) || !searchText.isEmpty ) { if collapsedCategories.contains(category) { collapsedCategories.remove(category) } else { collapsedCategories.insert(category) } } if !collapsedCategories.contains(category) || !searchText.isEmpty { ForEach(categoryGroups) { group in processGroup(group) } } } } } else { ForEach(displayedGroups) { group in processGroup(group) } } } } } .frame(width: processTableWidth, alignment: .leading) } .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() HStack { UpdateStatus() Spacer() Button("End task") { if let group = grouped.first(where: { $0.id == selectedGroupID }) { groupTerminationRequest = group } else if let process = monitor.processes.first(where: { $0.pid == selectedPID }) { terminationRequest = .init(process: process, kind: .normal) } } .buttonStyle(.bordered) .tint(.red) .disabled(selectedPID == nil && selectedGroupID == nil) } .padding(12) } .confirmationDialog( "\(terminationRequest?.kind.title ?? "End task"): \(terminationRequest?.process.displayName ?? "this task")?", isPresented: Binding(get: { terminationRequest != nil }, set: { if !$0 { terminationRequest = nil } }) ) { Button(terminationRequest?.kind.title ?? "End task", role: .destructive) { if let request = terminationRequest { switch request.kind { case .normal: monitor.terminate(request.process) case .force: monitor.terminate(request.process, force: true) case .tree: monitor.terminateTree(request.process) } } terminationRequest = nil } Button("Cancel", role: .cancel) { terminationRequest = nil } } message: { Text(terminationRequest?.kind == .tree ? "This will end the process and all of its child processes. Unsaved data may be lost." : "Unsaved data in this process may be lost.") } .confirmationDialog( "End task: \(groupTerminationRequest?.name ?? "this application")?", isPresented: Binding(get: { groupTerminationRequest != nil }, set: { if !$0 { groupTerminationRequest = nil } }) ) { Button("End task", role: .destructive) { if let groupTerminationRequest { monitor.terminateGroup(groupTerminationRequest.processes) } groupTerminationRequest = nil } Button("Cancel", role: .cancel) { groupTerminationRequest = nil } } message: { Text("This will end all \(groupTerminationRequest?.processes.count ?? 0) processes in the application group. Unsaved data may be lost.") } .sheet(item: $inspectedProcess) { process in ProcessPropertiesView(process: process) } .onAppear(perform: restoreColumnWidths) } private var processTableWidth: CGFloat { ProcessColumn.allCases.reduce(0) { $0 + (columnWidths[$1] ?? $1.defaultWidth) } + CGFloat(ProcessColumn.allCases.count - 1) * 12 + 32 } private func restoreColumnWidths() { guard let data = columnWidthsStorage.data(using: .utf8), let stored = try? JSONDecoder().decode([String: Double].self, from: data) else { return } for column in ProcessColumn.allCases { guard let value = stored[column.rawValue] else { continue } columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, CGFloat(value))) } } private func persistColumnWidths() { let stored = Dictionary(uniqueKeysWithValues: columnWidths.map { ($0.key.rawValue, Double($0.value)) }) guard let data = try? JSONEncoder().encode(stored), let value = String(data: data, encoding: .utf8) else { return } columnWidthsStorage = value } private func expansionBinding(for group: ProcessGroup) -> Binding { Binding( get: { !searchText.isEmpty || expandedGroupIDs.contains(group.id) }, set: { expanded in if expanded { expandedGroupIDs.insert(group.id) } else { expandedGroupIDs.remove(group.id) } } ) } @ViewBuilder private func processGroup(_ group: ProcessGroup) -> some View { if group.processes.count > 1 { VStack(spacing: 0) { ProcessGroupRow( group: group, selected: selectedGroupID == group.id, displayMode: resourceDisplayMode, columnWidths: columnWidths, isExpanded: expansionBinding(for: group), onToggleExpansion: { let binding = expansionBinding(for: group) binding.wrappedValue.toggle() } ) .contentShape(Rectangle()) .onTapGesture { selectedGroupID = group.id selectedPID = nil } .focusable() .onKeyPress(.return) { selectedGroupID = group.id selectedPID = nil return .handled } .accessibilityElement(children: .combine) .accessibilityAddTraits(selectedGroupID == group.id ? .isSelected : []) .accessibilityAction { selectedGroupID = group.id selectedPID = nil } .contextMenu { ProcessGroupContextMenu( group: group, onTerminate: { groupTerminationRequest = group }, onShowDetails: onShowDetails, onShowProperties: { inspectedProcess = $0 } ) } if expansionBinding(for: group).wrappedValue { ForEach(group.processes.sorted { $0.pid < $1.pid }) { process in processRow(process, isChild: true) } } } } else if let process = group.processes.first { processRow(process, isChild: false) } } private func processRow(_ process: ProcessRecord, isChild: Bool) -> some View { ProcessRow( process: process, selected: selectedPID == process.pid, isChild: isChild, displayMode: resourceDisplayMode, columnWidths: columnWidths ) .contentShape(Rectangle()) .onTapGesture { selectedPID = process.pid selectedGroupID = nil } .focusable() .onKeyPress(.return) { selectedPID = process.pid selectedGroupID = nil return .handled } .accessibilityElement(children: .combine) .accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user)") .accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))") .accessibilityAddTraits(selectedPID == process.pid ? .isSelected : []) .accessibilityAction { selectedPID = process.pid selectedGroupID = nil } .contextMenu { ProcessContextMenu( process: process, onTerminate: { terminationRequest = $0 }, onShowDetails: onShowDetails, onShowProperties: { inspectedProcess = $0 } ) } } } private struct ProcessCategoryHeader: View { let category: ProcessCategory let count: Int let isExpanded: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 7) { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .rotationEffect(.degrees(isExpanded ? 90 : 0)) .frame(width: 14) Text("\(category.rawValue) (\(count))") .font(.subheadline.weight(.semibold)) Spacer() } .padding(.horizontal, 8) .frame(height: 38) .contentShape(Rectangle()) } .buttonStyle(.plain) .background(Color(nsColor: .controlBackgroundColor).opacity(0.32)) .overlay(alignment: .bottom) { Divider().opacity(0.55) } .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") } } private struct ProcessGroup: Identifiable { let id: String let name: String let appPath: String? let processes: [ProcessRecord] let category: ProcessCategory var primary: ProcessRecord { processes.first(where: { process in guard let appPath else { return false } return process.executablePath.hasPrefix(appPath + "/Contents/MacOS/") }) ?? processes.min(by: { $0.pid < $1.pid })! } var cpu: Double { processes.reduce(0) { $0 + $1.cpu } } var residentBytes: UInt64 { processes.reduce(0) { $0 + $1.residentBytes } } var memoryPercent: Double { processes.reduce(0) { $0 + $1.memoryPercent } } func activity(using rates: [Int32: ProcessActivityRate]) -> ProcessActivityRate { processes.reduce(into: ProcessActivityRate()) { result, process in let activity = rates[process.pid] ?? ProcessActivityRate() result.diskRead += activity.diskRead result.diskWrite += activity.diskWrite result.networkReceive += activity.networkReceive result.networkSend += activity.networkSend } } } private enum ProcessGrouping { static func groups(from processes: [ProcessRecord], searchText: String) -> [ProcessGroup] { let foregroundPIDs = Set(NSWorkspace.shared.runningApplications .filter { $0.activationPolicy == .regular } .map(\.processIdentifier)) let grouped = Dictionary(grouping: processes) { process -> String in appPath(for: process.executablePath) ?? "pid:\(process.pid)" } return grouped.compactMap { key, members in let path = key.hasPrefix("pid:") ? nil : key let name = path.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent } ?? members[0].displayName let category = category(for: members, appPath: path, foregroundPIDs: foregroundPIDs) let group = ProcessGroup(id: key, name: name, appPath: path, processes: members, category: category) guard !searchText.isEmpty else { return group } let groupMatches = name.localizedCaseInsensitiveContains(searchText) let matchingMembers = members.filter { $0.displayName.localizedCaseInsensitiveContains(searchText) || $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText) } guard groupMatches || !matchingMembers.isEmpty else { return nil } return ProcessGroup( id: key, name: name, appPath: path, processes: groupMatches ? members : matchingMembers, category: category ) } } private static func category( for processes: [ProcessRecord], appPath: String?, foregroundPIDs: Set ) -> ProcessCategory { if appPath != nil, processes.contains(where: { foregroundPIDs.contains($0.pid) }) { return .apps } let systemPrefixes = ["/System/", "/usr/bin/", "/usr/libexec/", "/usr/sbin/", "/bin/", "/sbin/", "/Library/Apple/"] if processes.allSatisfy({ process in process.user == "root" || process.user.hasPrefix("_") || systemPrefixes.contains(where: { process.executablePath.hasPrefix($0) }) }) { return .system } return .background } private static func appPath(for executablePath: String) -> String? { guard let range = executablePath.range(of: ".app/Contents/", options: .caseInsensitive) else { return nil } return String(executablePath[.. Void let onResizeEnded: () -> Void var body: some View { HStack(spacing: 12) { resizable(.name, alignment: .leading) { header("Name", field: .name, column: .name) } resizable(.pid, alignment: .leading) { header("PID", field: .pid, column: .pid) } resizable(.status, alignment: .leading) { Text("Status") .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) .padding(.trailing, processNumericTextInset) } resizable(.cpu, alignment: .leading) { header("CPU", field: .cpu, column: .cpu) } resizable(.memory, alignment: .leading) { header("Memory", field: .memory, column: .memory) } resizable(.disk, alignment: .leading) { header("Disk", field: .disk, column: .disk) } resizable(.network, alignment: .leading) { header("Network", field: .network, column: .network) } } .font(.caption.weight(.medium)) .foregroundStyle(.secondary) .padding(.horizontal, 16) .padding(.vertical, 9) .background(.background) .overlay(alignment: .bottom) { Divider() } .contextMenu { Toggle("Group by type", isOn: $groupByType) Divider() Menu("Resource values") { ForEach(ResourceDisplayMode.allCases, id: \.rawValue) { mode in Button { resourceDisplayMode = mode } label: { Label(mode.rawValue, systemImage: resourceDisplayMode == mode ? "checkmark" : "number") } } } } } private func resizable( _ column: ProcessColumn, alignment: Alignment, @ViewBuilder content: @escaping () -> Content ) -> some View { ResizableProcessHeaderCell( width: Binding( get: { columnWidths[column] ?? column.defaultWidth }, set: { columnWidths[column] = $0 } ), minimumWidth: column.minimumWidth, maximumWidth: column.maximumWidth, alignment: alignment, onResizeStarted: onResizeStarted, onResizeEnded: onResizeEnded, content: content ) } private func header( _ title: String, field: ProcessSort, column: ProcessColumn ) -> some View { HStack(spacing: 0) { Text(title).lineLimit(1) Spacer(minLength: 4) if sort == field { Color.clear.frame(width: 6, height: 0) Image(systemName: ascending ? "chevron.up" : "chevron.down") .frame(width: 10) } Color.clear.frame(width: processNumericTextInset, height: 0) } .frame(width: columnWidths[column] ?? column.defaultWidth) .contentShape(Rectangle()) .onTapGesture { groupByType = false if sort == field { ascending.toggle() } else { sort = field; ascending = false } } .accessibilityAddTraits(.isButton) .accessibilityLabel(title) .accessibilityValue(sort == field ? (ascending ? "Sorted ascending" : "Sorted descending") : "Not sorted") } } private struct ResizableProcessHeaderCell: View { @Binding var width: CGFloat let minimumWidth: CGFloat let maximumWidth: CGFloat let alignment: Alignment let onResizeStarted: () -> Void let onResizeEnded: () -> Void @ViewBuilder let content: () -> Content @State private var dragStartWidth: CGFloat? @State private var proposedWidth: CGFloat? @State private var isHandleHovered = false var body: some View { content() .frame(width: width, alignment: alignment) .clipped() .overlay(alignment: .trailing) { ZStack { Rectangle() .fill(isHandleHovered || dragStartWidth != nil ? Color.accentColor : Color.secondary.opacity(0.25)) .frame(width: isHandleHovered || dragStartWidth != nil ? 2 : 1, height: 22) } .frame(width: 10, height: 34) .offset(x: (proposedWidth ?? width) - width + 5) .contentShape(Rectangle()) .onHover { hovering in isHandleHovered = hovering (hovering ? NSCursor.resizeLeftRight : NSCursor.arrow).set() } .highPriorityGesture( DragGesture(minimumDistance: 0) .onChanged { value in if dragStartWidth == nil { dragStartWidth = width onResizeStarted() } let proposed = (dragStartWidth ?? width) + value.translation.width proposedWidth = min(maximumWidth, max(minimumWidth, proposed)) } .onEnded { _ in if let proposedWidth { width = proposedWidth } proposedWidth = nil dragStartWidth = nil onResizeEnded() } ) .help("Drag to resize column") } } } private struct ProcessRow: View { @Environment(SystemMonitor.self) private var monitor let process: ProcessRecord let selected: Bool let isChild: Bool let displayMode: ResourceDisplayMode let columnWidths: [ProcessColumn: CGFloat] private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() } var body: some View { HStack(spacing: 12) { HStack(spacing: 10) { if isChild { Image(systemName: "arrow.turn.down.right") .font(.caption) .foregroundStyle(.tertiary) .frame(width: 16) } else { Color.clear.frame(width: 16) } ProcessIcon(name: process.displayName, path: process.executablePath) VStack(alignment: .leading, spacing: 2) { Text(process.displayName).lineLimit(1).truncationMode(.tail) Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail) } .frame(maxWidth: .infinity, alignment: .leading) } .frame(width: width(.name), alignment: .leading) .clipped() Text("\(process.pid)").monospacedDigit().lineLimit(1).minimumScaleFactor(0.75) .padding(.trailing, processNumericTextInset) .frame(width: width(.pid), alignment: .trailing).clipped() Text(process.state.hasPrefix("R") ? "Running" : "").lineLimit(1).truncationMode(.tail) .frame(width: width(.status), alignment: .leading).clipped() HeatCell(text: Formatters.percent(process.cpu), intensity: process.cpu / 100) .frame(width: width(.cpu), alignment: .trailing) HeatCell(text: memoryText, intensity: process.memoryPercent / 30) .frame(width: width(.memory), alignment: .trailing) .help(memoryHelp) HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000) .frame(width: width(.disk), alignment: .trailing) HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000) .frame(width: width(.network), alignment: .trailing) } .font(.callout) .padding(.horizontal, 16) .frame(minHeight: 48) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .overlay(alignment: .bottom) { Divider().opacity(0.45) } } private var memoryText: String { displayMode == .percentages ? Formatters.percent(process.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)) } private var memoryHelp: String { "Resident memory: \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))) • \(Formatters.percent(process.memoryPercent)) of physical memory" } private func width(_ column: ProcessColumn) -> CGFloat { columnWidths[column] ?? column.defaultWidth } private var diskText: String { displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal) } private var networkText: String { let total = monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate return displayMode == .percentages ? share(activity.networkTotal, of: total) : Formatters.rate(activity.networkTotal) } private func share(_ value: Double, of total: Double) -> String { Formatters.percent(total > 0 ? min(100, value / total * 100) : 0) } } private struct ProcessGroupRow: View { @Environment(SystemMonitor.self) private var monitor let group: ProcessGroup let selected: Bool let displayMode: ResourceDisplayMode let columnWidths: [ProcessColumn: CGFloat] @Binding var isExpanded: Bool let onToggleExpansion: () -> Void private var activity: ProcessActivityRate { group.activity(using: monitor.processActivity) } var body: some View { HStack(spacing: 12) { HStack(spacing: 10) { Button(action: onToggleExpansion) { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .rotationEffect(.degrees(isExpanded ? 90 : 0)) .frame(width: 16, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(.secondary) .help(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)") .accessibilityLabel(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)") ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath) VStack(alignment: .leading, spacing: 2) { Text("\(group.name) (\(group.processes.count))").lineLimit(1).truncationMode(.tail) Text("Application group").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail) } .frame(maxWidth: .infinity, alignment: .leading) } .frame(width: width(.name), alignment: .leading) .clipped() Text("—").lineLimit(1) .padding(.trailing, processNumericTextInset) .frame(width: width(.pid), alignment: .trailing).foregroundStyle(.secondary).clipped() Text(group.processes.contains(where: { $0.state.hasPrefix("R") }) ? "Running" : "") .lineLimit(1).truncationMode(.tail) .frame(width: width(.status), alignment: .leading).clipped() HeatCell(text: Formatters.percent(group.cpu), intensity: group.cpu / 100) .frame(width: width(.cpu), alignment: .trailing) HeatCell(text: memoryText, intensity: group.memoryPercent / 30) .frame(width: width(.memory), alignment: .trailing) .help(memoryHelp) HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000) .frame(width: width(.disk), alignment: .trailing) HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000) .frame(width: width(.network), alignment: .trailing) } .font(.callout.weight(.medium)) .padding(.horizontal, 16) .frame(minHeight: 48) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .overlay(alignment: .bottom) { Divider().opacity(0.45) } .accessibilityElement(children: .combine) .accessibilityLabel("\(group.name), \(group.processes.count) processes") .accessibilityValue("CPU \(Formatters.percent(group.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(group.residentBytes))), disk \(Formatters.rate(activity.diskTotal)), network \(Formatters.rate(activity.networkTotal))") } private var memoryText: String { displayMode == .percentages ? Formatters.percent(group.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(group.residentBytes)) } private var memoryHelp: String { "Resident memory: \(Formatters.bytes.string(fromByteCount: Int64(group.residentBytes))) • \(Formatters.percent(group.memoryPercent)) of physical memory" } private func width(_ column: ProcessColumn) -> CGFloat { columnWidths[column] ?? column.defaultWidth } private var diskText: String { displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal) } private var networkText: String { let total = monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate return displayMode == .percentages ? share(activity.networkTotal, of: total) : Formatters.rate(activity.networkTotal) } private func share(_ value: Double, of total: Double) -> String { Formatters.percent(total > 0 ? min(100, value / total * 100) : 0) } } private struct ProcessGroupContextMenu: View { @Environment(SystemMonitor.self) private var monitor let group: ProcessGroup let onTerminate: () -> Void let onShowDetails: (ProcessRecord) -> Void let onShowProperties: (ProcessRecord) -> Void var body: some View { Button("Efficiency mode", systemImage: "leaf") { group.processes.forEach { monitor.setPriority(10, for: $0) } } Menu("Process control", systemImage: "switch.2") { Button("Pause all") { group.processes.forEach { monitor.sendSignal(SIGSTOP, to: $0) } } Button("Resume all") { group.processes.forEach { monitor.sendSignal(SIGCONT, to: $0) } } } Menu("Set priority", systemImage: "speedometer") { Button("High") { setPriority(-10) } Button("Above normal") { setPriority(-5) } Button("Normal") { setPriority(0) } Button("Below normal") { setPriority(10) } Button("Low") { setPriority(15) } } Divider() Button("Go to details", systemImage: "list.bullet.rectangle") { onShowDetails(group.primary) } Menu("Copy", systemImage: "doc.on.doc") { Button("Application name") { copy(group.name) } Button("Process IDs") { copy(group.processes.map { String($0.pid) }.joined(separator: ", ")) } Button("Executable path") { copy(group.appPath ?? group.primary.executablePath) } Button("All details") { copy("\(group.name)\t\(group.processes.count) processes\tCPU \(Formatters.percent(group.cpu))\tMemory \(Formatters.bytes.string(fromByteCount: Int64(group.residentBytes)))") } } Menu("Inspect", systemImage: "magnifyingglass") { Button("Analyze dependencies…") { ProcessDependencyInspectorController.open(process: group.primary, allProcesses: monitor.processes) } Button("Reveal in Finder") { reveal() }.disabled((group.appPath ?? group.primary.executablePath).isEmpty) Button("Search online") { searchOnline() } Button("Create diagnostic report…") { ProcessDiagnosticReporter.chooseDestinationAndCapture(group.primary) } Button("Properties") { onShowProperties(group.primary) } } Divider() Button("End task", systemImage: "xmark.circle", role: .destructive, action: onTerminate) .disabled(group.processes.contains(where: { $0.pid == getpid() })) } private func setPriority(_ value: Int) { group.processes.forEach { monitor.setPriority(value, for: $0) } } private func copy(_ value: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(value, forType: .string) } private func reveal() { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: group.appPath ?? group.primary.executablePath)]) } private func searchOnline() { guard let query = group.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "https://www.google.com/search?q=\(query)+macOS+app") else { return } NSWorkspace.shared.open(url) } } private struct HeatCell: View { let text: String let intensity: Double var body: some View { Text(text) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.72) .truncationMode(.tail) .padding(.vertical, 9) .padding(.horizontal, processNumericTextInset) .frame(maxWidth: .infinity, alignment: .trailing) .clipped() .background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32))) .clipShape(RoundedRectangle(cornerRadius: 4)) } } struct ProcessIcon: View { let name: String var path: String? = nil var size: CGFloat = 30 var body: some View { Group { if let icon = ApplicationIconCache.icon(for: path, name: name) { Image(nsImage: icon) .resizable() .scaledToFit() } else { ZStack { RoundedRectangle(cornerRadius: 6) .fill(color.opacity(0.16)) Image(systemName: symbol) .font(.system(size: size * 0.47, weight: .medium)) .foregroundStyle(color) } } } .frame(width: size, height: size) .accessibilityHidden(true) } private var color: Color { let palette: [Color] = [.blue, .purple, .teal, .orange, .pink, .indigo] let stableHash = name.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0x7fff_ffff } return palette[stableHash % palette.count] } private var symbol: String { name.localizedCaseInsensitiveContains("helper") ? "puzzlepiece.extension" : "app.dashed" } } @MainActor private enum ApplicationIconCache { private static let cache = NSCache() static func icon(for rawPath: String?, name: String) -> NSImage? { guard let rawPath, !rawPath.isEmpty else { return nil } let path = iconPath(from: rawPath) let key = path as NSString if let cached = cache.object(forKey: key) { return cached } guard FileManager.default.fileExists(atPath: path) else { return nil } let icon = NSWorkspace.shared.icon(forFile: path) icon.size = NSSize(width: 64, height: 64) cache.setObject(icon, forKey: key) return icon } private static func iconPath(from path: String) -> String { if let range = path.range(of: ".app/", options: .caseInsensitive) { return String(path[..