Files
puter/Sources/MacTaskManager/ProcessActions.swift
T

289 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("End task", systemImage: "xmark.circle") {
onTerminate(.init(process: process, kind: .normal))
}
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) }
}
}
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 = "Task Manager 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 = "Couldnt 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<TerminationRequest?>) -> 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.")
}
}
}