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 \