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