Rename app and repository to Puter

This commit is contained in:
2026-08-14 23:55:16 -04:00
parent b7faf0ac70
commit 053bb71da2
28 changed files with 1804 additions and 302 deletions
+290
View File
@@ -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 = "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.")
}
}
}