diff --git a/Package.swift b/Package.swift index c2af46f..9c75930 100644 --- a/Package.swift +++ b/Package.swift @@ -2,20 +2,20 @@ import PackageDescription let package = Package( - name: "MacTaskManager", + name: "Puter", platforms: [.macOS(.v14)], products: [ - .executable(name: "MacTaskManager", targets: ["MacTaskManager"]) + .executable(name: "Puter", targets: ["Puter"]) ], targets: [ .executableTarget( - name: "MacTaskManager", - path: "Sources/MacTaskManager" + name: "Puter", + path: "Sources/Puter" ), .testTarget( - name: "MacTaskManagerTests", - dependencies: ["MacTaskManager"], - path: "Tests/MacTaskManagerTests" + name: "PuterTests", + dependencies: ["Puter"], + path: "Tests/PuterTests" ) ] ) diff --git a/Puter-design.afdesign b/Puter-design.afdesign new file mode 100644 index 0000000..5473d03 Binary files /dev/null and b/Puter-design.afdesign differ diff --git a/Puter.icon/Assets/puter-white.png b/Puter.icon/Assets/puter-white.png new file mode 100644 index 0000000..3a091a9 Binary files /dev/null and b/Puter.icon/Assets/puter-white.png differ diff --git a/Puter.icon/icon.json b/Puter.icon/icon.json new file mode 100644 index 0000000..416f4a6 --- /dev/null +++ b/Puter.icon/icon.json @@ -0,0 +1,55 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:0.36538,0.78082,0.24349,1.00000", + "display-p3:0.22503,0.48767,0.15523,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "puter-white.png", + "name" : "puter-white", + "opacity-specializations" : [ + { + "appearance" : "dark", + "value" : 0.6 + } + ], + "position" : { + "scale" : 0.9, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/README.md b/README.md index 530ba03..137dabf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Mac Task Manager +# Puter A native macOS system monitor written in SwiftUI and inspired by Windows 11 Task Manager. @@ -11,7 +11,7 @@ Validated with automated tests, debug and release builds, packaged-app code-sign - Search and sortable process columns, including live Disk and Network rates - Drag-resizable, persisted Processes columns with memory hover details showing both resident bytes and physical-memory percentage - Column sorting automatically flattens process categories for a true global order; the header menu can restore Group by type -- Expandable application groups plus an advanced Details table with persisted optional columns for threads, open handles, architecture, priority/nice, parent PID, CPU time, and other diagnostics +- Expandable application groups plus a resizable Details table with application icons, persisted optional columns, live selection actions, threads, open handles, architecture, priority/nice, parent PID, CPU time, and other diagnostics - Collapsible Windows-style Apps, Background processes, and macOS processes categories - Native process sampling reports from the Inspect submenu, with save-location memory and completion feedback - Persistent preferences for the default start page, Performance selection and CPU graph mode, update speed, always-on-top behavior, and process/detail table layouts @@ -26,8 +26,11 @@ Validated with automated tests, debug and release builds, packaged-app code-sign - Disk, uptime, process, and thread statistics - One live Performance entry per available removable volume, with capacity, availability, filesystem, mount point, and ejectable status; disconnected volumes are hidden - Detailed memory telemetry for active, cached, wired, compressed, and swap usage plus hardware type, manufacturer, and reported speed/frequency +- A maintained M1-through-M5 Apple-silicon memory catalog, including core-count configuration variants; Intel Macs continue to use DIMM speeds reported directly by System Information - Native disk read/write throughput, IOPS, active time, and response-time histories - Live Apple GPU utilization with overall, renderer, and tiler histories plus GPU memory and core statistics +- Hardware diagnostics for battery health, capacity, cycle life, temperature, charge state, live system watts, thermal pressure, USB-PD negotiation and advertised power profiles +- USB-C, USB, and Thunderbolt port inventory with negotiated link speeds, connected-device power requirements, and an optional available-port map - User, detail, service, persistent app history, and startup views covering native login items plus LaunchAgents - Estimated live startup impact with Low, Medium, and High classifications and detailed CPU/memory/disk evidence - Combined user and system launchd Services registry with scope filtering, running/stopped state, safe Start/Stop/Restart controls, protected-system labeling, configuration reveal, Details, and Properties actions @@ -38,6 +41,9 @@ Validated with automated tests, debug and release builds, packaged-app code-sign - Run New Task sheet and persistent update-speed settings - WinUI-style expanded and compact navigation rail controlled by the native title-bar sidebar button, with persisted state, balanced spacing, tooltips, and accessibility labels - Native macOS menus, toolbar, light/dark mode, and accessibility behavior +- Per-resource top-process panels on Performance with application icons, owning users, and live CPU, memory, disk, or network values +- Native JSON export of performance, process, user, battery, power-delivery, thermal, and connected-hardware snapshots +- Integrated SMC fan telemetry and safe in-app cooling controls with per-fan RPM ranges, manual targets, and one-click return to macOS automatic management ## Run @@ -51,7 +57,7 @@ To create a standard double-clickable app bundle: ```sh ./scripts/build-app.sh -open "dist/Task Manager.app" +open "dist/Puter.app" ``` To create a drag-to-install disk image containing the app and an Applications shortcut: diff --git a/Resources/Info.plist b/Resources/Info.plist index e9ffec0..4ab782d 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -5,19 +5,17 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - Task Manager + Puter CFBundleExecutable - MacTaskManager + Puter CFBundleIdentifier local.mactaskmanager.app - CFBundleIconFile - mactaskmanager.icns CFBundleIconName - mactaskmanager + Puter CFBundleInfoDictionaryVersion 6.0 CFBundleName - Task Manager + Puter CFBundlePackageType APPL CFBundleShortVersionString diff --git a/Resources/Stats-SMC-LICENSE.txt b/Resources/Stats-SMC-LICENSE.txt new file mode 100644 index 0000000..8d52fad --- /dev/null +++ b/Resources/Stats-SMC-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Serhiy Mytrovtsiy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Sources/MacTaskManager/MacTaskManagerApp.swift b/Sources/MacTaskManager/MacTaskManagerApp.swift deleted file mode 100644 index b7e28de..0000000 --- a/Sources/MacTaskManager/MacTaskManagerApp.swift +++ /dev/null @@ -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) - } - } - } -} diff --git a/Sources/MacTaskManager/AppUtilitiesViews.swift b/Sources/Puter/AppUtilitiesViews.swift similarity index 94% rename from Sources/MacTaskManager/AppUtilitiesViews.swift rename to Sources/Puter/AppUtilitiesViews.swift index 9ccbe1e..a796911 100644 --- a/Sources/MacTaskManager/AppUtilitiesViews.swift +++ b/Sources/Puter/AppUtilitiesViews.swift @@ -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" diff --git a/Sources/MacTaskManager/Components.swift b/Sources/Puter/Components.swift similarity index 87% rename from Sources/MacTaskManager/Components.swift rename to Sources/Puter/Components.swift index f09ffa9..ef2ab49 100644 --- a/Sources/MacTaskManager/Components.swift +++ b/Sources/Puter/Components.swift @@ -14,7 +14,7 @@ struct PageHeader: 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…") } diff --git a/Sources/MacTaskManager/ContentView.swift b/Sources/Puter/ContentView.swift similarity index 85% rename from Sources/MacTaskManager/ContentView.swift rename to Sources/Puter/ContentView.swift index 2dcb281..0b465b7 100644 --- a/Sources/MacTaskManager/ContentView.swift +++ b/Sources/Puter/ContentView.swift @@ -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) diff --git a/Sources/Puter/FanControlService.swift b/Sources/Puter/FanControlService.swift new file mode 100644 index 0000000..d9d4b4e --- /dev/null +++ b/Sources/Puter/FanControlService.swift @@ -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)) + } + } +} diff --git a/Sources/Puter/HardwareView.swift b/Sources/Puter/HardwareView.swift new file mode 100644 index 0000000..00a1b23 --- /dev/null +++ b/Sources/Puter/HardwareView.swift @@ -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) } + } +} diff --git a/Sources/MacTaskManager/Models.swift b/Sources/Puter/Models.swift similarity index 58% rename from Sources/MacTaskManager/Models.swift rename to Sources/Puter/Models.swift index 8ef0a69..1d4888c 100644 --- a/Sources/MacTaskManager/Models.swift +++ b/Sources/Puter/Models.swift @@ -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" diff --git a/Sources/MacTaskManager/PerformanceView.swift b/Sources/Puter/PerformanceView.swift similarity index 86% rename from Sources/MacTaskManager/PerformanceView.swift rename to Sources/Puter/PerformanceView.swift index a1d64d7..dac11e3 100644 --- a/Sources/MacTaskManager/PerformanceView.swift +++ b/Sources/Puter/PerformanceView.swift @@ -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) diff --git a/Sources/MacTaskManager/ProcessActions.swift b/Sources/Puter/ProcessActions.swift similarity index 98% rename from Sources/MacTaskManager/ProcessActions.swift rename to Sources/Puter/ProcessActions.swift index 3f2a18b..f945e48 100644 --- a/Sources/MacTaskManager/ProcessActions.swift +++ b/Sources/Puter/ProcessActions.swift @@ -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] diff --git a/Sources/MacTaskManager/ProcessDependencyInspector.swift b/Sources/Puter/ProcessDependencyInspector.swift similarity index 100% rename from Sources/MacTaskManager/ProcessDependencyInspector.swift rename to Sources/Puter/ProcessDependencyInspector.swift diff --git a/Sources/MacTaskManager/ProcessViews.swift b/Sources/Puter/ProcessViews.swift similarity index 96% rename from Sources/MacTaskManager/ProcessViews.swift rename to Sources/Puter/ProcessViews.swift index 51995ab..269972b 100644 --- a/Sources/MacTaskManager/ProcessViews.swift +++ b/Sources/Puter/ProcessViews.swift @@ -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) } diff --git a/Sources/Puter/PuterApp.swift b/Sources/Puter/PuterApp.swift new file mode 100644 index 0000000..19ebc7e --- /dev/null +++ b/Sources/Puter/PuterApp.swift @@ -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) + } + } + } +} diff --git a/Sources/MacTaskManager/SecondaryViews.swift b/Sources/Puter/SecondaryViews.swift similarity index 81% rename from Sources/MacTaskManager/SecondaryViews.swift rename to Sources/Puter/SecondaryViews.swift index 576bcae..0d49866 100644 --- a/Sources/MacTaskManager/SecondaryViews.swift +++ b/Sources/Puter/SecondaryViews.swift @@ -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 { 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 diff --git a/Sources/MacTaskManager/SystemMonitor.swift b/Sources/Puter/SystemMonitor.swift similarity index 76% rename from Sources/MacTaskManager/SystemMonitor.swift rename to Sources/Puter/SystemMonitor.swift index 8e2c743..9d11501 100644 --- a/Sources/MacTaskManager/SystemMonitor.swift +++ b/Sources/Puter/SystemMonitor.swift @@ -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? 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.. 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 diff --git a/Sources/Puter/SystemReportExporter.swift b/Sources/Puter/SystemReportExporter.swift new file mode 100644 index 0000000..101ba35 --- /dev/null +++ b/Sources/Puter/SystemReportExporter.swift @@ -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()) + } +} diff --git a/Tests/MacTaskManagerTests/ModelTests.swift b/Tests/MacTaskManagerTests/ModelTests.swift deleted file mode 100644 index 372776d..0000000 --- a/Tests/MacTaskManagerTests/ModelTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import XCTest -@testable import MacTaskManager - -final class ModelTests: XCTestCase { - func testAppleSystemServicePublisherAndDomainIdentity() { - let system = ServiceRecord(label: "com.apple.accessoryd", pid: 7750, lastExitStatus: 0, domain: .system) - let user = ServiceRecord(label: "com.apple.accessoryd", pid: 731, lastExitStatus: 0, domain: .user) - - XCTAssertEqual(system.publisher, "Apple") - XCTAssertEqual(system.domain.launchctlPrefix, "system") - XCTAssertFalse(system.isControllable) - XCTAssertTrue(user.isControllable) - XCTAssertNotEqual(system.id, user.id) - } - - func testThirdPartyApplicationServicePublisher() { - let service = ServiceRecord( - label: "application.com.adobe.AdobeResourceSynchronizer.210006977.210006983", - pid: 938, - lastExitStatus: 0, - domain: .user - ) - XCTAssertEqual(service.publisher, "Adobe") - XCTAssertEqual(service.displayName, "AdobeResourceSynchronizer") - } - - func testProcessDisplayNameFallback() { - let process = ProcessRecord( - pid: 42, - parentPID: 1, - name: " ", - executablePath: "/usr/bin/example", - user: "tester", - cpu: 0, - memoryPercent: 0, - residentBytes: 0, - state: "S", - elapsed: "00:01", - cpuTime: 0, - threadCount: 1, - openFileCount: 0, - architecture: "ARM64", - priority: 31, - nice: 0 - ) - XCTAssertEqual(process.displayName, "Process 42") - } - - func testAllNavigationSectionsHaveIcons() { - XCTAssertEqual(Set(TaskSection.allCases.map(\.rawValue)).count, TaskSection.allCases.count) - XCTAssertTrue(TaskSection.allCases.allSatisfy { !$0.icon.isEmpty }) - } -} diff --git a/Tests/PuterTests/ModelTests.swift b/Tests/PuterTests/ModelTests.swift new file mode 100644 index 0000000..ed41816 --- /dev/null +++ b/Tests/PuterTests/ModelTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import Puter + +final class ModelTests: XCTestCase { + func testAppleSystemServicePublisherAndDomainIdentity() { + let system = ServiceRecord(label: "com.apple.accessoryd", pid: 7750, lastExitStatus: 0, domain: .system) + let user = ServiceRecord(label: "com.apple.accessoryd", pid: 731, lastExitStatus: 0, domain: .user) + + XCTAssertEqual(system.publisher, "Apple") + XCTAssertEqual(system.domain.launchctlPrefix, "system") + XCTAssertFalse(system.isControllable) + XCTAssertTrue(user.isControllable) + XCTAssertNotEqual(system.id, user.id) + } + + func testThirdPartyApplicationServicePublisher() { + let service = ServiceRecord( + label: "application.com.adobe.AdobeResourceSynchronizer.210006977.210006983", + pid: 938, + lastExitStatus: 0, + domain: .user + ) + XCTAssertEqual(service.publisher, "Adobe") + XCTAssertEqual(service.displayName, "AdobeResourceSynchronizer") + } + + func testProcessDisplayNameFallback() { + let process = ProcessRecord( + pid: 42, + parentPID: 1, + name: " ", + executablePath: "/usr/bin/example", + user: "tester", + cpu: 0, + memoryPercent: 0, + residentBytes: 0, + state: "S", + elapsed: "00:01", + cpuTime: 0, + threadCount: 1, + openFileCount: 0, + architecture: "ARM64", + priority: 31, + nice: 0 + ) + XCTAssertEqual(process.displayName, "Process 42") + } + + func testAllNavigationSectionsHaveIcons() { + XCTAssertEqual(Set(TaskSection.allCases.map(\.rawValue)).count, TaskSection.allCases.count) + XCTAssertTrue(TaskSection.allCases.allSatisfy { !$0.icon.isEmpty }) + } + + func testBatteryHealthUsesFullChargeAndDesignCapacity() { + let battery = BatterySnapshot(fullChargeCapacityMAh: 5_761, designCapacityMAh: 6_249) + XCTAssertEqual(battery.healthPercent, 92.19, accuracy: 0.01) + } + + func testBatteryPowerRejectsCorruptTelemetry() { + let normal = BatterySnapshot(voltageVolts: 12.1, currentAmps: -2.5) + let corrupt = BatterySnapshot(voltageVolts: 12.1, currentAmps: Double(UInt64.max)) + + XCTAssertEqual(normal.batteryPowerWatts, 30.25, accuracy: 0.001) + XCTAssertEqual(corrupt.batteryPowerWatts, 0) + } + + func testUSBPDProfileAndNegotiatedPower() { + let profile = PowerProfile(voltageVolts: 20, currentAmps: 4.29) + let adapter = PowerAdapterSnapshot( + ratedWatts: 86, + negotiatedVoltage: profile.voltageVolts, + negotiatedCurrent: profile.currentAmps, + profiles: [profile] + ) + XCTAssertEqual(profile.watts, 85.8, accuracy: 0.001) + XCTAssertEqual(adapter.negotiatedWatts, 85.8, accuracy: 0.001) + } + + func testMemoryBandwidthCatalogUsesSpecificChipBeforeFamilyFallback() { + XCTAssertEqual( + MemoryBandwidthCatalog.description(for: "Apple M4 Pro", cpuCoreCount: 14), + "273 GB/s bandwidth (Apple specification)" + ) + XCTAssertEqual( + MemoryBandwidthCatalog.description(for: "Apple M4 Max", cpuCoreCount: 14), + "410 GB/s bandwidth (Apple specification)" + ) + XCTAssertEqual( + MemoryBandwidthCatalog.description(for: "Apple M4 Max", cpuCoreCount: 16), + "546 GB/s bandwidth (Apple specification)" + ) + XCTAssertEqual( + MemoryBandwidthCatalog.description(for: "Apple M5 Pro", cpuCoreCount: 18), + "Up to 307 GB/s bandwidth (Apple specification)" + ) + XCTAssertEqual(MemoryBandwidthCatalog.description(for: "Intel Core i9", cpuCoreCount: 8), "Clock not published by macOS") + XCTAssertEqual(MemoryBandwidthCatalog.specifications.count, 20) + } +} diff --git a/mactaskmanager.icon/Assets/icon.png b/mactaskmanager.icon/Assets/icon.png deleted file mode 100644 index 8e09391..0000000 Binary files a/mactaskmanager.icon/Assets/icon.png and /dev/null differ diff --git a/mactaskmanager.icon/icon.json b/mactaskmanager.icon/icon.json deleted file mode 100644 index 570d147..0000000 --- a/mactaskmanager.icon/icon.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "fill" : { - "automatic-gradient" : "extended-gray:0.75000,1.00000", - "orientation" : { - "start" : { - "x" : 0.5, - "y" : 0 - }, - "stop" : { - "x" : 0.5, - "y" : 0.7 - } - } - }, - "groups" : [ - { - "layers" : [ - { - "fill" : { - "linear-gradient" : [ - "extended-srgb:0.00000,0.75294,0.90980,1.00000", - "display-p3:0.00050,0.14736,0.89264,1.00000" - ], - "orientation" : { - "start" : { - "x" : 0.5, - "y" : -0.2058170223892607 - }, - "stop" : { - "x" : 0.5, - "y" : 1.2067929646579256 - } - } - }, - "image-name" : "icon.png", - "name" : "icon", - "position" : { - "scale" : 0.18, - "translation-in-points" : [ - 0, - 31.57455344686923 - ] - } - } - ], - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 - }, - "translucency" : { - "enabled" : true, - "value" : 0.5 - } - } - ], - "supported-platforms" : { - "circles" : [ - "watchOS" - ], - "squares" : "shared" - } -} \ No newline at end of file diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 2bd473e..493632f 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -2,10 +2,10 @@ set -euo pipefail PROJECT_DIR="${0:A:h:h}" -APP_DIR="$PROJECT_DIR/dist/Task Manager.app" -ICON_SOURCE="$PROJECT_DIR/mactaskmanager.icon" -STAGING_ROOT="$(mktemp -d /tmp/mactaskmanager-build.XXXXXX)" -STAGED_APP="$STAGING_ROOT/Task Manager.app" +APP_DIR="$PROJECT_DIR/dist/Puter.app" +ICON_SOURCE="${PUTER_ICON_SOURCE:-$PROJECT_DIR/Puter.icon}" +STAGING_ROOT="$(mktemp -d /tmp/puter-build.XXXXXX)" +STAGED_APP="$STAGING_ROOT/Puter.app" CONTENTS_DIR="$STAGED_APP/Contents" trap '/bin/rm -rf -- "$STAGING_ROOT"' EXIT @@ -14,17 +14,38 @@ cd "$PROJECT_DIR" swift build -c release mkdir -p "$CONTENTS_DIR/MacOS" "$CONTENTS_DIR/Resources" -cp "$PROJECT_DIR/.build/release/MacTaskManager" "$CONTENTS_DIR/MacOS/MacTaskManager" +cp "$PROJECT_DIR/.build/release/Puter" "$CONTENTS_DIR/MacOS/Puter" cp "$PROJECT_DIR/Resources/Info.plist" "$CONTENTS_DIR/Info.plist" -chmod +x "$CONTENTS_DIR/MacOS/MacTaskManager" +chmod +x "$CONTENTS_DIR/MacOS/Puter" + +SMC_SOURCE="/Applications/Stats.app/Contents/Resources/smc" +if [[ -x "$SMC_SOURCE" ]]; then + ditto --norsrc "$SMC_SOURCE" "$CONTENTS_DIR/Resources/smc" + chmod +x "$CONTENTS_DIR/Resources/smc" + cp "$PROJECT_DIR/Resources/Stats-SMC-LICENSE.txt" "$CONTENTS_DIR/Resources/Stats-SMC-LICENSE.txt" +fi if [[ -d "$ICON_SOURCE" ]]; then + print "Using Icon Composer source: $ICON_SOURCE" xcrun actool "$ICON_SOURCE" \ --compile "$CONTENTS_DIR/Resources" \ --platform macosx \ --minimum-deployment-target 14.0 \ - --app-icon mactaskmanager \ + --app-icon Puter \ --output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null + + [[ -s "$CONTENTS_DIR/Resources/Assets.car" ]] || { + print -u2 "Icon Composer did not produce an Assets.car file" + exit 1 + } + [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$CONTENTS_DIR/Info.plist")" == "Puter" ]] || { + print -u2 "CFBundleIconName must reference the Icon Composer asset" + exit 1 + } + if /usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$CONTENTS_DIR/Info.plist" >/dev/null 2>&1; then + print -u2 "CFBundleIconFile would override the live Icon Composer asset" + exit 1 + fi fi # Finder and cloud-sync metadata can make an otherwise valid app bundle @@ -34,5 +55,12 @@ codesign --force --deep --sign - "$STAGED_APP" >/dev/null codesign --verify --deep --strict "$STAGED_APP" mkdir -p "$PROJECT_DIR/dist" +/bin/rm -rf -- "$APP_DIR" ditto --norsrc "$STAGED_APP" "$APP_DIR" +# File-provider workspaces can attach Finder/provenance metadata during the +# final copy. Clean and sign at the delivery path so verification reflects +# the bundle users actually launch. +xattr -cr "$APP_DIR" +codesign --force --deep --sign - "$APP_DIR" >/dev/null +codesign --verify --deep --strict "$APP_DIR" print "Built $APP_DIR" diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index de53ae6..a07571f 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -2,9 +2,9 @@ set -euo pipefail PROJECT_DIR="${0:A:h:h}" -APP_PATH="$PROJECT_DIR/dist/Task Manager.app" -DMG_PATH="$PROJECT_DIR/dist/Task Manager-macOS.dmg" -STAGING_DIR="$(mktemp -d /tmp/mactaskmanager-dmg.XXXXXX)" +APP_PATH="$PROJECT_DIR/dist/Puter.app" +DMG_PATH="$PROJECT_DIR/dist/Puter-macOS.dmg" +STAGING_DIR="$(mktemp -d /tmp/puter-dmg.XXXXXX)" cleanup() { rm -rf "$STAGING_DIR" @@ -15,13 +15,13 @@ if [[ ! -d "$APP_PATH" ]]; then "$PROJECT_DIR/scripts/build-app.sh" fi -ditto --norsrc "$APP_PATH" "$STAGING_DIR/Task Manager.app" -xattr -cr "$STAGING_DIR/Task Manager.app" -codesign --verify --deep --strict "$STAGING_DIR/Task Manager.app" +ditto --norsrc "$APP_PATH" "$STAGING_DIR/Puter.app" +xattr -cr "$STAGING_DIR/Puter.app" +codesign --verify --deep --strict "$STAGING_DIR/Puter.app" ln -s /Applications "$STAGING_DIR/Applications" hdiutil create \ - -volname "Task Manager" \ + -volname "Puter" \ -srcfolder "$STAGING_DIR" \ -format UDZO \ -imagekey zlib-level=9 \