import AppKit import SwiftUI import UniformTypeIdentifiers enum TerminationKind { case normal case force case tree var title: String { switch self { case .normal: "End task" case .force: "Force quit" case .tree: "End process tree" } } } struct TerminationRequest { let process: ProcessRecord let kind: TerminationKind } struct ProcessContextMenu: View { @Environment(SystemMonitor.self) private var monitor let process: ProcessRecord let onTerminate: (TerminationRequest) -> Void let onShowDetails: (ProcessRecord) -> Void let onShowProperties: (ProcessRecord) -> Void var body: some View { Button("Efficiency mode", systemImage: "leaf") { monitor.setPriority(10, for: process) } Menu("Process control", systemImage: "switch.2") { Button("Interrupt") { monitor.sendSignal(SIGINT, to: process) } Button("Pause") { monitor.sendSignal(SIGSTOP, to: process) } Button("Resume") { monitor.sendSignal(SIGCONT, to: process) } Divider() Button("Force quit", role: .destructive) { onTerminate(.init(process: process, kind: .force)) } Button("End process tree", role: .destructive) { onTerminate(.init(process: process, kind: .tree)) } } Menu("Set priority", systemImage: "speedometer") { Button("High") { monitor.setPriority(-10, for: process) } Button("Above normal") { monitor.setPriority(-5, for: process) } Button("Normal") { monitor.setPriority(0, for: process) } Button("Below normal") { monitor.setPriority(10, for: process) } Button("Low") { monitor.setPriority(15, for: process) } } Divider() Button("Go to details", systemImage: "list.bullet.rectangle") { onShowDetails(process) } Menu("Copy", systemImage: "doc.on.doc") { Button("Name") { copy(process.displayName) } Button("PID") { copy(String(process.pid)) } Button("Executable path") { copy(process.executablePath) } Button("All details") { copy("\(process.displayName)\tPID \(process.pid)\tCPU \(Formatters.percent(process.cpu))\t\(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))\t\(process.executablePath)") } } Menu("Inspect", systemImage: "magnifyingglass") { Button("Analyze dependencies…") { ProcessDependencyInspectorController.open(process: process, allProcesses: monitor.processes) } Button("Reveal in Finder") { reveal(process) } .disabled(!process.executablePath.hasPrefix("/")) Button("Search online") { searchOnline(process.displayName) } Button("Create diagnostic report…") { ProcessDiagnosticReporter.chooseDestinationAndCapture(process) } Button("Properties") { onShowProperties(process) } } Divider() Button("End task", systemImage: "xmark.circle", role: .destructive) { onTerminate(.init(process: process, kind: .normal)) } } private func copy(_ text: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } private func reveal(_ process: ProcessRecord) { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: process.executablePath)]) } private func searchOnline(_ query: String) { guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "https://www.google.com/search?q=\(encoded)+macOS+process") else { return } NSWorkspace.shared.open(url) } } @MainActor enum ProcessDiagnosticReporter { private static let lastDirectoryKey = "diagnosticReportDirectory" private static let durationKey = "diagnosticReportDuration" private static let askEveryTimeKey = "diagnosticAskEveryTime" static func chooseDestinationAndCapture(_ process: ProcessRecord) { let defaults = UserDefaults.standard let duration = max(1, defaults.integer(forKey: durationKey) == 0 ? 5 : defaults.integer(forKey: durationKey)) let askEveryTime = defaults.object(forKey: askEveryTimeKey) as? Bool ?? true if !askEveryTime, let savedPath = defaults.string(forKey: lastDirectoryKey), FileManager.default.fileExists(atPath: savedPath) { let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") let filename = safeFilename("\(process.displayName)-\(process.pid)-\(stamp)-sample.txt") capture(process, duration: duration, to: URL(fileURLWithPath: savedPath, isDirectory: true).appendingPathComponent(filename)) return } let panel = NSSavePanel() panel.title = "Create diagnostic report" panel.message = "Puter will sample \(process.displayName) for \(duration) seconds and save a text report." panel.prompt = "Create Report" panel.nameFieldStringValue = safeFilename("\(process.displayName)-\(process.pid)-sample.txt") panel.allowedContentTypes = [.plainText] panel.canCreateDirectories = true if let savedPath = UserDefaults.standard.string(forKey: lastDirectoryKey) { panel.directoryURL = URL(fileURLWithPath: savedPath, isDirectory: true) } panel.begin { response in guard response == .OK, let destination = panel.url else { return } UserDefaults.standard.set(destination.deletingLastPathComponent().path, forKey: lastDirectoryKey) capture(process, duration: duration, to: destination) } } private static func capture(_ process: ProcessRecord, duration: Int, to destination: URL) { Task.detached(priority: .userInitiated) { let sampler = Process() let errorPipe = Pipe() sampler.executableURL = URL(fileURLWithPath: "/usr/bin/sample") sampler.arguments = [String(process.pid), String(duration), "1", "-mayDie", "-file", destination.path] sampler.standardError = errorPipe do { try sampler.run() sampler.waitUntilExit() let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() let errorText = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" await showResult( success: sampler.terminationStatus == 0 && FileManager.default.fileExists(atPath: destination.path), process: process, duration: duration, destination: destination, error: errorText ) } catch { await showResult(success: false, process: process, duration: duration, destination: destination, error: error.localizedDescription) } } } private static func showResult(success: Bool, process: ProcessRecord, duration: Int, destination: URL, error: String) { let alert = NSAlert() if success { alert.messageText = "Diagnostic report created" alert.informativeText = "A \(duration)-second sample of \(process.displayName) was saved to \(destination.path)." alert.alertStyle = .informational alert.addButton(withTitle: "Reveal in Finder") alert.addButton(withTitle: "Done") if alert.runModal() == .alertFirstButtonReturn { NSWorkspace.shared.activateFileViewerSelecting([destination]) } } else { alert.messageText = "Couldn’t create diagnostic report" alert.informativeText = error.isEmpty ? "The process may have exited or macOS denied access to its sample data." : error alert.alertStyle = .warning alert.addButton(withTitle: "OK") alert.runModal() } } private static func safeFilename(_ value: String) -> String { value.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") } } struct ProcessPropertiesView: View { @Environment(\.dismiss) private var dismiss @Environment(SystemMonitor.self) private var monitor let process: ProcessRecord private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() } var body: some View { VStack(spacing: 0) { HStack(spacing: 14) { ProcessIcon(name: process.displayName, path: process.executablePath) .scaleEffect(1.35) .frame(width: 42, height: 42) VStack(alignment: .leading, spacing: 3) { Text(process.displayName).font(.title2.weight(.semibold)) Text("Process \(process.pid)").foregroundStyle(.secondary) } Spacer() } .padding(20) Divider() Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) { property("Executable", process.executablePath) property("User", process.user) property("Parent PID", "\(process.parentPID)") property("State", process.state) property("CPU", Formatters.percent(process.cpu)) property("CPU time", Formatters.duration(process.cpuTime)) property("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))) property("Threads", "\(process.threadCount)") property("Open handles", "\(process.openFileCount)") property("Architecture", process.architecture) property("Priority", "\(process.priority) (nice \(process.nice))") property("Disk read", Formatters.rate(activity.diskRead)) property("Disk write", Formatters.rate(activity.diskWrite)) property("Network", Formatters.rate(activity.networkTotal)) property("Elapsed time", process.elapsed) } .padding(20) Divider() HStack { Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: process.executablePath)]) } .disabled(!process.executablePath.hasPrefix("/")) Spacer() Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } .padding(16) } .frame(width: 520) } private func property(_ name: String, _ value: String) -> some View { GridRow { Text(name).foregroundStyle(.secondary).frame(width: 90, alignment: .trailing) Text(value).textSelection(.enabled).lineLimit(2).frame(maxWidth: .infinity, alignment: .leading) } } } extension View { func terminationConfirmation(_ request: Binding) -> some View { modifier(TerminationConfirmationModifier(request: request)) } } private struct TerminationConfirmationModifier: ViewModifier { @Environment(SystemMonitor.self) private var monitor @Binding var request: TerminationRequest? func body(content: Content) -> some View { content.confirmationDialog( "\(request?.kind.title ?? "End task"): \(request?.process.displayName ?? "this task")?", isPresented: Binding(get: { request != nil }, set: { if !$0 { request = nil } }) ) { Button(request?.kind.title ?? "End task", role: .destructive) { if let request { switch request.kind { case .normal: monitor.terminate(request.process) case .force: monitor.terminate(request.process, force: true) case .tree: monitor.terminateTree(request.process) } } request = nil } Button("Cancel", role: .cancel) { request = nil } } message: { Text(request?.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.") } } }