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
@@ -1,48 +0,0 @@
import AppKit
import SwiftUI
@MainActor
private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillFinishLaunching(_ notification: Notification) {
applyApplicationIcon()
}
func applicationDidFinishLaunching(_ notification: Notification) {
applyApplicationIcon()
}
private func applyApplicationIcon() {
guard let iconURL = Bundle.main.url(forResource: "mactaskmanager", withExtension: "icns"),
let icon = NSImage(contentsOf: iconURL) else { return }
NSApplication.shared.applicationIconImage = icon
}
}
@main
struct MacTaskManagerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var monitor = SystemMonitor()
var body: some Scene {
WindowGroup("Task Manager") {
ContentView()
.environment(monitor)
.frame(minWidth: 980, minHeight: 640)
.task { monitor.start() }
}
.defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(replacing: .newItem) { }
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)
}
}
}
}
@@ -83,7 +83,7 @@ struct SettingsView: View {
var body: some View {
@Bindable var monitor = monitor
VStack(spacing: 0) {
PageHeader(title: "Settings", subtitle: "Customize Task Manager")
PageHeader(title: "Settings", subtitle: "Customize Puter")
Form {
Section("General") {
Picker("Default start page", selection: $defaultStartPage) {
@@ -125,8 +125,8 @@ struct SettingsView: View {
}
Section("About") {
LabeledContent("Application", value: "Task Manager for macOS")
LabeledContent("Version", value: "1.0")
LabeledContent("Application", value: "Puter")
LabeledContent("Version", value: appVersion)
LabeledContent("Framework", value: "Native SwiftUI")
}
}
@@ -149,6 +149,10 @@ struct SettingsView: View {
}
}
private var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Development"
}
private func chooseDiagnosticDirectory() {
let panel = NSOpenPanel()
panel.title = "Choose diagnostic report folder"
@@ -14,7 +14,7 @@ struct PageHeader<Trailing: View>: View {
var body: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 28, weight: .semibold))
Text(title).font(.title.weight(.semibold))
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
}
Spacer()
@@ -33,7 +33,7 @@ struct ResourceSummaryBar: View {
HStack(spacing: 22) {
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu")
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip")
Label("\(Formatters.percent(snapshot.diskPercent)) Disk", systemImage: "internaldrive")
Label("\(Formatters.percent(snapshot.diskActivePercent)) Disk activity", systemImage: "internaldrive")
Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
Spacer()
}
@@ -54,7 +54,11 @@ struct UpdateStatus: View {
if monitor.isPaused {
Text("Updates paused")
} else if let date = monitor.lastUpdated {
Text("Updated \(date, style: .relative)")
if Date().timeIntervalSince(date) < 1.5 {
Text("Updated now")
} else {
Text("Updated \(date, style: .relative)")
}
} else {
Text("Collecting system data…")
}
@@ -1,11 +1,14 @@
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 showingAbout = false
@State private var showingNewTask = false
@State private var detailSelection: Int32?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@@ -32,6 +35,7 @@ struct ContentView: View {
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
case .performance:
PerformanceView()
case .history:
@@ -39,6 +43,7 @@ struct ContentView: View {
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
case .startup:
StartupAppsView()
case .users:
@@ -48,33 +53,31 @@ struct ContentView: View {
}
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()
}
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Type a name, PID, or user")
.toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask, showingAbout: $showingAbout)
TaskToolbarContent(showingNewTask: $showingNewTask)
}
}
.navigationTitle("Task Manager")
.navigationTitle("Puter")
.animation(.snappy(duration: 0.22), value: sidebarCompact)
.onChange(of: columnVisibility) { _, visibility in
guard visibility == .detailOnly else { return }
columnVisibility = .all
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
withAnimation(.snappy(duration: 0.22)) { sidebarCompact.toggle() }
}
}
.onChange(of: selection) { _, _ in searchText = "" }
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
.sheet(isPresented: $showingNewTask) {
NewTaskView(isPresented: $showingNewTask)
}
.alert("Task Manager", isPresented: Binding(
.alert("Puter", isPresented: Binding(
get: { monitor.errorMessage != nil },
set: { if !$0 { monitor.errorMessage = nil } }
)) {
@@ -82,11 +85,6 @@ struct ContentView: View {
} message: {
Text(monitor.errorMessage ?? "")
}
.alert("Task Manager for macOS", isPresented: $showingAbout) {
Button("OK", role: .cancel) { }
} message: {
Text("A native SwiftUI system monitor inspired by Windows Task Manager.")
}
.onAppear {
DispatchQueue.main.async {
NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal
@@ -98,7 +96,7 @@ struct ContentView: View {
private struct TaskToolbarContent: ToolbarContent {
@Environment(SystemMonitor.self) private var monitor
@Binding var showingNewTask: Bool
@Binding var showingAbout: Bool
@AppStorage("sidebarCompact") private var sidebarCompact = false
var body: some ToolbarContent {
@Bindable var monitor = monitor
@@ -120,7 +118,7 @@ private struct TaskToolbarContent: ToolbarContent {
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
Divider()
Button("About Task Manager") { showingAbout = true }
Toggle("Compact Sidebar", isOn: $sidebarCompact)
} label: {
Image(systemName: "ellipsis")
}
@@ -143,7 +141,7 @@ private struct Sidebar: View {
} label: {
sidebarLabel(section.rawValue, icon: section.icon)
.padding(.horizontal, isCompact ? 0 : 8)
.frame(height: 40)
.frame(minHeight: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -166,7 +164,7 @@ private struct Sidebar: View {
} label: {
sidebarLabel("Settings", icon: "gearshape")
.padding(.horizontal, isCompact ? 0 : 8)
.frame(height: 40)
.frame(minHeight: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+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) }
}
}
@@ -9,6 +9,7 @@ enum TaskSection: String, CaseIterable, Identifiable {
case users = "Users"
case details = "Details"
case services = "Services"
case hardware = "Hardware"
case settings = "Settings"
var id: String { rawValue }
@@ -22,11 +23,153 @@ enum TaskSection: String, CaseIterable, Identifiable {
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"
@@ -37,7 +37,12 @@ struct PerformanceView: View {
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Performance") {
UpdateStatus()
HStack(spacing: 12) {
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: selectedResourceName)
}
UpdateStatus()
}
}
HStack(spacing: 0) {
ScrollView {
@@ -67,14 +72,94 @@ struct PerformanceView: View {
}
.padding(14)
}
.frame(width: 245)
.frame(minWidth: 190, idealWidth: 245, maxWidth: 245)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
Divider()
detail
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
@@ -226,7 +311,7 @@ private struct RemovableDiskDetail: View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text(volume.name).font(.system(size: 30, weight: .semibold))
Text(volume.name).font(.title.weight(.semibold))
Text("\(volume.fileSystem)\(volume.mountPath)")
.foregroundStyle(.secondary)
}
@@ -236,7 +321,7 @@ private struct RemovableDiskDetail: View {
.foregroundStyle(.orange)
Spacer()
Text(Formatters.percent(volume.usedPercent))
.font(.system(size: 34, weight: .light).monospacedDigit())
.font(.largeTitle.weight(.light).monospacedDigit())
}
ProgressView(value: volume.usedPercent, total: 100)
@@ -266,12 +351,12 @@ private struct GPUPerformanceDetail: View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("GPU").font(.system(size: 30, weight: .semibold))
Text("GPU").font(.title.weight(.semibold))
Text(name).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(snapshot.gpuPercent))
.font(.system(size: 34, weight: .light).monospacedDigit())
.font(.largeTitle.weight(.light).monospacedDigit())
}
VStack(alignment: .leading, spacing: 8) {
@@ -366,13 +451,13 @@ private struct DiskPerformanceDetail: View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Disk 0").font(.system(size: 30, weight: .semibold))
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(.system(size: 30, weight: .light).monospacedDigit())
.font(.title.weight(.light).monospacedDigit())
Text("\(Formatters.percent(snapshot.diskActivePercent)) active")
.font(.caption).foregroundStyle(.secondary)
}
@@ -499,12 +584,12 @@ private struct NetworkPerformanceDetail: View {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Network").font(.system(size: 30, weight: .semibold))
Text("Network").font(.title.weight(.semibold))
Text(interface).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.rate(receiveRate + sendRate))
.font(.system(size: 30, weight: .light).monospacedDigit())
.font(.title.weight(.light).monospacedDigit())
}
VStack(alignment: .leading, spacing: 8) {
@@ -584,12 +669,12 @@ private struct CPUPerformanceDetail: View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("CPU").font(.system(size: 30, weight: .semibold))
Text("CPU").font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(value))
.font(.system(size: 34, weight: .light).monospacedDigit())
.font(.largeTitle.weight(.light).monospacedDigit())
}
cpuGraph
@@ -620,9 +705,24 @@ private struct CPUPerformanceDetail: View {
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
.font(.caption)
.foregroundStyle(.secondary)
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 {
@@ -755,12 +855,12 @@ private struct PerformanceDetail: View {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.system(size: 30, weight: .semibold))
Text(title).font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.percent(value))
.font(.system(size: 34, weight: .light).monospacedDigit())
.font(.largeTitle.weight(.light).monospacedDigit())
}
VStack(alignment: .leading, spacing: 8) {
Text("% Utilization").font(.caption).foregroundStyle(.secondary)
@@ -29,10 +29,6 @@ struct ProcessContextMenu: View {
let onShowProperties: (ProcessRecord) -> Void
var body: some View {
Button("End task", systemImage: "xmark.circle") {
onTerminate(.init(process: process, kind: .normal))
}
Button("Efficiency mode", systemImage: "leaf") {
monitor.setPriority(10, for: process)
}
@@ -85,6 +81,12 @@ struct ProcessContextMenu: View {
}
Button("Properties") { onShowProperties(process) }
}
Divider()
Button("End task", systemImage: "xmark.circle", role: .destructive) {
onTerminate(.init(process: process, kind: .normal))
}
}
private func copy(_ text: String) {
@@ -124,7 +126,7 @@ enum ProcessDiagnosticReporter {
let panel = NSSavePanel()
panel.title = "Create diagnostic report"
panel.message = "Task Manager will sample \(process.displayName) for \(duration) seconds and save a text report."
panel.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]
@@ -181,7 +181,8 @@ struct ProcessesView: View {
terminationRequest = .init(process: process, kind: .normal)
}
}
.buttonStyle(.borderedProminent)
.buttonStyle(.bordered)
.tint(.red)
.disabled(selectedPID == nil && selectedGroupID == nil)
}
.padding(12)
@@ -274,6 +275,18 @@ struct ProcessesView: View {
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,
@@ -307,6 +320,20 @@ struct ProcessesView: View {
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,
@@ -620,7 +647,7 @@ private struct ProcessRow: View {
}
.font(.callout)
.padding(.horizontal, 16)
.frame(height: 48)
.frame(minHeight: 48)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
}
@@ -703,7 +730,7 @@ private struct ProcessGroupRow: View {
}
.font(.callout.weight(.medium))
.padding(.horizontal, 16)
.frame(height: 48)
.frame(minHeight: 48)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
.accessibilityElement(children: .combine)
@@ -745,9 +772,6 @@ private struct ProcessGroupContextMenu: View {
let onShowProperties: (ProcessRecord) -> Void
var body: some View {
Button("End task", systemImage: "xmark.circle", action: onTerminate)
.disabled(group.processes.contains(where: { $0.pid == getpid() }))
Button("Efficiency mode", systemImage: "leaf") {
group.processes.forEach { monitor.setPriority(10, for: $0) }
}
@@ -789,6 +813,11 @@ private struct ProcessGroupContextMenu: View {
}
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) {
@@ -833,6 +862,7 @@ private struct HeatCell: View {
struct ProcessIcon: View {
let name: String
var path: String? = nil
var size: CGFloat = 30
var body: some View {
Group {
@@ -845,12 +875,12 @@ struct ProcessIcon: View {
RoundedRectangle(cornerRadius: 6)
.fill(color.opacity(0.16))
Image(systemName: symbol)
.font(.system(size: 14, weight: .medium))
.font(.system(size: size * 0.47, weight: .medium))
.foregroundStyle(color)
}
}
}
.frame(width: 30, height: 30)
.frame(width: size, height: size)
.accessibilityHidden(true)
}
+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)
}
}
}
}
@@ -66,7 +66,7 @@ struct AppHistoryView: View {
Button("Cancel", role: .cancel) {}
Button("Reset", role: .destructive) { monitor.resetAppHistory() }
} message: {
Text("CPU time and network totals collected by Task Manager will be permanently cleared.")
Text("CPU time and network totals collected by Puter will be permanently cleared.")
}
.sheet(item: $inspectedUsage) { AppUsagePropertiesView(usage: $0) }
}
@@ -173,20 +173,30 @@ private struct AppUsagePropertiesView: View {
struct StartupAppsView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var items = StartupScanner.scan()
@State private var items: [StartupItem] = []
@State private var isLoading = true
@State private var inspectedItem: StartupItem?
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") {
HStack {
Button("Refresh", systemImage: "arrow.clockwise") { items = StartupScanner.scan() }
Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() }
.disabled(isLoading)
Button("Open Login Items") {
openLoginItemsSettings()
}
}
}
if items.isEmpty {
if isLoading && items.isEmpty {
VStack(spacing: 12) {
ProgressView()
Text("Scanning login items…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel("Scanning login items")
} else if items.isEmpty {
EmptyState(
icon: "rectangle.stack.badge.play",
title: "No startup items found",
@@ -223,6 +233,7 @@ struct StartupAppsView: View {
.sheet(item: $inspectedItem) { item in
StartupItemPropertiesView(item: item, impact: startupImpact(for: item))
}
.task { refreshItems() }
}
private func openLoginItemsSettings() {
@@ -231,6 +242,15 @@ struct StartupAppsView: View {
}
}
private func refreshItems() {
guard !isLoading || items.isEmpty else { return }
isLoading = true
Task {
items = await Task.detached(priority: .utility) { StartupScanner.scan() }.value
isLoading = false
}
}
private func startupImpact(for item: StartupItem) -> StartupImpact {
let candidates = monitor.processes.filter { process in
guard !item.executablePath.isEmpty else { return false }
@@ -566,14 +586,14 @@ private struct UserSessionPropertiesView: View {
}
}
private enum StartupItemKind {
private enum StartupItemKind: Sendable {
case loginItem
case launchAgent
var title: String { self == .loginItem ? "Login item" : "Launch agent" }
}
private struct StartupItem: Identifiable {
private struct StartupItem: Identifiable, Sendable {
let id: String
let name: String
let publisher: String
@@ -730,14 +750,29 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
case .nice: "Nice"
}
}
var width: CGFloat {
var defaultWidth: CGFloat {
switch self {
case .name: 230
case .user: 120
case .architecture: 95
case .cpuTime, .memory, .disk, .network, .elapsed: 100
case .parentPID: 80
default: 72
case .name: 260
case .user: 140
case .architecture, .state: 105
case .cpuTime, .memory, .disk, .network, .elapsed: 112
case .parentPID: 92
default: 78
}
}
var minimumWidth: CGFloat {
switch self {
case .name: 170
case .user: 90
case .cpuTime, .memory, .disk, .network, .elapsed: 82
default: 62
}
}
var maximumWidth: CGFloat { self == .name ? 620 : 300 }
var textAlignment: Alignment {
switch self {
case .name, .user, .state, .architecture: .leading
default: .trailing
}
}
var defaultVisible: Bool {
@@ -749,9 +784,11 @@ struct DetailsView: View {
@Environment(SystemMonitor.self) private var monitor
let searchText: String
@Binding var selection: Int32?
@AppStorage("detailVisibleColumns") private var visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
@AppStorage("detailVisibleColumnsV2") private var visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
@AppStorage("detailColumnWidthsV2") private var columnWidthsStorage = ""
@State private var terminationRequest: TerminationRequest?
@State private var inspectedProcess: ProcessRecord?
@State private var columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
@AppStorage("detailSortColumn") private var sortColumn: DetailColumn = .pid
@AppStorage("detailSortAscending") private var ascending = true
@@ -763,6 +800,10 @@ struct DetailsView: View {
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Details", subtitle: "Technical information for running processes") {
Button("Properties", systemImage: "info.circle") {
inspectedProcess = selectedProcess
}
.disabled(selectedProcess == nil)
Menu("Columns", systemImage: "rectangle.split.3x1") {
ForEach(DetailColumn.allCases) { column in
Toggle(column.title, isOn: columnBinding(column))
@@ -771,22 +812,30 @@ struct DetailsView: View {
Divider()
Button("Reset columns") {
visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
persistColumnWidths()
}
}
}
ScrollView([.horizontal, .vertical]) {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(filtered) { process in
detailRow(process)
ResourceSummaryBar(snapshot: monitor.snapshot)
GeometryReader { proxy in
ScrollView([.horizontal, .vertical]) {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
detailRow(process, index: index)
}
} header: {
detailHeader
}
} header: {
detailHeader
}
.frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
}
.frame(minWidth: visibleColumns.reduce(0) { $0 + $1.width + 12 })
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
selectionBar
}
.onAppear(perform: restoreColumnWidths)
.terminationConfirmation($terminationRequest)
.sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) }
}
@@ -796,8 +845,6 @@ struct DetailsView: View {
searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText)
|| $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText)
}.sorted { lhs, rhs in
if lhs.pid == selection { return true }
if rhs.pid == selection { return false }
let result = comparison(lhs, rhs, column: sortColumn)
if result == .orderedSame { return lhs.pid < rhs.pid }
return ascending ? result == .orderedAscending : result == .orderedDescending
@@ -805,42 +852,44 @@ struct DetailsView: View {
}
private var detailHeader: some View {
HStack(spacing: 12) {
HStack(spacing: 0) {
ForEach(visibleColumns) { column in
Button {
if sortColumn == column { ascending.toggle() }
else { sortColumn = column; ascending = true }
} label: {
HStack(spacing: 3) {
Text(column.title)
if sortColumn == column { Image(systemName: ascending ? "chevron.up" : "chevron.down") }
}
.frame(width: column.width, alignment: column == .name ? .leading : .trailing)
}
.buttonStyle(.plain)
detailHeaderCell(column)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.background)
.background(.regularMaterial)
.overlay(alignment: .bottom) { Divider() }
}
private func detailRow(_ process: ProcessRecord) -> some View {
HStack(spacing: 12) {
private func detailRow(_ process: ProcessRecord, index: Int) -> some View {
HStack(spacing: 0) {
ForEach(visibleColumns) { column in
detailCell(process, column: column)
.frame(width: column.width, alignment: column == .name || column == .user ? .leading : .trailing)
.padding(.horizontal, 10)
.frame(width: width(column), alignment: column.textAlignment)
.clipped()
.overlay(alignment: .trailing) { Divider().opacity(0.18) }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.callout)
.padding(.horizontal, 12)
.frame(height: 38)
.background(selection == process.pid ? Color.accentColor.opacity(0.18) : Color.clear)
.frame(minHeight: 42)
.background(rowBackground(process: process, index: index))
.contentShape(Rectangle())
.onTapGesture { selection = process.pid }
.focusable()
.onKeyPress(.return) {
selection = process.pid
return .handled
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user), \(readableState(process.state))")
.accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))), \(process.threadCount) threads")
.accessibilityAddTraits(selection == process.pid ? .isSelected : [])
.accessibilityAction { selection = process.pid }
.overlay(alignment: .bottom) { Divider().opacity(0.4) }
.contextMenu {
ProcessContextMenu(
@@ -855,7 +904,11 @@ struct DetailsView: View {
@ViewBuilder
private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View {
switch column {
case .name: Text(process.displayName).lineLimit(1)
case .name:
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1).truncationMode(.tail)
}
case .pid: Text("\(process.pid)").monospacedDigit()
case .parentPID: Text("\(process.parentPID)").monospacedDigit()
case .user: Text(process.user).lineLimit(1)
@@ -864,7 +917,10 @@ struct DetailsView: View {
case .memory: Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
case .disk: Text(Formatters.rate(monitor.processActivity[process.pid]?.diskTotal ?? 0)).monospacedDigit()
case .network: Text(Formatters.rate(monitor.processActivity[process.pid]?.networkTotal ?? 0)).monospacedDigit()
case .state: Text(process.state)
case .state:
Text(readableState(process.state))
.font(.caption.weight(.medium))
.foregroundStyle(process.state.hasPrefix("R") ? Color.green : Color.secondary)
case .elapsed: Text(process.elapsed).monospacedDigit()
case .threads: Text("\(process.threadCount)").monospacedDigit()
case .handles: Text("\(process.openFileCount)").monospacedDigit()
@@ -874,6 +930,106 @@ struct DetailsView: View {
}
}
private func detailHeaderCell(_ column: DetailColumn) -> some View {
HStack(spacing: 0) {
Button {
if sortColumn == column { ascending.toggle() }
else { sortColumn = column; ascending = true }
} label: {
HStack(spacing: 5) {
Text(column.title).lineLimit(1)
Spacer(minLength: 4)
if sortColumn == column {
Image(systemName: ascending ? "chevron.up" : "chevron.down")
.font(.caption2.weight(.semibold))
}
}
.padding(.horizontal, 10)
.frame(width: width(column), height: 36, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Sort by \(column.title)")
.accessibilityValue(sortColumn == column ? (ascending ? "Ascending" : "Descending") : "Not sorted")
}
.frame(width: width(column), height: 36)
.overlay(alignment: .trailing) {
Divider().opacity(0.45)
DetailResizeHandle(
width: Binding(get: { width(column) }, set: { columnWidths[column] = $0 }),
minimumWidth: column.minimumWidth,
maximumWidth: column.maximumWidth,
onEnded: persistColumnWidths
)
.offset(x: 5)
}
}
private var selectionBar: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 10) {
if let process = selectedProcess {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
Text("PID \(process.pid)").foregroundStyle(.secondary).monospacedDigit()
Text("").foregroundStyle(.tertiary)
Text(Formatters.percent(process.cpu) + " CPU").monospacedDigit()
Text("").foregroundStyle(.tertiary)
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
Spacer()
Button("End task") { terminationRequest = .init(process: process, kind: .normal) }
Button("Properties") { inspectedProcess = process }.buttonStyle(.borderedProminent)
} else {
Text("\(filtered.count) processes").foregroundStyle(.secondary)
Spacer()
Text("Select a process for actions and live details").foregroundStyle(.tertiary)
}
}
.font(.caption)
.padding(.horizontal, 14)
.frame(minHeight: 48)
}
.background(.bar)
}
private var selectedProcess: ProcessRecord? {
guard let selection else { return nil }
return monitor.processes.first { $0.pid == selection }
}
private var tableWidth: CGFloat { visibleColumns.reduce(0) { $0 + width($1) } }
private func width(_ column: DetailColumn) -> CGFloat { columnWidths[column] ?? column.defaultWidth }
private func rowBackground(process: ProcessRecord, index: Int) -> Color {
if selection == process.pid { return Color.accentColor.opacity(0.18) }
return index.isMultiple(of: 2) ? Color.clear : Color.secondary.opacity(0.035)
}
private func readableState(_ state: String) -> String {
if state.hasPrefix("R") { return "Running" }
if state.hasPrefix("S") { return "Sleeping" }
if state.hasPrefix("Z") { return "Zombie" }
if state.hasPrefix("T") { return "Stopped" }
return state
}
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 DetailColumn.allCases {
if let value = stored[column.rawValue] {
columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, value))
}
}
}
private func persistColumnWidths() {
let stored = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0.rawValue, Double(width($0))) })
guard let data = try? JSONEncoder().encode(stored), let value = String(data: data, encoding: .utf8) else { return }
columnWidthsStorage = value
}
private func columnBinding(_ column: DetailColumn) -> Binding<Bool> {
Binding(
get: { visibleColumns.contains(column) },
@@ -911,6 +1067,43 @@ struct DetailsView: View {
}
}
private struct DetailResizeHandle: View {
@Binding var width: CGFloat
let minimumWidth: CGFloat
let maximumWidth: CGFloat
let onEnded: () -> Void
@State private var startWidth: CGFloat?
@State private var proposedWidth: CGFloat?
@State private var hovered = false
var body: some View {
Rectangle()
.fill(hovered || startWidth != nil ? Color.accentColor : Color.clear)
.frame(width: 2, height: 24)
.frame(width: 10, height: 36)
.offset(x: (proposedWidth ?? width) - width)
.contentShape(Rectangle())
.onHover { value in
hovered = value
(value ? NSCursor.resizeLeftRight : NSCursor.arrow).set()
}
.highPriorityGesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if startWidth == nil { startWidth = width }
proposedWidth = min(maximumWidth, max(minimumWidth, (startWidth ?? width) + value.translation.width))
}
.onEnded { _ in
if let proposedWidth { width = proposedWidth }
proposedWidth = nil
startWidth = nil
onEnded()
}
)
.help("Drag to resize column")
}
}
struct ServicesView: View {
@Environment(SystemMonitor.self) private var monitor
let searchText: String
@@ -22,6 +22,8 @@ final class SystemMonitor {
var gpuTilerHistory: [MetricSample] = []
var networkReceiveHistory: [MetricSample] = []
var networkSendHistory: [MetricSample] = []
var hardware = HardwareSnapshot()
var systemPowerHistory: [MetricSample] = []
var updateSpeed: UpdateSpeed {
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") }
}
@@ -41,6 +43,7 @@ final class SystemMonitor {
private var previousProcessIODate: Date?
private var appNetworkTask: Task<Void, Never>?
private var lastAppNetworkSample: Date?
private var lastPortSample: Date?
init() {
updateSpeed = UpdateSpeed(rawValue: UserDefaults.standard.string(forKey: "updateSpeed") ?? "") ?? .normal
@@ -70,6 +73,11 @@ final class SystemMonitor {
let result = await Task.detached(priority: .userInitiated) {
ProcessScanner.capture()
}.value
let refreshPorts = lastPortSample.map { Date().timeIntervalSince($0) >= 10 } ?? true
let existingPorts = hardware.ports
let hardwareResult = await Task.detached(priority: .utility) {
HardwareScanner.capture(existingPorts: existingPorts, refreshPorts: refreshPorts)
}.value
processes = result.processes
updateCPUHistory(with: result.processes)
updateProcessDiskActivity(result.processIO, current: result.processes)
@@ -116,6 +124,8 @@ final class SystemMonitor {
previousNetworkCounters = result.network
previousNetworkDate = networkDate
snapshot = refreshedSnapshot
hardware = hardwareResult
if refreshPorts { lastPortSample = Date() }
lastUpdated = Date()
errorMessage = result.error
appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent)
@@ -137,7 +147,7 @@ final class SystemMonitor {
func terminate(_ process: ProcessRecord, force: Bool = false) {
guard process.pid != getpid() else {
errorMessage = "Task Manager cannot end itself."
errorMessage = "Puter cannot end itself."
return
}
let signal = force ? SIGKILL : SIGTERM
@@ -160,7 +170,7 @@ final class SystemMonitor {
func terminateGroup(_ group: [ProcessRecord]) {
guard !group.contains(where: { $0.pid == getpid() }) else {
errorMessage = "Task Manager cannot end an application group that contains itself."
errorMessage = "Puter cannot end an application group that contains itself."
return
}
let pids = Set(group.map(\.pid))
@@ -178,7 +188,7 @@ final class SystemMonitor {
func sendSignal(_ signal: Int32, to process: ProcessRecord, successMessage: String? = nil) {
guard process.pid != getpid() else {
errorMessage = "Task Manager cannot control itself."
errorMessage = "Puter cannot control itself."
return
}
if kill(process.pid, signal) != 0 {
@@ -260,6 +270,7 @@ final class SystemMonitor {
memoryHistory.append(MetricSample(date: now, value: memory))
networkReceiveHistory.append(MetricSample(date: now, value: snapshot.networkReceiveRate))
networkSendHistory.append(MetricSample(date: now, value: snapshot.networkSendRate))
systemPowerHistory.append(MetricSample(date: now, value: hardware.battery.systemPowerWatts))
diskReadHistory.append(MetricSample(date: now, value: snapshot.diskReadRate))
diskWriteHistory.append(MetricSample(date: now, value: snapshot.diskWriteRate))
diskLatencyHistory.append(MetricSample(date: now, value: snapshot.diskLatencyMilliseconds))
@@ -278,6 +289,7 @@ final class SystemMonitor {
memoryHistory.removeAll { $0.date < cutoff }
networkReceiveHistory.removeAll { $0.date < cutoff }
networkSendHistory.removeAll { $0.date < cutoff }
systemPowerHistory.removeAll { $0.date < cutoff }
diskReadHistory.removeAll { $0.date < cutoff }
diskWriteHistory.removeAll { $0.date < cutoff }
diskLatencyHistory.removeAll { $0.date < cutoff }
@@ -775,9 +787,15 @@ private enum ProcessScanner {
private static func memoryHardwareInfo() -> MemoryHardwareInfo {
let output = run("/usr/sbin/system_profiler", arguments: ["SPMemoryDataType", "-detailLevel", "mini"]).output
let processor = run("/usr/sbin/sysctl", arguments: ["-n", "machdep.cpu.brand_string"]).output
.trimmingCharacters(in: .whitespacesAndNewlines)
let reportedSpeed = output.firstMatch(#"Speed: +([^\n]+)"#)
return MemoryHardwareInfo(
type: output.firstMatch(#"Type: +([^\n]+)"#) ?? "Unified",
speed: output.firstMatch(#"Speed: +([^\n]+)"#) ?? "Not reported by macOS",
speed: reportedSpeed ?? MemoryBandwidthCatalog.description(
for: processor,
cpuCoreCount: ProcessInfo.processInfo.activeProcessorCount
),
manufacturer: output.firstMatch(#"Manufacturer: +([^\n]+)"#) ?? "Apple unified memory"
)
}
@@ -882,6 +900,227 @@ private enum ProcessScanner {
}
}
private enum HardwareScanner {
static func capture(existingPorts: [HardwarePortSnapshot], refreshPorts: Bool) -> HardwareSnapshot {
HardwareSnapshot(
battery: batterySnapshot(),
ports: refreshPorts ? portSnapshots() : existingPorts,
fans: fanSnapshots(),
thermalState: thermalState(),
capturedAt: Date()
)
}
private static func fanSnapshots() -> [FanSnapshot] {
let tool = FanControlService.toolPath
guard FileManager.default.isExecutableFile(atPath: tool) else { return [] }
let output = run(tool, arguments: ["fans"])
guard output.contains("Number of fans:") else { return [] }
return output.components(separatedBy: "\n\n").compactMap { block in
guard let identifier = block.firstMatch(#"(?m)^([0-9]+):"#).flatMap(Int.init) else { return nil }
func number(_ label: String) -> Double {
Double(block.firstMatch("(?m)^\(NSRegularExpression.escapedPattern(for: label)): ([0-9.]+)") ?? "0") ?? 0
}
let name = block.firstMatch(#"(?m)^[0-9]+: (.+)$"#) ?? "Fan \(identifier + 1)"
let mode = block.firstMatch(#"(?m)^Mode: (.+)$"#) ?? "automatic"
return FanSnapshot(
id: identifier,
name: name.replacingOccurrences(of: "#\(identifier)", with: "\(identifier + 1)"),
actualRPM: number("Actual speed"),
minimumRPM: number("Minimal speed"),
maximumRPM: number("Maximum speed"),
targetRPM: number("Target speed"),
mode: mode
)
}.sorted { $0.id < $1.id }
}
private static func batterySnapshot() -> BatterySnapshot {
let output = run("/usr/sbin/ioreg", arguments: ["-r", "-c", "AppleSmartBattery", "-l"])
guard output.contains("AppleSmartBattery") else { return BatterySnapshot() }
func topNumber(_ key: String) -> Double {
Double(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0") ?? 0
}
func topSignedNumber(_ key: String) -> Double {
let raw = output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0"
if let signed = Int64(raw) { return Double(signed) }
if let unsigned = UInt64(raw) { return Double(Int64(bitPattern: unsigned)) }
return 0
}
func topBool(_ key: String) -> Bool {
(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (Yes|No)") ?? "No") == "Yes"
}
func topString(_ key: String) -> String {
output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = \"([^\"]*)\"") ?? ""
}
let adapterLine = output.firstMatch(#"(?m)^ "AdapterDetails" = \{([^\n]+)\}"#) ?? ""
let telemetryLine = output.firstMatch(#"(?m)^ "PowerTelemetryData" = \{([^\n]+)\}"#) ?? ""
let externalPowerConnected = topBool("ExternalConnected")
let parsedAdapter = adapterLine.isEmpty ? nil : adapterSnapshot(from: adapterLine)
let adapter = externalPowerConnected && (parsedAdapter?.ratedWatts ?? 0) > 0 ? parsedAdapter : nil
let rawTime = Int(topNumber("TimeRemaining"))
let rawTemperature = topNumber("Temperature")
let temperature = rawTemperature > 1_000 ? rawTemperature / 10 - 273.15 : rawTemperature
let systemPowerMW = Double(telemetryLine.firstMatch(#""SystemPowerIn"=([0-9]+)"#) ?? "0") ?? 0
return BatterySnapshot(
isPresent: topBool("BatteryInstalled"),
chargePercent: topNumber("CurrentCapacity"),
isCharging: topBool("IsCharging"),
isFullyCharged: topBool("FullyCharged"),
externalPowerConnected: externalPowerConnected,
cycleCount: Int(topNumber("CycleCount")),
designCycleCount: Int(topNumber("DesignCycleCount9C")),
currentCapacityMAh: topNumber("AppleRawCurrentCapacity"),
fullChargeCapacityMAh: topNumber("NominalChargeCapacity"),
designCapacityMAh: topNumber("DesignCapacity"),
voltageVolts: topNumber("Voltage") / 1_000,
currentAmps: {
let amps = topSignedNumber("Amperage") / 1_000
return abs(amps) <= 100 ? amps : 0
}(),
temperatureCelsius: temperature,
timeRemainingMinutes: rawTime > 0 && rawTime < 65_535 ? rawTime : nil,
serial: topString("Serial"),
adapter: adapter,
systemPowerWatts: systemPowerMW / 1_000
)
}
private static func adapterSnapshot(from line: String) -> PowerAdapterSnapshot {
func number(_ key: String) -> Double {
Double(line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=([0-9]+)") ?? "0") ?? 0
}
func string(_ key: String) -> String {
line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=\"([^\"]*)\"") ?? ""
}
let regex = try? NSRegularExpression(pattern: #""MaxCurrent"=([0-9]+),"MaxVoltage"=([0-9]+)"#)
let range = NSRange(line.startIndex..<line.endIndex, in: line)
let profiles = (regex?.matches(in: line, range: range) ?? []).compactMap { match -> PowerProfile? in
guard let currentRange = Range(match.range(at: 1), in: line),
let voltageRange = Range(match.range(at: 2), in: line),
let current = Double(line[currentRange]),
let voltage = Double(line[voltageRange]) else { return nil }
return PowerProfile(voltageVolts: voltage / 1_000, currentAmps: current / 1_000)
}
return PowerAdapterSnapshot(
name: string("Name").isEmpty ? "USB-C power adapter" : string("Name"),
manufacturer: string("Manufacturer").isEmpty ? "Not reported" : string("Manufacturer"),
serial: string("SerialString"),
ratedWatts: number("Watts"),
negotiatedVoltage: number("AdapterVoltage") / 1_000,
negotiatedCurrent: number("Current") / 1_000,
profiles: profiles
)
}
private static func thermalState() -> String {
switch ProcessInfo.processInfo.thermalState {
case .nominal: "Nominal"
case .fair: "Fair"
case .serious: "Serious"
case .critical: "Critical"
@unknown default: "Unknown"
}
}
private static func portSnapshots() -> [HardwarePortSnapshot] {
let data = runData(
"/usr/sbin/system_profiler",
arguments: ["SPUSBDataType", "SPThunderboltDataType", "-json", "-detailLevel", "full"]
)
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
var ports: [HardwarePortSnapshot] = []
for (busIndex, value) in (root["SPThunderboltDataType"] as? [[String: Any]] ?? []).enumerated() {
let foundDevices = devices(in: value["_items"], depth: 0, prefix: "tb-\(busIndex)")
let receptacles = value.keys.filter { $0.hasPrefix("receptacle_") }.sorted()
for (index, key) in receptacles.enumerated() {
guard let receptacle = value[key] as? [String: Any] else { continue }
let rawStatus = receptacle["receptacle_status_key"] as? String ?? ""
let connected = !rawStatus.contains("no_devices")
let identifier = receptacle["receptacle_id_key"] as? String ?? "\(index + 1)"
ports.append(HardwarePortSnapshot(
id: "thunderbolt-\(busIndex)-\(identifier)",
name: "USB-C / Thunderbolt Port \(identifier)",
transport: "Thunderbolt / USB4",
maximumSpeed: receptacle["current_speed_key"] as? String ?? "Not reported",
isConnected: connected || !foundDevices.isEmpty,
status: connected ? "Device connected" : "Available",
devices: foundDevices
))
}
}
for (index, controller) in (root["SPUSBDataType"] as? [[String: Any]] ?? []).enumerated() {
let foundDevices = devices(in: controller["_items"], depth: 0, prefix: "usb-\(index)")
guard !foundDevices.isEmpty else { continue }
ports.append(HardwarePortSnapshot(
id: "usb-controller-\(index)",
name: controller["_name"] as? String ?? "USB Controller",
transport: "USB",
maximumSpeed: controller["device_speed"] as? String ?? "See connected devices",
isConnected: true,
status: "\(foundDevices.count) connected device\(foundDevices.count == 1 ? "" : "s")",
devices: foundDevices
))
}
return ports.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
private static func devices(in raw: Any?, depth: Int, prefix: String) -> [ConnectedHardwareDevice] {
guard let items = raw as? [[String: Any]] else { return [] }
return items.enumerated().flatMap { index, item -> [ConnectedHardwareDevice] in
let name = item["_name"] as? String ?? "Connected device"
let vendor = item["manufacturer"] as? String
?? item["vendor_name"] as? String
?? item["vendor_id"] as? String
?? "Not reported"
let device = ConnectedHardwareDevice(
id: "\(prefix)-\(index)-\(name)-\(depth)",
name: name,
vendor: vendor,
speed: item["device_speed"] as? String ?? item["current_speed_key"] as? String ?? "Not reported",
currentAvailableMA: integer(from: item["current_available"]),
currentRequiredMA: integer(from: item["current_required"]),
depth: depth
)
return [device] + devices(in: item["_items"], depth: depth + 1, prefix: "\(prefix)-\(index)")
}
}
private static func integer(from value: Any?) -> Int? {
if let number = value as? NSNumber { return number.intValue }
if let string = value as? String,
let raw = string.firstMatch(#"([0-9]+)"#) { return Int(raw) }
return nil
}
private static func run(_ path: String, arguments: [String]) -> String {
String(decoding: runData(path, arguments: arguments), as: UTF8.self)
}
private static func runData(_ path: String, arguments: [String]) -> Data {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = arguments
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return process.terminationStatus == 0 ? data : Data()
} catch {
return Data()
}
}
}
private enum BinaryArchitectureReader {
private static let machO64: UInt32 = 0xfeedfacf
private static let machO64Swapped: UInt32 = 0xcffaedfe
+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())
}
}