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
+170
View File
@@ -0,0 +1,170 @@
import AppKit
import SwiftUI
struct NewTaskView: View {
@Binding var isPresented: Bool
@State private var path = ""
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 14) {
Image(systemName: "plus.square.dashed")
.font(.system(size: 32))
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
Text("Run new task").font(.title2.weight(.semibold))
Text("Open an application, document, or executable.").foregroundStyle(.secondary)
}
}
HStack {
TextField("Application or executable path", text: $path)
.textFieldStyle(.roundedBorder)
.onSubmit { launch() }
Button("Browse…") { browse() }
}
if let errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.red)
}
HStack {
Text("Tip: drag an app or file from Finder into the path field.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Cancel") { isPresented = false }
.keyboardShortcut(.cancelAction)
Button("Run") { launch() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(width: 560)
}
private func browse() {
let panel = NSOpenPanel()
panel.title = "Choose an application, executable, or document"
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url { path = url.path }
}
private func launch() {
let cleaned = (path as NSString).expandingTildeInPath.trimmingCharacters(in: .whitespacesAndNewlines)
let url = URL(fileURLWithPath: cleaned)
guard FileManager.default.fileExists(atPath: cleaned) else {
errorMessage = "That file could not be found."
return
}
if NSWorkspace.shared.open(url) {
isPresented = false
} else {
errorMessage = "macOS could not open this item."
}
}
}
struct SettingsView: View {
@Environment(SystemMonitor.self) private var monitor
@AppStorage("alwaysOnTop") private var alwaysOnTop = false
@AppStorage("defaultStartPage") private var defaultStartPage: TaskSection = .processes
@AppStorage("diagnosticReportDuration") private var diagnosticDuration = 5
@AppStorage("diagnosticAskEveryTime") private var diagnosticAskEveryTime = true
@AppStorage("diagnosticReportDirectory") private var diagnosticDirectory = ""
var body: some View {
@Bindable var monitor = monitor
VStack(spacing: 0) {
PageHeader(title: "Settings", subtitle: "Customize Puter")
Form {
Section("General") {
Picker("Default start page", selection: $defaultStartPage) {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
Text(section.rawValue).tag(section)
}
}
Picker("Real-time update speed", selection: $monitor.updateSpeed) {
ForEach(UpdateSpeed.allCases) { speed in
Text(speed.rawValue).tag(speed)
}
}
Toggle("Always on top", isOn: $alwaysOnTop)
.onChange(of: alwaysOnTop) { _, enabled in
NSApp.keyWindow?.level = enabled ? .floating : .normal
}
}
Section("Process data") {
LabeledContent("Refresh interval", value: intervalLabel)
LabeledContent("Process source", value: "macOS process table")
LabeledContent("Memory units", value: "Automatic")
}
Section("Diagnostic reports") {
Picker("Sample duration", selection: $diagnosticDuration) {
Text("1 second").tag(1)
Text("5 seconds").tag(5)
Text("10 seconds").tag(10)
}
Toggle("Ask where to save each report", isOn: $diagnosticAskEveryTime)
LabeledContent("Output folder") {
HStack(spacing: 10) {
Text(diagnosticDirectory.isEmpty ? "Not selected" : URL(fileURLWithPath: diagnosticDirectory).lastPathComponent)
.foregroundStyle(.secondary)
Button("Choose…") { chooseDiagnosticDirectory() }
}
}
}
Section("About") {
LabeledContent("Application", value: "Puter")
LabeledContent("Version", value: appVersion)
LabeledContent("Framework", value: "Native SwiftUI")
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.frame(maxWidth: 720)
.frame(maxWidth: .infinity, alignment: .leading)
}
.task {
NSApp.keyWindow?.level = alwaysOnTop ? .floating : .normal
}
}
private var intervalLabel: String {
switch monitor.updateSpeed {
case .high: "Every second"
case .normal: "Every 2 seconds"
case .low: "Every 5 seconds"
case .paused: "Paused"
}
}
private var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Development"
}
private func chooseDiagnosticDirectory() {
let panel = NSOpenPanel()
panel.title = "Choose diagnostic report folder"
panel.prompt = "Choose"
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if !diagnosticDirectory.isEmpty {
panel.directoryURL = URL(fileURLWithPath: diagnosticDirectory, isDirectory: true)
}
if panel.runModal() == .OK, let url = panel.url {
diagnosticDirectory = url.path
}
}
}
+92
View File
@@ -0,0 +1,92 @@
import SwiftUI
struct PageHeader<Trailing: View>: View {
let title: String
let subtitle: String?
@ViewBuilder let trailing: () -> Trailing
init(title: String, subtitle: String? = nil, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) {
self.title = title
self.subtitle = subtitle
self.trailing = trailing
}
var body: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.title.weight(.semibold))
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
}
Spacer()
trailing()
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 14)
}
}
struct ResourceSummaryBar: View {
let snapshot: SystemSnapshot
var body: some View {
HStack(spacing: 22) {
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu")
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip")
Label("\(Formatters.percent(snapshot.diskActivePercent)) Disk activity", systemImage: "internaldrive")
Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
Spacer()
}
.font(.callout.weight(.medium))
.padding(.horizontal, 20)
.padding(.vertical, 9)
.background(Color.accentColor.opacity(0.06))
.overlay(alignment: .bottom) { Divider() }
}
}
struct UpdateStatus: View {
@Environment(SystemMonitor.self) private var monitor
var body: some View {
HStack(spacing: 6) {
Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 7, height: 7)
if monitor.isPaused {
Text("Updates paused")
} else if let date = monitor.lastUpdated {
if Date().timeIntervalSince(date) < 1.5 {
Text("Updated now")
} else {
Text("Updated \(date, style: .relative)")
}
} else {
Text("Collecting system data…")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
struct EmptyState: View {
let icon: String
let title: String
let message: String
var body: some View {
ContentUnavailableView(title, systemImage: icon, description: Text(message))
}
}
struct UpdateSpeedPicker: View {
@Binding var selection: UpdateSpeed
var body: some View {
Picker("Update speed", selection: $selection) {
Text("High").tag(UpdateSpeed.high)
Text("Normal").tag(UpdateSpeed.normal)
Text("Low").tag(UpdateSpeed.low)
Text("Paused").tag(UpdateSpeed.paused)
}
}
}
+194
View File
@@ -0,0 +1,194 @@
import AppKit
import SwiftUI
extension Notification.Name {
static let runNewTask = Notification.Name("Puter.runNewTask")
}
struct ContentView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var selection: TaskSection?
@State private var searchText = ""
@State private var showingNewTask = false
@State private var detailSelection: Int32?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@AppStorage("sidebarCompact") private var sidebarCompact = false
init() {
let stored = UserDefaults.standard.string(forKey: "defaultStartPage") ?? TaskSection.processes.rawValue
_selection = State(initialValue: TaskSection(rawValue: stored) ?? .processes)
}
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar(selection: $selection, isCompact: sidebarCompact)
.navigationSplitViewColumnWidth(
min: sidebarCompact ? 68 : 190,
ideal: sidebarCompact ? 68 : 210,
max: sidebarCompact ? 68 : 250
)
} detail: {
Group {
switch selection ?? .processes {
case .processes:
ProcessesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
case .performance:
PerformanceView()
case .history:
AppHistoryView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
case .startup:
StartupAppsView()
case .users:
UsersView { process in
detailSelection = process.pid
selection = .details
}
case .details:
DetailsView(searchText: searchText, selection: $detailSelection)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
case .services:
ServicesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
case .hardware:
HardwareView()
case .settings:
SettingsView()
}
}
.toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask)
}
}
.navigationTitle("Puter")
.animation(.snappy(duration: 0.22), value: sidebarCompact)
.onChange(of: selection) { _, _ in searchText = "" }
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
.sheet(isPresented: $showingNewTask) {
NewTaskView(isPresented: $showingNewTask)
}
.alert("Puter", isPresented: Binding(
get: { monitor.errorMessage != nil },
set: { if !$0 { monitor.errorMessage = nil } }
)) {
Button("OK", role: .cancel) { monitor.errorMessage = nil }
} message: {
Text(monitor.errorMessage ?? "")
}
.onAppear {
DispatchQueue.main.async {
NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal
}
}
}
}
private struct TaskToolbarContent: ToolbarContent {
@Environment(SystemMonitor.self) private var monitor
@Binding var showingNewTask: Bool
@AppStorage("sidebarCompact") private var sidebarCompact = false
var body: some ToolbarContent {
@Bindable var monitor = monitor
ToolbarItemGroup(placement: .primaryAction) {
Button {
showingNewTask = true
} label: {
Label("Run new task", systemImage: "plus.square")
}
.help("Launch an application or executable")
Button {
monitor.refresh()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.help("Refresh now (⌘R)")
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
Divider()
Toggle("Compact Sidebar", isOn: $sidebarCompact)
} label: {
Image(systemName: "ellipsis")
}
.menuStyle(.borderlessButton)
}
}
}
private struct Sidebar: View {
@Binding var selection: TaskSection?
let isCompact: Bool
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 4) {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
Button {
selection = section
} label: {
sidebarLabel(section.rawValue, icon: section.icon)
.padding(.horizontal, isCompact ? 0 : 8)
.frame(minHeight: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
.background(selection == section ? Color.accentColor.opacity(0.18) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 7))
.help(section.rawValue)
.accessibilityLabel(section.rawValue)
.accessibilityAddTraits(selection == section ? .isSelected : [])
}
}
.padding(.horizontal, isCompact ? 8 : 10)
.padding(.vertical, 8)
}
Divider().padding(.horizontal, isCompact ? 12 : 16)
Button {
selection = .settings
} label: {
sidebarLabel("Settings", icon: "gearshape")
.padding(.horizontal, isCompact ? 0 : 8)
.frame(minHeight: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.font(.callout)
.frame(maxWidth: .infinity)
.background(selection == .settings ? Color.accentColor.opacity(0.18) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 7))
.padding(.horizontal, isCompact ? 8 : 10)
.padding(.vertical, 8)
.foregroundStyle(selection == .settings ? Color.primary : Color.secondary)
.help("Settings")
.accessibilityLabel("Settings")
}
}
private func sidebarLabel(_ title: String, icon: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.frame(width: 22, height: 20)
if !isCompact {
Text(title).lineLimit(1)
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, alignment: isCompact ? .center : .leading)
}
}
+67
View File
@@ -0,0 +1,67 @@
import Foundation
enum FanControlError: LocalizedError {
case backendUnavailable
case commandFailed(String)
var errorDescription: String? {
switch self {
case .backendUnavailable:
"The SMC control backend is unavailable on this Mac."
case .commandFailed(let message):
message.isEmpty ? "The fan command did not complete." : message
}
}
}
enum FanControlService {
static var toolPath: String {
if let bundled = Bundle.main.url(forResource: "smc", withExtension: nil)?.path,
FileManager.default.isExecutableFile(atPath: bundled) {
return bundled
}
return "/Applications/Stats.app/Contents/Resources/smc"
}
static var isAvailable: Bool {
FileManager.default.isExecutableFile(atPath: toolPath)
}
static func setManualTargets(_ targets: [Int: Double]) throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
let commands = targets.sorted { $0.key < $1.key }.flatMap { id, speed in
let safeID = max(0, id)
let safeSpeed = max(0, Int(speed.rounded()))
return ["\(quotedTool) fan \(safeID) -m 1", "\(quotedTool) fan \(safeID) -v \(safeSpeed)"]
}
try runPrivileged(commands.joined(separator: " && "))
}
static func restoreAutomatic() throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
try runPrivileged("\(quotedTool) reset")
}
private static var quotedTool: String {
"'" + toolPath.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func runPrivileged(_ shellCommand: String) throws {
let escaped = shellCommand
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = "do shell script \"\(escaped)\" with administrator privileges"
let process = Process()
let errorPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
process.standardOutput = FileHandle.nullDevice
process.standardError = errorPipe
try process.run()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw FanControlError.commandFailed(String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
}
}
}
+468
View File
@@ -0,0 +1,468 @@
import AppKit
import Charts
import SwiftUI
struct HardwareView: View {
@Environment(SystemMonitor.self) private var monitor
@AppStorage("showEmptyHardwarePorts") private var showEmptyPorts = true
@State private var fanTargets: [Int: Double] = [:]
@State private var fanCommandInProgress = false
@State private var fanError: String?
@State private var fanStatus: String?
@State private var showingFanConfirmation = false
private var battery: BatterySnapshot { monitor.hardware.battery }
private var visiblePorts: [HardwarePortSnapshot] {
showEmptyPorts ? monitor.hardware.ports : monitor.hardware.ports.filter(\.isConnected)
}
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Hardware", subtitle: "Power, battery, thermals, ports, and connection diagnostics") {
HStack(spacing: 10) {
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: "Hardware")
}
Button("Power Settings", systemImage: "gear") { openPowerSettings() }
}
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
summaryGrid
verdictCard
if battery.isPresent { batterySection }
if let adapter = battery.adapter { adapterSection(adapter) }
powerSection
portsSection
coolingSection
topConsumersSection
}
.padding(20)
}
}
.onChange(of: monitor.hardware.fans) { _, fans in
seedFanTargets(fans)
}
.onAppear { seedFanTargets(monitor.hardware.fans) }
.confirmationDialog("Apply manual fan targets?", isPresented: $showingFanConfirmation) {
Button("Apply Targets") { updateFans(manual: true) }
Button("Cancel", role: .cancel) { }
} message: {
Text("\(fanTargetSummary). macOS automatic fan control will be disabled until you choose Automatic.")
}
.alert("Fan control failed", isPresented: Binding(
get: { fanError != nil },
set: { if !$0 { fanError = nil } }
)) {
Button("OK", role: .cancel) { fanError = nil }
} message: {
Text(fanError ?? "")
}
}
private var summaryGrid: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 10)], spacing: 10) {
summaryCard("CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), detail: "\(monitor.snapshot.corePercents.count) logical processors", icon: "cpu", color: .blue)
summaryCard("Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), detail: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed)), icon: "memorychip", color: .purple)
summaryCard("System power", value: watts(battery.systemPowerWatts), detail: battery.externalPowerConnected ? "External power" : "Battery power", icon: "bolt.fill", color: .orange)
summaryCard("Thermals", value: monitor.hardware.thermalState, detail: thermalDetail, icon: "thermometer.medium", color: thermalColor)
}
}
private func summaryCard(_ title: String, value: String, detail: String, icon: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(color)
Text(value).font(.title2.weight(.semibold)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
.frame(maxWidth: .infinity, minHeight: 86, alignment: .leading)
.padding(13)
.background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.18)) }
}
private var verdictCard: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: verdictIcon).font(.title2).foregroundStyle(verdictColor).frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(verdictTitle).font(.headline)
Text(verdictDetail).font(.callout).foregroundStyle(.secondary)
}
Spacer()
}
.padding(15)
.background(verdictColor.opacity(0.09), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(verdictColor.opacity(0.22)) }
}
private var batterySection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Battery").font(.headline)
HStack(alignment: .firstTextBaseline) {
metric("Charge", Formatters.percent(battery.chargePercent))
Spacer()
metric("Condition", battery.healthPercent > 80 ? "Normal" : "Service recommended")
Spacer()
metric("Health", Formatters.percent(battery.healthPercent))
}
HStack(alignment: .firstTextBaseline) {
metric("Cycles", cycleText)
Spacer()
metric("Temperature", String(format: "%.1f °C", battery.temperatureCelsius))
Spacer()
metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
}
HStack(alignment: .firstTextBaseline) {
metric("Battery power", watts(battery.batteryPowerWatts))
Spacer()
metric("Full capacity", capacity(battery.fullChargeCapacityMAh))
Spacer()
metric("Design capacity", capacity(battery.designCapacityMAh))
Spacer()
metric("Time remaining", timeRemaining)
}
Divider()
HStack {
Label(powerSourceText, systemImage: battery.externalPowerConnected ? "powerplug.fill" : "battery.75percent")
Spacer()
Text(batteryStateText).foregroundStyle(.secondary)
}
.font(.callout.weight(.medium))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func adapterSection(_ adapter: PowerAdapterSnapshot) -> some View {
VStack(alignment: .leading, spacing: 13) {
Text("Charger and USB Power Delivery").font(.headline)
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(adapter.name).font(.headline)
Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
}
Divider()
HStack(spacing: 24) {
metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage))
metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent))
metric("Negotiated ceiling", watts(adapter.negotiatedWatts))
metric("System draw", watts(battery.systemPowerWatts))
Spacer()
}
if !adapter.profiles.isEmpty {
VStack(alignment: .leading, spacing: 7) {
Text("Advertised power profiles").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
HStack(spacing: 7) {
ForEach(adapter.profiles) { profile in
Text(String(format: "%.0f V × %.2f A · %.0f W", profile.voltageVolts, profile.currentAmps, profile.watts))
.font(.caption.monospacedDigit())
.padding(.horizontal, 9).padding(.vertical, 5)
.background(Color.accentColor.opacity(profile.voltageVolts == adapter.negotiatedVoltage ? 0.18 : 0.07), in: Capsule())
}
}
}
}
Label(cableEvidence(adapter), systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var powerSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Power history — 60 seconds").font(.headline)
Chart(monitor.systemPowerHistory) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
.foregroundStyle(.orange.opacity(0.13))
LineMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
.foregroundStyle(.orange).lineStyle(.init(lineWidth: 2))
}
.chartYAxisLabel("W")
.chartXAxis(.hidden)
.frame(height: 150)
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var portsSection: some View {
VStack(spacing: 0) {
HStack {
Text("USB-C, USB, and Thunderbolt").font(.headline)
Spacer()
Toggle("Show available ports", isOn: $showEmptyPorts).toggleStyle(.switch).controlSize(.small)
}
.padding(8)
if !visiblePorts.isEmpty { Divider() }
ForEach(visiblePorts) { port in
portRow(port)
if port.id != visiblePorts.last?.id { Divider() }
}
}
.padding(8)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func portRow(_ port: HardwarePortSnapshot) -> some View {
VStack(alignment: .leading, spacing: 9) {
HStack {
Image(systemName: port.isConnected ? "cable.connector" : "bolt.horizontal.circle")
.foregroundStyle(port.isConnected ? Color.green : Color.secondary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(port.name).font(.callout.weight(.semibold))
Text("\(port.transport)\(port.maximumSpeed)").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(port.status).font(.caption.weight(.medium)).foregroundStyle(port.isConnected ? Color.green : Color.secondary)
}
ForEach(port.devices) { device in
HStack(spacing: 8) {
Image(systemName: "arrow.turn.down.right").foregroundStyle(.tertiary)
VStack(alignment: .leading, spacing: 2) {
Text(device.name).lineLimit(1)
Text(deviceDetail(device)).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
.padding(.leading, CGFloat(device.depth * 18 + 30))
}
}
.padding(10)
}
private var topConsumersSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top live consumers").font(.headline)
VStack(spacing: 0) {
ForEach(Array(monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5))) { process in
HStack {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpu)).monospacedDigit().frame(width: 64, alignment: .trailing)
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
}
.padding(.vertical, 6)
}
}
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var coolingSection: some View {
VStack(alignment: .leading, spacing: 13) {
HStack {
Label("Cooling and fan control", systemImage: "fan")
.font(.headline)
Spacer()
Text(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? "Automatic" : "Manual")
.font(.caption.weight(.medium))
.foregroundStyle(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? .green : .orange)
}
Divider()
if monitor.hardware.fans.isEmpty {
HStack(spacing: 12) {
Image(systemName: "fan.slash").font(.title2).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 3) {
Text("Fan telemetry unavailable").font(.callout.weight(.medium))
Text("This Mac is not publishing compatible SMC fan data.")
.font(.caption).foregroundStyle(.secondary)
}
}
} else {
ForEach(monitor.hardware.fans) { fan in
fanRow(fan)
if fan.id != monitor.hardware.fans.last?.id { Divider().opacity(0.55) }
}
}
HStack(spacing: 28) {
metric("Thermal pressure", monitor.hardware.thermalState)
metric("Low Power Mode", ProcessInfo.processInfo.isLowPowerModeEnabled ? "On" : "Off")
metric("Control backend", FanControlService.isAvailable ? "Ready" : "Unavailable")
Spacer()
}
Text("Puter reads fan sensors directly through the Mac's SMC interface. Manual targets are clamped to each fan's reported safe range; Automatic returns control to macOS.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button("Apply targets", systemImage: "speedometer") {
showingFanConfirmation = true
}
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
Button("Automatic", systemImage: "arrow.counterclockwise") { updateFans(manual: false) }
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
Button("Reduce monitor refresh", systemImage: "tortoise") {
monitor.updateSpeed = .low
}
Spacer()
if fanCommandInProgress {
ProgressView().controlSize(.small).accessibilityLabel("Applying fan settings")
} else if let fanStatus {
Label(fanStatus, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func fanRow(_ fan: FanSnapshot) -> some View {
let target = Binding(
get: { safeTarget(for: fan) },
set: { fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, $0)) }
)
return HStack(spacing: 16) {
ZStack {
Circle().fill(Color.cyan.opacity(0.12)).frame(width: 40, height: 40)
Image(systemName: "fan.fill").foregroundStyle(.cyan)
}
VStack(alignment: .leading, spacing: 3) {
Text(fan.name).font(.callout.weight(.semibold))
Text("\(Int(fan.actualRPM.rounded())) RPM now · \(Int(fan.minimumRPM))\(Int(fan.maximumRPM)) RPM")
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
}
.frame(width: 220, alignment: .leading)
Slider(value: target, in: fan.minimumRPM...max(fan.minimumRPM + 1, fan.maximumRPM), step: 25)
.accessibilityLabel("\(fan.name) target speed")
.accessibilityValue("\(Int(target.wrappedValue.rounded())) RPM")
.accessibilityHint("Adjusts from \(Int(fan.minimumRPM)) to \(Int(fan.maximumRPM)) RPM")
Text("\(Int(target.wrappedValue.rounded())) RPM")
.font(.callout.monospacedDigit()).frame(width: 82, alignment: .trailing)
Text(fan.isAutomatic ? "Auto" : "Manual")
.font(.caption.weight(.medium))
.foregroundStyle(fan.isAutomatic ? .green : .orange)
.frame(width: 52, alignment: .trailing)
}
}
private func updateFans(manual: Bool) {
fanCommandInProgress = true
fanStatus = nil
let targets = Dictionary(uniqueKeysWithValues: monitor.hardware.fans.map { fan in
(fan.id, safeTarget(for: fan))
})
Task {
do {
try await Task.detached {
if manual { try FanControlService.setManualTargets(targets) }
else { try FanControlService.restoreAutomatic() }
}.value
fanStatus = manual ? "Targets applied" : "Automatic control restored"
monitor.refresh()
} catch {
fanError = error.localizedDescription
}
fanCommandInProgress = false
}
}
private var fanTargetSummary: String {
monitor.hardware.fans.map { fan in
let target = safeTarget(for: fan)
return "\(fan.name): \(Int(target.rounded())) RPM"
}.joined(separator: ", ")
}
private func seedFanTargets(_ fans: [FanSnapshot]) {
for fan in fans where fanTargets[fan.id] == nil {
let reported = fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM
fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, reported))
}
}
private func safeTarget(for fan: FanSnapshot) -> Double {
let reported = fanTargets[fan.id] ?? (fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM)
return min(fan.maximumRPM, max(fan.minimumRPM, reported))
}
private func metric(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.medium)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
}
}
private var verdictTitle: String {
if !battery.isPresent { return "Power telemetry available for desktop hardware" }
if battery.isFullyCharged && battery.externalPowerConnected { return "Battery full — running from external power" }
if battery.isCharging { return "Battery charging normally" }
if battery.externalPowerConnected { return "External power connected — battery is not charging" }
return "Running on battery"
}
private var verdictDetail: String {
guard battery.isPresent else { return "Battery-specific fields are hidden because this Mac reports no internal battery." }
if let adapter = battery.adapter {
return "macOS reports a \(Int(adapter.ratedWatts.rounded())) W adapter and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Current system input is \(watts(battery.systemPowerWatts))."
}
return "Current battery draw is \(watts(battery.batteryPowerWatts)). Charger and cable capability are shown only when macOS publishes them."
}
private var verdictIcon: String {
battery.externalPowerConnected ? "checkmark.circle.fill" : "battery.75percent"
}
private var verdictColor: Color {
monitor.hardware.thermalState == "Serious" || monitor.hardware.thermalState == "Critical" ? .red : .green
}
private var thermalColor: Color {
switch monitor.hardware.thermalState {
case "Critical", "Serious": .red
case "Fair": .orange
default: .green
}
}
private var thermalDetail: String {
switch monitor.hardware.thermalState {
case "Critical": "Performance is heavily constrained"
case "Serious": "Performance may be reduced"
case "Fair": "Elevated thermal pressure"
default: "No thermal pressure"
}
}
private var batteryColor: Color { battery.chargePercent < 20 ? .red : (battery.isCharging ? .green : .accentColor) }
private var cycleText: String { battery.designCycleCount > 0 ? "\(battery.cycleCount) of \(battery.designCycleCount)" : "\(battery.cycleCount)" }
private var timeRemaining: String {
guard let minutes = battery.timeRemainingMinutes else { return battery.isFullyCharged ? "Full" : "Calculating" }
return "\(minutes / 60)h \(minutes % 60)m"
}
private var powerSourceText: String { battery.externalPowerConnected ? "Power adapter" : "Internal battery" }
private var batteryStateText: String { battery.isFullyCharged ? "Fully charged" : (battery.isCharging ? "Charging" : "Not charging") }
private func cableEvidence(_ adapter: PowerAdapterSnapshot) -> String {
"Observed USB-PD contract: \(String(format: "%.1f V at %.2f A", adapter.negotiatedVoltage, adapter.negotiatedCurrent)). Cable e-marker identity is not claimed unless macOS exposes it."
}
private func deviceDetail(_ device: ConnectedHardwareDevice) -> String {
var parts = [device.vendor, device.speed].filter { $0 != "Not reported" }
if let required = device.currentRequiredMA { parts.append("requests \(required) mA") }
if let available = device.currentAvailableMA { parts.append("\(available) mA available") }
return parts.isEmpty ? "Technical details not reported by macOS" : parts.joined(separator: "")
}
private func watts(_ value: Double) -> String { value > 0 ? String(format: "%.1f W", value) : "" }
private func capacity(_ value: Double) -> String { value > 0 ? String(format: "%.0f mAh", value) : "Not reported" }
private func openPowerSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.battery") { NSWorkspace.shared.open(url) }
}
}
+396
View File
@@ -0,0 +1,396 @@
import Foundation
import SwiftUI
enum TaskSection: String, CaseIterable, Identifiable {
case processes = "Processes"
case performance = "Performance"
case history = "App history"
case startup = "Startup apps"
case users = "Users"
case details = "Details"
case services = "Services"
case hardware = "Hardware"
case settings = "Settings"
var id: String { rawValue }
var icon: String {
switch self {
case .processes: "square.stack.3d.up"
case .performance: "waveform.path.ecg"
case .history: "clock.arrow.circlepath"
case .startup: "gauge.open.with.lines.needle.33percent"
case .users: "person.2"
case .details: "list.bullet.rectangle"
case .services: "gearshape.2"
case .hardware: "bolt.horizontal.circle"
case .settings: "gearshape"
}
}
}
struct PowerProfile: Identifiable, Hashable, Sendable {
let voltageVolts: Double
let currentAmps: Double
var id: String { "\(voltageVolts)-\(currentAmps)" }
var watts: Double { voltageVolts * currentAmps }
}
struct PowerAdapterSnapshot: Hashable, Sendable {
var name = "Power adapter"
var manufacturer = "Not reported"
var serial = ""
var ratedWatts = 0.0
var negotiatedVoltage = 0.0
var negotiatedCurrent = 0.0
var profiles: [PowerProfile] = []
var negotiatedWatts: Double { negotiatedVoltage * negotiatedCurrent }
}
struct BatterySnapshot: Hashable, Sendable {
var isPresent = false
var chargePercent = 0.0
var isCharging = false
var isFullyCharged = false
var externalPowerConnected = false
var cycleCount = 0
var designCycleCount = 0
var currentCapacityMAh = 0.0
var fullChargeCapacityMAh = 0.0
var designCapacityMAh = 0.0
var voltageVolts = 0.0
var currentAmps = 0.0
var temperatureCelsius = 0.0
var timeRemainingMinutes: Int?
var serial = ""
var adapter: PowerAdapterSnapshot?
var systemPowerWatts = 0.0
var healthPercent: Double {
guard designCapacityMAh > 0 else { return 0 }
return min(100, fullChargeCapacityMAh / designCapacityMAh * 100)
}
var batteryPowerWatts: Double {
let watts = abs(voltageVolts * currentAmps)
return watts.isFinite && watts <= 500 ? watts : 0
}
}
struct ConnectedHardwareDevice: Identifiable, Hashable, Sendable {
let id: String
let name: String
let vendor: String
let speed: String
let currentAvailableMA: Int?
let currentRequiredMA: Int?
let depth: Int
}
struct HardwarePortSnapshot: Identifiable, Hashable, Sendable {
let id: String
let name: String
let transport: String
let maximumSpeed: String
let isConnected: Bool
let status: String
let devices: [ConnectedHardwareDevice]
}
struct FanSnapshot: Identifiable, Hashable, Sendable {
let id: Int
let name: String
let actualRPM: Double
let minimumRPM: Double
let maximumRPM: Double
let targetRPM: Double
let mode: String
var isAutomatic: Bool { mode.localizedCaseInsensitiveContains("automatic") }
}
struct HardwareSnapshot: Hashable, Sendable {
var battery = BatterySnapshot()
var ports: [HardwarePortSnapshot] = []
var fans: [FanSnapshot] = []
var thermalState = "Nominal"
var capturedAt = Date.distantPast
}
struct MacMemorySpecification: Hashable, Sendable {
let chip: String
let cpuCoreCount: Int?
let bandwidthGBps: Double
let isMaximum: Bool
var displayValue: String {
let bandwidth = bandwidthGBps.rounded() == bandwidthGBps
? String(format: "%.0f", bandwidthGBps)
: String(format: "%.2f", bandwidthGBps)
return "\(isMaximum ? "Up to " : "")\(bandwidth) GB/s bandwidth (Apple specification)"
}
}
enum MemoryBandwidthCatalog {
// Ordered from most-specific to broadest. Core-count entries distinguish
// configurations where Apple ships one chip name with multiple memory buses.
static let specifications: [MacMemorySpecification] = [
.init(chip: "M5 Max", cpuCoreCount: nil, bandwidthGBps: 614, isMaximum: true),
.init(chip: "M5 Pro", cpuCoreCount: nil, bandwidthGBps: 307, isMaximum: true),
.init(chip: "M5", cpuCoreCount: nil, bandwidthGBps: 153, isMaximum: false),
.init(chip: "M4 Max", cpuCoreCount: 16, bandwidthGBps: 546, isMaximum: false),
.init(chip: "M4 Max", cpuCoreCount: 14, bandwidthGBps: 410, isMaximum: false),
.init(chip: "M4 Pro", cpuCoreCount: nil, bandwidthGBps: 273, isMaximum: false),
.init(chip: "M4", cpuCoreCount: nil, bandwidthGBps: 120, isMaximum: false),
.init(chip: "M3 Ultra", cpuCoreCount: nil, bandwidthGBps: 819, isMaximum: false),
.init(chip: "M3 Max", cpuCoreCount: 16, bandwidthGBps: 400, isMaximum: false),
.init(chip: "M3 Max", cpuCoreCount: 14, bandwidthGBps: 300, isMaximum: false),
.init(chip: "M3 Pro", cpuCoreCount: nil, bandwidthGBps: 150, isMaximum: false),
.init(chip: "M3", cpuCoreCount: nil, bandwidthGBps: 100, isMaximum: false),
.init(chip: "M2 Ultra", cpuCoreCount: nil, bandwidthGBps: 800, isMaximum: false),
.init(chip: "M2 Max", cpuCoreCount: nil, bandwidthGBps: 400, isMaximum: false),
.init(chip: "M2 Pro", cpuCoreCount: nil, bandwidthGBps: 200, isMaximum: false),
.init(chip: "M2", cpuCoreCount: nil, bandwidthGBps: 100, isMaximum: false),
.init(chip: "M1 Ultra", cpuCoreCount: nil, bandwidthGBps: 800, isMaximum: false),
.init(chip: "M1 Max", cpuCoreCount: nil, bandwidthGBps: 400, isMaximum: false),
.init(chip: "M1 Pro", cpuCoreCount: nil, bandwidthGBps: 200, isMaximum: false),
.init(chip: "M1", cpuCoreCount: nil, bandwidthGBps: 68.25, isMaximum: false)
]
static func specification(for processor: String, cpuCoreCount: Int = ProcessInfo.processInfo.activeProcessorCount) -> MacMemorySpecification? {
let normalized = processor.replacingOccurrences(of: "Apple ", with: "")
return specifications.first {
normalized == $0.chip && ($0.cpuCoreCount == nil || $0.cpuCoreCount == cpuCoreCount)
}
}
static func description(for processor: String, cpuCoreCount: Int = ProcessInfo.processInfo.activeProcessorCount) -> String {
specification(for: processor, cpuCoreCount: cpuCoreCount)?.displayValue ?? "Clock not published by macOS"
}
}
enum UpdateSpeed: String, CaseIterable, Identifiable {
case high = "High"
case normal = "Normal"
case low = "Low"
case paused = "Paused"
var id: String { rawValue }
var interval: Duration {
switch self {
case .high: .seconds(1)
case .normal: .seconds(2)
case .low: .seconds(5)
case .paused: .seconds(2)
}
}
}
struct ProcessRecord: Identifiable, Hashable, Sendable {
let pid: Int32
let parentPID: Int32
let name: String
let executablePath: String
let user: String
let cpu: Double
let memoryPercent: Double
let residentBytes: UInt64
let state: String
let elapsed: String
let cpuTime: TimeInterval
let threadCount: Int
let openFileCount: Int
let architecture: String
let priority: Int
let nice: Int
var id: Int32 { pid }
var displayName: String {
let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.isEmpty ? "Process \(pid)" : cleaned
}
}
struct AppUsageRecord: Identifiable, Codable, Hashable, Sendable {
let id: String
var name: String
var executablePath: String
var user: String
var cpuSeconds: TimeInterval
var networkReceivedBytes: UInt64
var networkSentBytes: UInt64
var lastSeen: Date
var networkTotalBytes: UInt64 { networkReceivedBytes + networkSentBytes }
}
struct ProcessActivityRate: Hashable, Sendable {
var diskRead = 0.0
var diskWrite = 0.0
var networkReceive = 0.0
var networkSend = 0.0
var diskTotal: Double { diskRead + diskWrite }
var networkTotal: Double { networkReceive + networkSend }
}
enum ServiceDomain: String, CaseIterable, Identifiable, Sendable {
case user = "User"
case system = "System"
var id: String { rawValue }
var launchctlPrefix: String { self == .system ? "system" : "gui/\(getuid())" }
}
struct ServiceRecord: Identifiable, Hashable, Sendable {
let label: String
let pid: Int32?
let lastExitStatus: Int32
let domain: ServiceDomain
var id: String { "\(domain.rawValue):\(label)" }
var isRunning: Bool { pid != nil }
var isControllable: Bool { domain == .user }
var configurationPath: String? {
let roots = domain == .system
? ["/Library/LaunchDaemons", "/System/Library/LaunchDaemons"]
: [NSHomeDirectory() + "/Library/LaunchAgents", "/Library/LaunchAgents", "/System/Library/LaunchAgents"]
return roots.map { "\($0)/\(label).plist" }.first { FileManager.default.fileExists(atPath: $0) }
}
var displayName: String {
let parts = label.split(separator: ".").map(String.init)
let meaningful = parts.filter { part in
let isNumber = part.allSatisfy(\.isNumber)
let isUUID = UUID(uuidString: part) != nil
return !isNumber && !isUUID
}
guard let last = meaningful.last else { return label }
let generic = ["agent", "helper", "service", "xpcservice", "app"]
if generic.contains(last.lowercased()), meaningful.count > 1 {
return "\(meaningful[meaningful.count - 2]) \(last)"
}
return last
}
var publisher: String {
let parts = label.split(separator: ".").map(String.init)
guard let first = parts.first else { return "Unknown" }
if label.hasPrefix("com.apple") { return "Apple" }
if first == "application", parts.count > 2 {
let vendorIndex = ["com", "org", "net", "io"].contains(parts[1].lowercased()) ? 2 : 1
let vendor = parts[vendorIndex]
return vendor.prefix(1).uppercased() + vendor.dropFirst()
}
if ["com", "org", "net", "io", "us"].contains(first.lowercased()), parts.count > 1 {
return parts[1].prefix(1).uppercased() + parts[1].dropFirst()
}
return first.prefix(1).uppercased() + first.dropFirst()
}
}
struct MetricSample: Identifiable {
let id = UUID()
let date: Date
let value: Double
}
struct VolumeSnapshot: Identifiable, Hashable, Sendable {
let id: String
let name: String
let mountPath: String
let fileSystem: String
let capacity: UInt64
let available: UInt64
let isEjectable: Bool
var used: UInt64 { capacity > available ? capacity - available : 0 }
var usedPercent: Double { capacity > 0 ? Double(used) / Double(capacity) * 100 : 0 }
}
struct SystemSnapshot {
var cpuPercent = 0.0
var memoryUsed: UInt64 = 0
var memoryTotal: UInt64 = ProcessInfo.processInfo.physicalMemory
var memoryActive: UInt64 = 0
var memoryWired: UInt64 = 0
var memoryCompressed: UInt64 = 0
var memoryCached: UInt64 = 0
var swapUsed: UInt64 = 0
var swapTotal: UInt64 = 0
var memoryType = "Unified"
var memorySpeed = "Not reported by macOS"
var memoryManufacturer = "Apple unified memory"
var diskFree: UInt64 = 0
var diskTotal: UInt64 = 0
var diskReadRate = 0.0
var diskWriteRate = 0.0
var diskOperationsPerSecond = 0.0
var diskLatencyMilliseconds = 0.0
var diskActivePercent = 0.0
var gpuPercent = 0.0
var gpuRendererPercent = 0.0
var gpuTilerPercent = 0.0
var gpuMemoryBytes: UInt64 = 0
var gpuAllocatedBytes: UInt64 = 0
var gpuCoreCount = 0
var processCount = 0
var threadCount = 0
var uptime: TimeInterval = ProcessInfo.processInfo.systemUptime
var corePercents: [Double] = []
var networkReceiveRate = 0.0
var networkSendRate = 0.0
var networkInterface = "Network"
var removableVolumes: [VolumeSnapshot] = []
var memoryPercent: Double {
guard memoryTotal > 0 else { return 0 }
return Double(memoryUsed) / Double(memoryTotal) * 100
}
var diskPercent: Double {
guard diskTotal > 0 else { return 0 }
return Double(diskTotal - diskFree) / Double(diskTotal) * 100
}
var diskThroughput: Double { diskReadRate + diskWriteRate }
}
@MainActor
enum Formatters {
static let bytes: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.countStyle = .memory
formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]
return formatter
}()
static func percent(_ value: Double) -> String {
value < 10 ? String(format: "%.1f%%", value) : String(format: "%.0f%%", value)
}
static func uptime(_ interval: TimeInterval) -> String {
let total = Int(interval)
let days = total / 86_400
let hours = (total % 86_400) / 3_600
let minutes = (total % 3_600) / 60
return days > 0 ? "\(days)d \(hours)h \(minutes)m" : "\(hours)h \(minutes)m"
}
static func rate(_ bytesPerSecond: Double) -> String {
"\(bytes.string(fromByteCount: Int64(max(0, bytesPerSecond))))/s"
}
static func duration(_ interval: TimeInterval) -> String {
let total = max(0, Int(interval.rounded()))
let hours = total / 3_600
let minutes = (total % 3_600) / 60
let seconds = total % 60
return hours > 0
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
: String(format: "%02d:%02d", minutes, seconds)
}
}
+894
View File
@@ -0,0 +1,894 @@
import Charts
import SwiftUI
struct PerformanceView: View {
@Environment(SystemMonitor.self) private var monitor
@AppStorage("performanceSelectedMetric") private var selectedMetric: PerformanceMetric = .cpu
@AppStorage("performanceCPUDisplayMode") private var cpuDisplayMode: CPUDisplayMode = .summary
@State private var selectedVolumeID: String?
private enum PerformanceMetric: String, CaseIterable, Identifiable {
case cpu = "CPU"
case memory = "Memory"
case disk = "Disk"
case gpu = "GPU"
case network = "Network"
var id: String { rawValue }
var icon: String {
switch self {
case .cpu: "cpu"
case .memory: "memorychip"
case .disk: "internaldrive"
case .gpu: "rectangle.3.group"
case .network: "network"
}
}
var color: Color {
switch self {
case .cpu: .blue
case .memory: .purple
case .disk: .green
case .gpu: .pink
case .network: .teal
}
}
}
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Performance") {
HStack(spacing: 12) {
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: selectedResourceName)
}
UpdateStatus()
}
}
HStack(spacing: 0) {
ScrollView {
VStack(spacing: 10) {
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors")
metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)))")
metricCard(
.disk,
value: monitor.snapshot.diskActivePercent,
valueText: Formatters.rate(monitor.snapshot.diskThroughput),
detail: "\(Formatters.percent(monitor.snapshot.diskActivePercent)) active • System volume"
)
ForEach(monitor.snapshot.removableVolumes) { volume in
volumeCard(volume)
}
metricCard(
.gpu,
value: monitor.snapshot.gpuPercent,
detail: "\(monitor.snapshot.gpuCoreCount) cores • \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.gpuMemoryBytes))) in use"
)
metricCard(
.network,
value: 0,
valueText: Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate),
detail: "\(Formatters.rate(monitor.snapshot.networkReceiveRate))\(Formatters.rate(monitor.snapshot.networkSendRate))"
)
}
.padding(14)
}
.frame(minWidth: 190, idealWidth: 245, maxWidth: 245)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
Divider()
VStack(spacing: 0) {
detail
Divider()
topConsumers
}
}
}
}
private var selectedResourceName: String {
if let selectedVolumeID,
let volume = monitor.snapshot.removableVolumes.first(where: { $0.id == selectedVolumeID }) {
return volume.name
}
return selectedMetric.rawValue
}
private var activeConsumerMetric: PerformanceMetric {
selectedVolumeID == nil ? selectedMetric : .disk
}
private var rankedConsumers: [ProcessRecord] {
guard activeConsumerMetric != .gpu else { return [] }
return monitor.processes
.filter { consumerValue($0) > 0 }
.sorted { consumerValue($0) > consumerValue($1) }
.prefix(4)
.map { $0 }
}
private var topConsumers: some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text("Top processes — \(selectedResourceName)").font(.callout.weight(.semibold))
Spacer()
Text("User").frame(width: 110, alignment: .leading)
Text(activeConsumerMetric.rawValue).frame(width: 92, alignment: .trailing)
}
.font(.caption)
.foregroundStyle(.secondary)
if activeConsumerMetric == .gpu {
Label("Per-process GPU attribution is not exposed by the current macOS telemetry.", systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 12)
} else if rankedConsumers.isEmpty {
Text("No active consumers in this sample.").font(.caption).foregroundStyle(.secondary).padding(.vertical, 12)
} else {
ForEach(rankedConsumers) { process in
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath, size: 20)
Text(process.displayName).lineLimit(1)
Spacer()
Text(process.user).lineLimit(1).frame(width: 110, alignment: .leading)
Text(consumerValueText(process)).monospacedDigit().frame(width: 92, alignment: .trailing)
}
.font(.caption)
.padding(.vertical, 2)
}
}
}
.padding(.horizontal, 18)
.padding(.vertical, 10)
.frame(height: 146, alignment: .top)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.35))
}
private func consumerValue(_ process: ProcessRecord) -> Double {
switch activeConsumerMetric {
case .cpu: process.cpu
case .memory: Double(process.residentBytes)
case .disk: monitor.processActivity[process.pid]?.diskTotal ?? 0
case .network: monitor.processActivity[process.pid]?.networkTotal ?? 0
case .gpu: 0
}
}
private func consumerValueText(_ process: ProcessRecord) -> String {
switch activeConsumerMetric {
case .cpu: Formatters.percent(process.cpu)
case .memory: Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))
case .disk, .network: Formatters.rate(consumerValue(process))
case .gpu: ""
}
}
private func metricCard(_ metric: PerformanceMetric, value: Double, valueText: String? = nil, detail: String) -> some View {
Button {
selectedVolumeID = nil
selectedMetric = metric
} label: {
HStack(spacing: 12) {
Image(systemName: metric.icon)
.font(.title3)
.foregroundStyle(metric.color)
.frame(width: 30)
VStack(alignment: .leading, spacing: 3) {
Text(metric.rawValue).font(.headline)
Text(valueText ?? Formatters.percent(value)).font(.title3.monospacedDigit())
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == nil && selectedMetric == metric ? metric.color.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
.overlay {
RoundedRectangle(cornerRadius: 9)
.stroke(
selectedVolumeID == nil && selectedMetric == metric ? metric.color : Color.secondary.opacity(0.15),
lineWidth: selectedVolumeID == nil && selectedMetric == metric ? 1.5 : 1
)
}
.clipShape(RoundedRectangle(cornerRadius: 9))
}
.buttonStyle(.plain)
}
private func volumeCard(_ volume: VolumeSnapshot) -> some View {
Button { selectedVolumeID = volume.id } label: {
HStack(spacing: 12) {
Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive")
.font(.title3)
.foregroundStyle(.orange)
.frame(width: 30)
VStack(alignment: .leading, spacing: 3) {
Text(volume.name).font(.headline).lineLimit(1)
Text(Formatters.percent(volume.usedPercent)).font(.title3.monospacedDigit())
Text("\(Formatters.bytes.string(fromByteCount: Int64(volume.capacity)))\(volume.fileSystem)")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == volume.id ? Color.orange.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
.overlay {
RoundedRectangle(cornerRadius: 9)
.stroke(selectedVolumeID == volume.id ? Color.orange : Color.secondary.opacity(0.15), lineWidth: selectedVolumeID == volume.id ? 1.5 : 1)
}
.clipShape(RoundedRectangle(cornerRadius: 9))
}
.buttonStyle(.plain)
.accessibilityLabel("\(volume.name), removable disk")
.accessibilityValue("\(Formatters.percent(volume.usedPercent)) used")
}
@ViewBuilder
private var detail: some View {
if let selectedVolumeID,
let volume = monitor.snapshot.removableVolumes.first(where: { $0.id == selectedVolumeID }) {
RemovableDiskDetail(volume: volume)
} else {
switch selectedMetric {
case .cpu:
CPUPerformanceDetail(
subtitle: processorName,
value: monitor.snapshot.cpuPercent,
history: monitor.cpuHistory,
coreHistories: monitor.coreHistories,
coreValues: monitor.snapshot.corePercents,
displayMode: $cpuDisplayMode,
stats: [
("Processes", "\(monitor.snapshot.processCount)"),
("Threads", "\(monitor.snapshot.threadCount)"),
("Uptime", Formatters.uptime(monitor.snapshot.uptime)),
("Logical processors", "\(ProcessInfo.processInfo.activeProcessorCount)")
]
)
case .memory:
PerformanceDetail(
title: "Memory",
subtitle: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)),
color: .purple,
value: monitor.snapshot.memoryPercent,
history: monitor.memoryHistory,
stats: [
("In use", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))),
("Available", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal - monitor.snapshot.memoryUsed))),
("Active", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryActive))),
("Cached", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCached))),
("Wired", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryWired))),
("Compressed", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCompressed))),
("Swap", "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapUsed))) / \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapTotal)))"),
("Type", monitor.snapshot.memoryType),
("Frequency / speed", monitor.snapshot.memorySpeed),
("Manufacturer", monitor.snapshot.memoryManufacturer)
]
)
case .disk:
DiskPerformanceDetail(
snapshot: monitor.snapshot,
readHistory: monitor.diskReadHistory,
writeHistory: monitor.diskWriteHistory,
latencyHistory: monitor.diskLatencyHistory,
activeHistory: monitor.diskActiveHistory
)
case .gpu:
GPUPerformanceDetail(
name: "\(processorName) GPU",
snapshot: monitor.snapshot,
history: monitor.gpuHistory,
rendererHistory: monitor.gpuRendererHistory,
tilerHistory: monitor.gpuTilerHistory
)
case .network:
NetworkPerformanceDetail(
interface: monitor.snapshot.networkInterface,
receiveRate: monitor.snapshot.networkReceiveRate,
sendRate: monitor.snapshot.networkSendRate,
receiveHistory: monitor.networkReceiveHistory,
sendHistory: monitor.networkSendHistory
)
}
}
}
private var processorName: String {
var size = 0
guard sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) == 0, size > 1 else {
return "Apple Silicon"
}
var buffer = [CChar](repeating: 0, count: size)
guard sysctlbyname("machdep.cpu.brand_string", &buffer, &size, nil, 0) == 0 else {
return "Apple Silicon"
}
return String(decoding: buffer.prefix { $0 != 0 }.map(UInt8.init(bitPattern:)), as: UTF8.self)
}
}
private struct RemovableDiskDetail: View {
let volume: VolumeSnapshot
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text(volume.name).font(.title.weight(.semibold))
Text("\(volume.fileSystem)\(volume.mountPath)")
.foregroundStyle(.secondary)
}
HStack {
Label(volume.isEjectable ? "Ejectable" : "Removable", systemImage: "externaldrive")
.foregroundStyle(.orange)
Spacer()
Text(Formatters.percent(volume.usedPercent))
.font(.largeTitle.weight(.light).monospacedDigit())
}
ProgressView(value: volume.usedPercent, total: 100)
.tint(.orange)
StatsGrid(stats: [
("Used", Formatters.bytes.string(fromByteCount: Int64(volume.used))),
("Available", Formatters.bytes.string(fromByteCount: Int64(volume.available))),
("Capacity", Formatters.bytes.string(fromByteCount: Int64(volume.capacity))),
("Ejectable", volume.isEjectable ? "Yes" : "No")
])
}
.padding(22)
}
}
}
private struct GPUPerformanceDetail: View {
let name: String
let snapshot: SystemSnapshot
let history: [MetricSample]
let rendererHistory: [MetricSample]
let tilerHistory: [MetricSample]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("GPU").font(.title.weight(.semibold))
Text(name).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(snapshot.gpuPercent))
.font(.largeTitle.weight(.light).monospacedDigit())
}
VStack(alignment: .leading, spacing: 8) {
Text("Overall utilization").font(.caption).foregroundStyle(.secondary)
Chart(history) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Utilization", sample.value))
.foregroundStyle(Color.pink.opacity(0.12))
LineMark(x: .value("Time", sample.date), y: .value("Utilization", sample.value))
.foregroundStyle(.pink).lineStyle(.init(lineWidth: 2))
}
.chartYScale(domain: 0...100)
.chartXAxis(.hidden)
.chartYAxis {
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
AxisGridLine().foregroundStyle(Color.pink.opacity(0.16))
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
}
}
.frame(height: 240)
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
}
.padding(16)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.pink.opacity(0.16)) }
HStack(spacing: 14) {
engineChart(title: "Renderer", value: snapshot.gpuRendererPercent, history: rendererHistory, color: .pink)
engineChart(title: "Tiler", value: snapshot.gpuTilerPercent, history: tilerHistory, color: .purple)
}
StatsGrid(stats: [
("Utilization", Formatters.percent(snapshot.gpuPercent)),
("Renderer", Formatters.percent(snapshot.gpuRendererPercent)),
("Tiler", Formatters.percent(snapshot.gpuTilerPercent)),
("GPU cores", "\(snapshot.gpuCoreCount)"),
("Memory in use", Formatters.bytes.string(fromByteCount: Int64(snapshot.gpuMemoryBytes))),
("Allocated", Formatters.bytes.string(fromByteCount: Int64(snapshot.gpuAllocatedBytes)))
])
}
.padding(22)
}
}
private func engineChart(title: String, value: Double, history: [MetricSample], color: Color) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(title).font(.caption).foregroundStyle(.secondary)
Spacer()
Text(Formatters.percent(value)).font(.callout.monospacedDigit())
}
Chart(history) { sample in
AreaMark(x: .value("Time", sample.date), y: .value(title, sample.value))
.foregroundStyle(color.opacity(0.1))
LineMark(x: .value("Time", sample.date), y: .value(title, sample.value))
.foregroundStyle(color)
}
.chartYScale(domain: 0...100)
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.frame(height: 92)
}
.padding(14)
.frame(maxWidth: .infinity)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.16)) }
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title) GPU utilization")
.accessibilityValue(Formatters.percent(value))
}
}
private struct DiskPerformanceDetail: View {
let snapshot: SystemSnapshot
let readHistory: [MetricSample]
let writeHistory: [MetricSample]
let latencyHistory: [MetricSample]
let activeHistory: [MetricSample]
private var maximumRate: Double {
let maximum = (readHistory.map(\.value) + writeHistory.map(\.value) + [snapshot.diskReadRate, snapshot.diskWriteRate]).max() ?? 0
return max(1024, maximum * 1.15)
}
private var maximumLatency: Double {
max(0.1, ((latencyHistory.map(\.value) + [snapshot.diskLatencyMilliseconds]).max() ?? 0) * 1.15)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Disk 0").font(.title.weight(.semibold))
Text("System volume • APFS • Solid-state").foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(Formatters.rate(snapshot.diskThroughput))
.font(.title.weight(.light).monospacedDigit())
Text("\(Formatters.percent(snapshot.diskActivePercent)) active")
.font(.caption).foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Disk transfer rate").font(.caption).foregroundStyle(.secondary)
Spacer()
Label("Read", systemImage: "arrow.down").foregroundStyle(.green)
Label("Write", systemImage: "arrow.up").foregroundStyle(.orange)
}
.font(.caption)
Chart {
ForEach(readHistory) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Read", sample.value))
.foregroundStyle(Color.green.opacity(0.1))
LineMark(x: .value("Time", sample.date), y: .value("Read", sample.value))
.foregroundStyle(by: .value("Direction", "Read"))
}
ForEach(writeHistory) { sample in
LineMark(x: .value("Time", sample.date), y: .value("Write", sample.value))
.foregroundStyle(by: .value("Direction", "Write"))
}
}
.chartForegroundStyleScale(["Read": Color.green, "Write": Color.orange])
.chartYScale(domain: 0...maximumRate)
.chartXAxis(.hidden)
.chartYAxis {
AxisMarks(position: .trailing) { value in
AxisGridLine().foregroundStyle(Color.green.opacity(0.14))
AxisValueLabel {
if let rate = value.as(Double.self) { Text(Formatters.rate(rate)) }
}
}
}
.chartLegend(.hidden)
.frame(height: 220)
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
}
.padding(16)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.green.opacity(0.16)) }
HStack(spacing: 14) {
miniChart(
title: "Average response time",
value: String(format: "%.2f ms", snapshot.diskLatencyMilliseconds),
history: latencyHistory,
maximum: maximumLatency,
color: .orange
)
miniChart(
title: "Active time",
value: Formatters.percent(snapshot.diskActivePercent),
history: activeHistory,
maximum: 100,
color: .green
)
}
StatsGrid(stats: [
("Read speed", Formatters.rate(snapshot.diskReadRate)),
("Write speed", Formatters.rate(snapshot.diskWriteRate)),
("Response time", String(format: "%.2f ms", snapshot.diskLatencyMilliseconds)),
("IOPS", String(format: "%.0f", snapshot.diskOperationsPerSecond)),
("Used space", Formatters.bytes.string(fromByteCount: Int64(snapshot.diskTotal - snapshot.diskFree))),
("Capacity", Formatters.bytes.string(fromByteCount: Int64(snapshot.diskTotal)))
])
}
.padding(22)
}
}
private func miniChart(
title: String,
value: String,
history: [MetricSample],
maximum: Double,
color: Color
) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(title).font(.caption).foregroundStyle(.secondary)
Spacer()
Text(value).font(.callout.monospacedDigit())
}
Chart(history) { sample in
AreaMark(x: .value("Time", sample.date), y: .value(title, sample.value))
.foregroundStyle(color.opacity(0.1))
LineMark(x: .value("Time", sample.date), y: .value(title, sample.value))
.foregroundStyle(color)
}
.chartYScale(domain: 0...maximum)
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.frame(height: 92)
.accessibilityLabel(title)
.accessibilityValue(value)
}
.padding(14)
.frame(maxWidth: .infinity)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.16)) }
}
}
private struct NetworkPerformanceDetail: View {
let interface: String
let receiveRate: Double
let sendRate: Double
let receiveHistory: [MetricSample]
let sendHistory: [MetricSample]
private var maximumRate: Double {
let maximum = (receiveHistory.map(\.value) + sendHistory.map(\.value) + [receiveRate, sendRate]).max() ?? 0
return max(1024, maximum * 1.15)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Network").font(.title.weight(.semibold))
Text(interface).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.rate(receiveRate + sendRate))
.font(.title.weight(.light).monospacedDigit())
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Throughput").font(.caption).foregroundStyle(.secondary)
Spacer()
Label("Receive", systemImage: "arrow.down").foregroundStyle(.teal)
Label("Send", systemImage: "arrow.up").foregroundStyle(.purple)
}
.font(.caption)
Chart {
ForEach(receiveHistory) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Receive", sample.value))
.foregroundStyle(Color.teal.opacity(0.1))
LineMark(x: .value("Time", sample.date), y: .value("Receive", sample.value))
.foregroundStyle(by: .value("Direction", "Receive"))
}
ForEach(sendHistory) { sample in
LineMark(x: .value("Time", sample.date), y: .value("Send", sample.value))
.foregroundStyle(by: .value("Direction", "Send"))
}
}
.chartForegroundStyleScale(["Receive": Color.teal, "Send": Color.purple])
.chartYScale(domain: 0...maximumRate)
.chartXAxis(.hidden)
.chartYAxis {
AxisMarks(position: .trailing) { value in
AxisGridLine().foregroundStyle(Color.teal.opacity(0.14))
AxisValueLabel {
if let rate = value.as(Double.self) { Text(Formatters.rate(rate)) }
}
}
}
.chartLegend(.hidden)
.frame(height: 280)
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
}
.padding(16)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.teal.opacity(0.16)) }
StatsGrid(stats: [
("Receive", Formatters.rate(receiveRate)),
("Send", Formatters.rate(sendRate)),
("Combined", Formatters.rate(receiveRate + sendRate)),
("Interface", interface)
])
}
.padding(22)
}
}
}
private enum CPUDisplayMode: String {
case summary = "Summary view"
case logicalProcessors = "Logical processors"
}
private struct CPUPerformanceDetail: View {
let subtitle: String
let value: Double
let history: [MetricSample]
let coreHistories: [[MetricSample]]
let coreValues: [Double]
@Binding var displayMode: CPUDisplayMode
let stats: [(String, String)]
private var chartData: [MetricSample] {
if !history.isEmpty { return history }
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("CPU").font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(value))
.font(.largeTitle.weight(.light).monospacedDigit())
}
cpuGraph
.contextMenu {
Button {
displayMode = .summary
} label: {
Label("Summary view", systemImage: displayMode == .summary ? "checkmark" : "chart.xyaxis.line")
}
Button {
displayMode = .logicalProcessors
} label: {
Label("Logical processors", systemImage: displayMode == .logicalProcessors ? "checkmark" : "square.grid.3x3")
}
}
.help("Right-click to switch between summary and logical processor graphs")
StatsGrid(stats: stats)
}
.padding(22)
}
}
private var cpuGraph: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(displayMode == .summary ? "% Utilization" : "Utilization by logical processor")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Menu {
Button {
displayMode = .summary
} label: {
Label("Summary view", systemImage: displayMode == .summary ? "checkmark" : "chart.xyaxis.line")
}
Button {
displayMode = .logicalProcessors
} label: {
Label("Logical processors", systemImage: displayMode == .logicalProcessors ? "checkmark" : "square.grid.3x3")
}
} label: {
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
.font(.caption)
}
.menuStyle(.borderlessButton)
.fixedSize()
.accessibilityLabel("CPU graph layout")
}
if displayMode == .summary {
Chart(chartData) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
.foregroundStyle(Color.blue.opacity(0.13))
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
.foregroundStyle(.blue)
.lineStyle(.init(lineWidth: 2))
}
.chartYScale(domain: 0...100)
.chartXAxis(.hidden)
.chartYAxis {
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
AxisGridLine().foregroundStyle(Color.blue.opacity(0.18))
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
}
}
.frame(height: 280)
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
ForEach(coreValues.indices, id: \.self) { index in
CoreChart(
index: index,
value: coreValues[index],
history: index < coreHistories.count ? coreHistories[index] : []
)
}
}
.frame(minHeight: 280)
}
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
}
.padding(16)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(Color.blue.opacity(0.16), lineWidth: 1)
}
}
}
private struct CoreChart: View {
let index: Int
let value: Double
let history: [MetricSample]
private var data: [MetricSample] {
if !history.isEmpty { return history }
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
}
var body: some View {
ZStack(alignment: .topLeading) {
Chart(data) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
.foregroundStyle(Color.blue.opacity(0.1))
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
.foregroundStyle(.blue)
.lineStyle(.init(lineWidth: 1.25))
}
.chartYScale(domain: 0...100)
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.background {
GridPattern().stroke(Color.blue.opacity(0.12), lineWidth: 0.5)
}
VStack(alignment: .leading, spacing: 1) {
Text("CPU \(index)").font(.caption2.weight(.medium))
Text(Formatters.percent(value)).font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
}
.padding(6)
}
.frame(height: 62)
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay { RoundedRectangle(cornerRadius: 5).stroke(Color.blue.opacity(0.28), lineWidth: 1) }
.accessibilityElement(children: .ignore)
.accessibilityLabel("CPU \(index), \(Formatters.percent(value)) utilization")
}
}
private struct GridPattern: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
for fraction in [0.25, 0.5, 0.75] {
let x = rect.width * fraction
let y = rect.height * fraction
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: rect.height))
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: rect.width, y: y))
}
return path
}
}
private struct StatsGrid: View {
let stats: [(String, String)]
var body: some View {
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 18) {
ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in
VStack(alignment: .leading, spacing: 4) {
Text(stat.0).font(.caption).foregroundStyle(.secondary)
Text(stat.1).font(.title3.monospacedDigit()).lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private struct PerformanceDetail: View {
let title: String
let subtitle: String
let color: Color
let value: Double
let history: [MetricSample]
let stats: [(String, String)]
var chartData: [MetricSample] {
if !history.isEmpty { return history }
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(value))
.font(.largeTitle.weight(.light).monospacedDigit())
}
VStack(alignment: .leading, spacing: 8) {
Text("% Utilization").font(.caption).foregroundStyle(.secondary)
Chart(chartData) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
.foregroundStyle(color.opacity(0.13))
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
.foregroundStyle(color)
.lineStyle(.init(lineWidth: 2))
}
.chartYScale(domain: 0...100)
.chartXAxis(.hidden)
.chartYAxis {
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
AxisGridLine().foregroundStyle(color.opacity(0.18))
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
}
}
.frame(minHeight: 280)
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
}
.padding(16)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
.clipShape(RoundedRectangle(cornerRadius: 12))
StatsGrid(stats: stats)
}
.padding(22)
}
}
}
+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.")
}
}
}
@@ -0,0 +1,279 @@
import AppKit
import SwiftUI
private enum DependencySection: String, CaseIterable, Identifiable {
case relationships = "Relationships"
case connections = "Connections"
case libraries = "Libraries"
case files = "Open files"
var id: String { rawValue }
}
private struct ProcessDependencySnapshot: Sendable {
var connections: [String] = []
var libraries: [String] = []
var files: [String] = []
var handleCount = 0
var error: String?
}
@MainActor
enum ProcessDependencyInspectorController {
private static var controllers: [Int32: NSWindowController] = [:]
static func open(process: ProcessRecord, allProcesses: [ProcessRecord]) {
if let controller = controllers[process.pid], let window = controller.window {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let view = ProcessDependencyInspectorView(process: process, allProcesses: allProcesses)
let window = NSWindow(contentViewController: NSHostingController(rootView: view))
window.title = "Analyze dependencies — \(process.displayName)"
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.setContentSize(NSSize(width: 780, height: 600))
window.minSize = NSSize(width: 660, height: 480)
window.center()
window.isReleasedWhenClosed = false
let controller = NSWindowController(window: window)
controllers[process.pid] = controller
controller.showWindow(nil)
NSApp.activate(ignoringOtherApps: true)
}
}
private struct ProcessDependencyInspectorView: View {
let process: ProcessRecord
let allProcesses: [ProcessRecord]
@State private var section: DependencySection = .relationships
@State private var snapshot = ProcessDependencySnapshot()
@State private var isLoading = true
@State private var refreshID = UUID()
private var processByPID: [Int32: ProcessRecord] {
Dictionary(uniqueKeysWithValues: allProcesses.map { ($0.pid, $0) })
}
private var parentChain: [ProcessRecord] {
var result: [ProcessRecord] = []
var currentPID = process.parentPID
var visited: Set<Int32> = [process.pid]
while currentPID > 0, !visited.contains(currentPID), let parent = processByPID[currentPID] {
result.append(parent)
visited.insert(currentPID)
currentPID = parent.parentPID
}
return result.reversed()
}
private var children: [ProcessRecord] {
allProcesses.filter { $0.parentPID == process.pid }.sorted { $0.displayName < $1.displayName }
}
var body: some View {
VStack(spacing: 0) {
header
Divider()
Picker("Dependency category", selection: $section) {
ForEach(DependencySection.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 20)
.padding(.vertical, 14)
Group {
switch section {
case .relationships: relationships
case .connections: itemList(snapshot.connections, icon: "network", empty: "No open network or local socket connections")
case .libraries: itemList(snapshot.libraries, icon: "shippingbox", empty: "No loaded libraries could be read")
case .files: itemList(snapshot.files, icon: "doc", empty: "No open regular files could be read")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.task(id: refreshID) { await refresh() }
}
private var header: some View {
VStack(spacing: 14) {
HStack(spacing: 14) {
ProcessIcon(name: process.displayName, path: process.executablePath)
.scaleEffect(1.25)
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 3) {
Text("Analyze dependencies").font(.title2.weight(.semibold))
Text("\(process.displayName) • PID \(process.pid)").foregroundStyle(.secondary)
}
Spacer()
if isLoading { ProgressView().controlSize(.small) }
Button("Refresh", systemImage: "arrow.clockwise") { refreshID = UUID() }
}
HStack(spacing: 10) {
stat("Parents", "\(parentChain.count)", "arrow.up.left")
stat("Children", "\(children.count)", "arrow.down.right")
stat("Connections", "\(snapshot.connections.count)", "network")
stat("Open handles", "\(snapshot.handleCount)", "link")
}
}
.padding(20)
}
private func stat(_ title: String, _ value: String, _ icon: String) -> some View {
HStack(spacing: 9) {
Image(systemName: icon).foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
Text(value).font(.headline.monospacedDigit())
Text(title).font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(10)
.frame(maxWidth: .infinity)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.65))
.clipShape(RoundedRectangle(cornerRadius: 9))
.accessibilityElement(children: .combine)
}
private var relationships: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
relationshipSection("Parent chain", items: parentChain, icon: "arrow.turn.down.right")
relationshipSection("Selected process", items: [process], icon: "scope", highlighted: true)
relationshipSection("Direct children", items: children, icon: "arrow.turn.down.right")
if let error = snapshot.error {
Label(error, systemImage: "exclamationmark.triangle")
.font(.callout).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
@ViewBuilder
private func relationshipSection(_ title: String, items: [ProcessRecord], icon: String, highlighted: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline)
if items.isEmpty {
Text("None").foregroundStyle(.secondary).padding(.vertical, 8)
} else {
ForEach(items) { item in
HStack(spacing: 10) {
Image(systemName: icon).foregroundStyle(highlighted ? Color.accentColor : Color.secondary)
ProcessIcon(name: item.displayName, path: item.executablePath)
VStack(alignment: .leading, spacing: 2) {
Text(item.displayName).fontWeight(highlighted ? .semibold : .regular)
Text(item.executablePath).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Text("PID \(item.pid)").monospacedDigit().foregroundStyle(.secondary)
}
.padding(10)
.background(highlighted ? Color.accentColor.opacity(0.12) : Color(nsColor: .controlBackgroundColor).opacity(0.45))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
private func itemList(_ items: [String], icon: String, empty: String) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if isLoading && items.isEmpty {
ProgressView("Reading process dependencies…").padding(30)
} else if items.isEmpty {
EmptyState(icon: icon, title: empty, message: snapshot.error ?? "The process may not expose this information to the current user.")
.padding(.top, 50)
} else {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
HStack(spacing: 10) {
Image(systemName: icon).foregroundStyle(.secondary).frame(width: 20)
Text(item).textSelection(.enabled).lineLimit(2)
Spacer()
}
.padding(.horizontal, 20).padding(.vertical, 9)
Divider().padding(.leading, 50)
}
}
}
}
}
private func refresh() async {
isLoading = true
snapshot = await Task.detached(priority: .userInitiated) {
ProcessDependencyScanner.capture(pid: process.pid)
}.value
isLoading = false
}
}
private enum ProcessDependencyScanner {
static func capture(pid: Int32) -> ProcessDependencySnapshot {
let task = Process()
let output = Pipe()
let errors = Pipe()
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
task.arguments = ["-n", "-P", "-a", "-p", String(pid)]
task.standardOutput = output
task.standardError = errors
do {
try task.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
let errorData = errors.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
let text = String(decoding: data, as: UTF8.self)
var result = parse(text)
if task.terminationStatus != 0 || text.isEmpty {
let message = String(decoding: errorData, as: UTF8.self)
.split(separator: "\n")
.first(where: { !$0.contains("WARNING") })
.map(String.init)
result.error = message ?? "Process information is unavailable or permission was denied."
}
return result
} catch {
return ProcessDependencySnapshot(error: error.localizedDescription)
}
}
private static func parse(_ output: String) -> ProcessDependencySnapshot {
var result = ProcessDependencySnapshot()
var seenConnections: Set<String> = []
var seenLibraries: Set<String> = []
var seenFiles: Set<String> = []
for line in output.split(separator: "\n").dropFirst() {
let fields = line.split(maxSplits: 8, whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
guard fields.count == 9 else { continue }
result.handleCount += 1
let type = fields[4]
let name = fields[8]
if ["IPv4", "IPv6", "unix"].contains(type) {
seenConnections.insert(name)
} else if type == "REG", name.hasPrefix("/") {
if let library = libraryIdentity(for: name) {
seenLibraries.insert(library)
} else {
seenFiles.insert(name)
}
}
}
result.connections = seenConnections.sorted()
result.libraries = seenLibraries.sorted()
result.files = seenFiles.sorted()
return result
}
private static func libraryIdentity(for path: String) -> String? {
let lower = path.lowercased()
if lower.hasSuffix(".dylib") { return path }
guard let range = lower.range(of: ".framework/") else {
return lower.hasSuffix(".framework") ? path : nil
}
return String(path[..<range.lowerBound]) + ".framework"
}
}
+920
View File
@@ -0,0 +1,920 @@
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
}
}
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
@main
struct PuterApp: App {
@State private var monitor = SystemMonitor()
@AppStorage("sidebarCompact") private var sidebarCompact = false
var body: some Scene {
WindowGroup("Puter") {
ContentView()
.environment(monitor)
.frame(minWidth: 820, minHeight: 560)
.task { monitor.start() }
}
.defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(replacing: .newItem) {
Button("Run New Task…") {
NotificationCenter.default.post(name: .runNewTask, object: nil)
}
.keyboardShortcut("n", modifiers: .command)
}
SidebarCommands()
CommandMenu("Options") {
Button("Refresh now") { monitor.refresh() }
.keyboardShortcut("r", modifiers: .command)
Divider()
Button(monitor.isPaused ? "Resume updates" : "Pause updates") {
monitor.isPaused.toggle()
}
.keyboardShortcut("p", modifiers: .command)
Divider()
Toggle("Compact Sidebar", isOn: $sidebarCompact)
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
@MainActor
enum SystemReportExporter {
static func export(monitor: SystemMonitor, selectedResource: String? = nil) {
let panel = NSSavePanel()
panel.title = "Export system report"
panel.message = "Save the current performance, process, power, battery, and connected-hardware snapshot as JSON."
panel.prompt = "Export"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
panel.nameFieldStringValue = "Task-Manager-Report-\(filenameDate()).json"
let data: Data
do {
data = try JSONSerialization.data(
withJSONObject: report(monitor: monitor, selectedResource: selectedResource),
options: [.prettyPrinted, .sortedKeys]
)
} catch {
monitor.errorMessage = "Could not create the system report: \(error.localizedDescription)"
return
}
panel.begin { response in
guard response == .OK, let destination = panel.url else { return }
do {
try data.write(to: destination, options: .atomic)
} catch {
monitor.errorMessage = "Could not export the system report: \(error.localizedDescription)"
}
}
}
private static func report(monitor: SystemMonitor, selectedResource: String?) -> [String: Any] {
let snapshot = monitor.snapshot
let battery = monitor.hardware.battery
return [
"generatedAt": ISO8601DateFormatter().string(from: Date()),
"selectedResource": selectedResource ?? "All",
"system": [
"cpuPercent": snapshot.cpuPercent,
"logicalProcessors": snapshot.corePercents.count,
"memoryBytes": snapshot.memoryTotal,
"memoryUsedBytes": snapshot.memoryUsed,
"memoryType": snapshot.memoryType,
"memorySpeed": snapshot.memorySpeed,
"memoryManufacturer": snapshot.memoryManufacturer,
"diskReadBytesPerSecond": snapshot.diskReadRate,
"diskWriteBytesPerSecond": snapshot.diskWriteRate,
"diskActivePercent": snapshot.diskActivePercent,
"gpuPercent": snapshot.gpuPercent,
"gpuCoreCount": snapshot.gpuCoreCount,
"networkInterface": snapshot.networkInterface,
"networkReceiveBytesPerSecond": snapshot.networkReceiveRate,
"networkSendBytesPerSecond": snapshot.networkSendRate,
"processCount": snapshot.processCount,
"threadCount": snapshot.threadCount,
"uptimeSeconds": snapshot.uptime
],
"power": [
"thermalState": monitor.hardware.thermalState,
"systemPowerWatts": battery.systemPowerWatts,
"externalPowerConnected": battery.externalPowerConnected,
"lowPowerMode": ProcessInfo.processInfo.isLowPowerModeEnabled
],
"battery": batteryReport(battery),
"ports": monitor.hardware.ports.map(portReport),
"topProcesses": topProcesses(monitor),
"topUsers": topUsers(monitor)
]
}
private static func batteryReport(_ battery: BatterySnapshot) -> [String: Any] {
var result: [String: Any] = [
"present": battery.isPresent,
"chargePercent": battery.chargePercent,
"charging": battery.isCharging,
"fullyCharged": battery.isFullyCharged,
"healthPercent": battery.healthPercent,
"cycleCount": battery.cycleCount,
"designCycleCount": battery.designCycleCount,
"temperatureCelsius": battery.temperatureCelsius,
"voltageVolts": battery.voltageVolts,
"fullChargeCapacityMAh": battery.fullChargeCapacityMAh,
"designCapacityMAh": battery.designCapacityMAh
]
if let adapter = battery.adapter {
result["adapter"] = [
"name": adapter.name,
"manufacturer": adapter.manufacturer,
"ratedWatts": adapter.ratedWatts,
"negotiatedVoltage": adapter.negotiatedVoltage,
"negotiatedCurrent": adapter.negotiatedCurrent,
"negotiatedWatts": adapter.negotiatedWatts,
"powerProfiles": adapter.profiles.map {
["voltage": $0.voltageVolts, "current": $0.currentAmps, "watts": $0.watts]
}
]
}
return result
}
private static func portReport(_ port: HardwarePortSnapshot) -> [String: Any] {
[
"name": port.name,
"transport": port.transport,
"maximumSpeed": port.maximumSpeed,
"connected": port.isConnected,
"status": port.status,
"devices": port.devices.map {
var device: [String: Any] = ["name": $0.name, "vendor": $0.vendor, "speed": $0.speed]
if let value = $0.currentAvailableMA { device["currentAvailableMA"] = value }
if let value = $0.currentRequiredMA { device["currentRequiredMA"] = value }
return device
}
]
}
private static func topProcesses(_ monitor: SystemMonitor) -> [[String: Any]] {
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(20).map { process in
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
return [
"name": process.displayName,
"pid": process.pid,
"user": process.user,
"cpuPercent": process.cpu,
"residentBytes": process.residentBytes,
"diskBytesPerSecond": activity.diskTotal,
"networkBytesPerSecond": activity.networkTotal
]
}
}
private static func topUsers(_ monitor: SystemMonitor) -> [[String: Any]] {
struct Totals {
var processes = 0
var cpu = 0.0
var memory: UInt64 = 0
var disk = 0.0
var network = 0.0
}
var users: [String: Totals] = [:]
for process in monitor.processes {
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
users[process.user, default: Totals()].processes += 1
users[process.user, default: Totals()].cpu += process.cpu
users[process.user, default: Totals()].memory += process.residentBytes
users[process.user, default: Totals()].disk += activity.diskTotal
users[process.user, default: Totals()].network += activity.networkTotal
}
return users.map { user, totals in
[
"user": user,
"processes": totals.processes,
"cpuPercent": totals.cpu,
"residentBytes": totals.memory,
"diskBytesPerSecond": totals.disk,
"networkBytesPerSecond": totals.network
]
}
.sorted { ($0["cpuPercent"] as? Double ?? 0) > ($1["cpuPercent"] as? Double ?? 0) }
}
private static func filenameDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
return formatter.string(from: Date())
}
}