Initial MacTaskManager release
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
.build/
|
||||
dist/
|
||||
*.xcuserstate
|
||||
DerivedData/
|
||||
@@ -0,0 +1,56 @@
|
||||
# Windows Task Manager parity tracker
|
||||
|
||||
This tracker keeps the macOS implementation aligned with the current Windows Task Manager surface while translating Windows-only concepts into native macOS equivalents.
|
||||
|
||||
## Implemented and verified
|
||||
|
||||
- [x] Native WinUI-style sidebar navigation for Processes, Performance, App history, Startup apps, Users, Details, Services, and Settings, including persisted expanded and compact rail states
|
||||
- [x] Live Processes table with CPU, memory, status, search, sorting, selection, and End task
|
||||
- [x] Process context menus with graceful/forced termination, process-tree termination, efficiency mode, priority, pause/resume, copy, search, file reveal, details, and properties
|
||||
- [x] Performance cards and 60-second histories for CPU, memory, disk capacity, and active-interface network throughput
|
||||
- [x] CPU right-click switching between summary and logical-processor graphs
|
||||
- [x] Real launch-agent Startup apps table with enable/disable and context actions
|
||||
- [x] Users resource grouping with expandable process lists
|
||||
- [x] Details table with process identity, parent PID, CPU, memory, state, elapsed time, and full context actions
|
||||
- [x] Real per-user launchd Services registry with running/stopped state, PID, exit status, Start, Stop, Restart, Details, copy, search, and properties
|
||||
- [x] Persistent App history with cumulative per-app CPU time, network/download/upload totals, last-used time, search, context actions, properties, and guarded Reset usage data
|
||||
- [x] Live per-process Disk I/O and Network rates in Processes and Details, with native counter collection, sorting, and read/write diagnostics in Properties
|
||||
- [x] Expandable application process groups with aggregate resource metrics, child-level actions, group-wide context submenus, search expansion, sorting, and guarded whole-group termination
|
||||
- [x] Windows-style collapsible Apps, Background processes, and macOS processes categories with search-aware expansion
|
||||
- [x] Sorting a Processes column flattens type categories into one globally ordered list, with a persisted Group by type header option to restore sections
|
||||
- [x] Native application icons are resolved from executable and app-bundle paths, cached, and reused across process groups, app history, startup apps, users, services, properties, and dependency inspection
|
||||
- [x] Startup Apps includes native session login items and LaunchAgents, detects disabled launch services, estimates Low/Medium/High live startup impact, and provides type-aware context menus and properties
|
||||
- [x] Disk Performance uses native block-storage counters for 60-second read/write throughput, active time, response-time histories, IOPS, capacity, and live summary-card activity
|
||||
- [x] Performance discovers removable and ejectable volumes live, renders each available disk as its own entry, hides disconnected disks, and reports capacity, free space, format, and mount point
|
||||
- [x] Memory Performance reports active, cached, wired, compressed, and swap memory plus hardware type, manufacturer, and speed/frequency when macOS exposes it
|
||||
- [x] GPU Performance uses live Apple AGX/Metal counters for overall, renderer, and tiler utilization histories, GPU memory, core count, and a dedicated summary card
|
||||
- [x] Details includes live thread and open-handle counts, Mach-O architecture, priority/nice, cumulative CPU time, sortable optional columns, persisted column choices, and expanded process properties
|
||||
- [x] Process Inspect menus create non-blocking 5-second macOS sample reports with a native save sheet, remembered output folder, completion/error feedback, and Finder reveal
|
||||
- [x] Settings persist the configurable default start page, selected Performance metric and CPU graph mode, update speed, always-on-top choice, Process sort, Details sort, and Details column visibility
|
||||
- [x] Services combines GUI-domain agents with live system-domain launch daemons, adds All/User/System filtering and scope metadata, resolves configuration plists, and keeps protected system controls read-only
|
||||
- [x] Analyze dependencies opens a refreshable native inspector for parent/child relationships, live connections, canonical loaded frameworks/libraries, open files, and handle counts
|
||||
- [x] The Processes header has a nested Resource values menu for persistent absolute-value or percentage display across memory, disk, and network columns
|
||||
- [x] Users enumerates real signed-in sessions, marks the active console session, aggregates its resources, and provides right-click Details, Lock, home-folder, account-management, Copy submenu, and Properties actions
|
||||
- [x] Diagnostic settings provide 1/5/10-second sampling, remembered output folders, and ask-every-time or automatic timestamped report capture
|
||||
- [x] Functional update speeds, pause, always-on-top, Run new task, menus, dark mode, and accessibility labels
|
||||
|
||||
## Remaining parity work
|
||||
|
||||
### Highest priority
|
||||
|
||||
The tracked Windows Task Manager feature surface now has a native macOS equivalent. Final parity work is comprehensive regression, accessibility, and interaction auditing rather than additional surface-area gaps.
|
||||
|
||||
## Validation
|
||||
|
||||
- [x] Debug and release builds complete successfully; the packaged application passes strict deep code-signature verification
|
||||
- [x] Automated tests cover navigation metadata, process-name fallback behavior, service publisher classification, domain identity, and protected-service control policy
|
||||
- [x] Hands-on Computer Use regression covers every navigation page plus representative row, group, header, graph, service, startup, user-session, diagnostic, and nested context-menu interactions
|
||||
- [x] Relaunch testing verifies the configured start page, logical-processor graph mode, Details columns, sorting, update speed, resource-value display mode, and compact navigation state persist
|
||||
|
||||
## Reference surface
|
||||
|
||||
- [Microsoft: System configuration tools in Windows](https://support.microsoft.com/en-us/windows/experience/system-configuration-tools-in-windows)
|
||||
- [Microsoft: Configure Startup applications](https://support.microsoft.com/en-us/windows/experience/startup-boot/configure-startup-applications-in-windows)
|
||||
- [Microsoft: Screen-reader guide to Task Manager sections](https://support.microsoft.com/en-us/accessibility/windows/use-a-screen-reader-to-navigate-windows-support-tools)
|
||||
- [Microsoft Learn: Troubleshoot processes with Task Manager](https://learn.microsoft.com/en-us/troubleshoot/windows-server/support-tools/support-tools-task-manager)
|
||||
- [Microsoft Learn: Task Manager live dump](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/task-manager-live-dump)
|
||||
@@ -0,0 +1,21 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MacTaskManager",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.executable(name: "MacTaskManager", targets: ["MacTaskManager"])
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "MacTaskManager",
|
||||
path: "Sources/MacTaskManager"
|
||||
),
|
||||
.testTarget(
|
||||
name: "MacTaskManagerTests",
|
||||
dependencies: ["MacTaskManager"],
|
||||
path: "Tests/MacTaskManagerTests"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
# Mac Task Manager
|
||||
|
||||
A native macOS system monitor written in SwiftUI and inspired by Windows 11 Task Manager.
|
||||
|
||||
Validated with automated tests, debug and release builds, packaged-app code-signature checks, and hands-on UI regression through macOS accessibility and screenshots.
|
||||
|
||||
## Features
|
||||
|
||||
- Live process list with CPU, memory, disk I/O, and network usage
|
||||
- Cached native application icons throughout process groups, app history, startup apps, users, services, properties, and dependency views
|
||||
- Search and sortable process columns, including live Disk and Network rates
|
||||
- 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
|
||||
- Collapsible Windows-style Apps, Background processes, and macOS processes categories
|
||||
- Native process sampling reports from the Inspect submenu, with save-location memory and completion feedback
|
||||
- Persistent preferences for the default start page, Performance selection and CPU graph mode, update speed, always-on-top behavior, and process/detail table layouts
|
||||
- Expandable application groups with aggregate metrics and individual child processes
|
||||
- Group-wide right-click controls for ending, pausing, resuming, reprioritizing, copying, and inspecting related processes
|
||||
- Nested right-click menus for process control, priority, copying, inspection, and properties
|
||||
- Graceful termination, force quit, process-tree termination, pause, and resume actions
|
||||
- CPU and memory history charts
|
||||
- Right-click CPU graph switching between summary and live per-core views
|
||||
- Live network receive/send throughput with an auto-scaling 60-second chart
|
||||
- Persistent per-app history with cumulative CPU time, network totals, download/upload breakdowns, and reset confirmation
|
||||
- Disk, uptime, process, and thread statistics
|
||||
- One live Performance entry per available removable volume, with capacity, availability, filesystem, mount point, and ejectable status; disconnected volumes are hidden
|
||||
- Detailed memory telemetry for active, cached, wired, compressed, and swap usage plus hardware type, manufacturer, and reported speed/frequency
|
||||
- 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
|
||||
- User, detail, service, persistent app history, and startup views covering native login items plus LaunchAgents
|
||||
- Estimated live startup impact with Low, Medium, and High classifications and detailed CPU/memory/disk evidence
|
||||
- Combined user and system launchd Services registry with scope filtering, running/stopped state, safe Start/Stop/Restart controls, protected-system labeling, configuration reveal, Details, and Properties actions
|
||||
- Process dependency inspector with relationship chains, network/local sockets, canonical framework dependencies, open files, and handle totals
|
||||
- Persistent Resource values submenu for switching memory, disk, and network cells between values and percentages
|
||||
- Signed-in Users sessions with active-state and aggregate resources plus Lock, home-folder, account-management, Copy, Details, and Properties actions
|
||||
- Configurable 1/5/10-second diagnostic samples with remembered folders and optional automatic timestamped output
|
||||
- Run New Task sheet and persistent update-speed settings
|
||||
- WinUI-style expanded and compact navigation rail controlled by the native title-bar sidebar button, with persisted state, balanced spacing, tooltips, and accessibility labels
|
||||
- Native macOS menus, toolbar, light/dark mode, and accessibility behavior
|
||||
|
||||
## Run
|
||||
|
||||
Open `Package.swift` in Xcode and press Run, or use:
|
||||
|
||||
```sh
|
||||
swift run
|
||||
```
|
||||
|
||||
To create a standard double-clickable app bundle:
|
||||
|
||||
```sh
|
||||
./scripts/build-app.sh
|
||||
open "dist/Task Manager.app"
|
||||
```
|
||||
|
||||
To create a drag-to-install disk image containing the app and an Applications shortcut:
|
||||
|
||||
```sh
|
||||
./scripts/build-app.sh
|
||||
./scripts/build-dmg.sh
|
||||
```
|
||||
|
||||
The app refreshes every two seconds. Some system-owned processes cannot be ended without elevated permissions.
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Task Manager</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>MacTaskManager</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>local.mactaskmanager.app</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>mactaskmanager.icns</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>mactaskmanager</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Task Manager</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,166 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct NewTaskView: View {
|
||||
@Binding var isPresented: Bool
|
||||
@State private var path = ""
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "plus.square.dashed")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Run new task").font(.title2.weight(.semibold))
|
||||
Text("Open an application, document, or executable.").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("Application or executable path", text: $path)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { launch() }
|
||||
Button("Browse…") { browse() }
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Tip: drag an app or file from Finder into the path field.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Cancel") { isPresented = false }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Run") { launch() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 560)
|
||||
}
|
||||
|
||||
private func browse() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Choose an application, executable, or document"
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url { path = url.path }
|
||||
}
|
||||
|
||||
private func launch() {
|
||||
let cleaned = (path as NSString).expandingTildeInPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let url = URL(fileURLWithPath: cleaned)
|
||||
guard FileManager.default.fileExists(atPath: cleaned) else {
|
||||
errorMessage = "That file could not be found."
|
||||
return
|
||||
}
|
||||
if NSWorkspace.shared.open(url) {
|
||||
isPresented = false
|
||||
} else {
|
||||
errorMessage = "macOS could not open this item."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@AppStorage("alwaysOnTop") private var alwaysOnTop = false
|
||||
@AppStorage("defaultStartPage") private var defaultStartPage: TaskSection = .processes
|
||||
@AppStorage("diagnosticReportDuration") private var diagnosticDuration = 5
|
||||
@AppStorage("diagnosticAskEveryTime") private var diagnosticAskEveryTime = true
|
||||
@AppStorage("diagnosticReportDirectory") private var diagnosticDirectory = ""
|
||||
|
||||
var body: some View {
|
||||
@Bindable var monitor = monitor
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Settings", subtitle: "Customize Task Manager")
|
||||
Form {
|
||||
Section("General") {
|
||||
Picker("Default start page", selection: $defaultStartPage) {
|
||||
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
|
||||
Text(section.rawValue).tag(section)
|
||||
}
|
||||
}
|
||||
Picker("Real-time update speed", selection: $monitor.updateSpeed) {
|
||||
ForEach(UpdateSpeed.allCases) { speed in
|
||||
Text(speed.rawValue).tag(speed)
|
||||
}
|
||||
}
|
||||
Toggle("Always on top", isOn: $alwaysOnTop)
|
||||
.onChange(of: alwaysOnTop) { _, enabled in
|
||||
NSApp.keyWindow?.level = enabled ? .floating : .normal
|
||||
}
|
||||
}
|
||||
|
||||
Section("Process data") {
|
||||
LabeledContent("Refresh interval", value: intervalLabel)
|
||||
LabeledContent("Process source", value: "macOS process table")
|
||||
LabeledContent("Memory units", value: "Automatic")
|
||||
}
|
||||
|
||||
Section("Diagnostic reports") {
|
||||
Picker("Sample duration", selection: $diagnosticDuration) {
|
||||
Text("1 second").tag(1)
|
||||
Text("5 seconds").tag(5)
|
||||
Text("10 seconds").tag(10)
|
||||
}
|
||||
Toggle("Ask where to save each report", isOn: $diagnosticAskEveryTime)
|
||||
LabeledContent("Output folder") {
|
||||
HStack(spacing: 10) {
|
||||
Text(diagnosticDirectory.isEmpty ? "Not selected" : URL(fileURLWithPath: diagnosticDirectory).lastPathComponent)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Choose…") { chooseDiagnosticDirectory() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Application", value: "Task Manager for macOS")
|
||||
LabeledContent("Version", value: "1.0")
|
||||
LabeledContent("Framework", value: "Native SwiftUI")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(maxWidth: 720)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.task {
|
||||
NSApp.keyWindow?.level = alwaysOnTop ? .floating : .normal
|
||||
}
|
||||
}
|
||||
|
||||
private var intervalLabel: String {
|
||||
switch monitor.updateSpeed {
|
||||
case .high: "Every second"
|
||||
case .normal: "Every 2 seconds"
|
||||
case .low: "Every 5 seconds"
|
||||
case .paused: "Paused"
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseDiagnosticDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Choose diagnostic report folder"
|
||||
panel.prompt = "Choose"
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
if !diagnosticDirectory.isEmpty {
|
||||
panel.directoryURL = URL(fileURLWithPath: diagnosticDirectory, isDirectory: true)
|
||||
}
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
diagnosticDirectory = url.path
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PageHeader<Trailing: View>: View {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
@ViewBuilder let trailing: () -> Trailing
|
||||
|
||||
init(title: String, subtitle: String? = nil, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.trailing = trailing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title).font(.system(size: 28, weight: .semibold))
|
||||
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
|
||||
}
|
||||
Spacer()
|
||||
trailing()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
|
||||
struct ResourceSummaryBar: View {
|
||||
let snapshot: SystemSnapshot
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 22) {
|
||||
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu")
|
||||
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip")
|
||||
Label("\(Formatters.percent(snapshot.diskPercent)) Disk", systemImage: "internaldrive")
|
||||
Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
|
||||
Spacer()
|
||||
}
|
||||
.font(.callout.weight(.medium))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 9)
|
||||
.background(Color.accentColor.opacity(0.06))
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateStatus: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 7, height: 7)
|
||||
if monitor.isPaused {
|
||||
Text("Updates paused")
|
||||
} else if let date = monitor.lastUpdated {
|
||||
Text("Updated \(date, style: .relative)")
|
||||
} else {
|
||||
Text("Collecting system data…")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyState: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView(title, systemImage: icon, description: Text(message))
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateSpeedPicker: View {
|
||||
@Binding var selection: UpdateSpeed
|
||||
|
||||
var body: some View {
|
||||
Picker("Update speed", selection: $selection) {
|
||||
Text("High").tag(UpdateSpeed.high)
|
||||
Text("Normal").tag(UpdateSpeed.normal)
|
||||
Text("Low").tag(UpdateSpeed.low)
|
||||
Text("Paused").tag(UpdateSpeed.paused)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@State private var selection: TaskSection?
|
||||
@State private var searchText = ""
|
||||
@State private var showingAbout = false
|
||||
@State private var showingNewTask = false
|
||||
@State private var detailSelection: Int32?
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@AppStorage("sidebarCompact") private var sidebarCompact = false
|
||||
|
||||
init() {
|
||||
let stored = UserDefaults.standard.string(forKey: "defaultStartPage") ?? TaskSection.processes.rawValue
|
||||
_selection = State(initialValue: TaskSection(rawValue: stored) ?? .processes)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
Sidebar(selection: $selection, isCompact: sidebarCompact)
|
||||
.navigationSplitViewColumnWidth(
|
||||
min: sidebarCompact ? 68 : 190,
|
||||
ideal: sidebarCompact ? 68 : 210,
|
||||
max: sidebarCompact ? 68 : 250
|
||||
)
|
||||
} detail: {
|
||||
Group {
|
||||
switch selection ?? .processes {
|
||||
case .processes:
|
||||
ProcessesView(searchText: searchText) { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
}
|
||||
case .performance:
|
||||
PerformanceView()
|
||||
case .history:
|
||||
AppHistoryView(searchText: searchText) { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
}
|
||||
case .startup:
|
||||
StartupAppsView()
|
||||
case .users:
|
||||
UsersView { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
}
|
||||
case .details:
|
||||
DetailsView(searchText: searchText, selection: $detailSelection)
|
||||
case .services:
|
||||
ServicesView(searchText: searchText) { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
}
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Type a name, PID, or user")
|
||||
.toolbar {
|
||||
TaskToolbarContent(showingNewTask: $showingNewTask, showingAbout: $showingAbout)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Task Manager")
|
||||
.animation(.snappy(duration: 0.22), value: sidebarCompact)
|
||||
.onChange(of: columnVisibility) { _, visibility in
|
||||
guard visibility == .detailOnly else { return }
|
||||
columnVisibility = .all
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
||||
withAnimation(.snappy(duration: 0.22)) { sidebarCompact.toggle() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewTask) {
|
||||
NewTaskView(isPresented: $showingNewTask)
|
||||
}
|
||||
.alert("Task Manager", isPresented: Binding(
|
||||
get: { monitor.errorMessage != nil },
|
||||
set: { if !$0 { monitor.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { monitor.errorMessage = nil }
|
||||
} message: {
|
||||
Text(monitor.errorMessage ?? "")
|
||||
}
|
||||
.alert("Task Manager for macOS", isPresented: $showingAbout) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text("A native SwiftUI system monitor inspired by Windows Task Manager.")
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TaskToolbarContent: ToolbarContent {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@Binding var showingNewTask: Bool
|
||||
@Binding var showingAbout: Bool
|
||||
|
||||
var body: some ToolbarContent {
|
||||
@Bindable var monitor = monitor
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
showingNewTask = true
|
||||
} label: {
|
||||
Label("Run new task", systemImage: "plus.square")
|
||||
}
|
||||
.help("Launch an application or executable")
|
||||
|
||||
Button {
|
||||
monitor.refresh()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh now (⌘R)")
|
||||
|
||||
Menu {
|
||||
UpdateSpeedPicker(selection: $monitor.updateSpeed)
|
||||
Divider()
|
||||
Button("About Task Manager") { showingAbout = true }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Sidebar: View {
|
||||
@Binding var selection: TaskSection?
|
||||
let isCompact: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
|
||||
Button {
|
||||
selection = section
|
||||
} label: {
|
||||
sidebarLabel(section.rawValue, icon: section.icon)
|
||||
.padding(.horizontal, isCompact ? 0 : 8)
|
||||
.frame(height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(selection == section ? Color.accentColor.opacity(0.18) : .clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
.help(section.rawValue)
|
||||
.accessibilityLabel(section.rawValue)
|
||||
.accessibilityAddTraits(selection == section ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isCompact ? 8 : 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Divider().padding(.horizontal, isCompact ? 12 : 16)
|
||||
|
||||
Button {
|
||||
selection = .settings
|
||||
} label: {
|
||||
sidebarLabel("Settings", icon: "gearshape")
|
||||
.padding(.horizontal, isCompact ? 0 : 8)
|
||||
.frame(height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(selection == .settings ? Color.accentColor.opacity(0.18) : .clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
.padding(.horizontal, isCompact ? 8 : 10)
|
||||
.padding(.vertical, 8)
|
||||
.foregroundStyle(selection == .settings ? Color.primary : Color.secondary)
|
||||
.help("Settings")
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarLabel(_ title: String, icon: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 22, height: 20)
|
||||
if !isCompact {
|
||||
Text(title).lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: isCompact ? .center : .leading)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum TaskSection: String, CaseIterable, Identifiable {
|
||||
case processes = "Processes"
|
||||
case performance = "Performance"
|
||||
case history = "App history"
|
||||
case startup = "Startup apps"
|
||||
case users = "Users"
|
||||
case details = "Details"
|
||||
case services = "Services"
|
||||
case settings = "Settings"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .processes: "square.stack.3d.up"
|
||||
case .performance: "waveform.path.ecg"
|
||||
case .history: "clock.arrow.circlepath"
|
||||
case .startup: "gauge.open.with.lines.needle.33percent"
|
||||
case .users: "person.2"
|
||||
case .details: "list.bullet.rectangle"
|
||||
case .services: "gearshape.2"
|
||||
case .settings: "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateSpeed: String, CaseIterable, Identifiable {
|
||||
case high = "High"
|
||||
case normal = "Normal"
|
||||
case low = "Low"
|
||||
case paused = "Paused"
|
||||
|
||||
var id: String { rawValue }
|
||||
var interval: Duration {
|
||||
switch self {
|
||||
case .high: .seconds(1)
|
||||
case .normal: .seconds(2)
|
||||
case .low: .seconds(5)
|
||||
case .paused: .seconds(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessRecord: Identifiable, Hashable, Sendable {
|
||||
let pid: Int32
|
||||
let parentPID: Int32
|
||||
let name: String
|
||||
let executablePath: String
|
||||
let user: String
|
||||
let cpu: Double
|
||||
let memoryPercent: Double
|
||||
let residentBytes: UInt64
|
||||
let state: String
|
||||
let elapsed: String
|
||||
let cpuTime: TimeInterval
|
||||
let threadCount: Int
|
||||
let openFileCount: Int
|
||||
let architecture: String
|
||||
let priority: Int
|
||||
let nice: Int
|
||||
|
||||
var id: Int32 { pid }
|
||||
|
||||
var displayName: String {
|
||||
let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return cleaned.isEmpty ? "Process \(pid)" : cleaned
|
||||
}
|
||||
}
|
||||
|
||||
struct AppUsageRecord: Identifiable, Codable, Hashable, Sendable {
|
||||
let id: String
|
||||
var name: String
|
||||
var executablePath: String
|
||||
var user: String
|
||||
var cpuSeconds: TimeInterval
|
||||
var networkReceivedBytes: UInt64
|
||||
var networkSentBytes: UInt64
|
||||
var lastSeen: Date
|
||||
|
||||
var networkTotalBytes: UInt64 { networkReceivedBytes + networkSentBytes }
|
||||
}
|
||||
|
||||
struct ProcessActivityRate: Hashable, Sendable {
|
||||
var diskRead = 0.0
|
||||
var diskWrite = 0.0
|
||||
var networkReceive = 0.0
|
||||
var networkSend = 0.0
|
||||
|
||||
var diskTotal: Double { diskRead + diskWrite }
|
||||
var networkTotal: Double { networkReceive + networkSend }
|
||||
}
|
||||
|
||||
enum ServiceDomain: String, CaseIterable, Identifiable, Sendable {
|
||||
case user = "User"
|
||||
case system = "System"
|
||||
|
||||
var id: String { rawValue }
|
||||
var launchctlPrefix: String { self == .system ? "system" : "gui/\(getuid())" }
|
||||
}
|
||||
|
||||
struct ServiceRecord: Identifiable, Hashable, Sendable {
|
||||
let label: String
|
||||
let pid: Int32?
|
||||
let lastExitStatus: Int32
|
||||
let domain: ServiceDomain
|
||||
|
||||
var id: String { "\(domain.rawValue):\(label)" }
|
||||
var isRunning: Bool { pid != nil }
|
||||
var isControllable: Bool { domain == .user }
|
||||
|
||||
var configurationPath: String? {
|
||||
let roots = domain == .system
|
||||
? ["/Library/LaunchDaemons", "/System/Library/LaunchDaemons"]
|
||||
: [NSHomeDirectory() + "/Library/LaunchAgents", "/Library/LaunchAgents", "/System/Library/LaunchAgents"]
|
||||
return roots.map { "\($0)/\(label).plist" }.first { FileManager.default.fileExists(atPath: $0) }
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
let parts = label.split(separator: ".").map(String.init)
|
||||
let meaningful = parts.filter { part in
|
||||
let isNumber = part.allSatisfy(\.isNumber)
|
||||
let isUUID = UUID(uuidString: part) != nil
|
||||
return !isNumber && !isUUID
|
||||
}
|
||||
guard let last = meaningful.last else { return label }
|
||||
let generic = ["agent", "helper", "service", "xpcservice", "app"]
|
||||
if generic.contains(last.lowercased()), meaningful.count > 1 {
|
||||
return "\(meaningful[meaningful.count - 2]) \(last)"
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
var publisher: String {
|
||||
let parts = label.split(separator: ".").map(String.init)
|
||||
guard let first = parts.first else { return "Unknown" }
|
||||
if label.hasPrefix("com.apple") { return "Apple" }
|
||||
if first == "application", parts.count > 2 {
|
||||
let vendorIndex = ["com", "org", "net", "io"].contains(parts[1].lowercased()) ? 2 : 1
|
||||
let vendor = parts[vendorIndex]
|
||||
return vendor.prefix(1).uppercased() + vendor.dropFirst()
|
||||
}
|
||||
if ["com", "org", "net", "io", "us"].contains(first.lowercased()), parts.count > 1 {
|
||||
return parts[1].prefix(1).uppercased() + parts[1].dropFirst()
|
||||
}
|
||||
return first.prefix(1).uppercased() + first.dropFirst()
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricSample: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
let value: Double
|
||||
}
|
||||
|
||||
struct VolumeSnapshot: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let mountPath: String
|
||||
let fileSystem: String
|
||||
let capacity: UInt64
|
||||
let available: UInt64
|
||||
let isEjectable: Bool
|
||||
|
||||
var used: UInt64 { capacity > available ? capacity - available : 0 }
|
||||
var usedPercent: Double { capacity > 0 ? Double(used) / Double(capacity) * 100 : 0 }
|
||||
}
|
||||
|
||||
struct SystemSnapshot {
|
||||
var cpuPercent = 0.0
|
||||
var memoryUsed: UInt64 = 0
|
||||
var memoryTotal: UInt64 = ProcessInfo.processInfo.physicalMemory
|
||||
var memoryActive: UInt64 = 0
|
||||
var memoryWired: UInt64 = 0
|
||||
var memoryCompressed: UInt64 = 0
|
||||
var memoryCached: UInt64 = 0
|
||||
var swapUsed: UInt64 = 0
|
||||
var swapTotal: UInt64 = 0
|
||||
var memoryType = "Unified"
|
||||
var memorySpeed = "Not reported by macOS"
|
||||
var memoryManufacturer = "Apple unified memory"
|
||||
var diskFree: UInt64 = 0
|
||||
var diskTotal: UInt64 = 0
|
||||
var diskReadRate = 0.0
|
||||
var diskWriteRate = 0.0
|
||||
var diskOperationsPerSecond = 0.0
|
||||
var diskLatencyMilliseconds = 0.0
|
||||
var diskActivePercent = 0.0
|
||||
var gpuPercent = 0.0
|
||||
var gpuRendererPercent = 0.0
|
||||
var gpuTilerPercent = 0.0
|
||||
var gpuMemoryBytes: UInt64 = 0
|
||||
var gpuAllocatedBytes: UInt64 = 0
|
||||
var gpuCoreCount = 0
|
||||
var processCount = 0
|
||||
var threadCount = 0
|
||||
var uptime: TimeInterval = ProcessInfo.processInfo.systemUptime
|
||||
var corePercents: [Double] = []
|
||||
var networkReceiveRate = 0.0
|
||||
var networkSendRate = 0.0
|
||||
var networkInterface = "Network"
|
||||
var removableVolumes: [VolumeSnapshot] = []
|
||||
|
||||
var memoryPercent: Double {
|
||||
guard memoryTotal > 0 else { return 0 }
|
||||
return Double(memoryUsed) / Double(memoryTotal) * 100
|
||||
}
|
||||
|
||||
var diskPercent: Double {
|
||||
guard diskTotal > 0 else { return 0 }
|
||||
return Double(diskTotal - diskFree) / Double(diskTotal) * 100
|
||||
}
|
||||
|
||||
var diskThroughput: Double { diskReadRate + diskWriteRate }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum Formatters {
|
||||
static let bytes: ByteCountFormatter = {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .memory
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static func percent(_ value: Double) -> String {
|
||||
value < 10 ? String(format: "%.1f%%", value) : String(format: "%.0f%%", value)
|
||||
}
|
||||
|
||||
static func uptime(_ interval: TimeInterval) -> String {
|
||||
let total = Int(interval)
|
||||
let days = total / 86_400
|
||||
let hours = (total % 86_400) / 3_600
|
||||
let minutes = (total % 3_600) / 60
|
||||
return days > 0 ? "\(days)d \(hours)h \(minutes)m" : "\(hours)h \(minutes)m"
|
||||
}
|
||||
|
||||
static func rate(_ bytesPerSecond: Double) -> String {
|
||||
"\(bytes.string(fromByteCount: Int64(max(0, bytesPerSecond))))/s"
|
||||
}
|
||||
|
||||
static func duration(_ interval: TimeInterval) -> String {
|
||||
let total = max(0, Int(interval.rounded()))
|
||||
let hours = total / 3_600
|
||||
let minutes = (total % 3_600) / 60
|
||||
let seconds = total % 60
|
||||
return hours > 0
|
||||
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
: String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
struct PerformanceView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@AppStorage("performanceSelectedMetric") private var selectedMetric: PerformanceMetric = .cpu
|
||||
@AppStorage("performanceCPUDisplayMode") private var cpuDisplayMode: CPUDisplayMode = .summary
|
||||
@State private var selectedVolumeID: String?
|
||||
|
||||
private enum PerformanceMetric: String, CaseIterable, Identifiable {
|
||||
case cpu = "CPU"
|
||||
case memory = "Memory"
|
||||
case disk = "Disk"
|
||||
case gpu = "GPU"
|
||||
case network = "Network"
|
||||
var id: String { rawValue }
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .cpu: "cpu"
|
||||
case .memory: "memorychip"
|
||||
case .disk: "internaldrive"
|
||||
case .gpu: "rectangle.3.group"
|
||||
case .network: "network"
|
||||
}
|
||||
}
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .cpu: .blue
|
||||
case .memory: .purple
|
||||
case .disk: .green
|
||||
case .gpu: .pink
|
||||
case .network: .teal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Performance") {
|
||||
UpdateStatus()
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors")
|
||||
metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)))")
|
||||
metricCard(
|
||||
.disk,
|
||||
value: monitor.snapshot.diskActivePercent,
|
||||
valueText: Formatters.rate(monitor.snapshot.diskThroughput),
|
||||
detail: "\(Formatters.percent(monitor.snapshot.diskActivePercent)) active • System volume"
|
||||
)
|
||||
ForEach(monitor.snapshot.removableVolumes) { volume in
|
||||
volumeCard(volume)
|
||||
}
|
||||
metricCard(
|
||||
.gpu,
|
||||
value: monitor.snapshot.gpuPercent,
|
||||
detail: "\(monitor.snapshot.gpuCoreCount) cores • \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.gpuMemoryBytes))) in use"
|
||||
)
|
||||
metricCard(
|
||||
.network,
|
||||
value: 0,
|
||||
valueText: Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate),
|
||||
detail: "↓ \(Formatters.rate(monitor.snapshot.networkReceiveRate)) ↑ \(Formatters.rate(monitor.snapshot.networkSendRate))"
|
||||
)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.frame(width: 245)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
|
||||
Divider()
|
||||
detail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func metricCard(_ metric: PerformanceMetric, value: Double, valueText: String? = nil, detail: String) -> some View {
|
||||
Button {
|
||||
selectedVolumeID = nil
|
||||
selectedMetric = metric
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: metric.icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(metric.color)
|
||||
.frame(width: 30)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(metric.rawValue).font(.headline)
|
||||
Text(valueText ?? Formatters.percent(value)).font(.title3.monospacedDigit())
|
||||
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(selectedVolumeID == nil && selectedMetric == metric ? metric.color.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke(
|
||||
selectedVolumeID == nil && selectedMetric == metric ? metric.color : Color.secondary.opacity(0.15),
|
||||
lineWidth: selectedVolumeID == nil && selectedMetric == metric ? 1.5 : 1
|
||||
)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func volumeCard(_ volume: VolumeSnapshot) -> some View {
|
||||
Button { selectedVolumeID = volume.id } label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 30)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(volume.name).font(.headline).lineLimit(1)
|
||||
Text(Formatters.percent(volume.usedPercent)).font(.title3.monospacedDigit())
|
||||
Text("\(Formatters.bytes.string(fromByteCount: Int64(volume.capacity))) • \(volume.fileSystem)")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(selectedVolumeID == volume.id ? Color.orange.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke(selectedVolumeID == volume.id ? Color.orange : Color.secondary.opacity(0.15), lineWidth: selectedVolumeID == volume.id ? 1.5 : 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(volume.name), removable disk")
|
||||
.accessibilityValue("\(Formatters.percent(volume.usedPercent)) used")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
if let selectedVolumeID,
|
||||
let volume = monitor.snapshot.removableVolumes.first(where: { $0.id == selectedVolumeID }) {
|
||||
RemovableDiskDetail(volume: volume)
|
||||
} else {
|
||||
switch selectedMetric {
|
||||
case .cpu:
|
||||
CPUPerformanceDetail(
|
||||
subtitle: processorName,
|
||||
value: monitor.snapshot.cpuPercent,
|
||||
history: monitor.cpuHistory,
|
||||
coreHistories: monitor.coreHistories,
|
||||
coreValues: monitor.snapshot.corePercents,
|
||||
displayMode: $cpuDisplayMode,
|
||||
stats: [
|
||||
("Processes", "\(monitor.snapshot.processCount)"),
|
||||
("Threads", "\(monitor.snapshot.threadCount)"),
|
||||
("Uptime", Formatters.uptime(monitor.snapshot.uptime)),
|
||||
("Logical processors", "\(ProcessInfo.processInfo.activeProcessorCount)")
|
||||
]
|
||||
)
|
||||
case .memory:
|
||||
PerformanceDetail(
|
||||
title: "Memory",
|
||||
subtitle: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)),
|
||||
color: .purple,
|
||||
value: monitor.snapshot.memoryPercent,
|
||||
history: monitor.memoryHistory,
|
||||
stats: [
|
||||
("In use", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))),
|
||||
("Available", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal - monitor.snapshot.memoryUsed))),
|
||||
("Active", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryActive))),
|
||||
("Cached", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCached))),
|
||||
("Wired", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryWired))),
|
||||
("Compressed", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCompressed))),
|
||||
("Swap", "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapUsed))) / \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapTotal)))"),
|
||||
("Type", monitor.snapshot.memoryType),
|
||||
("Frequency / speed", monitor.snapshot.memorySpeed),
|
||||
("Manufacturer", monitor.snapshot.memoryManufacturer)
|
||||
]
|
||||
)
|
||||
case .disk:
|
||||
DiskPerformanceDetail(
|
||||
snapshot: monitor.snapshot,
|
||||
readHistory: monitor.diskReadHistory,
|
||||
writeHistory: monitor.diskWriteHistory,
|
||||
latencyHistory: monitor.diskLatencyHistory,
|
||||
activeHistory: monitor.diskActiveHistory
|
||||
)
|
||||
case .gpu:
|
||||
GPUPerformanceDetail(
|
||||
name: "\(processorName) GPU",
|
||||
snapshot: monitor.snapshot,
|
||||
history: monitor.gpuHistory,
|
||||
rendererHistory: monitor.gpuRendererHistory,
|
||||
tilerHistory: monitor.gpuTilerHistory
|
||||
)
|
||||
case .network:
|
||||
NetworkPerformanceDetail(
|
||||
interface: monitor.snapshot.networkInterface,
|
||||
receiveRate: monitor.snapshot.networkReceiveRate,
|
||||
sendRate: monitor.snapshot.networkSendRate,
|
||||
receiveHistory: monitor.networkReceiveHistory,
|
||||
sendHistory: monitor.networkSendHistory
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var processorName: String {
|
||||
var size = 0
|
||||
guard sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) == 0, size > 1 else {
|
||||
return "Apple Silicon"
|
||||
}
|
||||
var buffer = [CChar](repeating: 0, count: size)
|
||||
guard sysctlbyname("machdep.cpu.brand_string", &buffer, &size, nil, 0) == 0 else {
|
||||
return "Apple Silicon"
|
||||
}
|
||||
return String(decoding: buffer.prefix { $0 != 0 }.map(UInt8.init(bitPattern:)), as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemovableDiskDetail: View {
|
||||
let volume: VolumeSnapshot
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(volume.name).font(.system(size: 30, weight: .semibold))
|
||||
Text("\(volume.fileSystem) • \(volume.mountPath)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Label(volume.isEjectable ? "Ejectable" : "Removable", systemImage: "externaldrive")
|
||||
.foregroundStyle(.orange)
|
||||
Spacer()
|
||||
Text(Formatters.percent(volume.usedPercent))
|
||||
.font(.system(size: 34, weight: .light).monospacedDigit())
|
||||
}
|
||||
|
||||
ProgressView(value: volume.usedPercent, total: 100)
|
||||
.tint(.orange)
|
||||
|
||||
StatsGrid(stats: [
|
||||
("Used", Formatters.bytes.string(fromByteCount: Int64(volume.used))),
|
||||
("Available", Formatters.bytes.string(fromByteCount: Int64(volume.available))),
|
||||
("Capacity", Formatters.bytes.string(fromByteCount: Int64(volume.capacity))),
|
||||
("Ejectable", volume.isEjectable ? "Yes" : "No")
|
||||
])
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GPUPerformanceDetail: View {
|
||||
let name: String
|
||||
let snapshot: SystemSnapshot
|
||||
let history: [MetricSample]
|
||||
let rendererHistory: [MetricSample]
|
||||
let tilerHistory: [MetricSample]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("GPU").font(.system(size: 30, weight: .semibold))
|
||||
Text(name).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Formatters.percent(snapshot.gpuPercent))
|
||||
.font(.system(size: 34, weight: .light).monospacedDigit())
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Overall utilization").font(.caption).foregroundStyle(.secondary)
|
||||
Chart(history) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value("Utilization", sample.value))
|
||||
.foregroundStyle(Color.pink.opacity(0.12))
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Utilization", sample.value))
|
||||
.foregroundStyle(.pink).lineStyle(.init(lineWidth: 2))
|
||||
}
|
||||
.chartYScale(domain: 0...100)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
|
||||
AxisGridLine().foregroundStyle(Color.pink.opacity(0.16))
|
||||
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
|
||||
}
|
||||
}
|
||||
.frame(height: 240)
|
||||
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.pink.opacity(0.16)) }
|
||||
|
||||
HStack(spacing: 14) {
|
||||
engineChart(title: "Renderer", value: snapshot.gpuRendererPercent, history: rendererHistory, color: .pink)
|
||||
engineChart(title: "Tiler", value: snapshot.gpuTilerPercent, history: tilerHistory, color: .purple)
|
||||
}
|
||||
|
||||
StatsGrid(stats: [
|
||||
("Utilization", Formatters.percent(snapshot.gpuPercent)),
|
||||
("Renderer", Formatters.percent(snapshot.gpuRendererPercent)),
|
||||
("Tiler", Formatters.percent(snapshot.gpuTilerPercent)),
|
||||
("GPU cores", "\(snapshot.gpuCoreCount)"),
|
||||
("Memory in use", Formatters.bytes.string(fromByteCount: Int64(snapshot.gpuMemoryBytes))),
|
||||
("Allocated", Formatters.bytes.string(fromByteCount: Int64(snapshot.gpuAllocatedBytes)))
|
||||
])
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
}
|
||||
|
||||
private func engineChart(title: String, value: Double, history: [MetricSample], color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(Formatters.percent(value)).font(.callout.monospacedDigit())
|
||||
}
|
||||
Chart(history) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value(title, sample.value))
|
||||
.foregroundStyle(color.opacity(0.1))
|
||||
LineMark(x: .value("Time", sample.date), y: .value(title, sample.value))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.chartYScale(domain: 0...100)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.frame(height: 92)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.16)) }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(title) GPU utilization")
|
||||
.accessibilityValue(Formatters.percent(value))
|
||||
}
|
||||
}
|
||||
|
||||
private struct DiskPerformanceDetail: View {
|
||||
let snapshot: SystemSnapshot
|
||||
let readHistory: [MetricSample]
|
||||
let writeHistory: [MetricSample]
|
||||
let latencyHistory: [MetricSample]
|
||||
let activeHistory: [MetricSample]
|
||||
|
||||
private var maximumRate: Double {
|
||||
let maximum = (readHistory.map(\.value) + writeHistory.map(\.value) + [snapshot.diskReadRate, snapshot.diskWriteRate]).max() ?? 0
|
||||
return max(1024, maximum * 1.15)
|
||||
}
|
||||
|
||||
private var maximumLatency: Double {
|
||||
max(0.1, ((latencyHistory.map(\.value) + [snapshot.diskLatencyMilliseconds]).max() ?? 0) * 1.15)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Disk 0").font(.system(size: 30, weight: .semibold))
|
||||
Text("System volume • APFS • Solid-state").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(Formatters.rate(snapshot.diskThroughput))
|
||||
.font(.system(size: 30, weight: .light).monospacedDigit())
|
||||
Text("\(Formatters.percent(snapshot.diskActivePercent)) active")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Disk transfer rate").font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Label("Read", systemImage: "arrow.down").foregroundStyle(.green)
|
||||
Label("Write", systemImage: "arrow.up").foregroundStyle(.orange)
|
||||
}
|
||||
.font(.caption)
|
||||
Chart {
|
||||
ForEach(readHistory) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value("Read", sample.value))
|
||||
.foregroundStyle(Color.green.opacity(0.1))
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Read", sample.value))
|
||||
.foregroundStyle(by: .value("Direction", "Read"))
|
||||
}
|
||||
ForEach(writeHistory) { sample in
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Write", sample.value))
|
||||
.foregroundStyle(by: .value("Direction", "Write"))
|
||||
}
|
||||
}
|
||||
.chartForegroundStyleScale(["Read": Color.green, "Write": Color.orange])
|
||||
.chartYScale(domain: 0...maximumRate)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .trailing) { value in
|
||||
AxisGridLine().foregroundStyle(Color.green.opacity(0.14))
|
||||
AxisValueLabel {
|
||||
if let rate = value.as(Double.self) { Text(Formatters.rate(rate)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(height: 220)
|
||||
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.green.opacity(0.16)) }
|
||||
|
||||
HStack(spacing: 14) {
|
||||
miniChart(
|
||||
title: "Average response time",
|
||||
value: String(format: "%.2f ms", snapshot.diskLatencyMilliseconds),
|
||||
history: latencyHistory,
|
||||
maximum: maximumLatency,
|
||||
color: .orange
|
||||
)
|
||||
miniChart(
|
||||
title: "Active time",
|
||||
value: Formatters.percent(snapshot.diskActivePercent),
|
||||
history: activeHistory,
|
||||
maximum: 100,
|
||||
color: .green
|
||||
)
|
||||
}
|
||||
|
||||
StatsGrid(stats: [
|
||||
("Read speed", Formatters.rate(snapshot.diskReadRate)),
|
||||
("Write speed", Formatters.rate(snapshot.diskWriteRate)),
|
||||
("Response time", String(format: "%.2f ms", snapshot.diskLatencyMilliseconds)),
|
||||
("IOPS", String(format: "%.0f", snapshot.diskOperationsPerSecond)),
|
||||
("Used space", Formatters.bytes.string(fromByteCount: Int64(snapshot.diskTotal - snapshot.diskFree))),
|
||||
("Capacity", Formatters.bytes.string(fromByteCount: Int64(snapshot.diskTotal)))
|
||||
])
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
}
|
||||
|
||||
private func miniChart(
|
||||
title: String,
|
||||
value: String,
|
||||
history: [MetricSample],
|
||||
maximum: Double,
|
||||
color: Color
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(value).font(.callout.monospacedDigit())
|
||||
}
|
||||
Chart(history) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value(title, sample.value))
|
||||
.foregroundStyle(color.opacity(0.1))
|
||||
LineMark(x: .value("Time", sample.date), y: .value(title, sample.value))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.chartYScale(domain: 0...maximum)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.frame(height: 92)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue(value)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.16)) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct NetworkPerformanceDetail: View {
|
||||
let interface: String
|
||||
let receiveRate: Double
|
||||
let sendRate: Double
|
||||
let receiveHistory: [MetricSample]
|
||||
let sendHistory: [MetricSample]
|
||||
|
||||
private var maximumRate: Double {
|
||||
let maximum = (receiveHistory.map(\.value) + sendHistory.map(\.value) + [receiveRate, sendRate]).max() ?? 0
|
||||
return max(1024, maximum * 1.15)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Network").font(.system(size: 30, weight: .semibold))
|
||||
Text(interface).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Formatters.rate(receiveRate + sendRate))
|
||||
.font(.system(size: 30, weight: .light).monospacedDigit())
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Throughput").font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Label("Receive", systemImage: "arrow.down").foregroundStyle(.teal)
|
||||
Label("Send", systemImage: "arrow.up").foregroundStyle(.purple)
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
Chart {
|
||||
ForEach(receiveHistory) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value("Receive", sample.value))
|
||||
.foregroundStyle(Color.teal.opacity(0.1))
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Receive", sample.value))
|
||||
.foregroundStyle(by: .value("Direction", "Receive"))
|
||||
}
|
||||
ForEach(sendHistory) { sample in
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Send", sample.value))
|
||||
.foregroundStyle(by: .value("Direction", "Send"))
|
||||
}
|
||||
}
|
||||
.chartForegroundStyleScale(["Receive": Color.teal, "Send": Color.purple])
|
||||
.chartYScale(domain: 0...maximumRate)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .trailing) { value in
|
||||
AxisGridLine().foregroundStyle(Color.teal.opacity(0.14))
|
||||
AxisValueLabel {
|
||||
if let rate = value.as(Double.self) { Text(Formatters.rate(rate)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(height: 280)
|
||||
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay { RoundedRectangle(cornerRadius: 12).stroke(Color.teal.opacity(0.16)) }
|
||||
|
||||
StatsGrid(stats: [
|
||||
("Receive", Formatters.rate(receiveRate)),
|
||||
("Send", Formatters.rate(sendRate)),
|
||||
("Combined", Formatters.rate(receiveRate + sendRate)),
|
||||
("Interface", interface)
|
||||
])
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum CPUDisplayMode: String {
|
||||
case summary = "Summary view"
|
||||
case logicalProcessors = "Logical processors"
|
||||
}
|
||||
|
||||
private struct CPUPerformanceDetail: View {
|
||||
let subtitle: String
|
||||
let value: Double
|
||||
let history: [MetricSample]
|
||||
let coreHistories: [[MetricSample]]
|
||||
let coreValues: [Double]
|
||||
@Binding var displayMode: CPUDisplayMode
|
||||
let stats: [(String, String)]
|
||||
|
||||
private var chartData: [MetricSample] {
|
||||
if !history.isEmpty { return history }
|
||||
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("CPU").font(.system(size: 30, weight: .semibold))
|
||||
Text(subtitle).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Formatters.percent(value))
|
||||
.font(.system(size: 34, weight: .light).monospacedDigit())
|
||||
}
|
||||
|
||||
cpuGraph
|
||||
.contextMenu {
|
||||
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")
|
||||
}
|
||||
}
|
||||
.help("Right-click to switch between summary and logical processor graphs")
|
||||
|
||||
StatsGrid(stats: stats)
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
}
|
||||
|
||||
private var cpuGraph: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(displayMode == .summary ? "% Utilization" : "Utilization by logical processor")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Label(displayMode.rawValue, systemImage: displayMode == .summary ? "chart.xyaxis.line" : "square.grid.3x3")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if displayMode == .summary {
|
||||
Chart(chartData) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
||||
.foregroundStyle(Color.blue.opacity(0.13))
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
||||
.foregroundStyle(.blue)
|
||||
.lineStyle(.init(lineWidth: 2))
|
||||
}
|
||||
.chartYScale(domain: 0...100)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
|
||||
AxisGridLine().foregroundStyle(Color.blue.opacity(0.18))
|
||||
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
|
||||
}
|
||||
}
|
||||
.frame(height: 280)
|
||||
} else {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
|
||||
ForEach(coreValues.indices, id: \.self) { index in
|
||||
CoreChart(
|
||||
index: index,
|
||||
value: coreValues[index],
|
||||
history: index < coreHistories.count ? coreHistories[index] : []
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 280)
|
||||
}
|
||||
|
||||
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.blue.opacity(0.16), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CoreChart: View {
|
||||
let index: Int
|
||||
let value: Double
|
||||
let history: [MetricSample]
|
||||
|
||||
private var data: [MetricSample] {
|
||||
if !history.isEmpty { return history }
|
||||
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
Chart(data) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
||||
.foregroundStyle(Color.blue.opacity(0.1))
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
||||
.foregroundStyle(.blue)
|
||||
.lineStyle(.init(lineWidth: 1.25))
|
||||
}
|
||||
.chartYScale(domain: 0...100)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.background {
|
||||
GridPattern().stroke(Color.blue.opacity(0.12), lineWidth: 0.5)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("CPU \(index)").font(.caption2.weight(.medium))
|
||||
Text(Formatters.percent(value)).font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
.frame(height: 62)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.overlay { RoundedRectangle(cornerRadius: 5).stroke(Color.blue.opacity(0.28), lineWidth: 1) }
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("CPU \(index), \(Formatters.percent(value)) utilization")
|
||||
}
|
||||
}
|
||||
|
||||
private struct GridPattern: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
for fraction in [0.25, 0.5, 0.75] {
|
||||
let x = rect.width * fraction
|
||||
let y = rect.height * fraction
|
||||
path.move(to: CGPoint(x: x, y: 0))
|
||||
path.addLine(to: CGPoint(x: x, y: rect.height))
|
||||
path.move(to: CGPoint(x: 0, y: y))
|
||||
path.addLine(to: CGPoint(x: rect.width, y: y))
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatsGrid: View {
|
||||
let stats: [(String, String)]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 18) {
|
||||
ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(stat.0).font(.caption).foregroundStyle(.secondary)
|
||||
Text(stat.1).font(.title3.monospacedDigit()).lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PerformanceDetail: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let color: Color
|
||||
let value: Double
|
||||
let history: [MetricSample]
|
||||
let stats: [(String, String)]
|
||||
|
||||
var chartData: [MetricSample] {
|
||||
if !history.isEmpty { return history }
|
||||
return [MetricSample(date: .now.addingTimeInterval(-60), value: value), MetricSample(date: .now, value: value)]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.system(size: 30, weight: .semibold))
|
||||
Text(subtitle).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Formatters.percent(value))
|
||||
.font(.system(size: 34, weight: .light).monospacedDigit())
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("% Utilization").font(.caption).foregroundStyle(.secondary)
|
||||
Chart(chartData) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
||||
.foregroundStyle(color.opacity(0.13))
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Usage", sample.value))
|
||||
.foregroundStyle(color)
|
||||
.lineStyle(.init(lineWidth: 2))
|
||||
}
|
||||
.chartYScale(domain: 0...100)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .trailing, values: [0, 25, 50, 75, 100]) { value in
|
||||
AxisGridLine().foregroundStyle(color.opacity(0.18))
|
||||
AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } }
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 280)
|
||||
Text("60 seconds").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.42))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
StatsGrid(stats: stats)
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum TerminationKind {
|
||||
case normal
|
||||
case force
|
||||
case tree
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .normal: "End task"
|
||||
case .force: "Force quit"
|
||||
case .tree: "End process tree"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminationRequest {
|
||||
let process: ProcessRecord
|
||||
let kind: TerminationKind
|
||||
}
|
||||
|
||||
struct ProcessContextMenu: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
let process: ProcessRecord
|
||||
let onTerminate: (TerminationRequest) -> Void
|
||||
let onShowDetails: (ProcessRecord) -> Void
|
||||
let onShowProperties: (ProcessRecord) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button("End task", systemImage: "xmark.circle") {
|
||||
onTerminate(.init(process: process, kind: .normal))
|
||||
}
|
||||
|
||||
Button("Efficiency mode", systemImage: "leaf") {
|
||||
monitor.setPriority(10, for: process)
|
||||
}
|
||||
|
||||
Menu("Process control", systemImage: "switch.2") {
|
||||
Button("Interrupt") { monitor.sendSignal(SIGINT, to: process) }
|
||||
Button("Pause") { monitor.sendSignal(SIGSTOP, to: process) }
|
||||
Button("Resume") { monitor.sendSignal(SIGCONT, to: process) }
|
||||
Divider()
|
||||
Button("Force quit", role: .destructive) {
|
||||
onTerminate(.init(process: process, kind: .force))
|
||||
}
|
||||
Button("End process tree", role: .destructive) {
|
||||
onTerminate(.init(process: process, kind: .tree))
|
||||
}
|
||||
}
|
||||
|
||||
Menu("Set priority", systemImage: "speedometer") {
|
||||
Button("High") { monitor.setPriority(-10, for: process) }
|
||||
Button("Above normal") { monitor.setPriority(-5, for: process) }
|
||||
Button("Normal") { monitor.setPriority(0, for: process) }
|
||||
Button("Below normal") { monitor.setPriority(10, for: process) }
|
||||
Button("Low") { monitor.setPriority(15, for: process) }
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Go to details", systemImage: "list.bullet.rectangle") {
|
||||
onShowDetails(process)
|
||||
}
|
||||
|
||||
Menu("Copy", systemImage: "doc.on.doc") {
|
||||
Button("Name") { copy(process.displayName) }
|
||||
Button("PID") { copy(String(process.pid)) }
|
||||
Button("Executable path") { copy(process.executablePath) }
|
||||
Button("All details") {
|
||||
copy("\(process.displayName)\tPID \(process.pid)\tCPU \(Formatters.percent(process.cpu))\t\(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))\t\(process.executablePath)")
|
||||
}
|
||||
}
|
||||
|
||||
Menu("Inspect", systemImage: "magnifyingglass") {
|
||||
Button("Analyze dependencies…") {
|
||||
ProcessDependencyInspectorController.open(process: process, allProcesses: monitor.processes)
|
||||
}
|
||||
Button("Reveal in Finder") { reveal(process) }
|
||||
.disabled(!process.executablePath.hasPrefix("/"))
|
||||
Button("Search online") { searchOnline(process.displayName) }
|
||||
Button("Create diagnostic report…") {
|
||||
ProcessDiagnosticReporter.chooseDestinationAndCapture(process)
|
||||
}
|
||||
Button("Properties") { onShowProperties(process) }
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(_ text: String) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
private func reveal(_ process: ProcessRecord) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: process.executablePath)])
|
||||
}
|
||||
|
||||
private func searchOnline(_ query: String) {
|
||||
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "https://www.google.com/search?q=\(encoded)+macOS+process") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum ProcessDiagnosticReporter {
|
||||
private static let lastDirectoryKey = "diagnosticReportDirectory"
|
||||
private static let durationKey = "diagnosticReportDuration"
|
||||
private static let askEveryTimeKey = "diagnosticAskEveryTime"
|
||||
|
||||
static func chooseDestinationAndCapture(_ process: ProcessRecord) {
|
||||
let defaults = UserDefaults.standard
|
||||
let duration = max(1, defaults.integer(forKey: durationKey) == 0 ? 5 : defaults.integer(forKey: durationKey))
|
||||
let askEveryTime = defaults.object(forKey: askEveryTimeKey) as? Bool ?? true
|
||||
if !askEveryTime,
|
||||
let savedPath = defaults.string(forKey: lastDirectoryKey),
|
||||
FileManager.default.fileExists(atPath: savedPath) {
|
||||
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
|
||||
let filename = safeFilename("\(process.displayName)-\(process.pid)-\(stamp)-sample.txt")
|
||||
capture(process, duration: duration, to: URL(fileURLWithPath: savedPath, isDirectory: true).appendingPathComponent(filename))
|
||||
return
|
||||
}
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Create diagnostic report"
|
||||
panel.message = "Task Manager will sample \(process.displayName) for \(duration) seconds and save a text report."
|
||||
panel.prompt = "Create Report"
|
||||
panel.nameFieldStringValue = safeFilename("\(process.displayName)-\(process.pid)-sample.txt")
|
||||
panel.allowedContentTypes = [.plainText]
|
||||
panel.canCreateDirectories = true
|
||||
if let savedPath = UserDefaults.standard.string(forKey: lastDirectoryKey) {
|
||||
panel.directoryURL = URL(fileURLWithPath: savedPath, isDirectory: true)
|
||||
}
|
||||
|
||||
panel.begin { response in
|
||||
guard response == .OK, let destination = panel.url else { return }
|
||||
UserDefaults.standard.set(destination.deletingLastPathComponent().path, forKey: lastDirectoryKey)
|
||||
capture(process, duration: duration, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
private static func capture(_ process: ProcessRecord, duration: Int, to destination: URL) {
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let sampler = Process()
|
||||
let errorPipe = Pipe()
|
||||
sampler.executableURL = URL(fileURLWithPath: "/usr/bin/sample")
|
||||
sampler.arguments = [String(process.pid), String(duration), "1", "-mayDie", "-file", destination.path]
|
||||
sampler.standardError = errorPipe
|
||||
|
||||
do {
|
||||
try sampler.run()
|
||||
sampler.waitUntilExit()
|
||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorText = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
await showResult(
|
||||
success: sampler.terminationStatus == 0 && FileManager.default.fileExists(atPath: destination.path),
|
||||
process: process,
|
||||
duration: duration,
|
||||
destination: destination,
|
||||
error: errorText
|
||||
)
|
||||
} catch {
|
||||
await showResult(success: false, process: process, duration: duration, destination: destination, error: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func showResult(success: Bool, process: ProcessRecord, duration: Int, destination: URL, error: String) {
|
||||
let alert = NSAlert()
|
||||
if success {
|
||||
alert.messageText = "Diagnostic report created"
|
||||
alert.informativeText = "A \(duration)-second sample of \(process.displayName) was saved to \(destination.path)."
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Reveal in Finder")
|
||||
alert.addButton(withTitle: "Done")
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
||||
}
|
||||
} else {
|
||||
alert.messageText = "Couldn’t create diagnostic report"
|
||||
alert.informativeText = error.isEmpty ? "The process may have exited or macOS denied access to its sample data." : error
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
private static func safeFilename(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-")
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessPropertiesView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
let process: ProcessRecord
|
||||
|
||||
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 14) {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
.scaleEffect(1.35)
|
||||
.frame(width: 42, height: 42)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(process.displayName).font(.title2.weight(.semibold))
|
||||
Text("Process \(process.pid)").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
|
||||
Divider()
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
|
||||
property("Executable", process.executablePath)
|
||||
property("User", process.user)
|
||||
property("Parent PID", "\(process.parentPID)")
|
||||
property("State", process.state)
|
||||
property("CPU", Formatters.percent(process.cpu))
|
||||
property("CPU time", Formatters.duration(process.cpuTime))
|
||||
property("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
|
||||
property("Threads", "\(process.threadCount)")
|
||||
property("Open handles", "\(process.openFileCount)")
|
||||
property("Architecture", process.architecture)
|
||||
property("Priority", "\(process.priority) (nice \(process.nice))")
|
||||
property("Disk read", Formatters.rate(activity.diskRead))
|
||||
property("Disk write", Formatters.rate(activity.diskWrite))
|
||||
property("Network", Formatters.rate(activity.networkTotal))
|
||||
property("Elapsed time", process.elapsed)
|
||||
}
|
||||
.padding(20)
|
||||
|
||||
Divider()
|
||||
HStack {
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: process.executablePath)])
|
||||
}
|
||||
.disabled(!process.executablePath.hasPrefix("/"))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(width: 520)
|
||||
}
|
||||
|
||||
private func property(_ name: String, _ value: String) -> some View {
|
||||
GridRow {
|
||||
Text(name).foregroundStyle(.secondary).frame(width: 90, alignment: .trailing)
|
||||
Text(value).textSelection(.enabled).lineLimit(2).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func terminationConfirmation(_ request: Binding<TerminationRequest?>) -> some View {
|
||||
modifier(TerminationConfirmationModifier(request: request))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TerminationConfirmationModifier: ViewModifier {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@Binding var request: TerminationRequest?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.confirmationDialog(
|
||||
"\(request?.kind.title ?? "End task"): \(request?.process.displayName ?? "this task")?",
|
||||
isPresented: Binding(get: { request != nil }, set: { if !$0 { request = nil } })
|
||||
) {
|
||||
Button(request?.kind.title ?? "End task", role: .destructive) {
|
||||
if let request {
|
||||
switch request.kind {
|
||||
case .normal: monitor.terminate(request.process)
|
||||
case .force: monitor.terminate(request.process, force: true)
|
||||
case .tree: monitor.terminateTree(request.process)
|
||||
}
|
||||
}
|
||||
request = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { request = nil }
|
||||
} message: {
|
||||
Text(request?.kind == .tree ? "This will end the process and all of its child processes. Unsaved data may be lost." : "Unsaved data in this process may be lost.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum DependencySection: String, CaseIterable, Identifiable {
|
||||
case relationships = "Relationships"
|
||||
case connections = "Connections"
|
||||
case libraries = "Libraries"
|
||||
case files = "Open files"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private struct ProcessDependencySnapshot: Sendable {
|
||||
var connections: [String] = []
|
||||
var libraries: [String] = []
|
||||
var files: [String] = []
|
||||
var handleCount = 0
|
||||
var error: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum ProcessDependencyInspectorController {
|
||||
private static var controllers: [Int32: NSWindowController] = [:]
|
||||
|
||||
static func open(process: ProcessRecord, allProcesses: [ProcessRecord]) {
|
||||
if let controller = controllers[process.pid], let window = controller.window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let view = ProcessDependencyInspectorView(process: process, allProcesses: allProcesses)
|
||||
let window = NSWindow(contentViewController: NSHostingController(rootView: view))
|
||||
window.title = "Analyze dependencies — \(process.displayName)"
|
||||
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||
window.setContentSize(NSSize(width: 780, height: 600))
|
||||
window.minSize = NSSize(width: 660, height: 480)
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
let controller = NSWindowController(window: window)
|
||||
controllers[process.pid] = controller
|
||||
controller.showWindow(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessDependencyInspectorView: View {
|
||||
let process: ProcessRecord
|
||||
let allProcesses: [ProcessRecord]
|
||||
@State private var section: DependencySection = .relationships
|
||||
@State private var snapshot = ProcessDependencySnapshot()
|
||||
@State private var isLoading = true
|
||||
@State private var refreshID = UUID()
|
||||
|
||||
private var processByPID: [Int32: ProcessRecord] {
|
||||
Dictionary(uniqueKeysWithValues: allProcesses.map { ($0.pid, $0) })
|
||||
}
|
||||
|
||||
private var parentChain: [ProcessRecord] {
|
||||
var result: [ProcessRecord] = []
|
||||
var currentPID = process.parentPID
|
||||
var visited: Set<Int32> = [process.pid]
|
||||
while currentPID > 0, !visited.contains(currentPID), let parent = processByPID[currentPID] {
|
||||
result.append(parent)
|
||||
visited.insert(currentPID)
|
||||
currentPID = parent.parentPID
|
||||
}
|
||||
return result.reversed()
|
||||
}
|
||||
|
||||
private var children: [ProcessRecord] {
|
||||
allProcesses.filter { $0.parentPID == process.pid }.sorted { $0.displayName < $1.displayName }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
Picker("Dependency category", selection: $section) {
|
||||
ForEach(DependencySection.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Group {
|
||||
switch section {
|
||||
case .relationships: relationships
|
||||
case .connections: itemList(snapshot.connections, icon: "network", empty: "No open network or local socket connections")
|
||||
case .libraries: itemList(snapshot.libraries, icon: "shippingbox", empty: "No loaded libraries could be read")
|
||||
case .files: itemList(snapshot.files, icon: "doc", empty: "No open regular files could be read")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.task(id: refreshID) { await refresh() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 14) {
|
||||
HStack(spacing: 14) {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
.scaleEffect(1.25)
|
||||
.frame(width: 40, height: 40)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Analyze dependencies").font(.title2.weight(.semibold))
|
||||
Text("\(process.displayName) • PID \(process.pid)").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if isLoading { ProgressView().controlSize(.small) }
|
||||
Button("Refresh", systemImage: "arrow.clockwise") { refreshID = UUID() }
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
stat("Parents", "\(parentChain.count)", "arrow.up.left")
|
||||
stat("Children", "\(children.count)", "arrow.down.right")
|
||||
stat("Connections", "\(snapshot.connections.count)", "network")
|
||||
stat("Open handles", "\(snapshot.handleCount)", "link")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private func stat(_ title: String, _ value: String, _ icon: String) -> some View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: icon).foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(value).font(.headline.monospacedDigit())
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.65))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var relationships: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
relationshipSection("Parent chain", items: parentChain, icon: "arrow.turn.down.right")
|
||||
relationshipSection("Selected process", items: [process], icon: "scope", highlighted: true)
|
||||
relationshipSection("Direct children", items: children, icon: "arrow.turn.down.right")
|
||||
if let error = snapshot.error {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func relationshipSection(_ title: String, items: [ProcessRecord], icon: String, highlighted: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title).font(.headline)
|
||||
if items.isEmpty {
|
||||
Text("None").foregroundStyle(.secondary).padding(.vertical, 8)
|
||||
} else {
|
||||
ForEach(items) { item in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon).foregroundStyle(highlighted ? Color.accentColor : Color.secondary)
|
||||
ProcessIcon(name: item.displayName, path: item.executablePath)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.displayName).fontWeight(highlighted ? .semibold : .regular)
|
||||
Text(item.executablePath).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text("PID \(item.pid)").monospacedDigit().foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(highlighted ? Color.accentColor.opacity(0.12) : Color(nsColor: .controlBackgroundColor).opacity(0.45))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func itemList(_ items: [String], icon: String, empty: String) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if isLoading && items.isEmpty {
|
||||
ProgressView("Reading process dependencies…").padding(30)
|
||||
} else if items.isEmpty {
|
||||
EmptyState(icon: icon, title: empty, message: snapshot.error ?? "The process may not expose this information to the current user.")
|
||||
.padding(.top, 50)
|
||||
} else {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon).foregroundStyle(.secondary).frame(width: 20)
|
||||
Text(item).textSelection(.enabled).lineLimit(2)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.vertical, 9)
|
||||
Divider().padding(.leading, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() async {
|
||||
isLoading = true
|
||||
snapshot = await Task.detached(priority: .userInitiated) {
|
||||
ProcessDependencyScanner.capture(pid: process.pid)
|
||||
}.value
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private enum ProcessDependencyScanner {
|
||||
static func capture(pid: Int32) -> ProcessDependencySnapshot {
|
||||
let task = Process()
|
||||
let output = Pipe()
|
||||
let errors = Pipe()
|
||||
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
|
||||
task.arguments = ["-n", "-P", "-a", "-p", String(pid)]
|
||||
task.standardOutput = output
|
||||
task.standardError = errors
|
||||
do {
|
||||
try task.run()
|
||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorData = errors.fileHandleForReading.readDataToEndOfFile()
|
||||
task.waitUntilExit()
|
||||
let text = String(decoding: data, as: UTF8.self)
|
||||
var result = parse(text)
|
||||
if task.terminationStatus != 0 || text.isEmpty {
|
||||
let message = String(decoding: errorData, as: UTF8.self)
|
||||
.split(separator: "\n")
|
||||
.first(where: { !$0.contains("WARNING") })
|
||||
.map(String.init)
|
||||
result.error = message ?? "Process information is unavailable or permission was denied."
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return ProcessDependencySnapshot(error: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parse(_ output: String) -> ProcessDependencySnapshot {
|
||||
var result = ProcessDependencySnapshot()
|
||||
var seenConnections: Set<String> = []
|
||||
var seenLibraries: Set<String> = []
|
||||
var seenFiles: Set<String> = []
|
||||
for line in output.split(separator: "\n").dropFirst() {
|
||||
let fields = line.split(maxSplits: 8, whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
guard fields.count == 9 else { continue }
|
||||
result.handleCount += 1
|
||||
let type = fields[4]
|
||||
let name = fields[8]
|
||||
if ["IPv4", "IPv6", "unix"].contains(type) {
|
||||
seenConnections.insert(name)
|
||||
} else if type == "REG", name.hasPrefix("/") {
|
||||
if let library = libraryIdentity(for: name) {
|
||||
seenLibraries.insert(library)
|
||||
} else {
|
||||
seenFiles.insert(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.connections = seenConnections.sorted()
|
||||
result.libraries = seenLibraries.sorted()
|
||||
result.files = seenFiles.sorted()
|
||||
return result
|
||||
}
|
||||
|
||||
private static func libraryIdentity(for path: String) -> String? {
|
||||
let lower = path.lowercased()
|
||||
if lower.hasSuffix(".dylib") { return path }
|
||||
guard let range = lower.range(of: ".framework/") else {
|
||||
return lower.hasSuffix(".framework") ? path : nil
|
||||
}
|
||||
return String(path[..<range.lowerBound]) + ".framework"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum ProcessSort: String, CaseIterable {
|
||||
case name = "Name"
|
||||
case cpu = "CPU"
|
||||
case memory = "Memory"
|
||||
case disk = "Disk"
|
||||
case network = "Network"
|
||||
case pid = "PID"
|
||||
}
|
||||
|
||||
private enum ResourceDisplayMode: String, CaseIterable {
|
||||
case values = "Values"
|
||||
case percentages = "Percentages"
|
||||
}
|
||||
|
||||
private enum ProcessCategory: String, CaseIterable, Identifiable {
|
||||
case apps = "Apps"
|
||||
case background = "Background processes"
|
||||
case system = "macOS processes"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct ProcessesView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
let searchText: String
|
||||
let onShowDetails: (ProcessRecord) -> Void
|
||||
@State private var selectedPID: Int32?
|
||||
@State private var selectedGroupID: String?
|
||||
@State private var expandedGroupIDs: Set<String> = []
|
||||
@State private var collapsedCategories: Set<ProcessCategory> = []
|
||||
@AppStorage("processSortColumn") private var sort: ProcessSort = .cpu
|
||||
@AppStorage("processSortAscending") private var ascending = false
|
||||
@AppStorage("processGroupByType") private var groupByType = true
|
||||
@AppStorage("processResourceDisplayMode") private var resourceDisplayMode: ResourceDisplayMode = .values
|
||||
@State private var terminationRequest: TerminationRequest?
|
||||
@State private var groupTerminationRequest: ProcessGroup?
|
||||
@State private var inspectedProcess: ProcessRecord?
|
||||
|
||||
private var grouped: [ProcessGroup] {
|
||||
ProcessGrouping.groups(from: monitor.processes, searchText: searchText).sorted { lhs, rhs in
|
||||
let ordering: ComparisonResult
|
||||
switch sort {
|
||||
case .name:
|
||||
ordering = lhs.name.localizedCaseInsensitiveCompare(rhs.name)
|
||||
case .cpu:
|
||||
ordering = lhs.cpu == rhs.cpu ? .orderedSame : (lhs.cpu < rhs.cpu ? .orderedAscending : .orderedDescending)
|
||||
case .memory:
|
||||
ordering = lhs.residentBytes == rhs.residentBytes ? .orderedSame : (lhs.residentBytes < rhs.residentBytes ? .orderedAscending : .orderedDescending)
|
||||
case .disk:
|
||||
let left = lhs.activity(using: monitor.processActivity).diskTotal
|
||||
let right = rhs.activity(using: monitor.processActivity).diskTotal
|
||||
ordering = left == right ? .orderedSame : (left < right ? .orderedAscending : .orderedDescending)
|
||||
case .network:
|
||||
let left = lhs.activity(using: monitor.processActivity).networkTotal
|
||||
let right = rhs.activity(using: monitor.processActivity).networkTotal
|
||||
ordering = left == right ? .orderedSame : (left < right ? .orderedAscending : .orderedDescending)
|
||||
case .pid:
|
||||
ordering = lhs.primary.pid == rhs.primary.pid ? .orderedSame : (lhs.primary.pid < rhs.primary.pid ? .orderedAscending : .orderedDescending)
|
||||
}
|
||||
if ordering == .orderedSame { return lhs.primary.pid < rhs.primary.pid }
|
||||
return ascending ? ordering == .orderedAscending : ordering == .orderedDescending
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") {
|
||||
if monitor.isPaused {
|
||||
Label("Paused", systemImage: "pause.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
ResourceSummaryBar(snapshot: monitor.snapshot)
|
||||
ProcessTableHeader(
|
||||
sort: $sort,
|
||||
ascending: $ascending,
|
||||
groupByType: $groupByType,
|
||||
resourceDisplayMode: $resourceDisplayMode
|
||||
)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if groupByType {
|
||||
ForEach(ProcessCategory.allCases) { category in
|
||||
let categoryGroups = grouped.filter { $0.category == category }
|
||||
if !categoryGroups.isEmpty {
|
||||
ProcessCategoryHeader(
|
||||
category: category,
|
||||
count: categoryGroups.count,
|
||||
isExpanded: !collapsedCategories.contains(category) || !searchText.isEmpty
|
||||
) {
|
||||
if collapsedCategories.contains(category) {
|
||||
collapsedCategories.remove(category)
|
||||
} else {
|
||||
collapsedCategories.insert(category)
|
||||
}
|
||||
}
|
||||
|
||||
if !collapsedCategories.contains(category) || !searchText.isEmpty {
|
||||
ForEach(categoryGroups) { group in
|
||||
processGroup(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(grouped) { group in
|
||||
processGroup(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
UpdateStatus()
|
||||
Spacer()
|
||||
Button("End task") {
|
||||
if let group = grouped.first(where: { $0.id == selectedGroupID }) {
|
||||
groupTerminationRequest = group
|
||||
} else if let process = monitor.processes.first(where: { $0.pid == selectedPID }) {
|
||||
terminationRequest = .init(process: process, kind: .normal)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(selectedPID == nil && selectedGroupID == nil)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"\(terminationRequest?.kind.title ?? "End task"): \(terminationRequest?.process.displayName ?? "this task")?",
|
||||
isPresented: Binding(get: { terminationRequest != nil }, set: { if !$0 { terminationRequest = nil } })
|
||||
) {
|
||||
Button(terminationRequest?.kind.title ?? "End task", role: .destructive) {
|
||||
if let request = terminationRequest {
|
||||
switch request.kind {
|
||||
case .normal: monitor.terminate(request.process)
|
||||
case .force: monitor.terminate(request.process, force: true)
|
||||
case .tree: monitor.terminateTree(request.process)
|
||||
}
|
||||
}
|
||||
terminationRequest = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { terminationRequest = nil }
|
||||
} message: {
|
||||
Text(terminationRequest?.kind == .tree ? "This will end the process and all of its child processes. Unsaved data may be lost." : "Unsaved data in this process may be lost.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"End task: \(groupTerminationRequest?.name ?? "this application")?",
|
||||
isPresented: Binding(get: { groupTerminationRequest != nil }, set: { if !$0 { groupTerminationRequest = nil } })
|
||||
) {
|
||||
Button("End task", role: .destructive) {
|
||||
if let groupTerminationRequest { monitor.terminateGroup(groupTerminationRequest.processes) }
|
||||
groupTerminationRequest = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { groupTerminationRequest = nil }
|
||||
} message: {
|
||||
Text("This will end all \(groupTerminationRequest?.processes.count ?? 0) processes in the application group. Unsaved data may be lost.")
|
||||
}
|
||||
.sheet(item: $inspectedProcess) { process in
|
||||
ProcessPropertiesView(process: process)
|
||||
}
|
||||
}
|
||||
|
||||
private func expansionBinding(for group: ProcessGroup) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { !searchText.isEmpty || expandedGroupIDs.contains(group.id) },
|
||||
set: { expanded in
|
||||
if expanded { expandedGroupIDs.insert(group.id) }
|
||||
else { expandedGroupIDs.remove(group.id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func processGroup(_ group: ProcessGroup) -> some View {
|
||||
if group.processes.count > 1 {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Button {
|
||||
let binding = expansionBinding(for: group)
|
||||
binding.wrappedValue.toggle()
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.rotationEffect(.degrees(expansionBinding(for: group).wrappedValue ? 90 : 0))
|
||||
.frame(width: 16, height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
|
||||
.accessibilityLabel(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
|
||||
|
||||
ProcessGroupRow(
|
||||
group: group,
|
||||
selected: selectedGroupID == group.id,
|
||||
displayMode: resourceDisplayMode,
|
||||
leadingPadding: 0
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedGroupID = group.id
|
||||
selectedPID = nil
|
||||
}
|
||||
.contextMenu {
|
||||
ProcessGroupContextMenu(
|
||||
group: group,
|
||||
onTerminate: { groupTerminationRequest = group },
|
||||
onShowDetails: onShowDetails,
|
||||
onShowProperties: { inspectedProcess = $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if expansionBinding(for: group).wrappedValue {
|
||||
ForEach(group.processes.sorted { $0.pid < $1.pid }) { process in
|
||||
processRow(process, isChild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let process = group.processes.first {
|
||||
processRow(process, isChild: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func processRow(_ process: ProcessRecord, isChild: Bool) -> some View {
|
||||
ProcessRow(process: process, selected: selectedPID == process.pid, isChild: isChild, displayMode: resourceDisplayMode)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedPID = process.pid
|
||||
selectedGroupID = nil
|
||||
}
|
||||
.contextMenu {
|
||||
ProcessContextMenu(
|
||||
process: process,
|
||||
onTerminate: { terminationRequest = $0 },
|
||||
onShowDetails: onShowDetails,
|
||||
onShowProperties: { inspectedProcess = $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessCategoryHeader: View {
|
||||
let category: ProcessCategory
|
||||
let count: Int
|
||||
let isExpanded: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.frame(width: 14)
|
||||
Text("\(category.rawValue) (\(count))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 38)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.32))
|
||||
.overlay(alignment: .bottom) { Divider().opacity(0.55) }
|
||||
.accessibilityValue(isExpanded ? "Expanded" : "Collapsed")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessGroup: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let appPath: String?
|
||||
let processes: [ProcessRecord]
|
||||
let category: ProcessCategory
|
||||
|
||||
var primary: ProcessRecord {
|
||||
processes.first(where: { process in
|
||||
guard let appPath else { return false }
|
||||
return process.executablePath.hasPrefix(appPath + "/Contents/MacOS/")
|
||||
}) ?? processes.min(by: { $0.pid < $1.pid })!
|
||||
}
|
||||
|
||||
var cpu: Double { processes.reduce(0) { $0 + $1.cpu } }
|
||||
var residentBytes: UInt64 { processes.reduce(0) { $0 + $1.residentBytes } }
|
||||
var memoryPercent: Double { processes.reduce(0) { $0 + $1.memoryPercent } }
|
||||
|
||||
func activity(using rates: [Int32: ProcessActivityRate]) -> ProcessActivityRate {
|
||||
processes.reduce(into: ProcessActivityRate()) { result, process in
|
||||
let activity = rates[process.pid] ?? ProcessActivityRate()
|
||||
result.diskRead += activity.diskRead
|
||||
result.diskWrite += activity.diskWrite
|
||||
result.networkReceive += activity.networkReceive
|
||||
result.networkSend += activity.networkSend
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ProcessGrouping {
|
||||
static func groups(from processes: [ProcessRecord], searchText: String) -> [ProcessGroup] {
|
||||
let foregroundPIDs = Set(NSWorkspace.shared.runningApplications
|
||||
.filter { $0.activationPolicy == .regular }
|
||||
.map(\.processIdentifier))
|
||||
let grouped = Dictionary(grouping: processes) { process -> String in
|
||||
appPath(for: process.executablePath) ?? "pid:\(process.pid)"
|
||||
}
|
||||
return grouped.compactMap { key, members in
|
||||
let path = key.hasPrefix("pid:") ? nil : key
|
||||
let name = path.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
|
||||
?? members[0].displayName
|
||||
let category = category(for: members, appPath: path, foregroundPIDs: foregroundPIDs)
|
||||
let group = ProcessGroup(id: key, name: name, appPath: path, processes: members, category: category)
|
||||
guard !searchText.isEmpty else { return group }
|
||||
let groupMatches = name.localizedCaseInsensitiveContains(searchText)
|
||||
let matchingMembers = members.filter {
|
||||
$0.displayName.localizedCaseInsensitiveContains(searchText)
|
||||
|| $0.user.localizedCaseInsensitiveContains(searchText)
|
||||
|| String($0.pid).contains(searchText)
|
||||
}
|
||||
guard groupMatches || !matchingMembers.isEmpty else { return nil }
|
||||
return ProcessGroup(
|
||||
id: key,
|
||||
name: name,
|
||||
appPath: path,
|
||||
processes: groupMatches ? members : matchingMembers,
|
||||
category: category
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func category(
|
||||
for processes: [ProcessRecord],
|
||||
appPath: String?,
|
||||
foregroundPIDs: Set<pid_t>
|
||||
) -> ProcessCategory {
|
||||
if appPath != nil, processes.contains(where: { foregroundPIDs.contains($0.pid) }) {
|
||||
return .apps
|
||||
}
|
||||
let systemPrefixes = ["/System/", "/usr/bin/", "/usr/libexec/", "/usr/sbin/", "/bin/", "/sbin/", "/Library/Apple/"]
|
||||
if processes.allSatisfy({ process in
|
||||
process.user == "root" || process.user.hasPrefix("_")
|
||||
|| systemPrefixes.contains(where: { process.executablePath.hasPrefix($0) })
|
||||
}) {
|
||||
return .system
|
||||
}
|
||||
return .background
|
||||
}
|
||||
|
||||
private static func appPath(for executablePath: String) -> String? {
|
||||
guard let range = executablePath.range(of: ".app/Contents/", options: .caseInsensitive) else { return nil }
|
||||
return String(executablePath[..<range.lowerBound]) + ".app"
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessTableHeader: View {
|
||||
@Binding var sort: ProcessSort
|
||||
@Binding var ascending: Bool
|
||||
@Binding var groupByType: Bool
|
||||
@Binding var resourceDisplayMode: ResourceDisplayMode
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
header("Name", field: .name).frame(maxWidth: .infinity, alignment: .leading)
|
||||
header("PID", field: .pid).frame(width: 70, alignment: .trailing)
|
||||
Text("Status").frame(width: 80, alignment: .leading)
|
||||
header("CPU", field: .cpu).frame(width: 86, alignment: .trailing)
|
||||
header("Memory", field: .memory).frame(width: 110, alignment: .trailing)
|
||||
header("Disk", field: .disk).frame(width: 88, alignment: .trailing)
|
||||
header("Network", field: .network).frame(width: 88, alignment: .trailing)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 9)
|
||||
.background(.background)
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
.contextMenu {
|
||||
Toggle("Group by type", isOn: $groupByType)
|
||||
Divider()
|
||||
Menu("Resource values") {
|
||||
ForEach(ResourceDisplayMode.allCases, id: \.rawValue) { mode in
|
||||
Button {
|
||||
resourceDisplayMode = mode
|
||||
} label: {
|
||||
Label(mode.rawValue, systemImage: resourceDisplayMode == mode ? "checkmark" : "number")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ title: String, field: ProcessSort) -> some View {
|
||||
Button {
|
||||
groupByType = false
|
||||
if sort == field { ascending.toggle() } else { sort = field; ascending = false }
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Text(title)
|
||||
if sort == field { Image(systemName: ascending ? "chevron.up" : "chevron.down") }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessRow: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
let process: ProcessRecord
|
||||
let selected: Bool
|
||||
let isChild: Bool
|
||||
let displayMode: ResourceDisplayMode
|
||||
|
||||
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
if isChild {
|
||||
Image(systemName: "arrow.turn.down.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(width: 12)
|
||||
}
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(process.displayName).lineLimit(1)
|
||||
Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("\(process.pid)").monospacedDigit().frame(width: 70, alignment: .trailing)
|
||||
Text(process.state.hasPrefix("R") ? "Running" : "").frame(width: 80, alignment: .leading)
|
||||
HeatCell(text: Formatters.percent(process.cpu), intensity: process.cpu / 100)
|
||||
.frame(width: 86, alignment: .trailing)
|
||||
HeatCell(text: memoryText, intensity: process.memoryPercent / 30)
|
||||
.frame(width: 110, alignment: .trailing)
|
||||
HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000)
|
||||
.frame(width: 88, alignment: .trailing)
|
||||
HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000)
|
||||
.frame(width: 88, alignment: .trailing)
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 48)
|
||||
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
|
||||
}
|
||||
|
||||
private var memoryText: String {
|
||||
displayMode == .percentages ? Formatters.percent(process.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))
|
||||
}
|
||||
|
||||
private var diskText: String {
|
||||
displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal)
|
||||
}
|
||||
|
||||
private var networkText: String {
|
||||
let total = monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate
|
||||
return displayMode == .percentages ? share(activity.networkTotal, of: total) : Formatters.rate(activity.networkTotal)
|
||||
}
|
||||
|
||||
private func share(_ value: Double, of total: Double) -> String {
|
||||
Formatters.percent(total > 0 ? min(100, value / total * 100) : 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessGroupRow: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
let group: ProcessGroup
|
||||
let selected: Bool
|
||||
let displayMode: ResourceDisplayMode
|
||||
let leadingPadding: CGFloat
|
||||
|
||||
private var activity: ProcessActivityRate { group.activity(using: monitor.processActivity) }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(group.name) (\(group.processes.count))").lineLimit(1)
|
||||
Text("Application group").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("—").frame(width: 70, alignment: .trailing).foregroundStyle(.secondary)
|
||||
Text(group.processes.contains(where: { $0.state.hasPrefix("R") }) ? "Running" : "")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
HeatCell(text: Formatters.percent(group.cpu), intensity: group.cpu / 100)
|
||||
.frame(width: 86, alignment: .trailing)
|
||||
HeatCell(text: memoryText, intensity: group.memoryPercent / 30)
|
||||
.frame(width: 110, alignment: .trailing)
|
||||
HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000)
|
||||
.frame(width: 88, alignment: .trailing)
|
||||
HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000)
|
||||
.frame(width: 88, alignment: .trailing)
|
||||
}
|
||||
.font(.callout.weight(.medium))
|
||||
.padding(.leading, leadingPadding)
|
||||
.padding(.trailing, 16)
|
||||
.frame(height: 48)
|
||||
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(group.name), \(group.processes.count) processes")
|
||||
.accessibilityValue("CPU \(Formatters.percent(group.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(group.residentBytes))), disk \(Formatters.rate(activity.diskTotal)), network \(Formatters.rate(activity.networkTotal))")
|
||||
}
|
||||
|
||||
private var memoryText: String {
|
||||
displayMode == .percentages ? Formatters.percent(group.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(group.residentBytes))
|
||||
}
|
||||
|
||||
private var diskText: String {
|
||||
displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal)
|
||||
}
|
||||
|
||||
private var networkText: String {
|
||||
let total = monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate
|
||||
return displayMode == .percentages ? share(activity.networkTotal, of: total) : Formatters.rate(activity.networkTotal)
|
||||
}
|
||||
|
||||
private func share(_ value: Double, of total: Double) -> String {
|
||||
Formatters.percent(total > 0 ? min(100, value / total * 100) : 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessGroupContextMenu: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
let group: ProcessGroup
|
||||
let onTerminate: () -> Void
|
||||
let onShowDetails: (ProcessRecord) -> Void
|
||||
let onShowProperties: (ProcessRecord) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button("End task", systemImage: "xmark.circle", action: onTerminate)
|
||||
.disabled(group.processes.contains(where: { $0.pid == getpid() }))
|
||||
|
||||
Button("Efficiency mode", systemImage: "leaf") {
|
||||
group.processes.forEach { monitor.setPriority(10, for: $0) }
|
||||
}
|
||||
|
||||
Menu("Process control", systemImage: "switch.2") {
|
||||
Button("Pause all") { group.processes.forEach { monitor.sendSignal(SIGSTOP, to: $0) } }
|
||||
Button("Resume all") { group.processes.forEach { monitor.sendSignal(SIGCONT, to: $0) } }
|
||||
}
|
||||
|
||||
Menu("Set priority", systemImage: "speedometer") {
|
||||
Button("High") { setPriority(-10) }
|
||||
Button("Above normal") { setPriority(-5) }
|
||||
Button("Normal") { setPriority(0) }
|
||||
Button("Below normal") { setPriority(10) }
|
||||
Button("Low") { setPriority(15) }
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Go to details", systemImage: "list.bullet.rectangle") { onShowDetails(group.primary) }
|
||||
|
||||
Menu("Copy", systemImage: "doc.on.doc") {
|
||||
Button("Application name") { copy(group.name) }
|
||||
Button("Process IDs") { copy(group.processes.map { String($0.pid) }.joined(separator: ", ")) }
|
||||
Button("Executable path") { copy(group.appPath ?? group.primary.executablePath) }
|
||||
Button("All details") {
|
||||
copy("\(group.name)\t\(group.processes.count) processes\tCPU \(Formatters.percent(group.cpu))\tMemory \(Formatters.bytes.string(fromByteCount: Int64(group.residentBytes)))")
|
||||
}
|
||||
}
|
||||
|
||||
Menu("Inspect", systemImage: "magnifyingglass") {
|
||||
Button("Analyze dependencies…") {
|
||||
ProcessDependencyInspectorController.open(process: group.primary, allProcesses: monitor.processes)
|
||||
}
|
||||
Button("Reveal in Finder") { reveal() }.disabled((group.appPath ?? group.primary.executablePath).isEmpty)
|
||||
Button("Search online") { searchOnline() }
|
||||
Button("Create diagnostic report…") {
|
||||
ProcessDiagnosticReporter.chooseDestinationAndCapture(group.primary)
|
||||
}
|
||||
Button("Properties") { onShowProperties(group.primary) }
|
||||
}
|
||||
}
|
||||
|
||||
private func setPriority(_ value: Int) {
|
||||
group.processes.forEach { monitor.setPriority(value, for: $0) }
|
||||
}
|
||||
|
||||
private func copy(_ value: String) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
|
||||
private func reveal() {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: group.appPath ?? group.primary.executablePath)])
|
||||
}
|
||||
|
||||
private func searchOnline() {
|
||||
guard let query = group.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "https://www.google.com/search?q=\(query)+macOS+app") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeatCell: View {
|
||||
let text: String
|
||||
let intensity: Double
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.monospacedDigit()
|
||||
.padding(.vertical, 9)
|
||||
.padding(.horizontal, 7)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessIcon: View {
|
||||
let name: String
|
||||
var path: String? = nil
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let icon = ApplicationIconCache.icon(for: path, name: name) {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(color.opacity(0.16))
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
let palette: [Color] = [.blue, .purple, .teal, .orange, .pink, .indigo]
|
||||
let stableHash = name.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0x7fff_ffff }
|
||||
return palette[stableHash % palette.count]
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
name.localizedCaseInsensitiveContains("helper") ? "puzzlepiece.extension" : "app.dashed"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum ApplicationIconCache {
|
||||
private static let cache = NSCache<NSString, NSImage>()
|
||||
|
||||
static func icon(for rawPath: String?, name: String) -> NSImage? {
|
||||
guard let rawPath, !rawPath.isEmpty else { return nil }
|
||||
let path = iconPath(from: rawPath)
|
||||
let key = path as NSString
|
||||
if let cached = cache.object(forKey: key) { return cached }
|
||||
guard FileManager.default.fileExists(atPath: path) else { return nil }
|
||||
let icon = NSWorkspace.shared.icon(forFile: path)
|
||||
icon.size = NSSize(width: 64, height: 64)
|
||||
cache.setObject(icon, forKey: key)
|
||||
return icon
|
||||
}
|
||||
|
||||
private static func iconPath(from path: String) -> String {
|
||||
if let range = path.range(of: ".app/", options: .caseInsensitive) {
|
||||
return String(path[..<range.lowerBound]) + ".app"
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SystemMonitor {
|
||||
var processes: [ProcessRecord] = []
|
||||
var services: [ServiceRecord] = []
|
||||
var appHistory: [AppUsageRecord] = []
|
||||
var appHistoryStartDate = Date()
|
||||
var processActivity: [Int32: ProcessActivityRate] = [:]
|
||||
var snapshot = SystemSnapshot()
|
||||
var cpuHistory: [MetricSample] = []
|
||||
var coreHistories: [[MetricSample]] = []
|
||||
var memoryHistory: [MetricSample] = []
|
||||
var diskReadHistory: [MetricSample] = []
|
||||
var diskWriteHistory: [MetricSample] = []
|
||||
var diskLatencyHistory: [MetricSample] = []
|
||||
var diskActiveHistory: [MetricSample] = []
|
||||
var gpuHistory: [MetricSample] = []
|
||||
var gpuRendererHistory: [MetricSample] = []
|
||||
var gpuTilerHistory: [MetricSample] = []
|
||||
var networkReceiveHistory: [MetricSample] = []
|
||||
var networkSendHistory: [MetricSample] = []
|
||||
var updateSpeed: UpdateSpeed {
|
||||
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") }
|
||||
}
|
||||
var lastUpdated: Date?
|
||||
var errorMessage: String?
|
||||
|
||||
private var updateTask: Task<Void, Never>?
|
||||
private var previousCoreTicks: [CoreTicks] = []
|
||||
private var previousNetworkCounters: NetworkCounters?
|
||||
private var previousNetworkDate: Date?
|
||||
private var previousDiskCounters: DiskCounters?
|
||||
private var previousDiskDate: Date?
|
||||
private var previousProcessCPU: [Int32: TimeInterval] = [:]
|
||||
private var previousProcessNetwork: [Int32: ProcessNetworkTotals] = [:]
|
||||
private var previousProcessNetworkDate: Date?
|
||||
private var previousProcessIO: [Int32: ProcessIOTotals] = [:]
|
||||
private var previousProcessIODate: Date?
|
||||
private var appNetworkTask: Task<Void, Never>?
|
||||
private var lastAppNetworkSample: Date?
|
||||
|
||||
init() {
|
||||
updateSpeed = UpdateSpeed(rawValue: UserDefaults.standard.string(forKey: "updateSpeed") ?? "") ?? .normal
|
||||
loadAppHistory()
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
get { updateSpeed == .paused }
|
||||
set { updateSpeed = newValue ? .paused : .normal }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard updateTask == nil else { return }
|
||||
refresh()
|
||||
updateTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
let interval = self?.updateSpeed.interval ?? .seconds(2)
|
||||
try? await Task.sleep(for: interval)
|
||||
guard let self, !self.isPaused else { continue }
|
||||
self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
Task {
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
ProcessScanner.capture()
|
||||
}.value
|
||||
processes = result.processes
|
||||
updateCPUHistory(with: result.processes)
|
||||
updateProcessDiskActivity(result.processIO, current: result.processes)
|
||||
services = result.services
|
||||
var refreshedSnapshot = result.snapshot
|
||||
refreshedSnapshot.corePercents = CoreCPUReader.capture(previous: &previousCoreTicks)
|
||||
let diskDate = Date()
|
||||
if let previousDiskCounters, let previousDiskDate {
|
||||
let elapsed = max(0.001, diskDate.timeIntervalSince(previousDiskDate))
|
||||
let readBytes = result.disk.readBytes >= previousDiskCounters.readBytes
|
||||
? result.disk.readBytes - previousDiskCounters.readBytes : 0
|
||||
let writeBytes = result.disk.writeBytes >= previousDiskCounters.writeBytes
|
||||
? result.disk.writeBytes - previousDiskCounters.writeBytes : 0
|
||||
let readOperations = result.disk.readOperations >= previousDiskCounters.readOperations
|
||||
? result.disk.readOperations - previousDiskCounters.readOperations : 0
|
||||
let writeOperations = result.disk.writeOperations >= previousDiskCounters.writeOperations
|
||||
? result.disk.writeOperations - previousDiskCounters.writeOperations : 0
|
||||
let readTime = result.disk.readTime >= previousDiskCounters.readTime
|
||||
? result.disk.readTime - previousDiskCounters.readTime : 0
|
||||
let writeTime = result.disk.writeTime >= previousDiskCounters.writeTime
|
||||
? result.disk.writeTime - previousDiskCounters.writeTime : 0
|
||||
let operations = readOperations + writeOperations
|
||||
let serviceTime = readTime + writeTime
|
||||
refreshedSnapshot.diskReadRate = Double(readBytes) / elapsed
|
||||
refreshedSnapshot.diskWriteRate = Double(writeBytes) / elapsed
|
||||
refreshedSnapshot.diskOperationsPerSecond = Double(operations) / elapsed
|
||||
refreshedSnapshot.diskLatencyMilliseconds = operations > 0
|
||||
? Double(serviceTime) / Double(operations) / 1_000_000 : 0
|
||||
refreshedSnapshot.diskActivePercent = min(100, Double(serviceTime) / (elapsed * 1_000_000_000) * 100)
|
||||
}
|
||||
previousDiskCounters = result.disk
|
||||
previousDiskDate = diskDate
|
||||
let networkDate = Date()
|
||||
if let previousNetworkCounters, let previousNetworkDate {
|
||||
let elapsed = max(0.001, networkDate.timeIntervalSince(previousNetworkDate))
|
||||
if result.network.received >= previousNetworkCounters.received {
|
||||
refreshedSnapshot.networkReceiveRate = Double(result.network.received - previousNetworkCounters.received) / elapsed
|
||||
}
|
||||
if result.network.sent >= previousNetworkCounters.sent {
|
||||
refreshedSnapshot.networkSendRate = Double(result.network.sent - previousNetworkCounters.sent) / elapsed
|
||||
}
|
||||
}
|
||||
refreshedSnapshot.networkInterface = result.network.interface
|
||||
previousNetworkCounters = result.network
|
||||
previousNetworkDate = networkDate
|
||||
snapshot = refreshedSnapshot
|
||||
lastUpdated = Date()
|
||||
errorMessage = result.error
|
||||
appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent)
|
||||
sampleAppNetworkIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func resetAppHistory() {
|
||||
appHistory = []
|
||||
appHistoryStartDate = Date()
|
||||
previousProcessCPU = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0.cpuTime) })
|
||||
previousProcessNetwork = [:]
|
||||
saveAppHistory()
|
||||
}
|
||||
|
||||
func activeProcess(for usage: AppUsageRecord) -> ProcessRecord? {
|
||||
processes.first { AppIdentity.forProcess($0).id == usage.id }
|
||||
}
|
||||
|
||||
func terminate(_ process: ProcessRecord, force: Bool = false) {
|
||||
guard process.pid != getpid() else {
|
||||
errorMessage = "Task Manager cannot end itself."
|
||||
return
|
||||
}
|
||||
let signal = force ? SIGKILL : SIGTERM
|
||||
if kill(process.pid, signal) == 0 {
|
||||
processes.removeAll { $0.pid == process.pid }
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
refresh()
|
||||
}
|
||||
} else {
|
||||
errorMessage = "Could not end \(process.displayName). You may not have permission."
|
||||
}
|
||||
}
|
||||
|
||||
func terminateTree(_ process: ProcessRecord) {
|
||||
let descendants = descendantPIDs(of: process.pid)
|
||||
for pid in descendants.reversed() { _ = kill(pid, SIGTERM) }
|
||||
terminate(process)
|
||||
}
|
||||
|
||||
func terminateGroup(_ group: [ProcessRecord]) {
|
||||
guard !group.contains(where: { $0.pid == getpid() }) else {
|
||||
errorMessage = "Task Manager cannot end an application group that contains itself."
|
||||
return
|
||||
}
|
||||
let pids = Set(group.map(\.pid))
|
||||
var failed = false
|
||||
for process in group {
|
||||
if kill(process.pid, SIGTERM) != 0 { failed = true }
|
||||
}
|
||||
processes.removeAll { pids.contains($0.pid) }
|
||||
if failed { errorMessage = "Some processes could not be ended. You may not have permission." }
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func sendSignal(_ signal: Int32, to process: ProcessRecord, successMessage: String? = nil) {
|
||||
guard process.pid != getpid() else {
|
||||
errorMessage = "Task Manager cannot control itself."
|
||||
return
|
||||
}
|
||||
if kill(process.pid, signal) != 0 {
|
||||
errorMessage = "Could not control \(process.displayName). You may not have permission."
|
||||
} else if let successMessage {
|
||||
errorMessage = successMessage
|
||||
}
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func setPriority(_ priority: Int, for process: ProcessRecord) {
|
||||
let command = Process()
|
||||
command.executableURL = URL(fileURLWithPath: "/usr/bin/renice")
|
||||
command.arguments = ["-n", "\(priority)", "-p", "\(process.pid)"]
|
||||
command.standardOutput = FileHandle.nullDevice
|
||||
command.standardError = FileHandle.nullDevice
|
||||
do {
|
||||
try command.run()
|
||||
command.waitUntilExit()
|
||||
if command.terminationStatus != 0 {
|
||||
errorMessage = "Could not change priority for \(process.displayName). Higher priorities may require administrator access."
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func startService(_ service: ServiceRecord) {
|
||||
runServiceCommand(["kickstart", serviceTarget(service)], action: "start", service: service)
|
||||
}
|
||||
|
||||
func stopService(_ service: ServiceRecord) {
|
||||
runServiceCommand(["kill", "SIGTERM", serviceTarget(service)], action: "stop", service: service)
|
||||
}
|
||||
|
||||
func restartService(_ service: ServiceRecord) {
|
||||
runServiceCommand(["kickstart", "-k", serviceTarget(service)], action: "restart", service: service)
|
||||
}
|
||||
|
||||
private func serviceTarget(_ service: ServiceRecord) -> String {
|
||||
"\(service.domain.launchctlPrefix)/\(service.label)"
|
||||
}
|
||||
|
||||
private func runServiceCommand(_ arguments: [String], action: String, service: ServiceRecord) {
|
||||
let command = Process()
|
||||
let errorPipe = Pipe()
|
||||
command.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||
command.arguments = arguments
|
||||
command.standardOutput = FileHandle.nullDevice
|
||||
command.standardError = errorPipe
|
||||
do {
|
||||
try command.run()
|
||||
command.waitUntilExit()
|
||||
if command.terminationStatus != 0 {
|
||||
let message = String(decoding: errorPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
errorMessage = message.isEmpty ? "Could not \(action) \(service.displayName)." : message
|
||||
}
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
refresh()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func descendantPIDs(of parentPID: Int32) -> [Int32] {
|
||||
let children = processes.filter { $0.parentPID == parentPID }.map(\.pid)
|
||||
return children + children.flatMap { descendantPIDs(of: $0) }
|
||||
}
|
||||
|
||||
private func appendHistory(cpu: Double, memory: Double) {
|
||||
let now = Date()
|
||||
cpuHistory.append(MetricSample(date: now, value: cpu))
|
||||
memoryHistory.append(MetricSample(date: now, value: memory))
|
||||
networkReceiveHistory.append(MetricSample(date: now, value: snapshot.networkReceiveRate))
|
||||
networkSendHistory.append(MetricSample(date: now, value: snapshot.networkSendRate))
|
||||
diskReadHistory.append(MetricSample(date: now, value: snapshot.diskReadRate))
|
||||
diskWriteHistory.append(MetricSample(date: now, value: snapshot.diskWriteRate))
|
||||
diskLatencyHistory.append(MetricSample(date: now, value: snapshot.diskLatencyMilliseconds))
|
||||
diskActiveHistory.append(MetricSample(date: now, value: snapshot.diskActivePercent))
|
||||
gpuHistory.append(MetricSample(date: now, value: snapshot.gpuPercent))
|
||||
gpuRendererHistory.append(MetricSample(date: now, value: snapshot.gpuRendererPercent))
|
||||
gpuTilerHistory.append(MetricSample(date: now, value: snapshot.gpuTilerPercent))
|
||||
if coreHistories.count != snapshot.corePercents.count {
|
||||
coreHistories = Array(repeating: [], count: snapshot.corePercents.count)
|
||||
}
|
||||
for index in snapshot.corePercents.indices {
|
||||
coreHistories[index].append(MetricSample(date: now, value: snapshot.corePercents[index]))
|
||||
}
|
||||
let cutoff = now.addingTimeInterval(-60)
|
||||
cpuHistory.removeAll { $0.date < cutoff }
|
||||
memoryHistory.removeAll { $0.date < cutoff }
|
||||
networkReceiveHistory.removeAll { $0.date < cutoff }
|
||||
networkSendHistory.removeAll { $0.date < cutoff }
|
||||
diskReadHistory.removeAll { $0.date < cutoff }
|
||||
diskWriteHistory.removeAll { $0.date < cutoff }
|
||||
diskLatencyHistory.removeAll { $0.date < cutoff }
|
||||
diskActiveHistory.removeAll { $0.date < cutoff }
|
||||
gpuHistory.removeAll { $0.date < cutoff }
|
||||
gpuRendererHistory.removeAll { $0.date < cutoff }
|
||||
gpuTilerHistory.removeAll { $0.date < cutoff }
|
||||
for index in coreHistories.indices {
|
||||
coreHistories[index].removeAll { $0.date < cutoff }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func updateCPUHistory(with current: [ProcessRecord]) {
|
||||
var records = Dictionary(uniqueKeysWithValues: appHistory.map { ($0.id, $0) })
|
||||
let now = Date()
|
||||
for process in current where process.user == NSUserName() {
|
||||
let identity = AppIdentity.forProcess(process)
|
||||
var usage = records[identity.id] ?? AppUsageRecord(
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
executablePath: identity.path,
|
||||
user: process.user,
|
||||
cpuSeconds: 0,
|
||||
networkReceivedBytes: 0,
|
||||
networkSentBytes: 0,
|
||||
lastSeen: now
|
||||
)
|
||||
if let old = previousProcessCPU[process.pid], process.cpuTime >= old {
|
||||
usage.cpuSeconds += process.cpuTime - old
|
||||
}
|
||||
usage.lastSeen = now
|
||||
records[identity.id] = usage
|
||||
}
|
||||
previousProcessCPU = Dictionary(uniqueKeysWithValues: current.map { ($0.pid, $0.cpuTime) })
|
||||
appHistory = Array(records.values)
|
||||
saveAppHistory()
|
||||
}
|
||||
|
||||
private func sampleAppNetworkIfNeeded() {
|
||||
guard appNetworkTask == nil,
|
||||
lastAppNetworkSample.map({ Date().timeIntervalSince($0) >= 10 }) ?? true else { return }
|
||||
lastAppNetworkSample = Date()
|
||||
appNetworkTask = Task { [weak self] in
|
||||
let totals = await Task.detached(priority: .utility) { AppNetworkScanner.capture() }.value
|
||||
guard let self else { return }
|
||||
self.mergeNetworkHistory(totals)
|
||||
self.appNetworkTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeNetworkHistory(_ totals: [Int32: ProcessNetworkTotals]) {
|
||||
var records = Dictionary(uniqueKeysWithValues: appHistory.map { ($0.id, $0) })
|
||||
let processByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) })
|
||||
let now = Date()
|
||||
let elapsed = previousProcessNetworkDate.map { max(0.001, now.timeIntervalSince($0)) }
|
||||
for (pid, total) in totals {
|
||||
guard let process = processByPID[pid], process.user == NSUserName() else { continue }
|
||||
let identity = AppIdentity.forProcess(process)
|
||||
guard var usage = records[identity.id] else { continue }
|
||||
if let old = previousProcessNetwork[pid] {
|
||||
if total.received >= old.received { usage.networkReceivedBytes += total.received - old.received }
|
||||
if total.sent >= old.sent { usage.networkSentBytes += total.sent - old.sent }
|
||||
}
|
||||
records[identity.id] = usage
|
||||
}
|
||||
if let elapsed {
|
||||
for (pid, total) in totals {
|
||||
guard let old = previousProcessNetwork[pid] else { continue }
|
||||
var activity = processActivity[pid] ?? ProcessActivityRate()
|
||||
activity.networkReceive = total.received >= old.received ? Double(total.received - old.received) / elapsed : 0
|
||||
activity.networkSend = total.sent >= old.sent ? Double(total.sent - old.sent) / elapsed : 0
|
||||
processActivity[pid] = activity
|
||||
}
|
||||
}
|
||||
previousProcessNetwork = totals
|
||||
previousProcessNetworkDate = now
|
||||
appHistory = Array(records.values)
|
||||
saveAppHistory()
|
||||
}
|
||||
|
||||
private func updateProcessDiskActivity(_ totals: [Int32: ProcessIOTotals], current: [ProcessRecord]) {
|
||||
let now = Date()
|
||||
let activePIDs = Set(current.map(\.pid))
|
||||
processActivity = processActivity.filter { activePIDs.contains($0.key) }
|
||||
if let previousProcessIODate {
|
||||
let elapsed = max(0.001, now.timeIntervalSince(previousProcessIODate))
|
||||
for (pid, total) in totals {
|
||||
guard let old = previousProcessIO[pid] else { continue }
|
||||
var activity = processActivity[pid] ?? ProcessActivityRate()
|
||||
activity.diskRead = total.read >= old.read ? Double(total.read - old.read) / elapsed : 0
|
||||
activity.diskWrite = total.written >= old.written ? Double(total.written - old.written) / elapsed : 0
|
||||
processActivity[pid] = activity
|
||||
}
|
||||
}
|
||||
previousProcessIO = totals
|
||||
previousProcessIODate = now
|
||||
}
|
||||
|
||||
private func loadAppHistory() {
|
||||
guard let data = try? Data(contentsOf: AppHistoryPersistence.url),
|
||||
let state = try? JSONDecoder().decode(AppHistoryPersistence.State.self, from: data) else { return }
|
||||
appHistoryStartDate = state.startDate
|
||||
appHistory = state.records
|
||||
}
|
||||
|
||||
private func saveAppHistory() {
|
||||
let state = AppHistoryPersistence.State(startDate: appHistoryStartDate, records: appHistory)
|
||||
guard let data = try? JSONEncoder().encode(state) else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: AppHistoryPersistence.directory, withIntermediateDirectories: true)
|
||||
try data.write(to: AppHistoryPersistence.url, options: .atomic)
|
||||
} catch {
|
||||
errorMessage = "App history could not be saved: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum AppHistoryPersistence {
|
||||
struct State: Codable {
|
||||
let startDate: Date
|
||||
let records: [AppUsageRecord]
|
||||
}
|
||||
|
||||
static let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("MacTaskManager", isDirectory: true)
|
||||
static let url = directory.appendingPathComponent("app-history.json")
|
||||
}
|
||||
|
||||
private struct AppIdentity {
|
||||
let id: String
|
||||
let name: String
|
||||
let path: String
|
||||
|
||||
static func forProcess(_ process: ProcessRecord) -> AppIdentity {
|
||||
let path = process.executablePath
|
||||
if let range = path.range(of: ".app/Contents/", options: .caseInsensitive) {
|
||||
let appPath = String(path[..<range.lowerBound]) + ".app"
|
||||
let filename = URL(fileURLWithPath: appPath).deletingPathExtension().lastPathComponent
|
||||
return AppIdentity(id: appPath, name: filename, path: appPath)
|
||||
}
|
||||
return AppIdentity(id: "\(process.user)|\(path)", name: process.displayName, path: path)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessNetworkTotals: Sendable {
|
||||
let received: UInt64
|
||||
let sent: UInt64
|
||||
}
|
||||
|
||||
private struct ProcessIOTotals: Sendable {
|
||||
let read: UInt64
|
||||
let written: UInt64
|
||||
}
|
||||
|
||||
private enum AppNetworkScanner {
|
||||
static func capture() -> [Int32: ProcessNetworkTotals] {
|
||||
let process = Process()
|
||||
let pipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop")
|
||||
process.arguments = ["-P", "-x", "-l", "1", "-J", "bytes_in,bytes_out"]
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
do {
|
||||
try process.run()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else { return [:] }
|
||||
return parse(String(decoding: data, as: UTF8.self))
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
private static func parse(_ output: String) -> [Int32: ProcessNetworkTotals] {
|
||||
var result: [Int32: ProcessNetworkTotals] = [:]
|
||||
for line in output.split(separator: "\n").dropFirst() {
|
||||
let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
|
||||
guard fields.count >= 3,
|
||||
let received = UInt64(fields[fields.count - 2]),
|
||||
let sent = UInt64(fields[fields.count - 1]) else { continue }
|
||||
let identity = fields.dropLast(2).joined(separator: " ")
|
||||
guard let dot = identity.lastIndex(of: "."), let pid = Int32(identity[identity.index(after: dot)...]) else { continue }
|
||||
result[pid] = ProcessNetworkTotals(received: received, sent: sent)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private struct NetworkCounters: Sendable {
|
||||
let received: UInt64
|
||||
let sent: UInt64
|
||||
let interface: String
|
||||
}
|
||||
|
||||
private struct DiskCounters: Sendable {
|
||||
var readBytes: UInt64 = 0
|
||||
var writeBytes: UInt64 = 0
|
||||
var readOperations: UInt64 = 0
|
||||
var writeOperations: UInt64 = 0
|
||||
var readTime: UInt64 = 0
|
||||
var writeTime: UInt64 = 0
|
||||
}
|
||||
|
||||
private struct GPUCounters: Sendable {
|
||||
var devicePercent = 0.0
|
||||
var rendererPercent = 0.0
|
||||
var tilerPercent = 0.0
|
||||
var memoryBytes: UInt64 = 0
|
||||
var allocatedBytes: UInt64 = 0
|
||||
var coreCount = 0
|
||||
}
|
||||
|
||||
private struct MemoryCounters: Sendable {
|
||||
var used: UInt64 = 0
|
||||
var active: UInt64 = 0
|
||||
var wired: UInt64 = 0
|
||||
var compressed: UInt64 = 0
|
||||
var cached: UInt64 = 0
|
||||
var swapUsed: UInt64 = 0
|
||||
var swapTotal: UInt64 = 0
|
||||
}
|
||||
|
||||
private struct MemoryHardwareInfo: Sendable {
|
||||
var type = "Unified"
|
||||
var speed = "Not reported by macOS"
|
||||
var manufacturer = "Apple unified memory"
|
||||
}
|
||||
|
||||
private struct CoreTicks {
|
||||
let active: UInt64
|
||||
let idle: UInt64
|
||||
}
|
||||
|
||||
private enum CoreCPUReader {
|
||||
static func capture(previous: inout [CoreTicks]) -> [Double] {
|
||||
var processorCount: natural_t = 0
|
||||
var info: processor_info_array_t?
|
||||
var infoCount: mach_msg_type_number_t = 0
|
||||
let result = host_processor_info(
|
||||
mach_host_self(),
|
||||
PROCESSOR_CPU_LOAD_INFO,
|
||||
&processorCount,
|
||||
&info,
|
||||
&infoCount
|
||||
)
|
||||
guard result == KERN_SUCCESS, let info else { return [] }
|
||||
defer {
|
||||
vm_deallocate(
|
||||
mach_task_self_,
|
||||
vm_address_t(UInt(bitPattern: info)),
|
||||
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride)
|
||||
)
|
||||
}
|
||||
|
||||
let current = (0..<Int(processorCount)).map { core -> CoreTicks in
|
||||
let offset = core * Int(CPU_STATE_MAX)
|
||||
let user = UInt64(info[offset + Int(CPU_STATE_USER)])
|
||||
let system = UInt64(info[offset + Int(CPU_STATE_SYSTEM)])
|
||||
let nice = UInt64(info[offset + Int(CPU_STATE_NICE)])
|
||||
let idle = UInt64(info[offset + Int(CPU_STATE_IDLE)])
|
||||
return CoreTicks(active: user + system + nice, idle: idle)
|
||||
}
|
||||
|
||||
defer { previous = current }
|
||||
guard previous.count == current.count else { return Array(repeating: 0, count: current.count) }
|
||||
return zip(current, previous).map { current, old in
|
||||
let activeDelta = current.active >= old.active ? current.active - old.active : 0
|
||||
let idleDelta = current.idle >= old.idle ? current.idle - old.idle : 0
|
||||
let total = activeDelta + idleDelta
|
||||
return total > 0 ? min(100, Double(activeDelta) / Double(total) * 100) : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ProcessScanner {
|
||||
private static let memoryHardware = memoryHardwareInfo()
|
||||
|
||||
struct Result: Sendable {
|
||||
let processes: [ProcessRecord]
|
||||
let services: [ServiceRecord]
|
||||
let snapshot: SystemSnapshot
|
||||
let network: NetworkCounters
|
||||
let disk: DiskCounters
|
||||
let processIO: [Int32: ProcessIOTotals]
|
||||
let error: String?
|
||||
}
|
||||
|
||||
static func capture() -> Result {
|
||||
let ps = run("/bin/ps", arguments: ["-axo", "pid=,ppid=,user=,%cpu=,%mem=,rss=,state=,etime=,time=,pri=,nice=,comm="])
|
||||
let threads = threadCounts()
|
||||
let records = parseProcesses(ps.output, threadCounts: threads)
|
||||
let userServices = parseServices(run("/bin/launchctl", arguments: ["list"]).output, domain: .user)
|
||||
let systemServices = parseSystemServices(run("/bin/launchctl", arguments: ["print", "system"]).output)
|
||||
let services = userServices + systemServices
|
||||
let cpu = min(100, records.reduce(0) { $0 + $1.cpu })
|
||||
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
||||
let memory = memoryCounters(total: totalMemory)
|
||||
let disk = diskUsage()
|
||||
var snapshot = SystemSnapshot()
|
||||
snapshot.cpuPercent = cpu
|
||||
snapshot.memoryUsed = memory.used
|
||||
snapshot.memoryTotal = totalMemory
|
||||
snapshot.memoryActive = memory.active
|
||||
snapshot.memoryWired = memory.wired
|
||||
snapshot.memoryCompressed = memory.compressed
|
||||
snapshot.memoryCached = memory.cached
|
||||
snapshot.swapUsed = memory.swapUsed
|
||||
snapshot.swapTotal = memory.swapTotal
|
||||
snapshot.memoryType = memoryHardware.type
|
||||
snapshot.memorySpeed = memoryHardware.speed
|
||||
snapshot.memoryManufacturer = memoryHardware.manufacturer
|
||||
snapshot.diskFree = disk.free
|
||||
snapshot.diskTotal = disk.total
|
||||
snapshot.removableVolumes = removableVolumes()
|
||||
snapshot.processCount = records.count
|
||||
snapshot.threadCount = threads.values.reduce(0, +)
|
||||
snapshot.uptime = ProcessInfo.processInfo.systemUptime
|
||||
let gpu = gpuCounters()
|
||||
snapshot.gpuPercent = gpu.devicePercent
|
||||
snapshot.gpuRendererPercent = gpu.rendererPercent
|
||||
snapshot.gpuTilerPercent = gpu.tilerPercent
|
||||
snapshot.gpuMemoryBytes = gpu.memoryBytes
|
||||
snapshot.gpuAllocatedBytes = gpu.allocatedBytes
|
||||
snapshot.gpuCoreCount = gpu.coreCount
|
||||
return Result(
|
||||
processes: records,
|
||||
services: services,
|
||||
snapshot: snapshot,
|
||||
network: networkCounters(),
|
||||
disk: diskCounters(),
|
||||
processIO: processIOTotals(records.map(\.pid)),
|
||||
error: ps.error
|
||||
)
|
||||
}
|
||||
|
||||
private static func processIOTotals(_ pids: [Int32]) -> [Int32: ProcessIOTotals] {
|
||||
var totals: [Int32: ProcessIOTotals] = [:]
|
||||
for pid in pids {
|
||||
var usage = rusage_info_v2()
|
||||
let result = withUnsafeMutablePointer(to: &usage) { pointer in
|
||||
pointer.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
|
||||
proc_pid_rusage(pid, RUSAGE_INFO_V2, $0)
|
||||
}
|
||||
}
|
||||
if result == 0 {
|
||||
totals[pid] = ProcessIOTotals(read: usage.ri_diskio_bytesread, written: usage.ri_diskio_byteswritten)
|
||||
}
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
private static func diskCounters() -> DiskCounters {
|
||||
let output = run(
|
||||
"/usr/sbin/ioreg",
|
||||
arguments: ["-r", "-c", "IOBlockStorageDriver", "-k", "Statistics", "-a"]
|
||||
).output
|
||||
guard let data = output.data(using: .utf8),
|
||||
let drivers = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] else {
|
||||
return DiskCounters()
|
||||
}
|
||||
var result = DiskCounters()
|
||||
for driver in drivers {
|
||||
guard let statistics = driver["Statistics"] as? [String: Any] else { continue }
|
||||
func value(_ key: String) -> UInt64 {
|
||||
(statistics[key] as? NSNumber)?.uint64Value ?? 0
|
||||
}
|
||||
result.readBytes += value("Bytes (Read)")
|
||||
result.writeBytes += value("Bytes (Write)")
|
||||
result.readOperations += value("Operations (Read)")
|
||||
result.writeOperations += value("Operations (Write)")
|
||||
result.readTime += value("Total Time (Read)")
|
||||
result.writeTime += value("Total Time (Write)")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func gpuCounters() -> GPUCounters {
|
||||
let output = run(
|
||||
"/usr/sbin/ioreg",
|
||||
arguments: ["-r", "-c", "AGXAccelerator", "-l"]
|
||||
).output
|
||||
func number(_ key: String) -> UInt64 {
|
||||
UInt64(output.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\" *= *(\\d+)") ?? "0") ?? 0
|
||||
}
|
||||
return GPUCounters(
|
||||
devicePercent: Double(number("Device Utilization %")),
|
||||
rendererPercent: Double(number("Renderer Utilization %")),
|
||||
tilerPercent: Double(number("Tiler Utilization %")),
|
||||
memoryBytes: number("In use system memory"),
|
||||
allocatedBytes: number("Alloc system memory"),
|
||||
coreCount: Int(number("gpu-core-count"))
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseProcesses(_ output: String, threadCounts: [Int32: Int]) -> [ProcessRecord] {
|
||||
output.split(separator: "\n").compactMap { line in
|
||||
let fields = line.split(maxSplits: 11, whereSeparator: { $0 == " " || $0 == "\t" })
|
||||
guard fields.count == 12,
|
||||
let pid = Int32(fields[0]),
|
||||
let ppid = Int32(fields[1]),
|
||||
let cpu = Double(fields[3]),
|
||||
let memory = Double(fields[4]),
|
||||
let rssKB = UInt64(fields[5]),
|
||||
let priority = Int(fields[9]),
|
||||
let nice = Int(fields[10]) else { return nil }
|
||||
let path = String(fields[11])
|
||||
let name = URL(fileURLWithPath: path).lastPathComponent
|
||||
let normalizedCPU = cpu / Double(max(1, ProcessInfo.processInfo.activeProcessorCount))
|
||||
return ProcessRecord(
|
||||
pid: pid,
|
||||
parentPID: ppid,
|
||||
name: name,
|
||||
executablePath: path,
|
||||
user: String(fields[2]),
|
||||
cpu: normalizedCPU,
|
||||
memoryPercent: memory,
|
||||
residentBytes: rssKB * 1024,
|
||||
state: String(fields[6]),
|
||||
elapsed: String(fields[7]),
|
||||
cpuTime: parseCPUTime(String(fields[8])),
|
||||
threadCount: threadCounts[pid] ?? 0,
|
||||
openFileCount: openFileCount(pid),
|
||||
architecture: BinaryArchitectureReader.architecture(at: path),
|
||||
priority: priority,
|
||||
nice: nice
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCPUTime(_ value: String) -> TimeInterval {
|
||||
let dayParts = value.split(separator: "-", maxSplits: 1).map(String.init)
|
||||
let days = dayParts.count == 2 ? Double(dayParts[0]) ?? 0 : 0
|
||||
let clock = (dayParts.count == 2 ? dayParts[1] : dayParts[0]).split(separator: ":").compactMap { Double($0) }
|
||||
guard !clock.isEmpty else { return days * 86_400 }
|
||||
var multiplier = 1.0
|
||||
var seconds = 0.0
|
||||
for component in clock.reversed() {
|
||||
seconds += component * multiplier
|
||||
multiplier *= 60
|
||||
}
|
||||
return days * 86_400 + seconds
|
||||
}
|
||||
|
||||
private static func parseServices(_ output: String, domain: ServiceDomain) -> [ServiceRecord] {
|
||||
output.split(separator: "\n").dropFirst().compactMap { line in
|
||||
let fields = line.split(maxSplits: 2, whereSeparator: { $0 == " " || $0 == "\t" })
|
||||
guard fields.count == 3, let status = Int32(fields[1]) else { return nil }
|
||||
let pid = fields[0] == "-" ? nil : Int32(fields[0])
|
||||
let label = String(fields[2]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !label.isEmpty else { return nil }
|
||||
return ServiceRecord(label: label, pid: pid, lastExitStatus: status, domain: domain)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseSystemServices(_ output: String) -> [ServiceRecord] {
|
||||
guard let start = output.range(of: "\n\tservices = {\n") else { return [] }
|
||||
let body = output[start.upperBound...]
|
||||
guard let end = body.range(of: "\n\t}") else { return [] }
|
||||
return body[..<end.lowerBound].split(separator: "\n").compactMap { line in
|
||||
let fields = line.split(maxSplits: 2, whereSeparator: { $0 == " " || $0 == "\t" })
|
||||
guard fields.count == 3, let rawPID = Int32(fields[0]) else { return nil }
|
||||
let status = Int32(fields[1]) ?? 0
|
||||
let label = String(fields[2]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !label.isEmpty else { return nil }
|
||||
return ServiceRecord(
|
||||
label: label,
|
||||
pid: rawPID > 0 ? rawPID : nil,
|
||||
lastExitStatus: status,
|
||||
domain: .system
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func memoryCounters(total: UInt64) -> MemoryCounters {
|
||||
let vm = run("/usr/bin/vm_stat", arguments: []).output
|
||||
let pageSize = UInt64(vm.firstMatch(#"page size of (\d+) bytes"#) ?? "4096") ?? 4096
|
||||
func pages(_ label: String) -> UInt64 {
|
||||
UInt64(vm.firstMatch("\(NSRegularExpression.escapedPattern(for: label)): +([0-9]+)") ?? "0") ?? 0
|
||||
}
|
||||
let free = (pages("Pages free") + pages("Pages speculative")) * pageSize
|
||||
var swap = xsw_usage()
|
||||
var swapSize = MemoryLayout<xsw_usage>.size
|
||||
_ = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
|
||||
return MemoryCounters(
|
||||
used: total > free ? total - free : 0,
|
||||
active: pages("Pages active") * pageSize,
|
||||
wired: pages("Pages wired down") * pageSize,
|
||||
compressed: pages("Pages occupied by compressor") * pageSize,
|
||||
cached: (pages("Pages inactive") + pages("Pages purgeable")) * pageSize,
|
||||
swapUsed: swap.xsu_used,
|
||||
swapTotal: swap.xsu_total
|
||||
)
|
||||
}
|
||||
|
||||
private static func memoryHardwareInfo() -> MemoryHardwareInfo {
|
||||
let output = run("/usr/sbin/system_profiler", arguments: ["SPMemoryDataType", "-detailLevel", "mini"]).output
|
||||
return MemoryHardwareInfo(
|
||||
type: output.firstMatch(#"Type: +([^\n]+)"#) ?? "Unified",
|
||||
speed: output.firstMatch(#"Speed: +([^\n]+)"#) ?? "Not reported by macOS",
|
||||
manufacturer: output.firstMatch(#"Manufacturer: +([^\n]+)"#) ?? "Apple unified memory"
|
||||
)
|
||||
}
|
||||
|
||||
private static func removableVolumes() -> [VolumeSnapshot] {
|
||||
let keys: [URLResourceKey] = [
|
||||
.volumeNameKey,
|
||||
.volumeLocalizedFormatDescriptionKey,
|
||||
.volumeIsRemovableKey,
|
||||
.volumeIsEjectableKey,
|
||||
.volumeTotalCapacityKey,
|
||||
.volumeAvailableCapacityForImportantUsageKey
|
||||
]
|
||||
let urls = FileManager.default.mountedVolumeURLs(
|
||||
includingResourceValuesForKeys: keys,
|
||||
options: [.skipHiddenVolumes]
|
||||
) ?? []
|
||||
return urls.compactMap { url in
|
||||
guard let values = try? url.resourceValues(forKeys: Set(keys)),
|
||||
values.volumeIsRemovable == true || values.volumeIsEjectable == true else { return nil }
|
||||
let capacity = UInt64(max(0, values.volumeTotalCapacity ?? 0))
|
||||
let available = UInt64(max(0, values.volumeAvailableCapacityForImportantUsage ?? 0))
|
||||
return VolumeSnapshot(
|
||||
id: url.path,
|
||||
name: values.volumeName ?? url.lastPathComponent,
|
||||
mountPath: url.path,
|
||||
fileSystem: values.volumeLocalizedFormatDescription ?? "Removable storage",
|
||||
capacity: capacity,
|
||||
available: available,
|
||||
isEjectable: values.volumeIsEjectable ?? false
|
||||
)
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private static func diskUsage() -> (free: UInt64, total: UInt64) {
|
||||
guard let values = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [
|
||||
.volumeAvailableCapacityForImportantUsageKey,
|
||||
.volumeTotalCapacityKey
|
||||
]) else { return (0, 0) }
|
||||
return (UInt64(max(0, values.volumeAvailableCapacityForImportantUsage ?? 0)), UInt64(max(0, values.volumeTotalCapacity ?? 0)))
|
||||
}
|
||||
|
||||
private static func threadCounts() -> [Int32: Int] {
|
||||
let result = run("/bin/ps", arguments: ["-M", "-axo", "pid="]).output
|
||||
let rawCounts = result.split(separator: "\n").reduce(into: [Int32: Int]()) { counts, line in
|
||||
guard let last = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).last,
|
||||
let pid = Int32(last) else { return }
|
||||
counts[pid, default: 0] += 1
|
||||
}
|
||||
return rawCounts.mapValues { max(1, $0 - 1) }
|
||||
}
|
||||
|
||||
private static func openFileCount(_ pid: Int32) -> Int {
|
||||
let bytes = Int(proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0))
|
||||
return bytes > 0 ? bytes / MemoryLayout<proc_fdinfo>.stride : 0
|
||||
}
|
||||
|
||||
private static func networkCounters() -> NetworkCounters {
|
||||
let route = run("/sbin/route", arguments: ["-n", "get", "default"]).output
|
||||
let primary = route.firstMatch(#"interface: +([^\s]+)"#) ?? "Network"
|
||||
let netstat = run("/usr/sbin/netstat", arguments: ["-ibn"]).output
|
||||
var received: UInt64 = 0
|
||||
var sent: UInt64 = 0
|
||||
var matchedPrimary = false
|
||||
|
||||
for line in netstat.split(separator: "\n") {
|
||||
let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
guard fields.count >= 10,
|
||||
fields[2].hasPrefix("<Link#"),
|
||||
fields[0] != "lo0",
|
||||
!fields[0].hasSuffix("*") else { continue }
|
||||
if primary != "Network", fields[0] != primary { continue }
|
||||
let counters = Array(fields.suffix(7))
|
||||
guard let inputBytes = UInt64(counters[2]), let outputBytes = UInt64(counters[5]) else { continue }
|
||||
received += inputBytes
|
||||
sent += outputBytes
|
||||
matchedPrimary = true
|
||||
}
|
||||
|
||||
if !matchedPrimary, primary != "Network" {
|
||||
return NetworkCounters(received: 0, sent: 0, interface: primary)
|
||||
}
|
||||
return NetworkCounters(received: received, sent: sent, interface: primary)
|
||||
}
|
||||
|
||||
private static func run(_ path: String, arguments: [String]) -> (output: String, error: String?) {
|
||||
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 (String(decoding: data, as: UTF8.self), process.terminationStatus == 0 ? nil : "System data is temporarily unavailable.")
|
||||
} catch {
|
||||
return ("", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum BinaryArchitectureReader {
|
||||
private static let machO64: UInt32 = 0xfeedfacf
|
||||
private static let machO64Swapped: UInt32 = 0xcffaedfe
|
||||
private static let fat32: UInt32 = 0xcafebabe
|
||||
private static let fat32Swapped: UInt32 = 0xbebafeca
|
||||
private static let fat64: UInt32 = 0xcafebabf
|
||||
private static let fat64Swapped: UInt32 = 0xbfbafeca
|
||||
private static let arm64: UInt32 = 0x0100000c
|
||||
private static let x86_64: UInt32 = 0x01000007
|
||||
|
||||
static func architecture(at path: String) -> String {
|
||||
guard path.hasPrefix("/"),
|
||||
let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { return "Unknown" }
|
||||
defer { try? handle.close() }
|
||||
guard let data = try? handle.read(upToCount: 512), data.count >= 8 else { return "Unknown" }
|
||||
let magicBE = read(data, offset: 0, bigEndian: true)
|
||||
let magicLE = read(data, offset: 0, bigEndian: false)
|
||||
if magicBE == fat32 || magicBE == fat64 || magicBE == fat32Swapped || magicBE == fat64Swapped {
|
||||
let bigEndian = magicBE == fat32 || magicBE == fat64
|
||||
let is64 = magicBE == fat64 || magicBE == fat64Swapped
|
||||
let count = min(Int(read(data, offset: 4, bigEndian: bigEndian)), 16)
|
||||
let stride = is64 ? 32 : 20
|
||||
var architectures: Set<UInt32> = []
|
||||
for index in 0..<count {
|
||||
let offset = 8 + index * stride
|
||||
guard offset + 4 <= data.count else { break }
|
||||
architectures.insert(read(data, offset: offset, bigEndian: bigEndian))
|
||||
}
|
||||
if architectures.contains(arm64) { return "ARM64" }
|
||||
if architectures.contains(x86_64) { return "x86_64" }
|
||||
return "Universal"
|
||||
}
|
||||
if magicLE == machO64 || magicBE == machO64Swapped {
|
||||
let type = read(data, offset: 4, bigEndian: false)
|
||||
if type == arm64 { return "ARM64" }
|
||||
if type == x86_64 { return "x86_64" }
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
private static func read(_ data: Data, offset: Int, bigEndian: Bool) -> UInt32 {
|
||||
guard offset + 4 <= data.count else { return 0 }
|
||||
let bytes = data[offset..<(offset + 4)]
|
||||
if bigEndian {
|
||||
return bytes.reduce(0) { ($0 << 8) | UInt32($1) }
|
||||
}
|
||||
return bytes.reversed().reduce(0) { ($0 << 8) | UInt32($1) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func firstMatch(_ pattern: String) -> String? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: self, range: NSRange(startIndex..., in: self)),
|
||||
match.numberOfRanges > 1,
|
||||
let range = Range(match.range(at: 1), in: self) else { return nil }
|
||||
return String(self[range])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 132 KiB |
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="${0:A:h:h}"
|
||||
APP_DIR="$PROJECT_DIR/dist/Task Manager.app"
|
||||
CONTENTS_DIR="$APP_DIR/Contents"
|
||||
ICON_SOURCE="$PROJECT_DIR/mactaskmanager.icon"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
swift build -c release
|
||||
|
||||
mkdir -p "$CONTENTS_DIR/MacOS" "$CONTENTS_DIR/Resources"
|
||||
cp "$PROJECT_DIR/.build/release/MacTaskManager" "$CONTENTS_DIR/MacOS/MacTaskManager"
|
||||
cp "$PROJECT_DIR/Resources/Info.plist" "$CONTENTS_DIR/Info.plist"
|
||||
chmod +x "$CONTENTS_DIR/MacOS/MacTaskManager"
|
||||
|
||||
if [[ -d "$ICON_SOURCE" ]]; then
|
||||
xcrun actool "$ICON_SOURCE" \
|
||||
--compile "$CONTENTS_DIR/Resources" \
|
||||
--platform macosx \
|
||||
--minimum-deployment-target 14.0 \
|
||||
--app-icon mactaskmanager \
|
||||
--output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null
|
||||
fi
|
||||
|
||||
codesign --force --deep --sign - "$APP_DIR" >/dev/null
|
||||
print "Built $APP_DIR"
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="${0:A:h:h}"
|
||||
APP_PATH="$PROJECT_DIR/dist/Task Manager.app"
|
||||
DMG_PATH="$PROJECT_DIR/dist/Task Manager-macOS.dmg"
|
||||
STAGING_DIR="$(mktemp -d /tmp/mactaskmanager-dmg.XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$STAGING_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ! -d "$APP_PATH" ]]; then
|
||||
"$PROJECT_DIR/scripts/build-app.sh"
|
||||
fi
|
||||
|
||||
ditto "$APP_PATH" "$STAGING_DIR/Task Manager.app"
|
||||
ln -s /Applications "$STAGING_DIR/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-volname "Task Manager" \
|
||||
-srcfolder "$STAGING_DIR" \
|
||||
-format UDZO \
|
||||
-imagekey zlib-level=9 \
|
||||
-ov \
|
||||
"$DMG_PATH"
|
||||
|
||||
print "Built $DMG_PATH"
|
||||