Files
puter/Sources/MacTaskManager/ProcessViews.swift
T

891 lines
38 KiB
Swift

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<String> = []
@State private var collapsedCategories: Set<ProcessCategory> = []
@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(.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)
}
.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<Bool> {
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
}
.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
}
.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<pid_t>
) -> 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[..<range.lowerBound]) + ".app"
}
}
private struct ProcessTableHeader: View {
@Binding var sort: ProcessSort
@Binding var ascending: Bool
@Binding var groupByType: Bool
@Binding var resourceDisplayMode: ResourceDisplayMode
@Binding var columnWidths: [ProcessColumn: CGFloat]
let onResizeStarted: () -> 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<Content: View>(
_ 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<Content: View>: 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(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 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(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 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("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()
.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 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<NSString, NSImage>()
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[..<range.lowerBound]) + ".app"
}
return path
}
}