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 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 @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 } } 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) ProcessTableHeader( sort: $sort, ascending: $ascending, groupByType: $groupByType, resourceDisplayMode: $resourceDisplayMode ) ScrollView { LazyVStack(spacing: 0) { if groupByType { ForEach(ProcessCategory.allCases) { category in let categoryGroups = grouped.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(grouped) { group in processGroup(group) } } } .padding(.leading, 10) } 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(.borderedProminent) .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) } } 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) { HStack(spacing: 0) { Button { let binding = expansionBinding(for: group) binding.wrappedValue.toggle() } label: { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .rotationEffect(.degrees(expansionBinding(for: group).wrappedValue ? 90 : 0)) .frame(width: 16, height: 48) .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(.secondary) .help(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)") .accessibilityLabel(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)") ProcessGroupRow( group: group, selected: selectedGroupID == group.id, displayMode: resourceDisplayMode, leadingPadding: 0 ) .contentShape(Rectangle()) .onTapGesture { 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) .contentShape(Rectangle()) .onTapGesture { 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[.. some View { Button { groupByType = false if sort == field { ascending.toggle() } else { sort = field; ascending = false } } label: { HStack(spacing: 3) { Text(title) if sort == field { Image(systemName: ascending ? "chevron.up" : "chevron.down") } } } .buttonStyle(.plain) } } private struct ProcessRow: View { @Environment(SystemMonitor.self) private var monitor let process: ProcessRecord let selected: Bool let isChild: Bool let displayMode: ResourceDisplayMode 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: 12) } ProcessIcon(name: process.displayName, path: process.executablePath) VStack(alignment: .leading, spacing: 2) { Text(process.displayName).lineLimit(1) Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) Text("\(process.pid)").monospacedDigit().frame(width: 70, alignment: .trailing) Text(process.state.hasPrefix("R") ? "Running" : "").frame(width: 80, alignment: .leading) HeatCell(text: Formatters.percent(process.cpu), intensity: process.cpu / 100) .frame(width: 86, alignment: .trailing) HeatCell(text: memoryText, intensity: process.memoryPercent / 30) .frame(width: 110, alignment: .trailing) HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000) .frame(width: 88, alignment: .trailing) HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000) .frame(width: 88, alignment: .trailing) } .font(.callout) .padding(.horizontal, 16) .frame(height: 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 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 leadingPadding: CGFloat private var activity: ProcessActivityRate { group.activity(using: monitor.processActivity) } var body: some View { HStack(spacing: 12) { HStack(spacing: 10) { ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath) VStack(alignment: .leading, spacing: 2) { Text("\(group.name) (\(group.processes.count))").lineLimit(1) Text("Application group").font(.caption).foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) Text("—").frame(width: 70, alignment: .trailing).foregroundStyle(.secondary) Text(group.processes.contains(where: { $0.state.hasPrefix("R") }) ? "Running" : "") .frame(width: 80, alignment: .leading) HeatCell(text: Formatters.percent(group.cpu), intensity: group.cpu / 100) .frame(width: 86, alignment: .trailing) HeatCell(text: memoryText, intensity: group.memoryPercent / 30) .frame(width: 110, alignment: .trailing) HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000) .frame(width: 88, alignment: .trailing) HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000) .frame(width: 88, alignment: .trailing) } .font(.callout.weight(.medium)) .padding(.leading, leadingPadding) .padding(.trailing, 16) .frame(height: 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 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("End task", systemImage: "xmark.circle", action: onTerminate) .disabled(group.processes.contains(where: { $0.pid == getpid() })) 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) } } } 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() .padding(.vertical, 9) .padding(.horizontal, 7) .frame(maxWidth: .infinity, alignment: .trailing) .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 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: 14, weight: .medium)) .foregroundStyle(color) } } } .frame(width: 30, height: 30) .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[..