Rename app and repository to Puter
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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<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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user