921 lines
39 KiB
Swift
921 lines
39 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(.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<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
|
|
}
|
|
.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<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(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<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
|
|
}
|
|
}
|