Rename app and repository to Puter
This commit is contained in:
+7
-7
@@ -2,20 +2,20 @@
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MacTaskManager",
|
||||
name: "Puter",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.executable(name: "MacTaskManager", targets: ["MacTaskManager"])
|
||||
.executable(name: "Puter", targets: ["Puter"])
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "MacTaskManager",
|
||||
path: "Sources/MacTaskManager"
|
||||
name: "Puter",
|
||||
path: "Sources/Puter"
|
||||
),
|
||||
.testTarget(
|
||||
name: "MacTaskManagerTests",
|
||||
dependencies: ["MacTaskManager"],
|
||||
path: "Tests/MacTaskManagerTests"
|
||||
name: "PuterTests",
|
||||
dependencies: ["Puter"],
|
||||
path: "Tests/PuterTests"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Mac Task Manager
|
||||
# Puter
|
||||
|
||||
A native macOS system monitor written in SwiftUI and inspired by Windows 11 Task Manager.
|
||||
|
||||
@@ -11,7 +11,7 @@ Validated with automated tests, debug and release builds, packaged-app code-sign
|
||||
- Search and sortable process columns, including live Disk and Network rates
|
||||
- Drag-resizable, persisted Processes columns with memory hover details showing both resident bytes and physical-memory percentage
|
||||
- Column sorting automatically flattens process categories for a true global order; the header menu can restore Group by type
|
||||
- Expandable application groups plus an advanced Details table with persisted optional columns for threads, open handles, architecture, priority/nice, parent PID, CPU time, and other diagnostics
|
||||
- Expandable application groups plus a resizable Details table with application icons, persisted optional columns, live selection actions, threads, open handles, architecture, priority/nice, parent PID, CPU time, and other diagnostics
|
||||
- Collapsible Windows-style Apps, Background processes, and macOS processes categories
|
||||
- Native process sampling reports from the Inspect submenu, with save-location memory and completion feedback
|
||||
- Persistent preferences for the default start page, Performance selection and CPU graph mode, update speed, always-on-top behavior, and process/detail table layouts
|
||||
@@ -26,8 +26,11 @@ Validated with automated tests, debug and release builds, packaged-app code-sign
|
||||
- Disk, uptime, process, and thread statistics
|
||||
- One live Performance entry per available removable volume, with capacity, availability, filesystem, mount point, and ejectable status; disconnected volumes are hidden
|
||||
- Detailed memory telemetry for active, cached, wired, compressed, and swap usage plus hardware type, manufacturer, and reported speed/frequency
|
||||
- A maintained M1-through-M5 Apple-silicon memory catalog, including core-count configuration variants; Intel Macs continue to use DIMM speeds reported directly by System Information
|
||||
- Native disk read/write throughput, IOPS, active time, and response-time histories
|
||||
- Live Apple GPU utilization with overall, renderer, and tiler histories plus GPU memory and core statistics
|
||||
- Hardware diagnostics for battery health, capacity, cycle life, temperature, charge state, live system watts, thermal pressure, USB-PD negotiation and advertised power profiles
|
||||
- USB-C, USB, and Thunderbolt port inventory with negotiated link speeds, connected-device power requirements, and an optional available-port map
|
||||
- User, detail, service, persistent app history, and startup views covering native login items plus LaunchAgents
|
||||
- Estimated live startup impact with Low, Medium, and High classifications and detailed CPU/memory/disk evidence
|
||||
- Combined user and system launchd Services registry with scope filtering, running/stopped state, safe Start/Stop/Restart controls, protected-system labeling, configuration reveal, Details, and Properties actions
|
||||
@@ -38,6 +41,9 @@ Validated with automated tests, debug and release builds, packaged-app code-sign
|
||||
- Run New Task sheet and persistent update-speed settings
|
||||
- WinUI-style expanded and compact navigation rail controlled by the native title-bar sidebar button, with persisted state, balanced spacing, tooltips, and accessibility labels
|
||||
- Native macOS menus, toolbar, light/dark mode, and accessibility behavior
|
||||
- Per-resource top-process panels on Performance with application icons, owning users, and live CPU, memory, disk, or network values
|
||||
- Native JSON export of performance, process, user, battery, power-delivery, thermal, and connected-hardware snapshots
|
||||
- Integrated SMC fan telemetry and safe in-app cooling controls with per-fan RPM ranges, manual targets, and one-click return to macOS automatic management
|
||||
|
||||
## Run
|
||||
|
||||
@@ -51,7 +57,7 @@ To create a standard double-clickable app bundle:
|
||||
|
||||
```sh
|
||||
./scripts/build-app.sh
|
||||
open "dist/Task Manager.app"
|
||||
open "dist/Puter.app"
|
||||
```
|
||||
|
||||
To create a drag-to-install disk image containing the app and an Applications shortcut:
|
||||
|
||||
@@ -5,19 +5,17 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Task Manager</string>
|
||||
<string>Puter</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>MacTaskManager</string>
|
||||
<string>Puter</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>local.mactaskmanager.app</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>mactaskmanager.icns</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>mactaskmanager</string>
|
||||
<string>Puter</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Task Manager</string>
|
||||
<string>Puter</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -83,7 +83,7 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
@Bindable var monitor = monitor
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Settings", subtitle: "Customize Task Manager")
|
||||
PageHeader(title: "Settings", subtitle: "Customize Puter")
|
||||
Form {
|
||||
Section("General") {
|
||||
Picker("Default start page", selection: $defaultStartPage) {
|
||||
@@ -125,8 +125,8 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Application", value: "Task Manager for macOS")
|
||||
LabeledContent("Version", value: "1.0")
|
||||
LabeledContent("Application", value: "Puter")
|
||||
LabeledContent("Version", value: appVersion)
|
||||
LabeledContent("Framework", value: "Native SwiftUI")
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,10 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Development"
|
||||
}
|
||||
|
||||
private func chooseDiagnosticDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Choose diagnostic report folder"
|
||||
@@ -14,7 +14,7 @@ struct PageHeader<Trailing: View>: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title).font(.system(size: 28, weight: .semibold))
|
||||
Text(title).font(.title.weight(.semibold))
|
||||
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
|
||||
}
|
||||
Spacer()
|
||||
@@ -33,7 +33,7 @@ struct ResourceSummaryBar: View {
|
||||
HStack(spacing: 22) {
|
||||
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu")
|
||||
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip")
|
||||
Label("\(Formatters.percent(snapshot.diskPercent)) Disk", systemImage: "internaldrive")
|
||||
Label("\(Formatters.percent(snapshot.diskActivePercent)) Disk activity", systemImage: "internaldrive")
|
||||
Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
|
||||
Spacer()
|
||||
}
|
||||
@@ -54,7 +54,11 @@ struct UpdateStatus: View {
|
||||
if monitor.isPaused {
|
||||
Text("Updates paused")
|
||||
} else if let date = monitor.lastUpdated {
|
||||
Text("Updated \(date, style: .relative)")
|
||||
if Date().timeIntervalSince(date) < 1.5 {
|
||||
Text("Updated now")
|
||||
} else {
|
||||
Text("Updated \(date, style: .relative)")
|
||||
}
|
||||
} else {
|
||||
Text("Collecting system data…")
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension Notification.Name {
|
||||
static let runNewTask = Notification.Name("Puter.runNewTask")
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@State private var selection: TaskSection?
|
||||
@State private var searchText = ""
|
||||
@State private var showingAbout = false
|
||||
@State private var showingNewTask = false
|
||||
@State private var detailSelection: Int32?
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@@ -32,6 +35,7 @@ struct ContentView: View {
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
|
||||
case .performance:
|
||||
PerformanceView()
|
||||
case .history:
|
||||
@@ -39,6 +43,7 @@ struct ContentView: View {
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
|
||||
case .startup:
|
||||
StartupAppsView()
|
||||
case .users:
|
||||
@@ -48,33 +53,31 @@ struct ContentView: View {
|
||||
}
|
||||
case .details:
|
||||
DetailsView(searchText: searchText, selection: $detailSelection)
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
|
||||
case .services:
|
||||
ServicesView(searchText: searchText) { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
|
||||
case .hardware:
|
||||
HardwareView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Type a name, PID, or user")
|
||||
.toolbar {
|
||||
TaskToolbarContent(showingNewTask: $showingNewTask, showingAbout: $showingAbout)
|
||||
TaskToolbarContent(showingNewTask: $showingNewTask)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Task Manager")
|
||||
.navigationTitle("Puter")
|
||||
.animation(.snappy(duration: 0.22), value: sidebarCompact)
|
||||
.onChange(of: columnVisibility) { _, visibility in
|
||||
guard visibility == .detailOnly else { return }
|
||||
columnVisibility = .all
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
||||
withAnimation(.snappy(duration: 0.22)) { sidebarCompact.toggle() }
|
||||
}
|
||||
}
|
||||
.onChange(of: selection) { _, _ in searchText = "" }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
|
||||
.sheet(isPresented: $showingNewTask) {
|
||||
NewTaskView(isPresented: $showingNewTask)
|
||||
}
|
||||
.alert("Task Manager", isPresented: Binding(
|
||||
.alert("Puter", isPresented: Binding(
|
||||
get: { monitor.errorMessage != nil },
|
||||
set: { if !$0 { monitor.errorMessage = nil } }
|
||||
)) {
|
||||
@@ -82,11 +85,6 @@ struct ContentView: View {
|
||||
} message: {
|
||||
Text(monitor.errorMessage ?? "")
|
||||
}
|
||||
.alert("Task Manager for macOS", isPresented: $showingAbout) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text("A native SwiftUI system monitor inspired by Windows Task Manager.")
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal
|
||||
@@ -98,7 +96,7 @@ struct ContentView: View {
|
||||
private struct TaskToolbarContent: ToolbarContent {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@Binding var showingNewTask: Bool
|
||||
@Binding var showingAbout: Bool
|
||||
@AppStorage("sidebarCompact") private var sidebarCompact = false
|
||||
|
||||
var body: some ToolbarContent {
|
||||
@Bindable var monitor = monitor
|
||||
@@ -120,7 +118,7 @@ private struct TaskToolbarContent: ToolbarContent {
|
||||
Menu {
|
||||
UpdateSpeedPicker(selection: $monitor.updateSpeed)
|
||||
Divider()
|
||||
Button("About Task Manager") { showingAbout = true }
|
||||
Toggle("Compact Sidebar", isOn: $sidebarCompact)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
@@ -143,7 +141,7 @@ private struct Sidebar: View {
|
||||
} label: {
|
||||
sidebarLabel(section.rawValue, icon: section.icon)
|
||||
.padding(.horizontal, isCompact ? 0 : 8)
|
||||
.frame(height: 40)
|
||||
.frame(minHeight: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -166,7 +164,7 @@ private struct Sidebar: View {
|
||||
} label: {
|
||||
sidebarLabel("Settings", icon: "gearshape")
|
||||
.padding(.horizontal, isCompact ? 0 : 8)
|
||||
.frame(height: 40)
|
||||
.frame(minHeight: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import AppKit
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
struct HardwareView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@AppStorage("showEmptyHardwarePorts") private var showEmptyPorts = true
|
||||
@State private var fanTargets: [Int: Double] = [:]
|
||||
@State private var fanCommandInProgress = false
|
||||
@State private var fanError: String?
|
||||
@State private var fanStatus: String?
|
||||
@State private var showingFanConfirmation = false
|
||||
|
||||
private var battery: BatterySnapshot { monitor.hardware.battery }
|
||||
private var visiblePorts: [HardwarePortSnapshot] {
|
||||
showEmptyPorts ? monitor.hardware.ports : monitor.hardware.ports.filter(\.isConnected)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Hardware", subtitle: "Power, battery, thermals, ports, and connection diagnostics") {
|
||||
HStack(spacing: 10) {
|
||||
Button("Export", systemImage: "square.and.arrow.up") {
|
||||
SystemReportExporter.export(monitor: monitor, selectedResource: "Hardware")
|
||||
}
|
||||
Button("Power Settings", systemImage: "gear") { openPowerSettings() }
|
||||
}
|
||||
}
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 18) {
|
||||
summaryGrid
|
||||
verdictCard
|
||||
if battery.isPresent { batterySection }
|
||||
if let adapter = battery.adapter { adapterSection(adapter) }
|
||||
powerSection
|
||||
portsSection
|
||||
coolingSection
|
||||
topConsumersSection
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
.onChange(of: monitor.hardware.fans) { _, fans in
|
||||
seedFanTargets(fans)
|
||||
}
|
||||
.onAppear { seedFanTargets(monitor.hardware.fans) }
|
||||
.confirmationDialog("Apply manual fan targets?", isPresented: $showingFanConfirmation) {
|
||||
Button("Apply Targets") { updateFans(manual: true) }
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text("\(fanTargetSummary). macOS automatic fan control will be disabled until you choose Automatic.")
|
||||
}
|
||||
.alert("Fan control failed", isPresented: Binding(
|
||||
get: { fanError != nil },
|
||||
set: { if !$0 { fanError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { fanError = nil }
|
||||
} message: {
|
||||
Text(fanError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryGrid: some View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 10)], spacing: 10) {
|
||||
summaryCard("CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), detail: "\(monitor.snapshot.corePercents.count) logical processors", icon: "cpu", color: .blue)
|
||||
summaryCard("Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), detail: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed)), icon: "memorychip", color: .purple)
|
||||
summaryCard("System power", value: watts(battery.systemPowerWatts), detail: battery.externalPowerConnected ? "External power" : "Battery power", icon: "bolt.fill", color: .orange)
|
||||
summaryCard("Thermals", value: monitor.hardware.thermalState, detail: thermalDetail, icon: "thermometer.medium", color: thermalColor)
|
||||
}
|
||||
}
|
||||
|
||||
private func summaryCard(_ title: String, value: String, detail: String, icon: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(color)
|
||||
Text(value).font(.title2.weight(.semibold)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
|
||||
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 86, alignment: .leading)
|
||||
.padding(13)
|
||||
.background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.18)) }
|
||||
}
|
||||
|
||||
private var verdictCard: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: verdictIcon).font(.title2).foregroundStyle(verdictColor).frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(verdictTitle).font(.headline)
|
||||
Text(verdictDetail).font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(15)
|
||||
.background(verdictColor.opacity(0.09), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(verdictColor.opacity(0.22)) }
|
||||
}
|
||||
|
||||
private var batterySection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Battery").font(.headline)
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
metric("Charge", Formatters.percent(battery.chargePercent))
|
||||
Spacer()
|
||||
metric("Condition", battery.healthPercent > 80 ? "Normal" : "Service recommended")
|
||||
Spacer()
|
||||
metric("Health", Formatters.percent(battery.healthPercent))
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
metric("Cycles", cycleText)
|
||||
Spacer()
|
||||
metric("Temperature", String(format: "%.1f °C", battery.temperatureCelsius))
|
||||
Spacer()
|
||||
metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
metric("Battery power", watts(battery.batteryPowerWatts))
|
||||
Spacer()
|
||||
metric("Full capacity", capacity(battery.fullChargeCapacityMAh))
|
||||
Spacer()
|
||||
metric("Design capacity", capacity(battery.designCapacityMAh))
|
||||
Spacer()
|
||||
metric("Time remaining", timeRemaining)
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
Label(powerSourceText, systemImage: battery.externalPowerConnected ? "powerplug.fill" : "battery.75percent")
|
||||
Spacer()
|
||||
Text(batteryStateText).foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.callout.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
|
||||
}
|
||||
|
||||
private func adapterSection(_ adapter: PowerAdapterSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 13) {
|
||||
Text("Charger and USB Power Delivery").font(.headline)
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(adapter.name).font(.headline)
|
||||
Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
|
||||
}
|
||||
Divider()
|
||||
HStack(spacing: 24) {
|
||||
metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage))
|
||||
metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent))
|
||||
metric("Negotiated ceiling", watts(adapter.negotiatedWatts))
|
||||
metric("System draw", watts(battery.systemPowerWatts))
|
||||
Spacer()
|
||||
}
|
||||
if !adapter.profiles.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("Advertised power profiles").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
HStack(spacing: 7) {
|
||||
ForEach(adapter.profiles) { profile in
|
||||
Text(String(format: "%.0f V × %.2f A · %.0f W", profile.voltageVolts, profile.currentAmps, profile.watts))
|
||||
.font(.caption.monospacedDigit())
|
||||
.padding(.horizontal, 9).padding(.vertical, 5)
|
||||
.background(Color.accentColor.opacity(profile.voltageVolts == adapter.negotiatedVoltage ? 0.18 : 0.07), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Label(cableEvidence(adapter), systemImage: "info.circle")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
|
||||
}
|
||||
|
||||
private var powerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Power history — 60 seconds").font(.headline)
|
||||
Chart(monitor.systemPowerHistory) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
|
||||
.foregroundStyle(.orange.opacity(0.13))
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
|
||||
.foregroundStyle(.orange).lineStyle(.init(lineWidth: 2))
|
||||
}
|
||||
.chartYAxisLabel("W")
|
||||
.chartXAxis(.hidden)
|
||||
.frame(height: 150)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
|
||||
}
|
||||
|
||||
private var portsSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("USB-C, USB, and Thunderbolt").font(.headline)
|
||||
Spacer()
|
||||
Toggle("Show available ports", isOn: $showEmptyPorts).toggleStyle(.switch).controlSize(.small)
|
||||
}
|
||||
.padding(8)
|
||||
if !visiblePorts.isEmpty { Divider() }
|
||||
ForEach(visiblePorts) { port in
|
||||
portRow(port)
|
||||
if port.id != visiblePorts.last?.id { Divider() }
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
|
||||
}
|
||||
|
||||
private func portRow(_ port: HardwarePortSnapshot) -> some View {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
HStack {
|
||||
Image(systemName: port.isConnected ? "cable.connector" : "bolt.horizontal.circle")
|
||||
.foregroundStyle(port.isConnected ? Color.green : Color.secondary)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(port.name).font(.callout.weight(.semibold))
|
||||
Text("\(port.transport) • \(port.maximumSpeed)").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(port.status).font(.caption.weight(.medium)).foregroundStyle(port.isConnected ? Color.green : Color.secondary)
|
||||
}
|
||||
ForEach(port.devices) { device in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.turn.down.right").foregroundStyle(.tertiary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(device.name).lineLimit(1)
|
||||
Text(deviceDetail(device)).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, CGFloat(device.depth * 18 + 30))
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
private var topConsumersSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Top live consumers").font(.headline)
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5))) { process in
|
||||
HStack {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
Text(process.displayName).lineLimit(1)
|
||||
Spacer()
|
||||
Text(Formatters.percent(process.cpu)).monospacedDigit().frame(width: 64, alignment: .trailing)
|
||||
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
|
||||
}
|
||||
|
||||
private var coolingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 13) {
|
||||
HStack {
|
||||
Label("Cooling and fan control", systemImage: "fan")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? "Automatic" : "Manual")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? .green : .orange)
|
||||
}
|
||||
Divider()
|
||||
if monitor.hardware.fans.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "fan.slash").font(.title2).foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Fan telemetry unavailable").font(.callout.weight(.medium))
|
||||
Text("This Mac is not publishing compatible SMC fan data.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(monitor.hardware.fans) { fan in
|
||||
fanRow(fan)
|
||||
if fan.id != monitor.hardware.fans.last?.id { Divider().opacity(0.55) }
|
||||
}
|
||||
}
|
||||
HStack(spacing: 28) {
|
||||
metric("Thermal pressure", monitor.hardware.thermalState)
|
||||
metric("Low Power Mode", ProcessInfo.processInfo.isLowPowerModeEnabled ? "On" : "Off")
|
||||
metric("Control backend", FanControlService.isAvailable ? "Ready" : "Unavailable")
|
||||
Spacer()
|
||||
}
|
||||
Text("Puter reads fan sensors directly through the Mac's SMC interface. Manual targets are clamped to each fan's reported safe range; Automatic returns control to macOS.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Apply targets", systemImage: "speedometer") {
|
||||
showingFanConfirmation = true
|
||||
}
|
||||
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
|
||||
Button("Automatic", systemImage: "arrow.counterclockwise") { updateFans(manual: false) }
|
||||
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
|
||||
Button("Reduce monitor refresh", systemImage: "tortoise") {
|
||||
monitor.updateSpeed = .low
|
||||
}
|
||||
Spacer()
|
||||
if fanCommandInProgress {
|
||||
ProgressView().controlSize(.small).accessibilityLabel("Applying fan settings")
|
||||
} else if let fanStatus {
|
||||
Label(fanStatus, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
|
||||
}
|
||||
|
||||
private func fanRow(_ fan: FanSnapshot) -> some View {
|
||||
let target = Binding(
|
||||
get: { safeTarget(for: fan) },
|
||||
set: { fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, $0)) }
|
||||
)
|
||||
return HStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle().fill(Color.cyan.opacity(0.12)).frame(width: 40, height: 40)
|
||||
Image(systemName: "fan.fill").foregroundStyle(.cyan)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(fan.name).font(.callout.weight(.semibold))
|
||||
Text("\(Int(fan.actualRPM.rounded())) RPM now · \(Int(fan.minimumRPM))–\(Int(fan.maximumRPM)) RPM")
|
||||
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
.frame(width: 220, alignment: .leading)
|
||||
Slider(value: target, in: fan.minimumRPM...max(fan.minimumRPM + 1, fan.maximumRPM), step: 25)
|
||||
.accessibilityLabel("\(fan.name) target speed")
|
||||
.accessibilityValue("\(Int(target.wrappedValue.rounded())) RPM")
|
||||
.accessibilityHint("Adjusts from \(Int(fan.minimumRPM)) to \(Int(fan.maximumRPM)) RPM")
|
||||
Text("\(Int(target.wrappedValue.rounded())) RPM")
|
||||
.font(.callout.monospacedDigit()).frame(width: 82, alignment: .trailing)
|
||||
Text(fan.isAutomatic ? "Auto" : "Manual")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(fan.isAutomatic ? .green : .orange)
|
||||
.frame(width: 52, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFans(manual: Bool) {
|
||||
fanCommandInProgress = true
|
||||
fanStatus = nil
|
||||
let targets = Dictionary(uniqueKeysWithValues: monitor.hardware.fans.map { fan in
|
||||
(fan.id, safeTarget(for: fan))
|
||||
})
|
||||
Task {
|
||||
do {
|
||||
try await Task.detached {
|
||||
if manual { try FanControlService.setManualTargets(targets) }
|
||||
else { try FanControlService.restoreAutomatic() }
|
||||
}.value
|
||||
fanStatus = manual ? "Targets applied" : "Automatic control restored"
|
||||
monitor.refresh()
|
||||
} catch {
|
||||
fanError = error.localizedDescription
|
||||
}
|
||||
fanCommandInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
private var fanTargetSummary: String {
|
||||
monitor.hardware.fans.map { fan in
|
||||
let target = safeTarget(for: fan)
|
||||
return "\(fan.name): \(Int(target.rounded())) RPM"
|
||||
}.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func seedFanTargets(_ fans: [FanSnapshot]) {
|
||||
for fan in fans where fanTargets[fan.id] == nil {
|
||||
let reported = fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM
|
||||
fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, reported))
|
||||
}
|
||||
}
|
||||
|
||||
private func safeTarget(for fan: FanSnapshot) -> Double {
|
||||
let reported = fanTargets[fan.id] ?? (fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM)
|
||||
return min(fan.maximumRPM, max(fan.minimumRPM, reported))
|
||||
}
|
||||
|
||||
private func metric(_ title: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
Text(value).font(.callout.weight(.medium)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
|
||||
}
|
||||
}
|
||||
|
||||
private var verdictTitle: String {
|
||||
if !battery.isPresent { return "Power telemetry available for desktop hardware" }
|
||||
if battery.isFullyCharged && battery.externalPowerConnected { return "Battery full — running from external power" }
|
||||
if battery.isCharging { return "Battery charging normally" }
|
||||
if battery.externalPowerConnected { return "External power connected — battery is not charging" }
|
||||
return "Running on battery"
|
||||
}
|
||||
|
||||
private var verdictDetail: String {
|
||||
guard battery.isPresent else { return "Battery-specific fields are hidden because this Mac reports no internal battery." }
|
||||
if let adapter = battery.adapter {
|
||||
return "macOS reports a \(Int(adapter.ratedWatts.rounded())) W adapter and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Current system input is \(watts(battery.systemPowerWatts))."
|
||||
}
|
||||
return "Current battery draw is \(watts(battery.batteryPowerWatts)). Charger and cable capability are shown only when macOS publishes them."
|
||||
}
|
||||
|
||||
private var verdictIcon: String {
|
||||
battery.externalPowerConnected ? "checkmark.circle.fill" : "battery.75percent"
|
||||
}
|
||||
|
||||
private var verdictColor: Color {
|
||||
monitor.hardware.thermalState == "Serious" || monitor.hardware.thermalState == "Critical" ? .red : .green
|
||||
}
|
||||
|
||||
private var thermalColor: Color {
|
||||
switch monitor.hardware.thermalState {
|
||||
case "Critical", "Serious": .red
|
||||
case "Fair": .orange
|
||||
default: .green
|
||||
}
|
||||
}
|
||||
|
||||
private var thermalDetail: String {
|
||||
switch monitor.hardware.thermalState {
|
||||
case "Critical": "Performance is heavily constrained"
|
||||
case "Serious": "Performance may be reduced"
|
||||
case "Fair": "Elevated thermal pressure"
|
||||
default: "No thermal pressure"
|
||||
}
|
||||
}
|
||||
|
||||
private var batteryColor: Color { battery.chargePercent < 20 ? .red : (battery.isCharging ? .green : .accentColor) }
|
||||
private var cycleText: String { battery.designCycleCount > 0 ? "\(battery.cycleCount) of \(battery.designCycleCount)" : "\(battery.cycleCount)" }
|
||||
private var timeRemaining: String {
|
||||
guard let minutes = battery.timeRemainingMinutes else { return battery.isFullyCharged ? "Full" : "Calculating" }
|
||||
return "\(minutes / 60)h \(minutes % 60)m"
|
||||
}
|
||||
private var powerSourceText: String { battery.externalPowerConnected ? "Power adapter" : "Internal battery" }
|
||||
private var batteryStateText: String { battery.isFullyCharged ? "Fully charged" : (battery.isCharging ? "Charging" : "Not charging") }
|
||||
|
||||
private func cableEvidence(_ adapter: PowerAdapterSnapshot) -> String {
|
||||
"Observed USB-PD contract: \(String(format: "%.1f V at %.2f A", adapter.negotiatedVoltage, adapter.negotiatedCurrent)). Cable e-marker identity is not claimed unless macOS exposes it."
|
||||
}
|
||||
|
||||
private func deviceDetail(_ device: ConnectedHardwareDevice) -> String {
|
||||
var parts = [device.vendor, device.speed].filter { $0 != "Not reported" }
|
||||
if let required = device.currentRequiredMA { parts.append("requests \(required) mA") }
|
||||
if let available = device.currentAvailableMA { parts.append("\(available) mA available") }
|
||||
return parts.isEmpty ? "Technical details not reported by macOS" : parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private func watts(_ value: Double) -> String { value > 0 ? String(format: "%.1f W", value) : "—" }
|
||||
private func capacity(_ value: Double) -> String { value > 0 ? String(format: "%.0f mAh", value) : "Not reported" }
|
||||
|
||||
private func openPowerSettings() {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.battery") { NSWorkspace.shared.open(url) }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ enum TaskSection: String, CaseIterable, Identifiable {
|
||||
case users = "Users"
|
||||
case details = "Details"
|
||||
case services = "Services"
|
||||
case hardware = "Hardware"
|
||||
case settings = "Settings"
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -22,11 +23,153 @@ enum TaskSection: String, CaseIterable, Identifiable {
|
||||
case .users: "person.2"
|
||||
case .details: "list.bullet.rectangle"
|
||||
case .services: "gearshape.2"
|
||||
case .hardware: "bolt.horizontal.circle"
|
||||
case .settings: "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PowerProfile: Identifiable, Hashable, Sendable {
|
||||
let voltageVolts: Double
|
||||
let currentAmps: Double
|
||||
var id: String { "\(voltageVolts)-\(currentAmps)" }
|
||||
var watts: Double { voltageVolts * currentAmps }
|
||||
}
|
||||
|
||||
struct PowerAdapterSnapshot: Hashable, Sendable {
|
||||
var name = "Power adapter"
|
||||
var manufacturer = "Not reported"
|
||||
var serial = ""
|
||||
var ratedWatts = 0.0
|
||||
var negotiatedVoltage = 0.0
|
||||
var negotiatedCurrent = 0.0
|
||||
var profiles: [PowerProfile] = []
|
||||
|
||||
var negotiatedWatts: Double { negotiatedVoltage * negotiatedCurrent }
|
||||
}
|
||||
|
||||
struct BatterySnapshot: Hashable, Sendable {
|
||||
var isPresent = false
|
||||
var chargePercent = 0.0
|
||||
var isCharging = false
|
||||
var isFullyCharged = false
|
||||
var externalPowerConnected = false
|
||||
var cycleCount = 0
|
||||
var designCycleCount = 0
|
||||
var currentCapacityMAh = 0.0
|
||||
var fullChargeCapacityMAh = 0.0
|
||||
var designCapacityMAh = 0.0
|
||||
var voltageVolts = 0.0
|
||||
var currentAmps = 0.0
|
||||
var temperatureCelsius = 0.0
|
||||
var timeRemainingMinutes: Int?
|
||||
var serial = ""
|
||||
var adapter: PowerAdapterSnapshot?
|
||||
var systemPowerWatts = 0.0
|
||||
|
||||
var healthPercent: Double {
|
||||
guard designCapacityMAh > 0 else { return 0 }
|
||||
return min(100, fullChargeCapacityMAh / designCapacityMAh * 100)
|
||||
}
|
||||
|
||||
var batteryPowerWatts: Double {
|
||||
let watts = abs(voltageVolts * currentAmps)
|
||||
return watts.isFinite && watts <= 500 ? watts : 0
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectedHardwareDevice: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let vendor: String
|
||||
let speed: String
|
||||
let currentAvailableMA: Int?
|
||||
let currentRequiredMA: Int?
|
||||
let depth: Int
|
||||
}
|
||||
|
||||
struct HardwarePortSnapshot: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let transport: String
|
||||
let maximumSpeed: String
|
||||
let isConnected: Bool
|
||||
let status: String
|
||||
let devices: [ConnectedHardwareDevice]
|
||||
}
|
||||
|
||||
struct FanSnapshot: Identifiable, Hashable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let actualRPM: Double
|
||||
let minimumRPM: Double
|
||||
let maximumRPM: Double
|
||||
let targetRPM: Double
|
||||
let mode: String
|
||||
|
||||
var isAutomatic: Bool { mode.localizedCaseInsensitiveContains("automatic") }
|
||||
}
|
||||
|
||||
struct HardwareSnapshot: Hashable, Sendable {
|
||||
var battery = BatterySnapshot()
|
||||
var ports: [HardwarePortSnapshot] = []
|
||||
var fans: [FanSnapshot] = []
|
||||
var thermalState = "Nominal"
|
||||
var capturedAt = Date.distantPast
|
||||
}
|
||||
|
||||
struct MacMemorySpecification: Hashable, Sendable {
|
||||
let chip: String
|
||||
let cpuCoreCount: Int?
|
||||
let bandwidthGBps: Double
|
||||
let isMaximum: Bool
|
||||
|
||||
var displayValue: String {
|
||||
let bandwidth = bandwidthGBps.rounded() == bandwidthGBps
|
||||
? String(format: "%.0f", bandwidthGBps)
|
||||
: String(format: "%.2f", bandwidthGBps)
|
||||
return "\(isMaximum ? "Up to " : "")\(bandwidth) GB/s bandwidth (Apple specification)"
|
||||
}
|
||||
}
|
||||
|
||||
enum MemoryBandwidthCatalog {
|
||||
// Ordered from most-specific to broadest. Core-count entries distinguish
|
||||
// configurations where Apple ships one chip name with multiple memory buses.
|
||||
static let specifications: [MacMemorySpecification] = [
|
||||
.init(chip: "M5 Max", cpuCoreCount: nil, bandwidthGBps: 614, isMaximum: true),
|
||||
.init(chip: "M5 Pro", cpuCoreCount: nil, bandwidthGBps: 307, isMaximum: true),
|
||||
.init(chip: "M5", cpuCoreCount: nil, bandwidthGBps: 153, isMaximum: false),
|
||||
.init(chip: "M4 Max", cpuCoreCount: 16, bandwidthGBps: 546, isMaximum: false),
|
||||
.init(chip: "M4 Max", cpuCoreCount: 14, bandwidthGBps: 410, isMaximum: false),
|
||||
.init(chip: "M4 Pro", cpuCoreCount: nil, bandwidthGBps: 273, isMaximum: false),
|
||||
.init(chip: "M4", cpuCoreCount: nil, bandwidthGBps: 120, isMaximum: false),
|
||||
.init(chip: "M3 Ultra", cpuCoreCount: nil, bandwidthGBps: 819, isMaximum: false),
|
||||
.init(chip: "M3 Max", cpuCoreCount: 16, bandwidthGBps: 400, isMaximum: false),
|
||||
.init(chip: "M3 Max", cpuCoreCount: 14, bandwidthGBps: 300, isMaximum: false),
|
||||
.init(chip: "M3 Pro", cpuCoreCount: nil, bandwidthGBps: 150, isMaximum: false),
|
||||
.init(chip: "M3", cpuCoreCount: nil, bandwidthGBps: 100, isMaximum: false),
|
||||
.init(chip: "M2 Ultra", cpuCoreCount: nil, bandwidthGBps: 800, isMaximum: false),
|
||||
.init(chip: "M2 Max", cpuCoreCount: nil, bandwidthGBps: 400, isMaximum: false),
|
||||
.init(chip: "M2 Pro", cpuCoreCount: nil, bandwidthGBps: 200, isMaximum: false),
|
||||
.init(chip: "M2", cpuCoreCount: nil, bandwidthGBps: 100, isMaximum: false),
|
||||
.init(chip: "M1 Ultra", cpuCoreCount: nil, bandwidthGBps: 800, isMaximum: false),
|
||||
.init(chip: "M1 Max", cpuCoreCount: nil, bandwidthGBps: 400, isMaximum: false),
|
||||
.init(chip: "M1 Pro", cpuCoreCount: nil, bandwidthGBps: 200, isMaximum: false),
|
||||
.init(chip: "M1", cpuCoreCount: nil, bandwidthGBps: 68.25, isMaximum: false)
|
||||
]
|
||||
|
||||
static func specification(for processor: String, cpuCoreCount: Int = ProcessInfo.processInfo.activeProcessorCount) -> MacMemorySpecification? {
|
||||
let normalized = processor.replacingOccurrences(of: "Apple ", with: "")
|
||||
return specifications.first {
|
||||
normalized == $0.chip && ($0.cpuCoreCount == nil || $0.cpuCoreCount == cpuCoreCount)
|
||||
}
|
||||
}
|
||||
|
||||
static func description(for processor: String, cpuCoreCount: Int = ProcessInfo.processInfo.activeProcessorCount) -> String {
|
||||
specification(for: processor, cpuCoreCount: cpuCoreCount)?.displayValue ?? "Clock not published by macOS"
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateSpeed: String, CaseIterable, Identifiable {
|
||||
case high = "High"
|
||||
case normal = "Normal"
|
||||
+118
-18
@@ -37,7 +37,12 @@ struct PerformanceView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Performance") {
|
||||
UpdateStatus()
|
||||
HStack(spacing: 12) {
|
||||
Button("Export", systemImage: "square.and.arrow.up") {
|
||||
SystemReportExporter.export(monitor: monitor, selectedResource: selectedResourceName)
|
||||
}
|
||||
UpdateStatus()
|
||||
}
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
ScrollView {
|
||||
@@ -67,14 +72,94 @@ struct PerformanceView: View {
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.frame(width: 245)
|
||||
.frame(minWidth: 190, idealWidth: 245, maxWidth: 245)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
|
||||
Divider()
|
||||
detail
|
||||
VStack(spacing: 0) {
|
||||
detail
|
||||
Divider()
|
||||
topConsumers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedResourceName: String {
|
||||
if let selectedVolumeID,
|
||||
let volume = monitor.snapshot.removableVolumes.first(where: { $0.id == selectedVolumeID }) {
|
||||
return volume.name
|
||||
}
|
||||
return selectedMetric.rawValue
|
||||
}
|
||||
|
||||
private var activeConsumerMetric: PerformanceMetric {
|
||||
selectedVolumeID == nil ? selectedMetric : .disk
|
||||
}
|
||||
|
||||
private var rankedConsumers: [ProcessRecord] {
|
||||
guard activeConsumerMetric != .gpu else { return [] }
|
||||
return monitor.processes
|
||||
.filter { consumerValue($0) > 0 }
|
||||
.sorted { consumerValue($0) > consumerValue($1) }
|
||||
.prefix(4)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
private var topConsumers: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Text("Top processes — \(selectedResourceName)").font(.callout.weight(.semibold))
|
||||
Spacer()
|
||||
Text("User").frame(width: 110, alignment: .leading)
|
||||
Text(activeConsumerMetric.rawValue).frame(width: 92, alignment: .trailing)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if activeConsumerMetric == .gpu {
|
||||
Label("Per-process GPU attribution is not exposed by the current macOS telemetry.", systemImage: "info.circle")
|
||||
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 12)
|
||||
} else if rankedConsumers.isEmpty {
|
||||
Text("No active consumers in this sample.").font(.caption).foregroundStyle(.secondary).padding(.vertical, 12)
|
||||
} else {
|
||||
ForEach(rankedConsumers) { process in
|
||||
HStack(spacing: 8) {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath, size: 20)
|
||||
Text(process.displayName).lineLimit(1)
|
||||
Spacer()
|
||||
Text(process.user).lineLimit(1).frame(width: 110, alignment: .leading)
|
||||
Text(consumerValueText(process)).monospacedDigit().frame(width: 92, alignment: .trailing)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 10)
|
||||
.frame(height: 146, alignment: .top)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.35))
|
||||
}
|
||||
|
||||
private func consumerValue(_ process: ProcessRecord) -> Double {
|
||||
switch activeConsumerMetric {
|
||||
case .cpu: process.cpu
|
||||
case .memory: Double(process.residentBytes)
|
||||
case .disk: monitor.processActivity[process.pid]?.diskTotal ?? 0
|
||||
case .network: monitor.processActivity[process.pid]?.networkTotal ?? 0
|
||||
case .gpu: 0
|
||||
}
|
||||
}
|
||||
|
||||
private func consumerValueText(_ process: ProcessRecord) -> String {
|
||||
switch activeConsumerMetric {
|
||||
case .cpu: Formatters.percent(process.cpu)
|
||||
case .memory: Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))
|
||||
case .disk, .network: Formatters.rate(consumerValue(process))
|
||||
case .gpu: "—"
|
||||
}
|
||||
}
|
||||
|
||||
private func metricCard(_ metric: PerformanceMetric, value: Double, valueText: String? = nil, detail: String) -> some View {
|
||||
Button {
|
||||
selectedVolumeID = nil
|
||||
@@ -226,7 +311,7 @@ private struct RemovableDiskDetail: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(volume.name).font(.system(size: 30, weight: .semibold))
|
||||
Text(volume.name).font(.title.weight(.semibold))
|
||||
Text("\(volume.fileSystem) • \(volume.mountPath)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -236,7 +321,7 @@ private struct RemovableDiskDetail: View {
|
||||
.foregroundStyle(.orange)
|
||||
Spacer()
|
||||
Text(Formatters.percent(volume.usedPercent))
|
||||
.font(.system(size: 34, weight: .light).monospacedDigit())
|
||||
.font(.largeTitle.weight(.light).monospacedDigit())
|
||||
}
|
||||
|
||||
ProgressView(value: volume.usedPercent, total: 100)
|
||||
@@ -266,12 +351,12 @@ private struct GPUPerformanceDetail: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("GPU").font(.system(size: 30, weight: .semibold))
|
||||
Text("GPU").font(.title.weight(.semibold))
|
||||
Text(name).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Formatters.percent(snapshot.gpuPercent))
|
||||
.font(.system(size: 34, weight: .light).monospacedDigit())
|
||||
.font(.largeTitle.weight(.light).monospacedDigit())
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -366,13 +451,13 @@ private struct DiskPerformanceDetail: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Disk 0").font(.system(size: 30, weight: .semibold))
|
||||
Text("Disk 0").font(.title.weight(.semibold))
|
||||
Text("System volume • APFS • Solid-state").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(Formatters.rate(snapshot.diskThroughput))
|
||||
.font(.system(size: 30, weight: .light).monospacedDigit())
|
||||
.font(.title.weight(.light).monospacedDigit())
|
||||
Text("\(Formatters.percent(snapshot.diskActivePercent)) active")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -499,12 +584,12 @@ private struct NetworkPerformanceDetail: View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Network").font(.system(size: 30, weight: .semibold))
|
||||
Text("Network").font(.title.weight(.semibold))
|
||||
Text(interface).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Formatters.rate(receiveRate + sendRate))
|
||||
.font(.system(size: 30, weight: .light).monospacedDigit())
|
||||
.font(.title.weight(.light).monospacedDigit())
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -584,12 +669,12 @@ private struct CPUPerformanceDetail: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("CPU").font(.system(size: 30, weight: .semibold))
|
||||
Text("CPU").font(.title.weight(.semibold))
|
||||
Text(subtitle).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Formatters.percent(value))
|
||||
.font(.system(size: 34, weight: .light).monospacedDigit())
|
||||
.font(.largeTitle.weight(.light).monospacedDigit())
|
||||
}
|
||||
|
||||
cpuGraph
|
||||
@@ -620,9 +705,24 @@ private struct CPUPerformanceDetail: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Menu {
|
||||
Button {
|
||||
displayMode = .summary
|
||||
} label: {
|
||||
Label("Summary view", systemImage: displayMode == .summary ? "checkmark" : "chart.xyaxis.line")
|
||||
}
|
||||
Button {
|
||||
displayMode = .logicalProcessors
|
||||
} label: {
|
||||
Label("Logical processors", systemImage: displayMode == .logicalProcessors ? "checkmark" : "square.grid.3x3")
|
||||
}
|
||||
} label: {
|
||||
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
|
||||
.font(.caption)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.accessibilityLabel("CPU graph layout")
|
||||
}
|
||||
|
||||
if displayMode == .summary {
|
||||
@@ -755,12 +855,12 @@ private struct PerformanceDetail: View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.system(size: 30, weight: .semibold))
|
||||
Text(title).font(.title.weight(.semibold))
|
||||
Text(subtitle).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Formatters.percent(value))
|
||||
.font(.system(size: 34, weight: .light).monospacedDigit())
|
||||
.font(.largeTitle.weight(.light).monospacedDigit())
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("% Utilization").font(.caption).foregroundStyle(.secondary)
|
||||
@@ -29,10 +29,6 @@ struct ProcessContextMenu: View {
|
||||
let onShowProperties: (ProcessRecord) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button("End task", systemImage: "xmark.circle") {
|
||||
onTerminate(.init(process: process, kind: .normal))
|
||||
}
|
||||
|
||||
Button("Efficiency mode", systemImage: "leaf") {
|
||||
monitor.setPriority(10, for: process)
|
||||
}
|
||||
@@ -85,6 +81,12 @@ struct ProcessContextMenu: View {
|
||||
}
|
||||
Button("Properties") { onShowProperties(process) }
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("End task", systemImage: "xmark.circle", role: .destructive) {
|
||||
onTerminate(.init(process: process, kind: .normal))
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(_ text: String) {
|
||||
@@ -124,7 +126,7 @@ enum ProcessDiagnosticReporter {
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Create diagnostic report"
|
||||
panel.message = "Task Manager will sample \(process.displayName) for \(duration) seconds and save a text report."
|
||||
panel.message = "Puter will sample \(process.displayName) for \(duration) seconds and save a text report."
|
||||
panel.prompt = "Create Report"
|
||||
panel.nameFieldStringValue = safeFilename("\(process.displayName)-\(process.pid)-sample.txt")
|
||||
panel.allowedContentTypes = [.plainText]
|
||||
@@ -181,7 +181,8 @@ struct ProcessesView: View {
|
||||
terminationRequest = .init(process: process, kind: .normal)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.disabled(selectedPID == nil && selectedGroupID == nil)
|
||||
}
|
||||
.padding(12)
|
||||
@@ -274,6 +275,18 @@ struct ProcessesView: View {
|
||||
selectedGroupID = group.id
|
||||
selectedPID = nil
|
||||
}
|
||||
.focusable()
|
||||
.onKeyPress(.return) {
|
||||
selectedGroupID = group.id
|
||||
selectedPID = nil
|
||||
return .handled
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits(selectedGroupID == group.id ? .isSelected : [])
|
||||
.accessibilityAction {
|
||||
selectedGroupID = group.id
|
||||
selectedPID = nil
|
||||
}
|
||||
.contextMenu {
|
||||
ProcessGroupContextMenu(
|
||||
group: group,
|
||||
@@ -307,6 +320,20 @@ struct ProcessesView: View {
|
||||
selectedPID = process.pid
|
||||
selectedGroupID = nil
|
||||
}
|
||||
.focusable()
|
||||
.onKeyPress(.return) {
|
||||
selectedPID = process.pid
|
||||
selectedGroupID = nil
|
||||
return .handled
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user)")
|
||||
.accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))")
|
||||
.accessibilityAddTraits(selectedPID == process.pid ? .isSelected : [])
|
||||
.accessibilityAction {
|
||||
selectedPID = process.pid
|
||||
selectedGroupID = nil
|
||||
}
|
||||
.contextMenu {
|
||||
ProcessContextMenu(
|
||||
process: process,
|
||||
@@ -620,7 +647,7 @@ private struct ProcessRow: View {
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.frame(minHeight: 48)
|
||||
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
|
||||
}
|
||||
@@ -703,7 +730,7 @@ private struct ProcessGroupRow: View {
|
||||
}
|
||||
.font(.callout.weight(.medium))
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.frame(minHeight: 48)
|
||||
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
|
||||
.accessibilityElement(children: .combine)
|
||||
@@ -745,9 +772,6 @@ private struct ProcessGroupContextMenu: View {
|
||||
let onShowProperties: (ProcessRecord) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button("End task", systemImage: "xmark.circle", action: onTerminate)
|
||||
.disabled(group.processes.contains(where: { $0.pid == getpid() }))
|
||||
|
||||
Button("Efficiency mode", systemImage: "leaf") {
|
||||
group.processes.forEach { monitor.setPriority(10, for: $0) }
|
||||
}
|
||||
@@ -789,6 +813,11 @@ private struct ProcessGroupContextMenu: View {
|
||||
}
|
||||
Button("Properties") { onShowProperties(group.primary) }
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("End task", systemImage: "xmark.circle", role: .destructive, action: onTerminate)
|
||||
.disabled(group.processes.contains(where: { $0.pid == getpid() }))
|
||||
}
|
||||
|
||||
private func setPriority(_ value: Int) {
|
||||
@@ -833,6 +862,7 @@ private struct HeatCell: View {
|
||||
struct ProcessIcon: View {
|
||||
let name: String
|
||||
var path: String? = nil
|
||||
var size: CGFloat = 30
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -845,12 +875,12 @@ struct ProcessIcon: View {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(color.opacity(0.16))
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.system(size: size * 0.47, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PuterApp: App {
|
||||
@State private var monitor = SystemMonitor()
|
||||
@AppStorage("sidebarCompact") private var sidebarCompact = false
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Puter") {
|
||||
ContentView()
|
||||
.environment(monitor)
|
||||
.frame(minWidth: 820, minHeight: 560)
|
||||
.task { monitor.start() }
|
||||
}
|
||||
.defaultSize(width: 1180, height: 760)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("Run New Task…") {
|
||||
NotificationCenter.default.post(name: .runNewTask, object: nil)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
}
|
||||
SidebarCommands()
|
||||
CommandMenu("Options") {
|
||||
Button("Refresh now") { monitor.refresh() }
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
Divider()
|
||||
Button(monitor.isPaused ? "Resume updates" : "Pause updates") {
|
||||
monitor.isPaused.toggle()
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: .command)
|
||||
Divider()
|
||||
Toggle("Compact Sidebar", isOn: $sidebarCompact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ struct AppHistoryView: View {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Reset", role: .destructive) { monitor.resetAppHistory() }
|
||||
} message: {
|
||||
Text("CPU time and network totals collected by Task Manager will be permanently cleared.")
|
||||
Text("CPU time and network totals collected by Puter will be permanently cleared.")
|
||||
}
|
||||
.sheet(item: $inspectedUsage) { AppUsagePropertiesView(usage: $0) }
|
||||
}
|
||||
@@ -173,20 +173,30 @@ private struct AppUsagePropertiesView: View {
|
||||
|
||||
struct StartupAppsView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@State private var items = StartupScanner.scan()
|
||||
@State private var items: [StartupItem] = []
|
||||
@State private var isLoading = true
|
||||
@State private var inspectedItem: StartupItem?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") {
|
||||
HStack {
|
||||
Button("Refresh", systemImage: "arrow.clockwise") { items = StartupScanner.scan() }
|
||||
Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() }
|
||||
.disabled(isLoading)
|
||||
Button("Open Login Items") {
|
||||
openLoginItemsSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
if items.isEmpty {
|
||||
if isLoading && items.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Scanning login items…").foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Scanning login items")
|
||||
} else if items.isEmpty {
|
||||
EmptyState(
|
||||
icon: "rectangle.stack.badge.play",
|
||||
title: "No startup items found",
|
||||
@@ -223,6 +233,7 @@ struct StartupAppsView: View {
|
||||
.sheet(item: $inspectedItem) { item in
|
||||
StartupItemPropertiesView(item: item, impact: startupImpact(for: item))
|
||||
}
|
||||
.task { refreshItems() }
|
||||
}
|
||||
|
||||
private func openLoginItemsSettings() {
|
||||
@@ -231,6 +242,15 @@ struct StartupAppsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshItems() {
|
||||
guard !isLoading || items.isEmpty else { return }
|
||||
isLoading = true
|
||||
Task {
|
||||
items = await Task.detached(priority: .utility) { StartupScanner.scan() }.value
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func startupImpact(for item: StartupItem) -> StartupImpact {
|
||||
let candidates = monitor.processes.filter { process in
|
||||
guard !item.executablePath.isEmpty else { return false }
|
||||
@@ -566,14 +586,14 @@ private struct UserSessionPropertiesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private enum StartupItemKind {
|
||||
private enum StartupItemKind: Sendable {
|
||||
case loginItem
|
||||
case launchAgent
|
||||
|
||||
var title: String { self == .loginItem ? "Login item" : "Launch agent" }
|
||||
}
|
||||
|
||||
private struct StartupItem: Identifiable {
|
||||
private struct StartupItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let publisher: String
|
||||
@@ -730,14 +750,29 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
|
||||
case .nice: "Nice"
|
||||
}
|
||||
}
|
||||
var width: CGFloat {
|
||||
var defaultWidth: CGFloat {
|
||||
switch self {
|
||||
case .name: 230
|
||||
case .user: 120
|
||||
case .architecture: 95
|
||||
case .cpuTime, .memory, .disk, .network, .elapsed: 100
|
||||
case .parentPID: 80
|
||||
default: 72
|
||||
case .name: 260
|
||||
case .user: 140
|
||||
case .architecture, .state: 105
|
||||
case .cpuTime, .memory, .disk, .network, .elapsed: 112
|
||||
case .parentPID: 92
|
||||
default: 78
|
||||
}
|
||||
}
|
||||
var minimumWidth: CGFloat {
|
||||
switch self {
|
||||
case .name: 170
|
||||
case .user: 90
|
||||
case .cpuTime, .memory, .disk, .network, .elapsed: 82
|
||||
default: 62
|
||||
}
|
||||
}
|
||||
var maximumWidth: CGFloat { self == .name ? 620 : 300 }
|
||||
var textAlignment: Alignment {
|
||||
switch self {
|
||||
case .name, .user, .state, .architecture: .leading
|
||||
default: .trailing
|
||||
}
|
||||
}
|
||||
var defaultVisible: Bool {
|
||||
@@ -749,9 +784,11 @@ struct DetailsView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
let searchText: String
|
||||
@Binding var selection: Int32?
|
||||
@AppStorage("detailVisibleColumns") private var visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
|
||||
@AppStorage("detailVisibleColumnsV2") private var visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
|
||||
@AppStorage("detailColumnWidthsV2") private var columnWidthsStorage = ""
|
||||
@State private var terminationRequest: TerminationRequest?
|
||||
@State private var inspectedProcess: ProcessRecord?
|
||||
@State private var columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
|
||||
@AppStorage("detailSortColumn") private var sortColumn: DetailColumn = .pid
|
||||
@AppStorage("detailSortAscending") private var ascending = true
|
||||
|
||||
@@ -763,6 +800,10 @@ struct DetailsView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Details", subtitle: "Technical information for running processes") {
|
||||
Button("Properties", systemImage: "info.circle") {
|
||||
inspectedProcess = selectedProcess
|
||||
}
|
||||
.disabled(selectedProcess == nil)
|
||||
Menu("Columns", systemImage: "rectangle.split.3x1") {
|
||||
ForEach(DetailColumn.allCases) { column in
|
||||
Toggle(column.title, isOn: columnBinding(column))
|
||||
@@ -771,22 +812,30 @@ struct DetailsView: View {
|
||||
Divider()
|
||||
Button("Reset columns") {
|
||||
visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
|
||||
columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
|
||||
persistColumnWidths()
|
||||
}
|
||||
}
|
||||
}
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
Section {
|
||||
ForEach(filtered) { process in
|
||||
detailRow(process)
|
||||
ResourceSummaryBar(snapshot: monitor.snapshot)
|
||||
GeometryReader { proxy in
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
Section {
|
||||
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
|
||||
detailRow(process, index: index)
|
||||
}
|
||||
} header: {
|
||||
detailHeader
|
||||
}
|
||||
} header: {
|
||||
detailHeader
|
||||
}
|
||||
.frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
|
||||
}
|
||||
.frame(minWidth: visibleColumns.reduce(0) { $0 + $1.width + 12 })
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
selectionBar
|
||||
}
|
||||
.onAppear(perform: restoreColumnWidths)
|
||||
.terminationConfirmation($terminationRequest)
|
||||
.sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) }
|
||||
}
|
||||
@@ -796,8 +845,6 @@ struct DetailsView: View {
|
||||
searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText)
|
||||
|| $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText)
|
||||
}.sorted { lhs, rhs in
|
||||
if lhs.pid == selection { return true }
|
||||
if rhs.pid == selection { return false }
|
||||
let result = comparison(lhs, rhs, column: sortColumn)
|
||||
if result == .orderedSame { return lhs.pid < rhs.pid }
|
||||
return ascending ? result == .orderedAscending : result == .orderedDescending
|
||||
@@ -805,42 +852,44 @@ struct DetailsView: View {
|
||||
}
|
||||
|
||||
private var detailHeader: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(visibleColumns) { column in
|
||||
Button {
|
||||
if sortColumn == column { ascending.toggle() }
|
||||
else { sortColumn = column; ascending = true }
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Text(column.title)
|
||||
if sortColumn == column { Image(systemName: ascending ? "chevron.up" : "chevron.down") }
|
||||
}
|
||||
.frame(width: column.width, alignment: column == .name ? .leading : .trailing)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
detailHeaderCell(column)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(.background)
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
}
|
||||
|
||||
private func detailRow(_ process: ProcessRecord) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
private func detailRow(_ process: ProcessRecord, index: Int) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(visibleColumns) { column in
|
||||
detailCell(process, column: column)
|
||||
.frame(width: column.width, alignment: column == .name || column == .user ? .leading : .trailing)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(width: width(column), alignment: column.textAlignment)
|
||||
.clipped()
|
||||
.overlay(alignment: .trailing) { Divider().opacity(0.18) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.callout)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 38)
|
||||
.background(selection == process.pid ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.frame(minHeight: 42)
|
||||
.background(rowBackground(process: process, index: index))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selection = process.pid }
|
||||
.focusable()
|
||||
.onKeyPress(.return) {
|
||||
selection = process.pid
|
||||
return .handled
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user), \(readableState(process.state))")
|
||||
.accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))), \(process.threadCount) threads")
|
||||
.accessibilityAddTraits(selection == process.pid ? .isSelected : [])
|
||||
.accessibilityAction { selection = process.pid }
|
||||
.overlay(alignment: .bottom) { Divider().opacity(0.4) }
|
||||
.contextMenu {
|
||||
ProcessContextMenu(
|
||||
@@ -855,7 +904,11 @@ struct DetailsView: View {
|
||||
@ViewBuilder
|
||||
private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View {
|
||||
switch column {
|
||||
case .name: Text(process.displayName).lineLimit(1)
|
||||
case .name:
|
||||
HStack(spacing: 8) {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
Text(process.displayName).lineLimit(1).truncationMode(.tail)
|
||||
}
|
||||
case .pid: Text("\(process.pid)").monospacedDigit()
|
||||
case .parentPID: Text("\(process.parentPID)").monospacedDigit()
|
||||
case .user: Text(process.user).lineLimit(1)
|
||||
@@ -864,7 +917,10 @@ struct DetailsView: View {
|
||||
case .memory: Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
|
||||
case .disk: Text(Formatters.rate(monitor.processActivity[process.pid]?.diskTotal ?? 0)).monospacedDigit()
|
||||
case .network: Text(Formatters.rate(monitor.processActivity[process.pid]?.networkTotal ?? 0)).monospacedDigit()
|
||||
case .state: Text(process.state)
|
||||
case .state:
|
||||
Text(readableState(process.state))
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(process.state.hasPrefix("R") ? Color.green : Color.secondary)
|
||||
case .elapsed: Text(process.elapsed).monospacedDigit()
|
||||
case .threads: Text("\(process.threadCount)").monospacedDigit()
|
||||
case .handles: Text("\(process.openFileCount)").monospacedDigit()
|
||||
@@ -874,6 +930,106 @@ struct DetailsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func detailHeaderCell(_ column: DetailColumn) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Button {
|
||||
if sortColumn == column { ascending.toggle() }
|
||||
else { sortColumn = column; ascending = true }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Text(column.title).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if sortColumn == column {
|
||||
Image(systemName: ascending ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(width: width(column), height: 36, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Sort by \(column.title)")
|
||||
.accessibilityValue(sortColumn == column ? (ascending ? "Ascending" : "Descending") : "Not sorted")
|
||||
}
|
||||
.frame(width: width(column), height: 36)
|
||||
.overlay(alignment: .trailing) {
|
||||
Divider().opacity(0.45)
|
||||
DetailResizeHandle(
|
||||
width: Binding(get: { width(column) }, set: { columnWidths[column] = $0 }),
|
||||
minimumWidth: column.minimumWidth,
|
||||
maximumWidth: column.maximumWidth,
|
||||
onEnded: persistColumnWidths
|
||||
)
|
||||
.offset(x: 5)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectionBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
HStack(spacing: 10) {
|
||||
if let process = selectedProcess {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
|
||||
Text("PID \(process.pid)").foregroundStyle(.secondary).monospacedDigit()
|
||||
Text("•").foregroundStyle(.tertiary)
|
||||
Text(Formatters.percent(process.cpu) + " CPU").monospacedDigit()
|
||||
Text("•").foregroundStyle(.tertiary)
|
||||
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
|
||||
Spacer()
|
||||
Button("End task") { terminationRequest = .init(process: process, kind: .normal) }
|
||||
Button("Properties") { inspectedProcess = process }.buttonStyle(.borderedProminent)
|
||||
} else {
|
||||
Text("\(filtered.count) processes").foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("Select a process for actions and live details").foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var selectedProcess: ProcessRecord? {
|
||||
guard let selection else { return nil }
|
||||
return monitor.processes.first { $0.pid == selection }
|
||||
}
|
||||
|
||||
private var tableWidth: CGFloat { visibleColumns.reduce(0) { $0 + width($1) } }
|
||||
private func width(_ column: DetailColumn) -> CGFloat { columnWidths[column] ?? column.defaultWidth }
|
||||
|
||||
private func rowBackground(process: ProcessRecord, index: Int) -> Color {
|
||||
if selection == process.pid { return Color.accentColor.opacity(0.18) }
|
||||
return index.isMultiple(of: 2) ? Color.clear : Color.secondary.opacity(0.035)
|
||||
}
|
||||
|
||||
private func readableState(_ state: String) -> String {
|
||||
if state.hasPrefix("R") { return "Running" }
|
||||
if state.hasPrefix("S") { return "Sleeping" }
|
||||
if state.hasPrefix("Z") { return "Zombie" }
|
||||
if state.hasPrefix("T") { return "Stopped" }
|
||||
return state
|
||||
}
|
||||
|
||||
private func restoreColumnWidths() {
|
||||
guard let data = columnWidthsStorage.data(using: .utf8),
|
||||
let stored = try? JSONDecoder().decode([String: Double].self, from: data) else { return }
|
||||
for column in DetailColumn.allCases {
|
||||
if let value = stored[column.rawValue] {
|
||||
columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func persistColumnWidths() {
|
||||
let stored = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0.rawValue, Double(width($0))) })
|
||||
guard let data = try? JSONEncoder().encode(stored), let value = String(data: data, encoding: .utf8) else { return }
|
||||
columnWidthsStorage = value
|
||||
}
|
||||
|
||||
private func columnBinding(_ column: DetailColumn) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { visibleColumns.contains(column) },
|
||||
@@ -911,6 +1067,43 @@ struct DetailsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailResizeHandle: View {
|
||||
@Binding var width: CGFloat
|
||||
let minimumWidth: CGFloat
|
||||
let maximumWidth: CGFloat
|
||||
let onEnded: () -> Void
|
||||
@State private var startWidth: CGFloat?
|
||||
@State private var proposedWidth: CGFloat?
|
||||
@State private var hovered = false
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(hovered || startWidth != nil ? Color.accentColor : Color.clear)
|
||||
.frame(width: 2, height: 24)
|
||||
.frame(width: 10, height: 36)
|
||||
.offset(x: (proposedWidth ?? width) - width)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { value in
|
||||
hovered = value
|
||||
(value ? NSCursor.resizeLeftRight : NSCursor.arrow).set()
|
||||
}
|
||||
.highPriorityGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if startWidth == nil { startWidth = width }
|
||||
proposedWidth = min(maximumWidth, max(minimumWidth, (startWidth ?? width) + value.translation.width))
|
||||
}
|
||||
.onEnded { _ in
|
||||
if let proposedWidth { width = proposedWidth }
|
||||
proposedWidth = nil
|
||||
startWidth = nil
|
||||
onEnded()
|
||||
}
|
||||
)
|
||||
.help("Drag to resize column")
|
||||
}
|
||||
}
|
||||
|
||||
struct ServicesView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
let searchText: String
|
||||
@@ -22,6 +22,8 @@ final class SystemMonitor {
|
||||
var gpuTilerHistory: [MetricSample] = []
|
||||
var networkReceiveHistory: [MetricSample] = []
|
||||
var networkSendHistory: [MetricSample] = []
|
||||
var hardware = HardwareSnapshot()
|
||||
var systemPowerHistory: [MetricSample] = []
|
||||
var updateSpeed: UpdateSpeed {
|
||||
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") }
|
||||
}
|
||||
@@ -41,6 +43,7 @@ final class SystemMonitor {
|
||||
private var previousProcessIODate: Date?
|
||||
private var appNetworkTask: Task<Void, Never>?
|
||||
private var lastAppNetworkSample: Date?
|
||||
private var lastPortSample: Date?
|
||||
|
||||
init() {
|
||||
updateSpeed = UpdateSpeed(rawValue: UserDefaults.standard.string(forKey: "updateSpeed") ?? "") ?? .normal
|
||||
@@ -70,6 +73,11 @@ final class SystemMonitor {
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
ProcessScanner.capture()
|
||||
}.value
|
||||
let refreshPorts = lastPortSample.map { Date().timeIntervalSince($0) >= 10 } ?? true
|
||||
let existingPorts = hardware.ports
|
||||
let hardwareResult = await Task.detached(priority: .utility) {
|
||||
HardwareScanner.capture(existingPorts: existingPorts, refreshPorts: refreshPorts)
|
||||
}.value
|
||||
processes = result.processes
|
||||
updateCPUHistory(with: result.processes)
|
||||
updateProcessDiskActivity(result.processIO, current: result.processes)
|
||||
@@ -116,6 +124,8 @@ final class SystemMonitor {
|
||||
previousNetworkCounters = result.network
|
||||
previousNetworkDate = networkDate
|
||||
snapshot = refreshedSnapshot
|
||||
hardware = hardwareResult
|
||||
if refreshPorts { lastPortSample = Date() }
|
||||
lastUpdated = Date()
|
||||
errorMessage = result.error
|
||||
appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent)
|
||||
@@ -137,7 +147,7 @@ final class SystemMonitor {
|
||||
|
||||
func terminate(_ process: ProcessRecord, force: Bool = false) {
|
||||
guard process.pid != getpid() else {
|
||||
errorMessage = "Task Manager cannot end itself."
|
||||
errorMessage = "Puter cannot end itself."
|
||||
return
|
||||
}
|
||||
let signal = force ? SIGKILL : SIGTERM
|
||||
@@ -160,7 +170,7 @@ final class SystemMonitor {
|
||||
|
||||
func terminateGroup(_ group: [ProcessRecord]) {
|
||||
guard !group.contains(where: { $0.pid == getpid() }) else {
|
||||
errorMessage = "Task Manager cannot end an application group that contains itself."
|
||||
errorMessage = "Puter cannot end an application group that contains itself."
|
||||
return
|
||||
}
|
||||
let pids = Set(group.map(\.pid))
|
||||
@@ -178,7 +188,7 @@ final class SystemMonitor {
|
||||
|
||||
func sendSignal(_ signal: Int32, to process: ProcessRecord, successMessage: String? = nil) {
|
||||
guard process.pid != getpid() else {
|
||||
errorMessage = "Task Manager cannot control itself."
|
||||
errorMessage = "Puter cannot control itself."
|
||||
return
|
||||
}
|
||||
if kill(process.pid, signal) != 0 {
|
||||
@@ -260,6 +270,7 @@ final class SystemMonitor {
|
||||
memoryHistory.append(MetricSample(date: now, value: memory))
|
||||
networkReceiveHistory.append(MetricSample(date: now, value: snapshot.networkReceiveRate))
|
||||
networkSendHistory.append(MetricSample(date: now, value: snapshot.networkSendRate))
|
||||
systemPowerHistory.append(MetricSample(date: now, value: hardware.battery.systemPowerWatts))
|
||||
diskReadHistory.append(MetricSample(date: now, value: snapshot.diskReadRate))
|
||||
diskWriteHistory.append(MetricSample(date: now, value: snapshot.diskWriteRate))
|
||||
diskLatencyHistory.append(MetricSample(date: now, value: snapshot.diskLatencyMilliseconds))
|
||||
@@ -278,6 +289,7 @@ final class SystemMonitor {
|
||||
memoryHistory.removeAll { $0.date < cutoff }
|
||||
networkReceiveHistory.removeAll { $0.date < cutoff }
|
||||
networkSendHistory.removeAll { $0.date < cutoff }
|
||||
systemPowerHistory.removeAll { $0.date < cutoff }
|
||||
diskReadHistory.removeAll { $0.date < cutoff }
|
||||
diskWriteHistory.removeAll { $0.date < cutoff }
|
||||
diskLatencyHistory.removeAll { $0.date < cutoff }
|
||||
@@ -775,9 +787,15 @@ private enum ProcessScanner {
|
||||
|
||||
private static func memoryHardwareInfo() -> MemoryHardwareInfo {
|
||||
let output = run("/usr/sbin/system_profiler", arguments: ["SPMemoryDataType", "-detailLevel", "mini"]).output
|
||||
let processor = run("/usr/sbin/sysctl", arguments: ["-n", "machdep.cpu.brand_string"]).output
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let reportedSpeed = output.firstMatch(#"Speed: +([^\n]+)"#)
|
||||
return MemoryHardwareInfo(
|
||||
type: output.firstMatch(#"Type: +([^\n]+)"#) ?? "Unified",
|
||||
speed: output.firstMatch(#"Speed: +([^\n]+)"#) ?? "Not reported by macOS",
|
||||
speed: reportedSpeed ?? MemoryBandwidthCatalog.description(
|
||||
for: processor,
|
||||
cpuCoreCount: ProcessInfo.processInfo.activeProcessorCount
|
||||
),
|
||||
manufacturer: output.firstMatch(#"Manufacturer: +([^\n]+)"#) ?? "Apple unified memory"
|
||||
)
|
||||
}
|
||||
@@ -882,6 +900,227 @@ private enum ProcessScanner {
|
||||
}
|
||||
}
|
||||
|
||||
private enum HardwareScanner {
|
||||
static func capture(existingPorts: [HardwarePortSnapshot], refreshPorts: Bool) -> HardwareSnapshot {
|
||||
HardwareSnapshot(
|
||||
battery: batterySnapshot(),
|
||||
ports: refreshPorts ? portSnapshots() : existingPorts,
|
||||
fans: fanSnapshots(),
|
||||
thermalState: thermalState(),
|
||||
capturedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private static func fanSnapshots() -> [FanSnapshot] {
|
||||
let tool = FanControlService.toolPath
|
||||
guard FileManager.default.isExecutableFile(atPath: tool) else { return [] }
|
||||
let output = run(tool, arguments: ["fans"])
|
||||
guard output.contains("Number of fans:") else { return [] }
|
||||
|
||||
return output.components(separatedBy: "\n\n").compactMap { block in
|
||||
guard let identifier = block.firstMatch(#"(?m)^([0-9]+):"#).flatMap(Int.init) else { return nil }
|
||||
func number(_ label: String) -> Double {
|
||||
Double(block.firstMatch("(?m)^\(NSRegularExpression.escapedPattern(for: label)): ([0-9.]+)") ?? "0") ?? 0
|
||||
}
|
||||
let name = block.firstMatch(#"(?m)^[0-9]+: (.+)$"#) ?? "Fan \(identifier + 1)"
|
||||
let mode = block.firstMatch(#"(?m)^Mode: (.+)$"#) ?? "automatic"
|
||||
return FanSnapshot(
|
||||
id: identifier,
|
||||
name: name.replacingOccurrences(of: "#\(identifier)", with: "\(identifier + 1)"),
|
||||
actualRPM: number("Actual speed"),
|
||||
minimumRPM: number("Minimal speed"),
|
||||
maximumRPM: number("Maximum speed"),
|
||||
targetRPM: number("Target speed"),
|
||||
mode: mode
|
||||
)
|
||||
}.sorted { $0.id < $1.id }
|
||||
}
|
||||
|
||||
private static func batterySnapshot() -> BatterySnapshot {
|
||||
let output = run("/usr/sbin/ioreg", arguments: ["-r", "-c", "AppleSmartBattery", "-l"])
|
||||
guard output.contains("AppleSmartBattery") else { return BatterySnapshot() }
|
||||
|
||||
func topNumber(_ key: String) -> Double {
|
||||
Double(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0") ?? 0
|
||||
}
|
||||
func topSignedNumber(_ key: String) -> Double {
|
||||
let raw = output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0"
|
||||
if let signed = Int64(raw) { return Double(signed) }
|
||||
if let unsigned = UInt64(raw) { return Double(Int64(bitPattern: unsigned)) }
|
||||
return 0
|
||||
}
|
||||
func topBool(_ key: String) -> Bool {
|
||||
(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (Yes|No)") ?? "No") == "Yes"
|
||||
}
|
||||
func topString(_ key: String) -> String {
|
||||
output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = \"([^\"]*)\"") ?? ""
|
||||
}
|
||||
|
||||
let adapterLine = output.firstMatch(#"(?m)^ "AdapterDetails" = \{([^\n]+)\}"#) ?? ""
|
||||
let telemetryLine = output.firstMatch(#"(?m)^ "PowerTelemetryData" = \{([^\n]+)\}"#) ?? ""
|
||||
let externalPowerConnected = topBool("ExternalConnected")
|
||||
let parsedAdapter = adapterLine.isEmpty ? nil : adapterSnapshot(from: adapterLine)
|
||||
let adapter = externalPowerConnected && (parsedAdapter?.ratedWatts ?? 0) > 0 ? parsedAdapter : nil
|
||||
let rawTime = Int(topNumber("TimeRemaining"))
|
||||
let rawTemperature = topNumber("Temperature")
|
||||
let temperature = rawTemperature > 1_000 ? rawTemperature / 10 - 273.15 : rawTemperature
|
||||
let systemPowerMW = Double(telemetryLine.firstMatch(#""SystemPowerIn"=([0-9]+)"#) ?? "0") ?? 0
|
||||
|
||||
return BatterySnapshot(
|
||||
isPresent: topBool("BatteryInstalled"),
|
||||
chargePercent: topNumber("CurrentCapacity"),
|
||||
isCharging: topBool("IsCharging"),
|
||||
isFullyCharged: topBool("FullyCharged"),
|
||||
externalPowerConnected: externalPowerConnected,
|
||||
cycleCount: Int(topNumber("CycleCount")),
|
||||
designCycleCount: Int(topNumber("DesignCycleCount9C")),
|
||||
currentCapacityMAh: topNumber("AppleRawCurrentCapacity"),
|
||||
fullChargeCapacityMAh: topNumber("NominalChargeCapacity"),
|
||||
designCapacityMAh: topNumber("DesignCapacity"),
|
||||
voltageVolts: topNumber("Voltage") / 1_000,
|
||||
currentAmps: {
|
||||
let amps = topSignedNumber("Amperage") / 1_000
|
||||
return abs(amps) <= 100 ? amps : 0
|
||||
}(),
|
||||
temperatureCelsius: temperature,
|
||||
timeRemainingMinutes: rawTime > 0 && rawTime < 65_535 ? rawTime : nil,
|
||||
serial: topString("Serial"),
|
||||
adapter: adapter,
|
||||
systemPowerWatts: systemPowerMW / 1_000
|
||||
)
|
||||
}
|
||||
|
||||
private static func adapterSnapshot(from line: String) -> PowerAdapterSnapshot {
|
||||
func number(_ key: String) -> Double {
|
||||
Double(line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=([0-9]+)") ?? "0") ?? 0
|
||||
}
|
||||
func string(_ key: String) -> String {
|
||||
line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=\"([^\"]*)\"") ?? ""
|
||||
}
|
||||
let regex = try? NSRegularExpression(pattern: #""MaxCurrent"=([0-9]+),"MaxVoltage"=([0-9]+)"#)
|
||||
let range = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
let profiles = (regex?.matches(in: line, range: range) ?? []).compactMap { match -> PowerProfile? in
|
||||
guard let currentRange = Range(match.range(at: 1), in: line),
|
||||
let voltageRange = Range(match.range(at: 2), in: line),
|
||||
let current = Double(line[currentRange]),
|
||||
let voltage = Double(line[voltageRange]) else { return nil }
|
||||
return PowerProfile(voltageVolts: voltage / 1_000, currentAmps: current / 1_000)
|
||||
}
|
||||
return PowerAdapterSnapshot(
|
||||
name: string("Name").isEmpty ? "USB-C power adapter" : string("Name"),
|
||||
manufacturer: string("Manufacturer").isEmpty ? "Not reported" : string("Manufacturer"),
|
||||
serial: string("SerialString"),
|
||||
ratedWatts: number("Watts"),
|
||||
negotiatedVoltage: number("AdapterVoltage") / 1_000,
|
||||
negotiatedCurrent: number("Current") / 1_000,
|
||||
profiles: profiles
|
||||
)
|
||||
}
|
||||
|
||||
private static func thermalState() -> String {
|
||||
switch ProcessInfo.processInfo.thermalState {
|
||||
case .nominal: "Nominal"
|
||||
case .fair: "Fair"
|
||||
case .serious: "Serious"
|
||||
case .critical: "Critical"
|
||||
@unknown default: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private static func portSnapshots() -> [HardwarePortSnapshot] {
|
||||
let data = runData(
|
||||
"/usr/sbin/system_profiler",
|
||||
arguments: ["SPUSBDataType", "SPThunderboltDataType", "-json", "-detailLevel", "full"]
|
||||
)
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
|
||||
var ports: [HardwarePortSnapshot] = []
|
||||
|
||||
for (busIndex, value) in (root["SPThunderboltDataType"] as? [[String: Any]] ?? []).enumerated() {
|
||||
let foundDevices = devices(in: value["_items"], depth: 0, prefix: "tb-\(busIndex)")
|
||||
let receptacles = value.keys.filter { $0.hasPrefix("receptacle_") }.sorted()
|
||||
for (index, key) in receptacles.enumerated() {
|
||||
guard let receptacle = value[key] as? [String: Any] else { continue }
|
||||
let rawStatus = receptacle["receptacle_status_key"] as? String ?? ""
|
||||
let connected = !rawStatus.contains("no_devices")
|
||||
let identifier = receptacle["receptacle_id_key"] as? String ?? "\(index + 1)"
|
||||
ports.append(HardwarePortSnapshot(
|
||||
id: "thunderbolt-\(busIndex)-\(identifier)",
|
||||
name: "USB-C / Thunderbolt Port \(identifier)",
|
||||
transport: "Thunderbolt / USB4",
|
||||
maximumSpeed: receptacle["current_speed_key"] as? String ?? "Not reported",
|
||||
isConnected: connected || !foundDevices.isEmpty,
|
||||
status: connected ? "Device connected" : "Available",
|
||||
devices: foundDevices
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
for (index, controller) in (root["SPUSBDataType"] as? [[String: Any]] ?? []).enumerated() {
|
||||
let foundDevices = devices(in: controller["_items"], depth: 0, prefix: "usb-\(index)")
|
||||
guard !foundDevices.isEmpty else { continue }
|
||||
ports.append(HardwarePortSnapshot(
|
||||
id: "usb-controller-\(index)",
|
||||
name: controller["_name"] as? String ?? "USB Controller",
|
||||
transport: "USB",
|
||||
maximumSpeed: controller["device_speed"] as? String ?? "See connected devices",
|
||||
isConnected: true,
|
||||
status: "\(foundDevices.count) connected device\(foundDevices.count == 1 ? "" : "s")",
|
||||
devices: foundDevices
|
||||
))
|
||||
}
|
||||
return ports.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private static func devices(in raw: Any?, depth: Int, prefix: String) -> [ConnectedHardwareDevice] {
|
||||
guard let items = raw as? [[String: Any]] else { return [] }
|
||||
return items.enumerated().flatMap { index, item -> [ConnectedHardwareDevice] in
|
||||
let name = item["_name"] as? String ?? "Connected device"
|
||||
let vendor = item["manufacturer"] as? String
|
||||
?? item["vendor_name"] as? String
|
||||
?? item["vendor_id"] as? String
|
||||
?? "Not reported"
|
||||
let device = ConnectedHardwareDevice(
|
||||
id: "\(prefix)-\(index)-\(name)-\(depth)",
|
||||
name: name,
|
||||
vendor: vendor,
|
||||
speed: item["device_speed"] as? String ?? item["current_speed_key"] as? String ?? "Not reported",
|
||||
currentAvailableMA: integer(from: item["current_available"]),
|
||||
currentRequiredMA: integer(from: item["current_required"]),
|
||||
depth: depth
|
||||
)
|
||||
return [device] + devices(in: item["_items"], depth: depth + 1, prefix: "\(prefix)-\(index)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func integer(from value: Any?) -> Int? {
|
||||
if let number = value as? NSNumber { return number.intValue }
|
||||
if let string = value as? String,
|
||||
let raw = string.firstMatch(#"([0-9]+)"#) { return Int(raw) }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func run(_ path: String, arguments: [String]) -> String {
|
||||
String(decoding: runData(path, arguments: arguments), as: UTF8.self)
|
||||
}
|
||||
|
||||
private static func runData(_ path: String, arguments: [String]) -> Data {
|
||||
let process = Process()
|
||||
let pipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
process.arguments = arguments
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
do {
|
||||
try process.run()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0 ? data : Data()
|
||||
} catch {
|
||||
return Data()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum BinaryArchitectureReader {
|
||||
private static let machO64: UInt32 = 0xfeedfacf
|
||||
private static let machO64Swapped: UInt32 = 0xcffaedfe
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -2,10 +2,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="${0:A:h:h}"
|
||||
APP_DIR="$PROJECT_DIR/dist/Task Manager.app"
|
||||
ICON_SOURCE="$PROJECT_DIR/mactaskmanager.icon"
|
||||
STAGING_ROOT="$(mktemp -d /tmp/mactaskmanager-build.XXXXXX)"
|
||||
STAGED_APP="$STAGING_ROOT/Task Manager.app"
|
||||
APP_DIR="$PROJECT_DIR/dist/Puter.app"
|
||||
ICON_SOURCE="${PUTER_ICON_SOURCE:-$PROJECT_DIR/Puter.icon}"
|
||||
STAGING_ROOT="$(mktemp -d /tmp/puter-build.XXXXXX)"
|
||||
STAGED_APP="$STAGING_ROOT/Puter.app"
|
||||
CONTENTS_DIR="$STAGED_APP/Contents"
|
||||
|
||||
trap '/bin/rm -rf -- "$STAGING_ROOT"' EXIT
|
||||
@@ -14,17 +14,38 @@ cd "$PROJECT_DIR"
|
||||
swift build -c release
|
||||
|
||||
mkdir -p "$CONTENTS_DIR/MacOS" "$CONTENTS_DIR/Resources"
|
||||
cp "$PROJECT_DIR/.build/release/MacTaskManager" "$CONTENTS_DIR/MacOS/MacTaskManager"
|
||||
cp "$PROJECT_DIR/.build/release/Puter" "$CONTENTS_DIR/MacOS/Puter"
|
||||
cp "$PROJECT_DIR/Resources/Info.plist" "$CONTENTS_DIR/Info.plist"
|
||||
chmod +x "$CONTENTS_DIR/MacOS/MacTaskManager"
|
||||
chmod +x "$CONTENTS_DIR/MacOS/Puter"
|
||||
|
||||
SMC_SOURCE="/Applications/Stats.app/Contents/Resources/smc"
|
||||
if [[ -x "$SMC_SOURCE" ]]; then
|
||||
ditto --norsrc "$SMC_SOURCE" "$CONTENTS_DIR/Resources/smc"
|
||||
chmod +x "$CONTENTS_DIR/Resources/smc"
|
||||
cp "$PROJECT_DIR/Resources/Stats-SMC-LICENSE.txt" "$CONTENTS_DIR/Resources/Stats-SMC-LICENSE.txt"
|
||||
fi
|
||||
|
||||
if [[ -d "$ICON_SOURCE" ]]; then
|
||||
print "Using Icon Composer source: $ICON_SOURCE"
|
||||
xcrun actool "$ICON_SOURCE" \
|
||||
--compile "$CONTENTS_DIR/Resources" \
|
||||
--platform macosx \
|
||||
--minimum-deployment-target 14.0 \
|
||||
--app-icon mactaskmanager \
|
||||
--app-icon Puter \
|
||||
--output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null
|
||||
|
||||
[[ -s "$CONTENTS_DIR/Resources/Assets.car" ]] || {
|
||||
print -u2 "Icon Composer did not produce an Assets.car file"
|
||||
exit 1
|
||||
}
|
||||
[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$CONTENTS_DIR/Info.plist")" == "Puter" ]] || {
|
||||
print -u2 "CFBundleIconName must reference the Icon Composer asset"
|
||||
exit 1
|
||||
}
|
||||
if /usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$CONTENTS_DIR/Info.plist" >/dev/null 2>&1; then
|
||||
print -u2 "CFBundleIconFile would override the live Icon Composer asset"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Finder and cloud-sync metadata can make an otherwise valid app bundle
|
||||
@@ -34,5 +55,12 @@ codesign --force --deep --sign - "$STAGED_APP" >/dev/null
|
||||
codesign --verify --deep --strict "$STAGED_APP"
|
||||
|
||||
mkdir -p "$PROJECT_DIR/dist"
|
||||
/bin/rm -rf -- "$APP_DIR"
|
||||
ditto --norsrc "$STAGED_APP" "$APP_DIR"
|
||||
# File-provider workspaces can attach Finder/provenance metadata during the
|
||||
# final copy. Clean and sign at the delivery path so verification reflects
|
||||
# the bundle users actually launch.
|
||||
xattr -cr "$APP_DIR"
|
||||
codesign --force --deep --sign - "$APP_DIR" >/dev/null
|
||||
codesign --verify --deep --strict "$APP_DIR"
|
||||
print "Built $APP_DIR"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="${0:A:h:h}"
|
||||
APP_PATH="$PROJECT_DIR/dist/Task Manager.app"
|
||||
DMG_PATH="$PROJECT_DIR/dist/Task Manager-macOS.dmg"
|
||||
STAGING_DIR="$(mktemp -d /tmp/mactaskmanager-dmg.XXXXXX)"
|
||||
APP_PATH="$PROJECT_DIR/dist/Puter.app"
|
||||
DMG_PATH="$PROJECT_DIR/dist/Puter-macOS.dmg"
|
||||
STAGING_DIR="$(mktemp -d /tmp/puter-dmg.XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$STAGING_DIR"
|
||||
@@ -15,13 +15,13 @@ if [[ ! -d "$APP_PATH" ]]; then
|
||||
"$PROJECT_DIR/scripts/build-app.sh"
|
||||
fi
|
||||
|
||||
ditto --norsrc "$APP_PATH" "$STAGING_DIR/Task Manager.app"
|
||||
xattr -cr "$STAGING_DIR/Task Manager.app"
|
||||
codesign --verify --deep --strict "$STAGING_DIR/Task Manager.app"
|
||||
ditto --norsrc "$APP_PATH" "$STAGING_DIR/Puter.app"
|
||||
xattr -cr "$STAGING_DIR/Puter.app"
|
||||
codesign --verify --deep --strict "$STAGING_DIR/Puter.app"
|
||||
ln -s /Applications "$STAGING_DIR/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-volname "Task Manager" \
|
||||
-volname "Puter" \
|
||||
-srcfolder "$STAGING_DIR" \
|
||||
-format UDZO \
|
||||
-imagekey zlib-level=9 \
|
||||
|
||||
Reference in New Issue
Block a user