Rename app and repository to Puter

This commit is contained in:
2026-08-14 23:55:16 -04:00
parent b7faf0ac70
commit 053bb71da2
28 changed files with 1804 additions and 302 deletions
+7 -7
View File
@@ -2,20 +2,20 @@
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "MacTaskManager", name: "Puter",
platforms: [.macOS(.v14)], platforms: [.macOS(.v14)],
products: [ products: [
.executable(name: "MacTaskManager", targets: ["MacTaskManager"]) .executable(name: "Puter", targets: ["Puter"])
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "MacTaskManager", name: "Puter",
path: "Sources/MacTaskManager" path: "Sources/Puter"
), ),
.testTarget( .testTarget(
name: "MacTaskManagerTests", name: "PuterTests",
dependencies: ["MacTaskManager"], dependencies: ["Puter"],
path: "Tests/MacTaskManagerTests" path: "Tests/PuterTests"
) )
] ]
) )
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+55
View File
@@ -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"
}
}
+9 -3
View File
@@ -1,4 +1,4 @@
# Mac Task Manager # Puter
A native macOS system monitor written in SwiftUI and inspired by Windows 11 Task Manager. 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 ## Run
@@ -51,7 +57,7 @@ To create a standard double-clickable app bundle:
```sh ```sh
./scripts/build-app.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: To create a drag-to-install disk image containing the app and an Applications shortcut:
+4 -6
View File
@@ -5,19 +5,17 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Task Manager</string> <string>Puter</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>MacTaskManager</string> <string>Puter</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>local.mactaskmanager.app</string> <string>local.mactaskmanager.app</string>
<key>CFBundleIconFile</key>
<string>mactaskmanager.icns</string>
<key>CFBundleIconName</key> <key>CFBundleIconName</key>
<string>mactaskmanager</string> <string>Puter</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Task Manager</string> <string>Puter</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
+21
View File
@@ -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.
@@ -1,48 +0,0 @@
import AppKit
import SwiftUI
@MainActor
private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillFinishLaunching(_ notification: Notification) {
applyApplicationIcon()
}
func applicationDidFinishLaunching(_ notification: Notification) {
applyApplicationIcon()
}
private func applyApplicationIcon() {
guard let iconURL = Bundle.main.url(forResource: "mactaskmanager", withExtension: "icns"),
let icon = NSImage(contentsOf: iconURL) else { return }
NSApplication.shared.applicationIconImage = icon
}
}
@main
struct MacTaskManagerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var monitor = SystemMonitor()
var body: some Scene {
WindowGroup("Task Manager") {
ContentView()
.environment(monitor)
.frame(minWidth: 980, minHeight: 640)
.task { monitor.start() }
}
.defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(replacing: .newItem) { }
CommandMenu("Options") {
Button("Refresh now") { monitor.refresh() }
.keyboardShortcut("r", modifiers: .command)
Divider()
Button(monitor.isPaused ? "Resume updates" : "Pause updates") {
monitor.isPaused.toggle()
}
.keyboardShortcut("p", modifiers: .command)
}
}
}
}
@@ -83,7 +83,7 @@ struct SettingsView: View {
var body: some View { var body: some View {
@Bindable var monitor = monitor @Bindable var monitor = monitor
VStack(spacing: 0) { VStack(spacing: 0) {
PageHeader(title: "Settings", subtitle: "Customize Task Manager") PageHeader(title: "Settings", subtitle: "Customize Puter")
Form { Form {
Section("General") { Section("General") {
Picker("Default start page", selection: $defaultStartPage) { Picker("Default start page", selection: $defaultStartPage) {
@@ -125,8 +125,8 @@ struct SettingsView: View {
} }
Section("About") { Section("About") {
LabeledContent("Application", value: "Task Manager for macOS") LabeledContent("Application", value: "Puter")
LabeledContent("Version", value: "1.0") LabeledContent("Version", value: appVersion)
LabeledContent("Framework", value: "Native SwiftUI") 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() { private func chooseDiagnosticDirectory() {
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.title = "Choose diagnostic report folder" panel.title = "Choose diagnostic report folder"
@@ -14,7 +14,7 @@ struct PageHeader<Trailing: View>: View {
var body: some View { var body: some View {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) { 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) } if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
} }
Spacer() Spacer()
@@ -33,7 +33,7 @@ struct ResourceSummaryBar: View {
HStack(spacing: 22) { HStack(spacing: 22) {
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu") Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu")
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip") 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") Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
Spacer() Spacer()
} }
@@ -54,7 +54,11 @@ struct UpdateStatus: View {
if monitor.isPaused { if monitor.isPaused {
Text("Updates paused") Text("Updates paused")
} else if let date = monitor.lastUpdated { } else if let date = monitor.lastUpdated {
if Date().timeIntervalSince(date) < 1.5 {
Text("Updated now")
} else {
Text("Updated \(date, style: .relative)") Text("Updated \(date, style: .relative)")
}
} else { } else {
Text("Collecting system data…") Text("Collecting system data…")
} }
@@ -1,11 +1,14 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
extension Notification.Name {
static let runNewTask = Notification.Name("Puter.runNewTask")
}
struct ContentView: View { struct ContentView: View {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
@State private var selection: TaskSection? @State private var selection: TaskSection?
@State private var searchText = "" @State private var searchText = ""
@State private var showingAbout = false
@State private var showingNewTask = false @State private var showingNewTask = false
@State private var detailSelection: Int32? @State private var detailSelection: Int32?
@State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var columnVisibility: NavigationSplitViewVisibility = .all
@@ -32,6 +35,7 @@ struct ContentView: View {
detailSelection = process.pid detailSelection = process.pid
selection = .details selection = .details
} }
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
case .performance: case .performance:
PerformanceView() PerformanceView()
case .history: case .history:
@@ -39,6 +43,7 @@ struct ContentView: View {
detailSelection = process.pid detailSelection = process.pid
selection = .details selection = .details
} }
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
case .startup: case .startup:
StartupAppsView() StartupAppsView()
case .users: case .users:
@@ -48,33 +53,31 @@ struct ContentView: View {
} }
case .details: case .details:
DetailsView(searchText: searchText, selection: $detailSelection) DetailsView(searchText: searchText, selection: $detailSelection)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
case .services: case .services:
ServicesView(searchText: searchText) { process in ServicesView(searchText: searchText) { process in
detailSelection = process.pid detailSelection = process.pid
selection = .details selection = .details
} }
.searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
case .hardware:
HardwareView()
case .settings: case .settings:
SettingsView() SettingsView()
} }
} }
.searchable(text: $searchText, placement: .toolbar, prompt: "Type a name, PID, or user")
.toolbar { .toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask, showingAbout: $showingAbout) TaskToolbarContent(showingNewTask: $showingNewTask)
} }
} }
.navigationTitle("Task Manager") .navigationTitle("Puter")
.animation(.snappy(duration: 0.22), value: sidebarCompact) .animation(.snappy(duration: 0.22), value: sidebarCompact)
.onChange(of: columnVisibility) { _, visibility in .onChange(of: selection) { _, _ in searchText = "" }
guard visibility == .detailOnly else { return } .onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
columnVisibility = .all
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
withAnimation(.snappy(duration: 0.22)) { sidebarCompact.toggle() }
}
}
.sheet(isPresented: $showingNewTask) { .sheet(isPresented: $showingNewTask) {
NewTaskView(isPresented: $showingNewTask) NewTaskView(isPresented: $showingNewTask)
} }
.alert("Task Manager", isPresented: Binding( .alert("Puter", isPresented: Binding(
get: { monitor.errorMessage != nil }, get: { monitor.errorMessage != nil },
set: { if !$0 { monitor.errorMessage = nil } } set: { if !$0 { monitor.errorMessage = nil } }
)) { )) {
@@ -82,11 +85,6 @@ struct ContentView: View {
} message: { } message: {
Text(monitor.errorMessage ?? "") 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 { .onAppear {
DispatchQueue.main.async { DispatchQueue.main.async {
NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal
@@ -98,7 +96,7 @@ struct ContentView: View {
private struct TaskToolbarContent: ToolbarContent { private struct TaskToolbarContent: ToolbarContent {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
@Binding var showingNewTask: Bool @Binding var showingNewTask: Bool
@Binding var showingAbout: Bool @AppStorage("sidebarCompact") private var sidebarCompact = false
var body: some ToolbarContent { var body: some ToolbarContent {
@Bindable var monitor = monitor @Bindable var monitor = monitor
@@ -120,7 +118,7 @@ private struct TaskToolbarContent: ToolbarContent {
Menu { Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed) UpdateSpeedPicker(selection: $monitor.updateSpeed)
Divider() Divider()
Button("About Task Manager") { showingAbout = true } Toggle("Compact Sidebar", isOn: $sidebarCompact)
} label: { } label: {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
} }
@@ -143,7 +141,7 @@ private struct Sidebar: View {
} label: { } label: {
sidebarLabel(section.rawValue, icon: section.icon) sidebarLabel(section.rawValue, icon: section.icon)
.padding(.horizontal, isCompact ? 0 : 8) .padding(.horizontal, isCompact ? 0 : 8)
.frame(height: 40) .frame(minHeight: 40)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -166,7 +164,7 @@ private struct Sidebar: View {
} label: { } label: {
sidebarLabel("Settings", icon: "gearshape") sidebarLabel("Settings", icon: "gearshape")
.padding(.horizontal, isCompact ? 0 : 8) .padding(.horizontal, isCompact ? 0 : 8)
.frame(height: 40) .frame(minHeight: 40)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
+67
View File
@@ -0,0 +1,67 @@
import Foundation
enum FanControlError: LocalizedError {
case backendUnavailable
case commandFailed(String)
var errorDescription: String? {
switch self {
case .backendUnavailable:
"The SMC control backend is unavailable on this Mac."
case .commandFailed(let message):
message.isEmpty ? "The fan command did not complete." : message
}
}
}
enum FanControlService {
static var toolPath: String {
if let bundled = Bundle.main.url(forResource: "smc", withExtension: nil)?.path,
FileManager.default.isExecutableFile(atPath: bundled) {
return bundled
}
return "/Applications/Stats.app/Contents/Resources/smc"
}
static var isAvailable: Bool {
FileManager.default.isExecutableFile(atPath: toolPath)
}
static func setManualTargets(_ targets: [Int: Double]) throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
let commands = targets.sorted { $0.key < $1.key }.flatMap { id, speed in
let safeID = max(0, id)
let safeSpeed = max(0, Int(speed.rounded()))
return ["\(quotedTool) fan \(safeID) -m 1", "\(quotedTool) fan \(safeID) -v \(safeSpeed)"]
}
try runPrivileged(commands.joined(separator: " && "))
}
static func restoreAutomatic() throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
try runPrivileged("\(quotedTool) reset")
}
private static var quotedTool: String {
"'" + toolPath.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func runPrivileged(_ shellCommand: String) throws {
let escaped = shellCommand
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = "do shell script \"\(escaped)\" with administrator privileges"
let process = Process()
let errorPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
process.standardOutput = FileHandle.nullDevice
process.standardError = errorPipe
try process.run()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw FanControlError.commandFailed(String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
}
}
}
+468
View File
@@ -0,0 +1,468 @@
import AppKit
import Charts
import SwiftUI
struct HardwareView: View {
@Environment(SystemMonitor.self) private var monitor
@AppStorage("showEmptyHardwarePorts") private var showEmptyPorts = true
@State private var fanTargets: [Int: Double] = [:]
@State private var fanCommandInProgress = false
@State private var fanError: String?
@State private var fanStatus: String?
@State private var showingFanConfirmation = false
private var battery: BatterySnapshot { monitor.hardware.battery }
private var visiblePorts: [HardwarePortSnapshot] {
showEmptyPorts ? monitor.hardware.ports : monitor.hardware.ports.filter(\.isConnected)
}
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Hardware", subtitle: "Power, battery, thermals, ports, and connection diagnostics") {
HStack(spacing: 10) {
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: "Hardware")
}
Button("Power Settings", systemImage: "gear") { openPowerSettings() }
}
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
summaryGrid
verdictCard
if battery.isPresent { batterySection }
if let adapter = battery.adapter { adapterSection(adapter) }
powerSection
portsSection
coolingSection
topConsumersSection
}
.padding(20)
}
}
.onChange(of: monitor.hardware.fans) { _, fans in
seedFanTargets(fans)
}
.onAppear { seedFanTargets(monitor.hardware.fans) }
.confirmationDialog("Apply manual fan targets?", isPresented: $showingFanConfirmation) {
Button("Apply Targets") { updateFans(manual: true) }
Button("Cancel", role: .cancel) { }
} message: {
Text("\(fanTargetSummary). macOS automatic fan control will be disabled until you choose Automatic.")
}
.alert("Fan control failed", isPresented: Binding(
get: { fanError != nil },
set: { if !$0 { fanError = nil } }
)) {
Button("OK", role: .cancel) { fanError = nil }
} message: {
Text(fanError ?? "")
}
}
private var summaryGrid: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 10)], spacing: 10) {
summaryCard("CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), detail: "\(monitor.snapshot.corePercents.count) logical processors", icon: "cpu", color: .blue)
summaryCard("Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), detail: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed)), icon: "memorychip", color: .purple)
summaryCard("System power", value: watts(battery.systemPowerWatts), detail: battery.externalPowerConnected ? "External power" : "Battery power", icon: "bolt.fill", color: .orange)
summaryCard("Thermals", value: monitor.hardware.thermalState, detail: thermalDetail, icon: "thermometer.medium", color: thermalColor)
}
}
private func summaryCard(_ title: String, value: String, detail: String, icon: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(color)
Text(value).font(.title2.weight(.semibold)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
.frame(maxWidth: .infinity, minHeight: 86, alignment: .leading)
.padding(13)
.background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.18)) }
}
private var verdictCard: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: verdictIcon).font(.title2).foregroundStyle(verdictColor).frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(verdictTitle).font(.headline)
Text(verdictDetail).font(.callout).foregroundStyle(.secondary)
}
Spacer()
}
.padding(15)
.background(verdictColor.opacity(0.09), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(verdictColor.opacity(0.22)) }
}
private var batterySection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Battery").font(.headline)
HStack(alignment: .firstTextBaseline) {
metric("Charge", Formatters.percent(battery.chargePercent))
Spacer()
metric("Condition", battery.healthPercent > 80 ? "Normal" : "Service recommended")
Spacer()
metric("Health", Formatters.percent(battery.healthPercent))
}
HStack(alignment: .firstTextBaseline) {
metric("Cycles", cycleText)
Spacer()
metric("Temperature", String(format: "%.1f °C", battery.temperatureCelsius))
Spacer()
metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
}
HStack(alignment: .firstTextBaseline) {
metric("Battery power", watts(battery.batteryPowerWatts))
Spacer()
metric("Full capacity", capacity(battery.fullChargeCapacityMAh))
Spacer()
metric("Design capacity", capacity(battery.designCapacityMAh))
Spacer()
metric("Time remaining", timeRemaining)
}
Divider()
HStack {
Label(powerSourceText, systemImage: battery.externalPowerConnected ? "powerplug.fill" : "battery.75percent")
Spacer()
Text(batteryStateText).foregroundStyle(.secondary)
}
.font(.callout.weight(.medium))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func adapterSection(_ adapter: PowerAdapterSnapshot) -> some View {
VStack(alignment: .leading, spacing: 13) {
Text("Charger and USB Power Delivery").font(.headline)
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(adapter.name).font(.headline)
Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
}
Divider()
HStack(spacing: 24) {
metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage))
metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent))
metric("Negotiated ceiling", watts(adapter.negotiatedWatts))
metric("System draw", watts(battery.systemPowerWatts))
Spacer()
}
if !adapter.profiles.isEmpty {
VStack(alignment: .leading, spacing: 7) {
Text("Advertised power profiles").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
HStack(spacing: 7) {
ForEach(adapter.profiles) { profile in
Text(String(format: "%.0f V × %.2f A · %.0f W", profile.voltageVolts, profile.currentAmps, profile.watts))
.font(.caption.monospacedDigit())
.padding(.horizontal, 9).padding(.vertical, 5)
.background(Color.accentColor.opacity(profile.voltageVolts == adapter.negotiatedVoltage ? 0.18 : 0.07), in: Capsule())
}
}
}
}
Label(cableEvidence(adapter), systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var powerSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Power history — 60 seconds").font(.headline)
Chart(monitor.systemPowerHistory) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
.foregroundStyle(.orange.opacity(0.13))
LineMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
.foregroundStyle(.orange).lineStyle(.init(lineWidth: 2))
}
.chartYAxisLabel("W")
.chartXAxis(.hidden)
.frame(height: 150)
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var portsSection: some View {
VStack(spacing: 0) {
HStack {
Text("USB-C, USB, and Thunderbolt").font(.headline)
Spacer()
Toggle("Show available ports", isOn: $showEmptyPorts).toggleStyle(.switch).controlSize(.small)
}
.padding(8)
if !visiblePorts.isEmpty { Divider() }
ForEach(visiblePorts) { port in
portRow(port)
if port.id != visiblePorts.last?.id { Divider() }
}
}
.padding(8)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func portRow(_ port: HardwarePortSnapshot) -> some View {
VStack(alignment: .leading, spacing: 9) {
HStack {
Image(systemName: port.isConnected ? "cable.connector" : "bolt.horizontal.circle")
.foregroundStyle(port.isConnected ? Color.green : Color.secondary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(port.name).font(.callout.weight(.semibold))
Text("\(port.transport)\(port.maximumSpeed)").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(port.status).font(.caption.weight(.medium)).foregroundStyle(port.isConnected ? Color.green : Color.secondary)
}
ForEach(port.devices) { device in
HStack(spacing: 8) {
Image(systemName: "arrow.turn.down.right").foregroundStyle(.tertiary)
VStack(alignment: .leading, spacing: 2) {
Text(device.name).lineLimit(1)
Text(deviceDetail(device)).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
.padding(.leading, CGFloat(device.depth * 18 + 30))
}
}
.padding(10)
}
private var topConsumersSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top live consumers").font(.headline)
VStack(spacing: 0) {
ForEach(Array(monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5))) { process in
HStack {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpu)).monospacedDigit().frame(width: 64, alignment: .trailing)
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
}
.padding(.vertical, 6)
}
}
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var coolingSection: some View {
VStack(alignment: .leading, spacing: 13) {
HStack {
Label("Cooling and fan control", systemImage: "fan")
.font(.headline)
Spacer()
Text(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? "Automatic" : "Manual")
.font(.caption.weight(.medium))
.foregroundStyle(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? .green : .orange)
}
Divider()
if monitor.hardware.fans.isEmpty {
HStack(spacing: 12) {
Image(systemName: "fan.slash").font(.title2).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 3) {
Text("Fan telemetry unavailable").font(.callout.weight(.medium))
Text("This Mac is not publishing compatible SMC fan data.")
.font(.caption).foregroundStyle(.secondary)
}
}
} else {
ForEach(monitor.hardware.fans) { fan in
fanRow(fan)
if fan.id != monitor.hardware.fans.last?.id { Divider().opacity(0.55) }
}
}
HStack(spacing: 28) {
metric("Thermal pressure", monitor.hardware.thermalState)
metric("Low Power Mode", ProcessInfo.processInfo.isLowPowerModeEnabled ? "On" : "Off")
metric("Control backend", FanControlService.isAvailable ? "Ready" : "Unavailable")
Spacer()
}
Text("Puter reads fan sensors directly through the Mac's SMC interface. Manual targets are clamped to each fan's reported safe range; Automatic returns control to macOS.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button("Apply targets", systemImage: "speedometer") {
showingFanConfirmation = true
}
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
Button("Automatic", systemImage: "arrow.counterclockwise") { updateFans(manual: false) }
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
Button("Reduce monitor refresh", systemImage: "tortoise") {
monitor.updateSpeed = .low
}
Spacer()
if fanCommandInProgress {
ProgressView().controlSize(.small).accessibilityLabel("Applying fan settings")
} else if let fanStatus {
Label(fanStatus, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func fanRow(_ fan: FanSnapshot) -> some View {
let target = Binding(
get: { safeTarget(for: fan) },
set: { fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, $0)) }
)
return HStack(spacing: 16) {
ZStack {
Circle().fill(Color.cyan.opacity(0.12)).frame(width: 40, height: 40)
Image(systemName: "fan.fill").foregroundStyle(.cyan)
}
VStack(alignment: .leading, spacing: 3) {
Text(fan.name).font(.callout.weight(.semibold))
Text("\(Int(fan.actualRPM.rounded())) RPM now · \(Int(fan.minimumRPM))\(Int(fan.maximumRPM)) RPM")
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
}
.frame(width: 220, alignment: .leading)
Slider(value: target, in: fan.minimumRPM...max(fan.minimumRPM + 1, fan.maximumRPM), step: 25)
.accessibilityLabel("\(fan.name) target speed")
.accessibilityValue("\(Int(target.wrappedValue.rounded())) RPM")
.accessibilityHint("Adjusts from \(Int(fan.minimumRPM)) to \(Int(fan.maximumRPM)) RPM")
Text("\(Int(target.wrappedValue.rounded())) RPM")
.font(.callout.monospacedDigit()).frame(width: 82, alignment: .trailing)
Text(fan.isAutomatic ? "Auto" : "Manual")
.font(.caption.weight(.medium))
.foregroundStyle(fan.isAutomatic ? .green : .orange)
.frame(width: 52, alignment: .trailing)
}
}
private func updateFans(manual: Bool) {
fanCommandInProgress = true
fanStatus = nil
let targets = Dictionary(uniqueKeysWithValues: monitor.hardware.fans.map { fan in
(fan.id, safeTarget(for: fan))
})
Task {
do {
try await Task.detached {
if manual { try FanControlService.setManualTargets(targets) }
else { try FanControlService.restoreAutomatic() }
}.value
fanStatus = manual ? "Targets applied" : "Automatic control restored"
monitor.refresh()
} catch {
fanError = error.localizedDescription
}
fanCommandInProgress = false
}
}
private var fanTargetSummary: String {
monitor.hardware.fans.map { fan in
let target = safeTarget(for: fan)
return "\(fan.name): \(Int(target.rounded())) RPM"
}.joined(separator: ", ")
}
private func seedFanTargets(_ fans: [FanSnapshot]) {
for fan in fans where fanTargets[fan.id] == nil {
let reported = fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM
fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, reported))
}
}
private func safeTarget(for fan: FanSnapshot) -> Double {
let reported = fanTargets[fan.id] ?? (fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM)
return min(fan.maximumRPM, max(fan.minimumRPM, reported))
}
private func metric(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.medium)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
}
}
private var verdictTitle: String {
if !battery.isPresent { return "Power telemetry available for desktop hardware" }
if battery.isFullyCharged && battery.externalPowerConnected { return "Battery full — running from external power" }
if battery.isCharging { return "Battery charging normally" }
if battery.externalPowerConnected { return "External power connected — battery is not charging" }
return "Running on battery"
}
private var verdictDetail: String {
guard battery.isPresent else { return "Battery-specific fields are hidden because this Mac reports no internal battery." }
if let adapter = battery.adapter {
return "macOS reports a \(Int(adapter.ratedWatts.rounded())) W adapter and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Current system input is \(watts(battery.systemPowerWatts))."
}
return "Current battery draw is \(watts(battery.batteryPowerWatts)). Charger and cable capability are shown only when macOS publishes them."
}
private var verdictIcon: String {
battery.externalPowerConnected ? "checkmark.circle.fill" : "battery.75percent"
}
private var verdictColor: Color {
monitor.hardware.thermalState == "Serious" || monitor.hardware.thermalState == "Critical" ? .red : .green
}
private var thermalColor: Color {
switch monitor.hardware.thermalState {
case "Critical", "Serious": .red
case "Fair": .orange
default: .green
}
}
private var thermalDetail: String {
switch monitor.hardware.thermalState {
case "Critical": "Performance is heavily constrained"
case "Serious": "Performance may be reduced"
case "Fair": "Elevated thermal pressure"
default: "No thermal pressure"
}
}
private var batteryColor: Color { battery.chargePercent < 20 ? .red : (battery.isCharging ? .green : .accentColor) }
private var cycleText: String { battery.designCycleCount > 0 ? "\(battery.cycleCount) of \(battery.designCycleCount)" : "\(battery.cycleCount)" }
private var timeRemaining: String {
guard let minutes = battery.timeRemainingMinutes else { return battery.isFullyCharged ? "Full" : "Calculating" }
return "\(minutes / 60)h \(minutes % 60)m"
}
private var powerSourceText: String { battery.externalPowerConnected ? "Power adapter" : "Internal battery" }
private var batteryStateText: String { battery.isFullyCharged ? "Fully charged" : (battery.isCharging ? "Charging" : "Not charging") }
private func cableEvidence(_ adapter: PowerAdapterSnapshot) -> String {
"Observed USB-PD contract: \(String(format: "%.1f V at %.2f A", adapter.negotiatedVoltage, adapter.negotiatedCurrent)). Cable e-marker identity is not claimed unless macOS exposes it."
}
private func deviceDetail(_ device: ConnectedHardwareDevice) -> String {
var parts = [device.vendor, device.speed].filter { $0 != "Not reported" }
if let required = device.currentRequiredMA { parts.append("requests \(required) mA") }
if let available = device.currentAvailableMA { parts.append("\(available) mA available") }
return parts.isEmpty ? "Technical details not reported by macOS" : parts.joined(separator: "")
}
private func watts(_ value: Double) -> String { value > 0 ? String(format: "%.1f W", value) : "" }
private func capacity(_ value: Double) -> String { value > 0 ? String(format: "%.0f mAh", value) : "Not reported" }
private func openPowerSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.battery") { NSWorkspace.shared.open(url) }
}
}
@@ -9,6 +9,7 @@ enum TaskSection: String, CaseIterable, Identifiable {
case users = "Users" case users = "Users"
case details = "Details" case details = "Details"
case services = "Services" case services = "Services"
case hardware = "Hardware"
case settings = "Settings" case settings = "Settings"
var id: String { rawValue } var id: String { rawValue }
@@ -22,11 +23,153 @@ enum TaskSection: String, CaseIterable, Identifiable {
case .users: "person.2" case .users: "person.2"
case .details: "list.bullet.rectangle" case .details: "list.bullet.rectangle"
case .services: "gearshape.2" case .services: "gearshape.2"
case .hardware: "bolt.horizontal.circle"
case .settings: "gearshape" 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 { enum UpdateSpeed: String, CaseIterable, Identifiable {
case high = "High" case high = "High"
case normal = "Normal" case normal = "Normal"
@@ -37,8 +37,13 @@ struct PerformanceView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
PageHeader(title: "Performance") { PageHeader(title: "Performance") {
HStack(spacing: 12) {
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: selectedResourceName)
}
UpdateStatus() UpdateStatus()
} }
}
HStack(spacing: 0) { HStack(spacing: 0) {
ScrollView { ScrollView {
VStack(spacing: 10) { VStack(spacing: 10) {
@@ -67,13 +72,93 @@ struct PerformanceView: View {
} }
.padding(14) .padding(14)
} }
.frame(width: 245) .frame(minWidth: 190, idealWidth: 245, maxWidth: 245)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45)) .background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
Divider() Divider()
VStack(spacing: 0) {
detail 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 { private func metricCard(_ metric: PerformanceMetric, value: Double, valueText: String? = nil, detail: String) -> some View {
Button { Button {
@@ -226,7 +311,7 @@ private struct RemovableDiskDetail: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) { 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)") Text("\(volume.fileSystem)\(volume.mountPath)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -236,7 +321,7 @@ private struct RemovableDiskDetail: View {
.foregroundStyle(.orange) .foregroundStyle(.orange)
Spacer() Spacer()
Text(Formatters.percent(volume.usedPercent)) Text(Formatters.percent(volume.usedPercent))
.font(.system(size: 34, weight: .light).monospacedDigit()) .font(.largeTitle.weight(.light).monospacedDigit())
} }
ProgressView(value: volume.usedPercent, total: 100) ProgressView(value: volume.usedPercent, total: 100)
@@ -266,12 +351,12 @@ private struct GPUPerformanceDetail: View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("GPU").font(.system(size: 30, weight: .semibold)) Text("GPU").font(.title.weight(.semibold))
Text(name).foregroundStyle(.secondary) Text(name).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(Formatters.percent(snapshot.gpuPercent)) Text(Formatters.percent(snapshot.gpuPercent))
.font(.system(size: 34, weight: .light).monospacedDigit()) .font(.largeTitle.weight(.light).monospacedDigit())
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -366,13 +451,13 @@ private struct DiskPerformanceDetail: View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { 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) Text("System volume • APFS • Solid-state").foregroundStyle(.secondary)
} }
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
Text(Formatters.rate(snapshot.diskThroughput)) Text(Formatters.rate(snapshot.diskThroughput))
.font(.system(size: 30, weight: .light).monospacedDigit()) .font(.title.weight(.light).monospacedDigit())
Text("\(Formatters.percent(snapshot.diskActivePercent)) active") Text("\(Formatters.percent(snapshot.diskActivePercent)) active")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
@@ -499,12 +584,12 @@ private struct NetworkPerformanceDetail: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Network").font(.system(size: 30, weight: .semibold)) Text("Network").font(.title.weight(.semibold))
Text(interface).foregroundStyle(.secondary) Text(interface).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(Formatters.rate(receiveRate + sendRate)) Text(Formatters.rate(receiveRate + sendRate))
.font(.system(size: 30, weight: .light).monospacedDigit()) .font(.title.weight(.light).monospacedDigit())
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -584,12 +669,12 @@ private struct CPUPerformanceDetail: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("CPU").font(.system(size: 30, weight: .semibold)) Text("CPU").font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary) Text(subtitle).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(Formatters.percent(value)) Text(Formatters.percent(value))
.font(.system(size: 34, weight: .light).monospacedDigit()) .font(.largeTitle.weight(.light).monospacedDigit())
} }
cpuGraph cpuGraph
@@ -620,9 +705,24 @@ private struct CPUPerformanceDetail: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
Menu {
Button {
displayMode = .summary
} label: {
Label("Summary view", systemImage: displayMode == .summary ? "checkmark" : "chart.xyaxis.line")
}
Button {
displayMode = .logicalProcessors
} label: {
Label("Logical processors", systemImage: displayMode == .logicalProcessors ? "checkmark" : "square.grid.3x3")
}
} label: {
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3") Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) }
.menuStyle(.borderlessButton)
.fixedSize()
.accessibilityLabel("CPU graph layout")
} }
if displayMode == .summary { if displayMode == .summary {
@@ -755,12 +855,12 @@ private struct PerformanceDetail: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(title).font(.system(size: 30, weight: .semibold)) Text(title).font(.title.weight(.semibold))
Text(subtitle).foregroundStyle(.secondary) Text(subtitle).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(Formatters.percent(value)) Text(Formatters.percent(value))
.font(.system(size: 34, weight: .light).monospacedDigit()) .font(.largeTitle.weight(.light).monospacedDigit())
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("% Utilization").font(.caption).foregroundStyle(.secondary) Text("% Utilization").font(.caption).foregroundStyle(.secondary)
@@ -29,10 +29,6 @@ struct ProcessContextMenu: View {
let onShowProperties: (ProcessRecord) -> Void let onShowProperties: (ProcessRecord) -> Void
var body: some View { var body: some View {
Button("End task", systemImage: "xmark.circle") {
onTerminate(.init(process: process, kind: .normal))
}
Button("Efficiency mode", systemImage: "leaf") { Button("Efficiency mode", systemImage: "leaf") {
monitor.setPriority(10, for: process) monitor.setPriority(10, for: process)
} }
@@ -85,6 +81,12 @@ struct ProcessContextMenu: View {
} }
Button("Properties") { onShowProperties(process) } Button("Properties") { onShowProperties(process) }
} }
Divider()
Button("End task", systemImage: "xmark.circle", role: .destructive) {
onTerminate(.init(process: process, kind: .normal))
}
} }
private func copy(_ text: String) { private func copy(_ text: String) {
@@ -124,7 +126,7 @@ enum ProcessDiagnosticReporter {
let panel = NSSavePanel() let panel = NSSavePanel()
panel.title = "Create diagnostic report" 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.prompt = "Create Report"
panel.nameFieldStringValue = safeFilename("\(process.displayName)-\(process.pid)-sample.txt") panel.nameFieldStringValue = safeFilename("\(process.displayName)-\(process.pid)-sample.txt")
panel.allowedContentTypes = [.plainText] panel.allowedContentTypes = [.plainText]
@@ -181,7 +181,8 @@ struct ProcessesView: View {
terminationRequest = .init(process: process, kind: .normal) terminationRequest = .init(process: process, kind: .normal)
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.bordered)
.tint(.red)
.disabled(selectedPID == nil && selectedGroupID == nil) .disabled(selectedPID == nil && selectedGroupID == nil)
} }
.padding(12) .padding(12)
@@ -274,6 +275,18 @@ struct ProcessesView: View {
selectedGroupID = group.id selectedGroupID = group.id
selectedPID = nil 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 { .contextMenu {
ProcessGroupContextMenu( ProcessGroupContextMenu(
group: group, group: group,
@@ -307,6 +320,20 @@ struct ProcessesView: View {
selectedPID = process.pid selectedPID = process.pid
selectedGroupID = nil 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 { .contextMenu {
ProcessContextMenu( ProcessContextMenu(
process: process, process: process,
@@ -620,7 +647,7 @@ private struct ProcessRow: View {
} }
.font(.callout) .font(.callout)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(height: 48) .frame(minHeight: 48)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) } .overlay(alignment: .bottom) { Divider().opacity(0.45) }
} }
@@ -703,7 +730,7 @@ private struct ProcessGroupRow: View {
} }
.font(.callout.weight(.medium)) .font(.callout.weight(.medium))
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(height: 48) .frame(minHeight: 48)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) } .overlay(alignment: .bottom) { Divider().opacity(0.45) }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
@@ -745,9 +772,6 @@ private struct ProcessGroupContextMenu: View {
let onShowProperties: (ProcessRecord) -> Void let onShowProperties: (ProcessRecord) -> Void
var body: some View { 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") { Button("Efficiency mode", systemImage: "leaf") {
group.processes.forEach { monitor.setPriority(10, for: $0) } group.processes.forEach { monitor.setPriority(10, for: $0) }
} }
@@ -789,6 +813,11 @@ private struct ProcessGroupContextMenu: View {
} }
Button("Properties") { onShowProperties(group.primary) } Button("Properties") { onShowProperties(group.primary) }
} }
Divider()
Button("End task", systemImage: "xmark.circle", role: .destructive, action: onTerminate)
.disabled(group.processes.contains(where: { $0.pid == getpid() }))
} }
private func setPriority(_ value: Int) { private func setPriority(_ value: Int) {
@@ -833,6 +862,7 @@ private struct HeatCell: View {
struct ProcessIcon: View { struct ProcessIcon: View {
let name: String let name: String
var path: String? = nil var path: String? = nil
var size: CGFloat = 30
var body: some View { var body: some View {
Group { Group {
@@ -845,12 +875,12 @@ struct ProcessIcon: View {
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.fill(color.opacity(0.16)) .fill(color.opacity(0.16))
Image(systemName: symbol) Image(systemName: symbol)
.font(.system(size: 14, weight: .medium)) .font(.system(size: size * 0.47, weight: .medium))
.foregroundStyle(color) .foregroundStyle(color)
} }
} }
} }
.frame(width: 30, height: 30) .frame(width: size, height: size)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
@main
struct PuterApp: App {
@State private var monitor = SystemMonitor()
@AppStorage("sidebarCompact") private var sidebarCompact = false
var body: some Scene {
WindowGroup("Puter") {
ContentView()
.environment(monitor)
.frame(minWidth: 820, minHeight: 560)
.task { monitor.start() }
}
.defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(replacing: .newItem) {
Button("Run New Task…") {
NotificationCenter.default.post(name: .runNewTask, object: nil)
}
.keyboardShortcut("n", modifiers: .command)
}
SidebarCommands()
CommandMenu("Options") {
Button("Refresh now") { monitor.refresh() }
.keyboardShortcut("r", modifiers: .command)
Divider()
Button(monitor.isPaused ? "Resume updates" : "Pause updates") {
monitor.isPaused.toggle()
}
.keyboardShortcut("p", modifiers: .command)
Divider()
Toggle("Compact Sidebar", isOn: $sidebarCompact)
}
}
}
}
@@ -66,7 +66,7 @@ struct AppHistoryView: View {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
Button("Reset", role: .destructive) { monitor.resetAppHistory() } Button("Reset", role: .destructive) { monitor.resetAppHistory() }
} message: { } 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) } .sheet(item: $inspectedUsage) { AppUsagePropertiesView(usage: $0) }
} }
@@ -173,20 +173,30 @@ private struct AppUsagePropertiesView: View {
struct StartupAppsView: View { struct StartupAppsView: View {
@Environment(SystemMonitor.self) private var monitor @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? @State private var inspectedItem: StartupItem?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") { PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") {
HStack { HStack {
Button("Refresh", systemImage: "arrow.clockwise") { items = StartupScanner.scan() } Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() }
.disabled(isLoading)
Button("Open Login Items") { Button("Open Login Items") {
openLoginItemsSettings() 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( EmptyState(
icon: "rectangle.stack.badge.play", icon: "rectangle.stack.badge.play",
title: "No startup items found", title: "No startup items found",
@@ -223,6 +233,7 @@ struct StartupAppsView: View {
.sheet(item: $inspectedItem) { item in .sheet(item: $inspectedItem) { item in
StartupItemPropertiesView(item: item, impact: startupImpact(for: item)) StartupItemPropertiesView(item: item, impact: startupImpact(for: item))
} }
.task { refreshItems() }
} }
private func openLoginItemsSettings() { 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 { private func startupImpact(for item: StartupItem) -> StartupImpact {
let candidates = monitor.processes.filter { process in let candidates = monitor.processes.filter { process in
guard !item.executablePath.isEmpty else { return false } 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 loginItem
case launchAgent case launchAgent
var title: String { self == .loginItem ? "Login item" : "Launch agent" } var title: String { self == .loginItem ? "Login item" : "Launch agent" }
} }
private struct StartupItem: Identifiable { private struct StartupItem: Identifiable, Sendable {
let id: String let id: String
let name: String let name: String
let publisher: String let publisher: String
@@ -730,14 +750,29 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
case .nice: "Nice" case .nice: "Nice"
} }
} }
var width: CGFloat { var defaultWidth: CGFloat {
switch self { switch self {
case .name: 230 case .name: 260
case .user: 120 case .user: 140
case .architecture: 95 case .architecture, .state: 105
case .cpuTime, .memory, .disk, .network, .elapsed: 100 case .cpuTime, .memory, .disk, .network, .elapsed: 112
case .parentPID: 80 case .parentPID: 92
default: 72 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 { var defaultVisible: Bool {
@@ -749,9 +784,11 @@ struct DetailsView: View {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
let searchText: String let searchText: String
@Binding var selection: Int32? @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 terminationRequest: TerminationRequest?
@State private var inspectedProcess: ProcessRecord? @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("detailSortColumn") private var sortColumn: DetailColumn = .pid
@AppStorage("detailSortAscending") private var ascending = true @AppStorage("detailSortAscending") private var ascending = true
@@ -763,6 +800,10 @@ struct DetailsView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
PageHeader(title: "Details", subtitle: "Technical information for running processes") { 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") { Menu("Columns", systemImage: "rectangle.split.3x1") {
ForEach(DetailColumn.allCases) { column in ForEach(DetailColumn.allCases) { column in
Toggle(column.title, isOn: columnBinding(column)) Toggle(column.title, isOn: columnBinding(column))
@@ -771,22 +812,30 @@ struct DetailsView: View {
Divider() Divider()
Button("Reset columns") { Button("Reset columns") {
visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",") visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
persistColumnWidths()
} }
} }
} }
ResourceSummaryBar(snapshot: monitor.snapshot)
GeometryReader { proxy in
ScrollView([.horizontal, .vertical]) { ScrollView([.horizontal, .vertical]) {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section { Section {
ForEach(filtered) { process in ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
detailRow(process) detailRow(process, index: index)
} }
} header: { } header: {
detailHeader detailHeader
} }
} }
.frame(minWidth: visibleColumns.reduce(0) { $0 + $1.width + 12 }) .frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
selectionBar
}
.onAppear(perform: restoreColumnWidths)
.terminationConfirmation($terminationRequest) .terminationConfirmation($terminationRequest)
.sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) } .sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) }
} }
@@ -796,8 +845,6 @@ struct DetailsView: View {
searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText) searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText)
|| $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText) || $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText)
}.sorted { lhs, rhs in }.sorted { lhs, rhs in
if lhs.pid == selection { return true }
if rhs.pid == selection { return false }
let result = comparison(lhs, rhs, column: sortColumn) let result = comparison(lhs, rhs, column: sortColumn)
if result == .orderedSame { return lhs.pid < rhs.pid } if result == .orderedSame { return lhs.pid < rhs.pid }
return ascending ? result == .orderedAscending : result == .orderedDescending return ascending ? result == .orderedAscending : result == .orderedDescending
@@ -805,42 +852,44 @@ struct DetailsView: View {
} }
private var detailHeader: some View { private var detailHeader: some View {
HStack(spacing: 12) { HStack(spacing: 0) {
ForEach(visibleColumns) { column in ForEach(visibleColumns) { column in
Button { detailHeaderCell(column)
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)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 12) .background(.regularMaterial)
.padding(.vertical, 9)
.background(.background)
.overlay(alignment: .bottom) { Divider() } .overlay(alignment: .bottom) { Divider() }
} }
private func detailRow(_ process: ProcessRecord) -> some View { private func detailRow(_ process: ProcessRecord, index: Int) -> some View {
HStack(spacing: 12) { HStack(spacing: 0) {
ForEach(visibleColumns) { column in ForEach(visibleColumns) { column in
detailCell(process, column: column) 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) .font(.callout)
.padding(.horizontal, 12) .frame(minHeight: 42)
.frame(height: 38) .background(rowBackground(process: process, index: index))
.background(selection == process.pid ? Color.accentColor.opacity(0.18) : Color.clear)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { selection = process.pid } .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) } .overlay(alignment: .bottom) { Divider().opacity(0.4) }
.contextMenu { .contextMenu {
ProcessContextMenu( ProcessContextMenu(
@@ -855,7 +904,11 @@ struct DetailsView: View {
@ViewBuilder @ViewBuilder
private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View { private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View {
switch column { 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 .pid: Text("\(process.pid)").monospacedDigit()
case .parentPID: Text("\(process.parentPID)").monospacedDigit() case .parentPID: Text("\(process.parentPID)").monospacedDigit()
case .user: Text(process.user).lineLimit(1) 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 .memory: Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
case .disk: Text(Formatters.rate(monitor.processActivity[process.pid]?.diskTotal ?? 0)).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 .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 .elapsed: Text(process.elapsed).monospacedDigit()
case .threads: Text("\(process.threadCount)").monospacedDigit() case .threads: Text("\(process.threadCount)").monospacedDigit()
case .handles: Text("\(process.openFileCount)").monospacedDigit() case .handles: Text("\(process.openFileCount)").monospacedDigit()
@@ -874,6 +930,106 @@ struct DetailsView: View {
} }
} }
private func detailHeaderCell(_ column: DetailColumn) -> some View {
HStack(spacing: 0) {
Button {
if sortColumn == column { ascending.toggle() }
else { sortColumn = column; ascending = true }
} label: {
HStack(spacing: 5) {
Text(column.title).lineLimit(1)
Spacer(minLength: 4)
if sortColumn == column {
Image(systemName: ascending ? "chevron.up" : "chevron.down")
.font(.caption2.weight(.semibold))
}
}
.padding(.horizontal, 10)
.frame(width: width(column), height: 36, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Sort by \(column.title)")
.accessibilityValue(sortColumn == column ? (ascending ? "Ascending" : "Descending") : "Not sorted")
}
.frame(width: width(column), height: 36)
.overlay(alignment: .trailing) {
Divider().opacity(0.45)
DetailResizeHandle(
width: Binding(get: { width(column) }, set: { columnWidths[column] = $0 }),
minimumWidth: column.minimumWidth,
maximumWidth: column.maximumWidth,
onEnded: persistColumnWidths
)
.offset(x: 5)
}
}
private var selectionBar: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 10) {
if let process = selectedProcess {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
Text("PID \(process.pid)").foregroundStyle(.secondary).monospacedDigit()
Text("").foregroundStyle(.tertiary)
Text(Formatters.percent(process.cpu) + " CPU").monospacedDigit()
Text("").foregroundStyle(.tertiary)
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
Spacer()
Button("End task") { terminationRequest = .init(process: process, kind: .normal) }
Button("Properties") { inspectedProcess = process }.buttonStyle(.borderedProminent)
} else {
Text("\(filtered.count) processes").foregroundStyle(.secondary)
Spacer()
Text("Select a process for actions and live details").foregroundStyle(.tertiary)
}
}
.font(.caption)
.padding(.horizontal, 14)
.frame(minHeight: 48)
}
.background(.bar)
}
private var selectedProcess: ProcessRecord? {
guard let selection else { return nil }
return monitor.processes.first { $0.pid == selection }
}
private var tableWidth: CGFloat { visibleColumns.reduce(0) { $0 + width($1) } }
private func width(_ column: DetailColumn) -> CGFloat { columnWidths[column] ?? column.defaultWidth }
private func rowBackground(process: ProcessRecord, index: Int) -> Color {
if selection == process.pid { return Color.accentColor.opacity(0.18) }
return index.isMultiple(of: 2) ? Color.clear : Color.secondary.opacity(0.035)
}
private func readableState(_ state: String) -> String {
if state.hasPrefix("R") { return "Running" }
if state.hasPrefix("S") { return "Sleeping" }
if state.hasPrefix("Z") { return "Zombie" }
if state.hasPrefix("T") { return "Stopped" }
return state
}
private func restoreColumnWidths() {
guard let data = columnWidthsStorage.data(using: .utf8),
let stored = try? JSONDecoder().decode([String: Double].self, from: data) else { return }
for column in DetailColumn.allCases {
if let value = stored[column.rawValue] {
columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, value))
}
}
}
private func persistColumnWidths() {
let stored = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0.rawValue, Double(width($0))) })
guard let data = try? JSONEncoder().encode(stored), let value = String(data: data, encoding: .utf8) else { return }
columnWidthsStorage = value
}
private func columnBinding(_ column: DetailColumn) -> Binding<Bool> { private func columnBinding(_ column: DetailColumn) -> Binding<Bool> {
Binding( Binding(
get: { visibleColumns.contains(column) }, 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 { struct ServicesView: View {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
let searchText: String let searchText: String
@@ -22,6 +22,8 @@ final class SystemMonitor {
var gpuTilerHistory: [MetricSample] = [] var gpuTilerHistory: [MetricSample] = []
var networkReceiveHistory: [MetricSample] = [] var networkReceiveHistory: [MetricSample] = []
var networkSendHistory: [MetricSample] = [] var networkSendHistory: [MetricSample] = []
var hardware = HardwareSnapshot()
var systemPowerHistory: [MetricSample] = []
var updateSpeed: UpdateSpeed { var updateSpeed: UpdateSpeed {
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") } didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") }
} }
@@ -41,6 +43,7 @@ final class SystemMonitor {
private var previousProcessIODate: Date? private var previousProcessIODate: Date?
private var appNetworkTask: Task<Void, Never>? private var appNetworkTask: Task<Void, Never>?
private var lastAppNetworkSample: Date? private var lastAppNetworkSample: Date?
private var lastPortSample: Date?
init() { init() {
updateSpeed = UpdateSpeed(rawValue: UserDefaults.standard.string(forKey: "updateSpeed") ?? "") ?? .normal updateSpeed = UpdateSpeed(rawValue: UserDefaults.standard.string(forKey: "updateSpeed") ?? "") ?? .normal
@@ -70,6 +73,11 @@ final class SystemMonitor {
let result = await Task.detached(priority: .userInitiated) { let result = await Task.detached(priority: .userInitiated) {
ProcessScanner.capture() ProcessScanner.capture()
}.value }.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 processes = result.processes
updateCPUHistory(with: result.processes) updateCPUHistory(with: result.processes)
updateProcessDiskActivity(result.processIO, current: result.processes) updateProcessDiskActivity(result.processIO, current: result.processes)
@@ -116,6 +124,8 @@ final class SystemMonitor {
previousNetworkCounters = result.network previousNetworkCounters = result.network
previousNetworkDate = networkDate previousNetworkDate = networkDate
snapshot = refreshedSnapshot snapshot = refreshedSnapshot
hardware = hardwareResult
if refreshPorts { lastPortSample = Date() }
lastUpdated = Date() lastUpdated = Date()
errorMessage = result.error errorMessage = result.error
appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent) appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent)
@@ -137,7 +147,7 @@ final class SystemMonitor {
func terminate(_ process: ProcessRecord, force: Bool = false) { func terminate(_ process: ProcessRecord, force: Bool = false) {
guard process.pid != getpid() else { guard process.pid != getpid() else {
errorMessage = "Task Manager cannot end itself." errorMessage = "Puter cannot end itself."
return return
} }
let signal = force ? SIGKILL : SIGTERM let signal = force ? SIGKILL : SIGTERM
@@ -160,7 +170,7 @@ final class SystemMonitor {
func terminateGroup(_ group: [ProcessRecord]) { func terminateGroup(_ group: [ProcessRecord]) {
guard !group.contains(where: { $0.pid == getpid() }) else { 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 return
} }
let pids = Set(group.map(\.pid)) let pids = Set(group.map(\.pid))
@@ -178,7 +188,7 @@ final class SystemMonitor {
func sendSignal(_ signal: Int32, to process: ProcessRecord, successMessage: String? = nil) { func sendSignal(_ signal: Int32, to process: ProcessRecord, successMessage: String? = nil) {
guard process.pid != getpid() else { guard process.pid != getpid() else {
errorMessage = "Task Manager cannot control itself." errorMessage = "Puter cannot control itself."
return return
} }
if kill(process.pid, signal) != 0 { if kill(process.pid, signal) != 0 {
@@ -260,6 +270,7 @@ final class SystemMonitor {
memoryHistory.append(MetricSample(date: now, value: memory)) memoryHistory.append(MetricSample(date: now, value: memory))
networkReceiveHistory.append(MetricSample(date: now, value: snapshot.networkReceiveRate)) networkReceiveHistory.append(MetricSample(date: now, value: snapshot.networkReceiveRate))
networkSendHistory.append(MetricSample(date: now, value: snapshot.networkSendRate)) 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)) diskReadHistory.append(MetricSample(date: now, value: snapshot.diskReadRate))
diskWriteHistory.append(MetricSample(date: now, value: snapshot.diskWriteRate)) diskWriteHistory.append(MetricSample(date: now, value: snapshot.diskWriteRate))
diskLatencyHistory.append(MetricSample(date: now, value: snapshot.diskLatencyMilliseconds)) diskLatencyHistory.append(MetricSample(date: now, value: snapshot.diskLatencyMilliseconds))
@@ -278,6 +289,7 @@ final class SystemMonitor {
memoryHistory.removeAll { $0.date < cutoff } memoryHistory.removeAll { $0.date < cutoff }
networkReceiveHistory.removeAll { $0.date < cutoff } networkReceiveHistory.removeAll { $0.date < cutoff }
networkSendHistory.removeAll { $0.date < cutoff } networkSendHistory.removeAll { $0.date < cutoff }
systemPowerHistory.removeAll { $0.date < cutoff }
diskReadHistory.removeAll { $0.date < cutoff } diskReadHistory.removeAll { $0.date < cutoff }
diskWriteHistory.removeAll { $0.date < cutoff } diskWriteHistory.removeAll { $0.date < cutoff }
diskLatencyHistory.removeAll { $0.date < cutoff } diskLatencyHistory.removeAll { $0.date < cutoff }
@@ -775,9 +787,15 @@ private enum ProcessScanner {
private static func memoryHardwareInfo() -> MemoryHardwareInfo { private static func memoryHardwareInfo() -> MemoryHardwareInfo {
let output = run("/usr/sbin/system_profiler", arguments: ["SPMemoryDataType", "-detailLevel", "mini"]).output 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( return MemoryHardwareInfo(
type: output.firstMatch(#"Type: +([^\n]+)"#) ?? "Unified", 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" manufacturer: output.firstMatch(#"Manufacturer: +([^\n]+)"#) ?? "Apple unified memory"
) )
} }
@@ -882,6 +900,227 @@ private enum ProcessScanner {
} }
} }
private enum HardwareScanner {
static func capture(existingPorts: [HardwarePortSnapshot], refreshPorts: Bool) -> HardwareSnapshot {
HardwareSnapshot(
battery: batterySnapshot(),
ports: refreshPorts ? portSnapshots() : existingPorts,
fans: fanSnapshots(),
thermalState: thermalState(),
capturedAt: Date()
)
}
private static func fanSnapshots() -> [FanSnapshot] {
let tool = FanControlService.toolPath
guard FileManager.default.isExecutableFile(atPath: tool) else { return [] }
let output = run(tool, arguments: ["fans"])
guard output.contains("Number of fans:") else { return [] }
return output.components(separatedBy: "\n\n").compactMap { block in
guard let identifier = block.firstMatch(#"(?m)^([0-9]+):"#).flatMap(Int.init) else { return nil }
func number(_ label: String) -> Double {
Double(block.firstMatch("(?m)^\(NSRegularExpression.escapedPattern(for: label)): ([0-9.]+)") ?? "0") ?? 0
}
let name = block.firstMatch(#"(?m)^[0-9]+: (.+)$"#) ?? "Fan \(identifier + 1)"
let mode = block.firstMatch(#"(?m)^Mode: (.+)$"#) ?? "automatic"
return FanSnapshot(
id: identifier,
name: name.replacingOccurrences(of: "#\(identifier)", with: "\(identifier + 1)"),
actualRPM: number("Actual speed"),
minimumRPM: number("Minimal speed"),
maximumRPM: number("Maximum speed"),
targetRPM: number("Target speed"),
mode: mode
)
}.sorted { $0.id < $1.id }
}
private static func batterySnapshot() -> BatterySnapshot {
let output = run("/usr/sbin/ioreg", arguments: ["-r", "-c", "AppleSmartBattery", "-l"])
guard output.contains("AppleSmartBattery") else { return BatterySnapshot() }
func topNumber(_ key: String) -> Double {
Double(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0") ?? 0
}
func topSignedNumber(_ key: String) -> Double {
let raw = output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0"
if let signed = Int64(raw) { return Double(signed) }
if let unsigned = UInt64(raw) { return Double(Int64(bitPattern: unsigned)) }
return 0
}
func topBool(_ key: String) -> Bool {
(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (Yes|No)") ?? "No") == "Yes"
}
func topString(_ key: String) -> String {
output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = \"([^\"]*)\"") ?? ""
}
let adapterLine = output.firstMatch(#"(?m)^ "AdapterDetails" = \{([^\n]+)\}"#) ?? ""
let telemetryLine = output.firstMatch(#"(?m)^ "PowerTelemetryData" = \{([^\n]+)\}"#) ?? ""
let externalPowerConnected = topBool("ExternalConnected")
let parsedAdapter = adapterLine.isEmpty ? nil : adapterSnapshot(from: adapterLine)
let adapter = externalPowerConnected && (parsedAdapter?.ratedWatts ?? 0) > 0 ? parsedAdapter : nil
let rawTime = Int(topNumber("TimeRemaining"))
let rawTemperature = topNumber("Temperature")
let temperature = rawTemperature > 1_000 ? rawTemperature / 10 - 273.15 : rawTemperature
let systemPowerMW = Double(telemetryLine.firstMatch(#""SystemPowerIn"=([0-9]+)"#) ?? "0") ?? 0
return BatterySnapshot(
isPresent: topBool("BatteryInstalled"),
chargePercent: topNumber("CurrentCapacity"),
isCharging: topBool("IsCharging"),
isFullyCharged: topBool("FullyCharged"),
externalPowerConnected: externalPowerConnected,
cycleCount: Int(topNumber("CycleCount")),
designCycleCount: Int(topNumber("DesignCycleCount9C")),
currentCapacityMAh: topNumber("AppleRawCurrentCapacity"),
fullChargeCapacityMAh: topNumber("NominalChargeCapacity"),
designCapacityMAh: topNumber("DesignCapacity"),
voltageVolts: topNumber("Voltage") / 1_000,
currentAmps: {
let amps = topSignedNumber("Amperage") / 1_000
return abs(amps) <= 100 ? amps : 0
}(),
temperatureCelsius: temperature,
timeRemainingMinutes: rawTime > 0 && rawTime < 65_535 ? rawTime : nil,
serial: topString("Serial"),
adapter: adapter,
systemPowerWatts: systemPowerMW / 1_000
)
}
private static func adapterSnapshot(from line: String) -> PowerAdapterSnapshot {
func number(_ key: String) -> Double {
Double(line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=([0-9]+)") ?? "0") ?? 0
}
func string(_ key: String) -> String {
line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=\"([^\"]*)\"") ?? ""
}
let regex = try? NSRegularExpression(pattern: #""MaxCurrent"=([0-9]+),"MaxVoltage"=([0-9]+)"#)
let range = NSRange(line.startIndex..<line.endIndex, in: line)
let profiles = (regex?.matches(in: line, range: range) ?? []).compactMap { match -> PowerProfile? in
guard let currentRange = Range(match.range(at: 1), in: line),
let voltageRange = Range(match.range(at: 2), in: line),
let current = Double(line[currentRange]),
let voltage = Double(line[voltageRange]) else { return nil }
return PowerProfile(voltageVolts: voltage / 1_000, currentAmps: current / 1_000)
}
return PowerAdapterSnapshot(
name: string("Name").isEmpty ? "USB-C power adapter" : string("Name"),
manufacturer: string("Manufacturer").isEmpty ? "Not reported" : string("Manufacturer"),
serial: string("SerialString"),
ratedWatts: number("Watts"),
negotiatedVoltage: number("AdapterVoltage") / 1_000,
negotiatedCurrent: number("Current") / 1_000,
profiles: profiles
)
}
private static func thermalState() -> String {
switch ProcessInfo.processInfo.thermalState {
case .nominal: "Nominal"
case .fair: "Fair"
case .serious: "Serious"
case .critical: "Critical"
@unknown default: "Unknown"
}
}
private static func portSnapshots() -> [HardwarePortSnapshot] {
let data = runData(
"/usr/sbin/system_profiler",
arguments: ["SPUSBDataType", "SPThunderboltDataType", "-json", "-detailLevel", "full"]
)
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
var ports: [HardwarePortSnapshot] = []
for (busIndex, value) in (root["SPThunderboltDataType"] as? [[String: Any]] ?? []).enumerated() {
let foundDevices = devices(in: value["_items"], depth: 0, prefix: "tb-\(busIndex)")
let receptacles = value.keys.filter { $0.hasPrefix("receptacle_") }.sorted()
for (index, key) in receptacles.enumerated() {
guard let receptacle = value[key] as? [String: Any] else { continue }
let rawStatus = receptacle["receptacle_status_key"] as? String ?? ""
let connected = !rawStatus.contains("no_devices")
let identifier = receptacle["receptacle_id_key"] as? String ?? "\(index + 1)"
ports.append(HardwarePortSnapshot(
id: "thunderbolt-\(busIndex)-\(identifier)",
name: "USB-C / Thunderbolt Port \(identifier)",
transport: "Thunderbolt / USB4",
maximumSpeed: receptacle["current_speed_key"] as? String ?? "Not reported",
isConnected: connected || !foundDevices.isEmpty,
status: connected ? "Device connected" : "Available",
devices: foundDevices
))
}
}
for (index, controller) in (root["SPUSBDataType"] as? [[String: Any]] ?? []).enumerated() {
let foundDevices = devices(in: controller["_items"], depth: 0, prefix: "usb-\(index)")
guard !foundDevices.isEmpty else { continue }
ports.append(HardwarePortSnapshot(
id: "usb-controller-\(index)",
name: controller["_name"] as? String ?? "USB Controller",
transport: "USB",
maximumSpeed: controller["device_speed"] as? String ?? "See connected devices",
isConnected: true,
status: "\(foundDevices.count) connected device\(foundDevices.count == 1 ? "" : "s")",
devices: foundDevices
))
}
return ports.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
private static func devices(in raw: Any?, depth: Int, prefix: String) -> [ConnectedHardwareDevice] {
guard let items = raw as? [[String: Any]] else { return [] }
return items.enumerated().flatMap { index, item -> [ConnectedHardwareDevice] in
let name = item["_name"] as? String ?? "Connected device"
let vendor = item["manufacturer"] as? String
?? item["vendor_name"] as? String
?? item["vendor_id"] as? String
?? "Not reported"
let device = ConnectedHardwareDevice(
id: "\(prefix)-\(index)-\(name)-\(depth)",
name: name,
vendor: vendor,
speed: item["device_speed"] as? String ?? item["current_speed_key"] as? String ?? "Not reported",
currentAvailableMA: integer(from: item["current_available"]),
currentRequiredMA: integer(from: item["current_required"]),
depth: depth
)
return [device] + devices(in: item["_items"], depth: depth + 1, prefix: "\(prefix)-\(index)")
}
}
private static func integer(from value: Any?) -> Int? {
if let number = value as? NSNumber { return number.intValue }
if let string = value as? String,
let raw = string.firstMatch(#"([0-9]+)"#) { return Int(raw) }
return nil
}
private static func run(_ path: String, arguments: [String]) -> String {
String(decoding: runData(path, arguments: arguments), as: UTF8.self)
}
private static func runData(_ path: String, arguments: [String]) -> Data {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = arguments
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return process.terminationStatus == 0 ? data : Data()
} catch {
return Data()
}
}
}
private enum BinaryArchitectureReader { private enum BinaryArchitectureReader {
private static let machO64: UInt32 = 0xfeedfacf private static let machO64: UInt32 = 0xfeedfacf
private static let machO64Swapped: UInt32 = 0xcffaedfe private static let machO64Swapped: UInt32 = 0xcffaedfe
+172
View File
@@ -0,0 +1,172 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
@MainActor
enum SystemReportExporter {
static func export(monitor: SystemMonitor, selectedResource: String? = nil) {
let panel = NSSavePanel()
panel.title = "Export system report"
panel.message = "Save the current performance, process, power, battery, and connected-hardware snapshot as JSON."
panel.prompt = "Export"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
panel.nameFieldStringValue = "Task-Manager-Report-\(filenameDate()).json"
let data: Data
do {
data = try JSONSerialization.data(
withJSONObject: report(monitor: monitor, selectedResource: selectedResource),
options: [.prettyPrinted, .sortedKeys]
)
} catch {
monitor.errorMessage = "Could not create the system report: \(error.localizedDescription)"
return
}
panel.begin { response in
guard response == .OK, let destination = panel.url else { return }
do {
try data.write(to: destination, options: .atomic)
} catch {
monitor.errorMessage = "Could not export the system report: \(error.localizedDescription)"
}
}
}
private static func report(monitor: SystemMonitor, selectedResource: String?) -> [String: Any] {
let snapshot = monitor.snapshot
let battery = monitor.hardware.battery
return [
"generatedAt": ISO8601DateFormatter().string(from: Date()),
"selectedResource": selectedResource ?? "All",
"system": [
"cpuPercent": snapshot.cpuPercent,
"logicalProcessors": snapshot.corePercents.count,
"memoryBytes": snapshot.memoryTotal,
"memoryUsedBytes": snapshot.memoryUsed,
"memoryType": snapshot.memoryType,
"memorySpeed": snapshot.memorySpeed,
"memoryManufacturer": snapshot.memoryManufacturer,
"diskReadBytesPerSecond": snapshot.diskReadRate,
"diskWriteBytesPerSecond": snapshot.diskWriteRate,
"diskActivePercent": snapshot.diskActivePercent,
"gpuPercent": snapshot.gpuPercent,
"gpuCoreCount": snapshot.gpuCoreCount,
"networkInterface": snapshot.networkInterface,
"networkReceiveBytesPerSecond": snapshot.networkReceiveRate,
"networkSendBytesPerSecond": snapshot.networkSendRate,
"processCount": snapshot.processCount,
"threadCount": snapshot.threadCount,
"uptimeSeconds": snapshot.uptime
],
"power": [
"thermalState": monitor.hardware.thermalState,
"systemPowerWatts": battery.systemPowerWatts,
"externalPowerConnected": battery.externalPowerConnected,
"lowPowerMode": ProcessInfo.processInfo.isLowPowerModeEnabled
],
"battery": batteryReport(battery),
"ports": monitor.hardware.ports.map(portReport),
"topProcesses": topProcesses(monitor),
"topUsers": topUsers(monitor)
]
}
private static func batteryReport(_ battery: BatterySnapshot) -> [String: Any] {
var result: [String: Any] = [
"present": battery.isPresent,
"chargePercent": battery.chargePercent,
"charging": battery.isCharging,
"fullyCharged": battery.isFullyCharged,
"healthPercent": battery.healthPercent,
"cycleCount": battery.cycleCount,
"designCycleCount": battery.designCycleCount,
"temperatureCelsius": battery.temperatureCelsius,
"voltageVolts": battery.voltageVolts,
"fullChargeCapacityMAh": battery.fullChargeCapacityMAh,
"designCapacityMAh": battery.designCapacityMAh
]
if let adapter = battery.adapter {
result["adapter"] = [
"name": adapter.name,
"manufacturer": adapter.manufacturer,
"ratedWatts": adapter.ratedWatts,
"negotiatedVoltage": adapter.negotiatedVoltage,
"negotiatedCurrent": adapter.negotiatedCurrent,
"negotiatedWatts": adapter.negotiatedWatts,
"powerProfiles": adapter.profiles.map {
["voltage": $0.voltageVolts, "current": $0.currentAmps, "watts": $0.watts]
}
]
}
return result
}
private static func portReport(_ port: HardwarePortSnapshot) -> [String: Any] {
[
"name": port.name,
"transport": port.transport,
"maximumSpeed": port.maximumSpeed,
"connected": port.isConnected,
"status": port.status,
"devices": port.devices.map {
var device: [String: Any] = ["name": $0.name, "vendor": $0.vendor, "speed": $0.speed]
if let value = $0.currentAvailableMA { device["currentAvailableMA"] = value }
if let value = $0.currentRequiredMA { device["currentRequiredMA"] = value }
return device
}
]
}
private static func topProcesses(_ monitor: SystemMonitor) -> [[String: Any]] {
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(20).map { process in
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
return [
"name": process.displayName,
"pid": process.pid,
"user": process.user,
"cpuPercent": process.cpu,
"residentBytes": process.residentBytes,
"diskBytesPerSecond": activity.diskTotal,
"networkBytesPerSecond": activity.networkTotal
]
}
}
private static func topUsers(_ monitor: SystemMonitor) -> [[String: Any]] {
struct Totals {
var processes = 0
var cpu = 0.0
var memory: UInt64 = 0
var disk = 0.0
var network = 0.0
}
var users: [String: Totals] = [:]
for process in monitor.processes {
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
users[process.user, default: Totals()].processes += 1
users[process.user, default: Totals()].cpu += process.cpu
users[process.user, default: Totals()].memory += process.residentBytes
users[process.user, default: Totals()].disk += activity.diskTotal
users[process.user, default: Totals()].network += activity.networkTotal
}
return users.map { user, totals in
[
"user": user,
"processes": totals.processes,
"cpuPercent": totals.cpu,
"residentBytes": totals.memory,
"diskBytesPerSecond": totals.disk,
"networkBytesPerSecond": totals.network
]
}
.sorted { ($0["cpuPercent"] as? Double ?? 0) > ($1["cpuPercent"] as? Double ?? 0) }
}
private static func filenameDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
return formatter.string(from: Date())
}
}
@@ -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 })
}
}
+99
View File
@@ -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)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

-62
View File
@@ -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"
}
}
+35 -7
View File
@@ -2,10 +2,10 @@
set -euo pipefail set -euo pipefail
PROJECT_DIR="${0:A:h:h}" PROJECT_DIR="${0:A:h:h}"
APP_DIR="$PROJECT_DIR/dist/Task Manager.app" APP_DIR="$PROJECT_DIR/dist/Puter.app"
ICON_SOURCE="$PROJECT_DIR/mactaskmanager.icon" ICON_SOURCE="${PUTER_ICON_SOURCE:-$PROJECT_DIR/Puter.icon}"
STAGING_ROOT="$(mktemp -d /tmp/mactaskmanager-build.XXXXXX)" STAGING_ROOT="$(mktemp -d /tmp/puter-build.XXXXXX)"
STAGED_APP="$STAGING_ROOT/Task Manager.app" STAGED_APP="$STAGING_ROOT/Puter.app"
CONTENTS_DIR="$STAGED_APP/Contents" CONTENTS_DIR="$STAGED_APP/Contents"
trap '/bin/rm -rf -- "$STAGING_ROOT"' EXIT trap '/bin/rm -rf -- "$STAGING_ROOT"' EXIT
@@ -14,17 +14,38 @@ cd "$PROJECT_DIR"
swift build -c release swift build -c release
mkdir -p "$CONTENTS_DIR/MacOS" "$CONTENTS_DIR/Resources" 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" 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 if [[ -d "$ICON_SOURCE" ]]; then
print "Using Icon Composer source: $ICON_SOURCE"
xcrun actool "$ICON_SOURCE" \ xcrun actool "$ICON_SOURCE" \
--compile "$CONTENTS_DIR/Resources" \ --compile "$CONTENTS_DIR/Resources" \
--platform macosx \ --platform macosx \
--minimum-deployment-target 14.0 \ --minimum-deployment-target 14.0 \
--app-icon mactaskmanager \ --app-icon Puter \
--output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null --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 fi
# Finder and cloud-sync metadata can make an otherwise valid app bundle # 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" codesign --verify --deep --strict "$STAGED_APP"
mkdir -p "$PROJECT_DIR/dist" mkdir -p "$PROJECT_DIR/dist"
/bin/rm -rf -- "$APP_DIR"
ditto --norsrc "$STAGED_APP" "$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" print "Built $APP_DIR"
+7 -7
View File
@@ -2,9 +2,9 @@
set -euo pipefail set -euo pipefail
PROJECT_DIR="${0:A:h:h}" PROJECT_DIR="${0:A:h:h}"
APP_PATH="$PROJECT_DIR/dist/Task Manager.app" APP_PATH="$PROJECT_DIR/dist/Puter.app"
DMG_PATH="$PROJECT_DIR/dist/Task Manager-macOS.dmg" DMG_PATH="$PROJECT_DIR/dist/Puter-macOS.dmg"
STAGING_DIR="$(mktemp -d /tmp/mactaskmanager-dmg.XXXXXX)" STAGING_DIR="$(mktemp -d /tmp/puter-dmg.XXXXXX)"
cleanup() { cleanup() {
rm -rf "$STAGING_DIR" rm -rf "$STAGING_DIR"
@@ -15,13 +15,13 @@ if [[ ! -d "$APP_PATH" ]]; then
"$PROJECT_DIR/scripts/build-app.sh" "$PROJECT_DIR/scripts/build-app.sh"
fi fi
ditto --norsrc "$APP_PATH" "$STAGING_DIR/Task Manager.app" ditto --norsrc "$APP_PATH" "$STAGING_DIR/Puter.app"
xattr -cr "$STAGING_DIR/Task Manager.app" xattr -cr "$STAGING_DIR/Puter.app"
codesign --verify --deep --strict "$STAGING_DIR/Task Manager.app" codesign --verify --deep --strict "$STAGING_DIR/Puter.app"
ln -s /Applications "$STAGING_DIR/Applications" ln -s /Applications "$STAGING_DIR/Applications"
hdiutil create \ hdiutil create \
-volname "Task Manager" \ -volname "Puter" \
-srcfolder "$STAGING_DIR" \ -srcfolder "$STAGING_DIR" \
-format UDZO \ -format UDZO \
-imagekey zlib-level=9 \ -imagekey zlib-level=9 \