commit 9795096656bf9bd4de400e7a8679c7f82a4dcacc Author: Sean O'Connor Date: Fri Aug 14 13:07:44 2026 -0400 Initial MacTaskManager release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5da3f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.build/ +dist/ +*.xcuserstate +DerivedData/ diff --git a/PARITY.md b/PARITY.md new file mode 100644 index 0000000..68cf9dd --- /dev/null +++ b/PARITY.md @@ -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) diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c2af46f --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..73518f6 --- /dev/null +++ b/README.md @@ -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. diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..e9ffec0 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Task Manager + CFBundleExecutable + MacTaskManager + CFBundleIdentifier + local.mactaskmanager.app + CFBundleIconFile + mactaskmanager.icns + CFBundleIconName + mactaskmanager + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Task Manager + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/Screenshots/aligned-process-groups.png b/Screenshots/aligned-process-groups.png new file mode 100644 index 0000000..e1416e5 Binary files /dev/null and b/Screenshots/aligned-process-groups.png differ diff --git a/Screenshots/app-history.png b/Screenshots/app-history.png new file mode 100644 index 0000000..168e890 Binary files /dev/null and b/Screenshots/app-history.png differ diff --git a/Screenshots/cpu-context-menu.png b/Screenshots/cpu-context-menu.png new file mode 100644 index 0000000..a26d345 Binary files /dev/null and b/Screenshots/cpu-context-menu.png differ diff --git a/Screenshots/cpu-logical-processors.png b/Screenshots/cpu-logical-processors.png new file mode 100644 index 0000000..ee90efe Binary files /dev/null and b/Screenshots/cpu-logical-processors.png differ diff --git a/Screenshots/cpu-summary.png b/Screenshots/cpu-summary.png new file mode 100644 index 0000000..422f44c Binary files /dev/null and b/Screenshots/cpu-summary.png differ diff --git a/Screenshots/details-diagnostics.png b/Screenshots/details-diagnostics.png new file mode 100644 index 0000000..864f4fc Binary files /dev/null and b/Screenshots/details-diagnostics.png differ diff --git a/Screenshots/diagnostic-report-created.png b/Screenshots/diagnostic-report-created.png new file mode 100644 index 0000000..5dbffea Binary files /dev/null and b/Screenshots/diagnostic-report-created.png differ diff --git a/Screenshots/disk-performance.png b/Screenshots/disk-performance.png new file mode 100644 index 0000000..de1820d Binary files /dev/null and b/Screenshots/disk-performance.png differ diff --git a/Screenshots/gpu-performance.png b/Screenshots/gpu-performance.png new file mode 100644 index 0000000..f0595a8 Binary files /dev/null and b/Screenshots/gpu-performance.png differ diff --git a/Screenshots/native-application-icons.png b/Screenshots/native-application-icons.png new file mode 100644 index 0000000..1adb37a Binary files /dev/null and b/Screenshots/native-application-icons.png differ diff --git a/Screenshots/native-toolbar-compact.png b/Screenshots/native-toolbar-compact.png new file mode 100644 index 0000000..2327748 Binary files /dev/null and b/Screenshots/native-toolbar-compact.png differ diff --git a/Screenshots/network-performance.png b/Screenshots/network-performance.png new file mode 100644 index 0000000..56df746 Binary files /dev/null and b/Screenshots/network-performance.png differ diff --git a/Screenshots/process-activity.png b/Screenshots/process-activity.png new file mode 100644 index 0000000..a1403ca Binary files /dev/null and b/Screenshots/process-activity.png differ diff --git a/Screenshots/process-categories.png b/Screenshots/process-categories.png new file mode 100644 index 0000000..aebbce2 Binary files /dev/null and b/Screenshots/process-categories.png differ diff --git a/Screenshots/process-dependencies.png b/Screenshots/process-dependencies.png new file mode 100644 index 0000000..fe16c07 Binary files /dev/null and b/Screenshots/process-dependencies.png differ diff --git a/Screenshots/process-groups-expanded.png b/Screenshots/process-groups-expanded.png new file mode 100644 index 0000000..244db5d Binary files /dev/null and b/Screenshots/process-groups-expanded.png differ diff --git a/Screenshots/process-resource-percentages.png b/Screenshots/process-resource-percentages.png new file mode 100644 index 0000000..8266ce1 Binary files /dev/null and b/Screenshots/process-resource-percentages.png differ diff --git a/Screenshots/removable-volume-detected.png b/Screenshots/removable-volume-detected.png new file mode 100644 index 0000000..5532990 Binary files /dev/null and b/Screenshots/removable-volume-detected.png differ diff --git a/Screenshots/separate-removable-disks.png b/Screenshots/separate-removable-disks.png new file mode 100644 index 0000000..1f87841 Binary files /dev/null and b/Screenshots/separate-removable-disks.png differ diff --git a/Screenshots/services.png b/Screenshots/services.png new file mode 100644 index 0000000..825c403 Binary files /dev/null and b/Screenshots/services.png differ diff --git a/Screenshots/settings-diagnostics.png b/Screenshots/settings-diagnostics.png new file mode 100644 index 0000000..ca73295 Binary files /dev/null and b/Screenshots/settings-diagnostics.png differ diff --git a/Screenshots/sidebar-compact.png b/Screenshots/sidebar-compact.png new file mode 100644 index 0000000..e3fd9c7 Binary files /dev/null and b/Screenshots/sidebar-compact.png differ diff --git a/Screenshots/startup-apps.png b/Screenshots/startup-apps.png new file mode 100644 index 0000000..81ff769 Binary files /dev/null and b/Screenshots/startup-apps.png differ diff --git a/Screenshots/system-service-properties.png b/Screenshots/system-service-properties.png new file mode 100644 index 0000000..56141fa Binary files /dev/null and b/Screenshots/system-service-properties.png differ diff --git a/Screenshots/system-services.png b/Screenshots/system-services.png new file mode 100644 index 0000000..9137919 Binary files /dev/null and b/Screenshots/system-services.png differ diff --git a/Screenshots/user-session-properties.png b/Screenshots/user-session-properties.png new file mode 100644 index 0000000..6c6eb7d Binary files /dev/null and b/Screenshots/user-session-properties.png differ diff --git a/Sources/MacTaskManager/AppUtilitiesViews.swift b/Sources/MacTaskManager/AppUtilitiesViews.swift new file mode 100644 index 0000000..9ccbe1e --- /dev/null +++ b/Sources/MacTaskManager/AppUtilitiesViews.swift @@ -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 + } + } +} diff --git a/Sources/MacTaskManager/Components.swift b/Sources/MacTaskManager/Components.swift new file mode 100644 index 0000000..f09ffa9 --- /dev/null +++ b/Sources/MacTaskManager/Components.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct PageHeader: 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) + } + } +} diff --git a/Sources/MacTaskManager/ContentView.swift b/Sources/MacTaskManager/ContentView.swift new file mode 100644 index 0000000..2dcb281 --- /dev/null +++ b/Sources/MacTaskManager/ContentView.swift @@ -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) + } +} diff --git a/Sources/MacTaskManager/MacTaskManagerApp.swift b/Sources/MacTaskManager/MacTaskManagerApp.swift new file mode 100644 index 0000000..b7e28de --- /dev/null +++ b/Sources/MacTaskManager/MacTaskManagerApp.swift @@ -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) + } + } + } +} diff --git a/Sources/MacTaskManager/Models.swift b/Sources/MacTaskManager/Models.swift new file mode 100644 index 0000000..8ef0a69 --- /dev/null +++ b/Sources/MacTaskManager/Models.swift @@ -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) + } +} diff --git a/Sources/MacTaskManager/PerformanceView.swift b/Sources/MacTaskManager/PerformanceView.swift new file mode 100644 index 0000000..a1d64d7 --- /dev/null +++ b/Sources/MacTaskManager/PerformanceView.swift @@ -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) + } + } +} diff --git a/Sources/MacTaskManager/ProcessActions.swift b/Sources/MacTaskManager/ProcessActions.swift new file mode 100644 index 0000000..3f2a18b --- /dev/null +++ b/Sources/MacTaskManager/ProcessActions.swift @@ -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) -> 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.") + } + } +} diff --git a/Sources/MacTaskManager/ProcessDependencyInspector.swift b/Sources/MacTaskManager/ProcessDependencyInspector.swift new file mode 100644 index 0000000..3677cf7 --- /dev/null +++ b/Sources/MacTaskManager/ProcessDependencyInspector.swift @@ -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 = [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 = [] + var seenLibraries: Set = [] + var seenFiles: Set = [] + 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[.. Void + @State private var selectedPID: Int32? + @State private var selectedGroupID: String? + @State private var expandedGroupIDs: Set = [] + @State private var collapsedCategories: Set = [] + @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 { + 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 + ) -> 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[.. 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() + + 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[.. Void + @State private var selection: String? + @State private var inspectedUsage: AppUsageRecord? + @State private var showingResetConfirmation = false + + var body: some View { + VStack(spacing: 0) { + PageHeader( + title: "App history", + subtitle: "Resource usage since \(monitor.appHistoryStartDate.formatted(date: .abbreviated, time: .shortened))" + ) { + Button("Reset usage data", systemImage: "arrow.counterclockwise") { + showingResetConfirmation = true + } + .disabled(monitor.appHistory.isEmpty) + } + if filtered.isEmpty { + EmptyState( + icon: "clock.arrow.circlepath", + title: searchText.isEmpty ? "Collecting app history" : "No matching apps", + message: searchText.isEmpty + ? "CPU and network usage will appear as applications run." + : "Try a different app name, user, or path." + ) + } else { + Table(Array(filtered.prefix(250)), selection: $selection) { + TableColumn("Name") { usage in + HStack(spacing: 8) { + ProcessIcon(name: usage.name, path: usage.executablePath) + Text(usage.name).lineLimit(1) + } + }.width(min: 170, ideal: 260) + TableColumn("CPU time") { usage in + Text(Formatters.duration(usage.cpuSeconds)).monospacedDigit() + }.width(90) + TableColumn("Network") { usage in + Text(Formatters.bytes.string(fromByteCount: Int64(usage.networkTotalBytes))).monospacedDigit() + }.width(100) + TableColumn("Downloads") { usage in + Text(Formatters.bytes.string(fromByteCount: Int64(usage.networkReceivedBytes))).monospacedDigit() + }.width(100) + TableColumn("Uploads") { usage in + Text(Formatters.bytes.string(fromByteCount: Int64(usage.networkSentBytes))).monospacedDigit() + }.width(100) + TableColumn("Last used") { usage in + Text(usage.lastSeen, style: .relative).foregroundStyle(.secondary) + }.width(min: 90, ideal: 115) + } + .contextMenu(forSelectionType: String.self) { selectedIDs in + if let id = selectedIDs.first, + let usage = monitor.appHistory.first(where: { $0.id == id }) { + appHistoryMenu(usage) + } + } + } + } + .alert("Reset app usage history?", isPresented: $showingResetConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Reset", role: .destructive) { monitor.resetAppHistory() } + } message: { + Text("CPU time and network totals collected by Task Manager will be permanently cleared.") + } + .sheet(item: $inspectedUsage) { AppUsagePropertiesView(usage: $0) } + } + + private var filtered: [AppUsageRecord] { + monitor.appHistory.filter { + searchText.isEmpty + || $0.name.localizedCaseInsensitiveContains(searchText) + || $0.user.localizedCaseInsensitiveContains(searchText) + || $0.executablePath.localizedCaseInsensitiveContains(searchText) + }.sorted { + if $0.cpuSeconds != $1.cpuSeconds { return $0.cpuSeconds > $1.cpuSeconds } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + @ViewBuilder + private func appHistoryMenu(_ usage: AppUsageRecord) -> some View { + Button("Go to details", systemImage: "list.bullet.rectangle") { + guard let process = monitor.activeProcess(for: usage) else { return } + onShowDetails(process) + } + .disabled(monitor.activeProcess(for: usage) == nil) + + Divider() + + Button("Open file location", systemImage: "folder") { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: usage.executablePath)]) + } + .disabled(!usage.executablePath.hasPrefix("/")) + + Button("Search online", systemImage: "magnifyingglass") { + guard let query = usage.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://www.google.com/search?q=\(query)+macOS+app") else { return } + NSWorkspace.shared.open(url) + } + + Menu("Copy", systemImage: "doc.on.doc") { + Button("Name") { copy(usage.name) } + Button("CPU time") { copy(Formatters.duration(usage.cpuSeconds)) } + Button("Network total") { copy(Formatters.bytes.string(fromByteCount: Int64(usage.networkTotalBytes))) } + Button("Executable path") { copy(usage.executablePath) } + Button("All details") { + copy("\(usage.name)\tCPU \(Formatters.duration(usage.cpuSeconds))\tNetwork \(Formatters.bytes.string(fromByteCount: Int64(usage.networkTotalBytes)))\t\(usage.executablePath)") + } + } + + Button("Properties", systemImage: "info.circle") { inspectedUsage = usage } + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } +} + +private struct AppUsagePropertiesView: View { + @Environment(\.dismiss) private var dismiss + let usage: AppUsageRecord + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 14) { + ProcessIcon(name: usage.name, path: usage.executablePath).scaleEffect(1.35).frame(width: 42, height: 42) + VStack(alignment: .leading, spacing: 3) { + Text(usage.name).font(.title2.weight(.semibold)) + Text("Application usage").foregroundStyle(.secondary) + } + Spacer() + } + .padding(20) + Divider() + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) { + property("Executable", usage.executablePath) + property("User", usage.user) + property("CPU time", Formatters.duration(usage.cpuSeconds)) + property("Network", Formatters.bytes.string(fromByteCount: Int64(usage.networkTotalBytes))) + property("Downloads", Formatters.bytes.string(fromByteCount: Int64(usage.networkReceivedBytes))) + property("Uploads", Formatters.bytes.string(fromByteCount: Int64(usage.networkSentBytes))) + property("Last used", usage.lastSeen.formatted(date: .abbreviated, time: .standard)) + } + .padding(20) + Divider() + HStack { + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: usage.executablePath)]) + } + .disabled(!usage.executablePath.hasPrefix("/")) + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + } + .padding(16) + } + .frame(width: 570) + } + + 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) + } + } +} + +struct StartupAppsView: View { + @Environment(SystemMonitor.self) private var monitor + @State private var items = StartupScanner.scan() + @State private var inspectedItem: StartupItem? + + var body: some View { + VStack(spacing: 0) { + PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") { + HStack { + Button("Refresh", systemImage: "arrow.clockwise") { items = StartupScanner.scan() } + Button("Open Login Items") { + openLoginItemsSettings() + } + } + } + if items.isEmpty { + EmptyState( + icon: "rectangle.stack.badge.play", + title: "No startup items found", + message: "Other login items can be managed in System Settings." + ) + } else { + Table(items) { + TableColumn("Name") { item in + HStack(spacing: 8) { + ProcessIcon(name: item.name, path: item.executablePath) + Text(item.name).lineLimit(1) + } + }.width(min: 190, ideal: 280) + TableColumn("Publisher") { item in Text(item.publisher).lineLimit(1) }.width(min: 90, ideal: 140) + TableColumn("Status") { item in + Text(item.enabled ? "Enabled" : "Disabled") + .foregroundStyle(item.enabled ? .green : .secondary) + }.width(80) + TableColumn("Startup impact") { item in + let impact = startupImpact(for: item) + Text(impact.label) + .foregroundStyle(impact.color) + .help(impact.detail) + }.width(105) + TableColumn("Type") { item in Text(item.kind.title).foregroundStyle(.secondary) }.width(90) + } + .contextMenu(forSelectionType: String.self) { selectedIDs in + if let id = selectedIDs.first, let item = items.first(where: { $0.id == id }) { + startupMenu(item) + } + } + } + } + .sheet(item: $inspectedItem) { item in + StartupItemPropertiesView(item: item, impact: startupImpact(for: item)) + } + } + + private func openLoginItemsSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension") { + NSWorkspace.shared.open(url) + } + } + + private func startupImpact(for item: StartupItem) -> StartupImpact { + let candidates = monitor.processes.filter { process in + guard !item.executablePath.isEmpty else { return false } + return process.executablePath.hasPrefix(item.executablePath) + || URL(fileURLWithPath: process.executablePath).lastPathComponent + .localizedCaseInsensitiveCompare(URL(fileURLWithPath: item.executablePath).lastPathComponent) == .orderedSame + } + guard !candidates.isEmpty else { + return StartupImpact(label: "Not measured", color: .secondary, detail: "The item is not currently running.") + } + let cpu = candidates.reduce(0) { $0 + $1.cpu } + let memory = candidates.reduce(UInt64(0)) { $0 + $1.residentBytes } + let disk = candidates.reduce(0.0) { $0 + (monitor.processActivity[$1.pid]?.diskTotal ?? 0) } + let detail = "Current footprint: CPU \(Formatters.percent(cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(memory))), disk \(Formatters.rate(disk))." + if cpu >= 2 || memory >= 1_000_000_000 || disk >= 1_000_000 { + return StartupImpact(label: "High", color: .red, detail: detail) + } + if cpu >= 0.2 || memory >= 250_000_000 || disk >= 100_000 { + return StartupImpact(label: "Medium", color: .orange, detail: detail) + } + return StartupImpact(label: "Low", color: .green, detail: detail) + } + + @ViewBuilder + private func startupMenu(_ item: StartupItem) -> some View { + if item.isDirectlyManageable { + Button(item.enabled ? "Disable" : "Enable") { setEnabled(!item.enabled, item: item) } + } else { + Button("Manage in Login Items", systemImage: "gear") { openLoginItemsSettings() } + } + Divider() + Button("Open file location") { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: item.sourcePath)]) + } + Button("Search online") { + guard let query = item.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://www.google.com/search?q=\(query)+macOS") else { return } + NSWorkspace.shared.open(url) + } + Menu("Copy") { + Button("Name") { copy(item.name) } + Button("Path") { copy(item.sourcePath) } + Button("Identifier") { copy(item.id) } + Button("Startup impact") { copy(startupImpact(for: item).label) } + } + Button("Properties") { inspectedItem = item } + } + + private func setEnabled(_ enabled: Bool, item: StartupItem) { + guard item.isDirectlyManageable else { + openLoginItemsSettings() + return + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = [enabled ? "enable" : "disable", "gui/\(getuid())/\(item.serviceIdentifier)"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + monitor.errorMessage = "macOS did not allow this startup item to be changed. Try System Settings." + return + } + if let index = items.firstIndex(where: { $0.id == item.id }) { items[index].enabled = enabled } + } catch { + monitor.errorMessage = error.localizedDescription + } + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } +} + +private struct StartupImpact { + let label: String + let color: Color + let detail: String +} + +private struct StartupItemPropertiesView: View { + @Environment(\.dismiss) private var dismiss + let item: StartupItem + let impact: StartupImpact + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 14) { + Image(systemName: item.kind == .loginItem ? "person.crop.circle.badge.checkmark" : "shippingbox") + .font(.system(size: 28)).foregroundStyle(.tint).frame(width: 42, height: 42) + VStack(alignment: .leading, spacing: 3) { + Text(item.name).font(.title2.weight(.semibold)) + Text(item.kind.title).foregroundStyle(.secondary) + } + Spacer() + } + .padding(20) + Divider() + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) { + property("Publisher", item.publisher) + property("Status", item.enabled ? "Enabled" : "Disabled") + property("Impact", impact.label) + property("Impact detail", impact.detail) + property("Executable", item.executablePath) + property("Definition", item.sourcePath) + property("Identifier", item.serviceIdentifier) + } + .padding(20) + Divider() + HStack { + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: item.sourcePath)]) + } + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + } + .padding(16) + } + .frame(width: 600) + } + + private func property(_ name: String, _ value: String) -> some View { + GridRow { + Text(name).foregroundStyle(.secondary).frame(width: 100, alignment: .trailing) + Text(value).textSelection(.enabled).lineLimit(3).frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct UsersView: View { + @Environment(SystemMonitor.self) private var monitor + let onShowDetails: (ProcessRecord) -> Void + @State private var terminationRequest: TerminationRequest? + @State private var inspectedProcess: ProcessRecord? + @State private var inspectedUser: UserSessionSummary? + @State private var sessions: [LoginSession] = [] + + private var groups: [UserSessionSummary] { + let available = sessions.isEmpty ? [LoginSession(user: NSUserName(), terminal: "console", loginDescription: "Current session")] : sessions + return available.map { session in + UserSessionSummary(session: session, processes: monitor.processes.filter { $0.user == session.user }) + }.sorted { lhs, rhs in + if lhs.session.isConsole != rhs.session.isConsole { return lhs.session.isConsole } + return lhs.cpu > rhs.cpu + } + } + + var body: some View { + VStack(spacing: 0) { + PageHeader(title: "Users", subtitle: "\(groups.count) signed-in session\(groups.count == 1 ? "" : "s")") + List(groups) { group in + DisclosureGroup { + ForEach(group.processes.sorted { $0.cpu > $1.cpu }) { process in + HStack { + ProcessIcon(name: process.displayName, path: process.executablePath) + Text(process.displayName) + Spacer() + Text(Formatters.percent(process.cpu)).monospacedDigit() + Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit().frame(width: 110, alignment: .trailing) + } + .contextMenu { + ProcessContextMenu( + process: process, + onTerminate: { terminationRequest = $0 }, + onShowDetails: onShowDetails, + onShowProperties: { inspectedProcess = $0 } + ) + } + } + } label: { + HStack { + Label(group.session.user, systemImage: group.session.isConsole ? "person.crop.circle.fill.badge.checkmark" : "person.crop.circle.fill") + if group.session.isConsole { + Text("Active").font(.caption.weight(.medium)).foregroundStyle(.green) + } + Spacer() + Text("\(group.processes.count) processes") + Text(Formatters.percent(group.cpu)) + .monospacedDigit().frame(width: 70, alignment: .trailing) + } + .font(.headline) + .padding(.vertical, 7) + .contentShape(Rectangle()) + .contextMenu { userMenu(group) } + } + } + .listStyle(.inset) + } + .terminationConfirmation($terminationRequest) + .sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) } + .sheet(item: $inspectedUser) { UserSessionPropertiesView(summary: $0) } + .task { + sessions = await Task.detached(priority: .utility) { LoginSessionScanner.capture() }.value + } + } + + @ViewBuilder + private func userMenu(_ group: UserSessionSummary) -> some View { + Button("Go to details", systemImage: "list.bullet.rectangle") { + if let process = group.processes.max(by: { $0.cpu < $1.cpu }) { onShowDetails(process) } + } + .disabled(group.processes.isEmpty) + + if group.session.isConsole { + Button("Lock session", systemImage: "lock") { SessionUserActions.lockScreen() } + } + + Divider() + Button("Open home folder", systemImage: "folder") { SessionUserActions.openHome(group.session.user) } + Button("Manage account…", systemImage: "person.crop.circle.badge.gearshape") { SessionUserActions.openAccountsSettings() } + Menu { + Button("User name") { copy(group.session.user) } + Button("Process IDs") { copy(group.processes.map { String($0.pid) }.joined(separator: ", ")) } + Button("Resource summary") { + copy("\(group.session.user)\t\(group.processes.count) processes\tCPU \(Formatters.percent(group.cpu))\tMemory \(Formatters.bytes.string(fromByteCount: Int64(group.memory)))") + } + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + Button("Properties", systemImage: "info.circle") { inspectedUser = group } + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } +} + +private struct LoginSession: Identifiable, Hashable, Sendable { + let user: String + let terminal: String + let loginDescription: String + var id: String { "\(user):\(terminal)" } + var isConsole: Bool { terminal == "console" } +} + +private struct UserSessionSummary: Identifiable, Hashable { + let session: LoginSession + let processes: [ProcessRecord] + var id: String { session.id } + var cpu: Double { processes.reduce(0) { $0 + $1.cpu } } + var memory: UInt64 { processes.reduce(0) { $0 + $1.residentBytes } } +} + +private enum LoginSessionScanner { + static func capture() -> [LoginSession] { + let task = Process() + let pipe = Pipe() + task.executableURL = URL(fileURLWithPath: "/usr/bin/who") + task.standardOutput = pipe + task.standardError = FileHandle.nullDevice + do { + try task.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + var seen: Set = [] + return String(decoding: data, as: UTF8.self).split(separator: "\n").compactMap { line in + let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + guard fields.count >= 2 else { return nil } + let key = "\(fields[0]):\(fields[1])" + guard seen.insert(key).inserted else { return nil } + return LoginSession(user: fields[0], terminal: fields[1], loginDescription: fields.dropFirst(2).joined(separator: " ")) + } + } catch { + return [] + } + } +} + +private enum SessionUserActions { + static func lockScreen() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/pmset") + task.arguments = ["displaysleepnow"] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + } + + static func openHome(_ user: String) { + guard let account = getpwnam(user), let path = account.pointee.pw_dir else { return } + NSWorkspace.shared.open(URL(fileURLWithPath: String(cString: path), isDirectory: true)) + } + + static func openAccountsSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.Users-Groups-Settings.extension") else { return } + NSWorkspace.shared.open(url) + } +} + +private struct UserSessionPropertiesView: View { + @Environment(\.dismiss) private var dismiss + let summary: UserSessionSummary + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 14) { + Image(systemName: summary.session.isConsole ? "person.crop.circle.fill.badge.checkmark" : "person.crop.circle.fill") + .font(.system(size: 32)).foregroundStyle(.tint).frame(width: 44) + VStack(alignment: .leading, spacing: 3) { + Text(summary.session.user).font(.title2.weight(.semibold)) + Text(summary.session.isConsole ? "Active console session" : "Signed-in session").foregroundStyle(.secondary) + } + Spacer() + }.padding(20) + Divider() + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) { + property("Terminal", summary.session.terminal) + property("Signed in", summary.session.loginDescription) + property("Processes", "\(summary.processes.count)") + property("CPU", Formatters.percent(summary.cpu)) + property("Memory", Formatters.bytes.string(fromByteCount: Int64(summary.memory))) + property("Session", summary.session.isConsole ? "Active" : "Connected") + }.padding(20) + Divider() + HStack { + Button("Open home folder") { SessionUserActions.openHome(summary.session.user) } + 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).frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private enum StartupItemKind { + case loginItem + case launchAgent + + var title: String { self == .loginItem ? "Login item" : "Launch agent" } +} + +private struct StartupItem: Identifiable { + let id: String + let name: String + let publisher: String + let sourcePath: String + let executablePath: String + let serviceIdentifier: String + let kind: StartupItemKind + let isDirectlyManageable: Bool + var enabled: Bool +} + +private enum StartupScanner { + static func scan() -> [StartupItem] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let directories = ["\(home)/Library/LaunchAgents", "/Library/LaunchAgents"] + let disabled = disabledServices() + let agents = directories.flatMap { scanDirectory($0, disabled: disabled) } + let loginItems = sessionLoginItems() + return (loginItems + agents).sorted { + if $0.kind != $1.kind { return $0.kind == .loginItem } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + private static func scanDirectory(_ directory: String, disabled: Set) -> [StartupItem] { + guard let files = try? FileManager.default.contentsOfDirectory(atPath: directory) else { return [] } + return files.filter { $0.hasSuffix(".plist") }.compactMap { filename in + let path = (directory as NSString).appendingPathComponent(filename) + guard let data = FileManager.default.contents(atPath: path), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil } + let label = plist["Label"] as? String ?? (filename as NSString).deletingPathExtension + let command = plist["Program"] as? String ?? ((plist["ProgramArguments"] as? [String])?.first ?? "") + let publisher = publisherName(label: label, command: command) + return StartupItem( + id: "agent:\(label)", + name: friendlyName(label), + publisher: publisher, + sourcePath: path, + executablePath: command, + serviceIdentifier: label, + kind: .launchAgent, + isDirectlyManageable: true, + enabled: !disabled.contains(label) && !(plist["Disabled"] as? Bool ?? false) + ) + } + } + + private static func sessionLoginItems() -> [StartupItem] { + guard let unmanagedList = LSSharedFileListCreate( + nil, + "com.apple.LSSharedFileList.SessionLoginItems" as CFString, + nil + ) else { return [] } + let list = unmanagedList.takeRetainedValue() + guard let unmanagedSnapshot = LSSharedFileListCopySnapshot(list, nil) else { return [] } + let snapshot = unmanagedSnapshot.takeRetainedValue() + var seen: Set = [] + var items: [StartupItem] = [] + let flags = UInt32(kLSSharedFileListNoUserInteraction | kLSSharedFileListDoNotMountVolumes) + for index in 0.. Set { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["print-disabled", "gui/\(getuid())"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + let output = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + process.waitUntilExit() + let pattern = #"\"([^\"]+)\"\s*=>\s*true"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + return Set(regex.matches(in: output, range: NSRange(output.startIndex..., in: output)).compactMap { match in + guard let range = Range(match.range(at: 1), in: output) else { return nil } + return String(output[range]) + }) + } catch { + return [] + } + } + + private static func friendlyName(_ label: String) -> String { + let parts = label.split(separator: ".").map(String.init) + guard let last = parts.last else { return label } + let generic = ["agent", "updater", "check", "wake", "xpcservice", "helper"] + if generic.contains(last.lowercased()), parts.count > 1 { + return "\(parts[parts.count - 2]) \(last)" + } + return last + } + + private static func publisherName(label: String, command: String) -> String { + if label.hasPrefix("com.apple") { return "Apple" } + let parts = label.split(separator: ".").map(String.init) + if let vendor = parts.drop(while: { ["com", "org", "net", "io", "us"].contains($0.lowercased()) }).first { + return vendor.prefix(1).uppercased() + vendor.dropFirst() + } + if let appComponent = command.split(separator: "/").first(where: { $0.hasSuffix(".app") }) { + return String(appComponent.dropLast(4)) + } + return "Unknown" + } +} + +private enum DetailColumn: String, CaseIterable, Identifiable { + case name, pid, parentPID, user, cpu, cpuTime, memory, disk, network, state, elapsed + case threads, handles, architecture, priority, nice + + var id: String { rawValue } + var title: String { + switch self { + case .name: "Name" + case .pid: "PID" + case .parentPID: "Parent PID" + case .user: "User" + case .cpu: "CPU" + case .cpuTime: "CPU time" + case .memory: "Memory" + case .disk: "Disk I/O" + case .network: "Network" + case .state: "State" + case .elapsed: "Elapsed" + case .threads: "Threads" + case .handles: "Handles" + case .architecture: "Architecture" + case .priority: "Priority" + case .nice: "Nice" + } + } + var width: CGFloat { + switch self { + case .name: 230 + case .user: 120 + case .architecture: 95 + case .cpuTime, .memory, .disk, .network, .elapsed: 100 + case .parentPID: 80 + default: 72 + } + } + var defaultVisible: Bool { + [.name, .pid, .user, .cpu, .memory, .threads, .handles, .architecture, .priority, .cpuTime].contains(self) + } +} + +struct DetailsView: View { + @Environment(SystemMonitor.self) private var monitor + let searchText: String + @Binding var selection: Int32? + @AppStorage("detailVisibleColumns") private var visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",") + @State private var terminationRequest: TerminationRequest? + @State private var inspectedProcess: ProcessRecord? + @AppStorage("detailSortColumn") private var sortColumn: DetailColumn = .pid + @AppStorage("detailSortAscending") private var ascending = true + + private var visibleColumns: [DetailColumn] { + let stored = Set(visibleColumnStorage.split(separator: ",").map(String.init)) + return DetailColumn.allCases.filter { stored.contains($0.rawValue) || $0 == .name || $0 == .pid } + } + + var body: some View { + VStack(spacing: 0) { + PageHeader(title: "Details", subtitle: "Technical information for running processes") { + Menu("Columns", systemImage: "rectangle.split.3x1") { + ForEach(DetailColumn.allCases) { column in + Toggle(column.title, isOn: columnBinding(column)) + .disabled(column == .name || column == .pid) + } + Divider() + Button("Reset columns") { + visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",") + } + } + } + ScrollView([.horizontal, .vertical]) { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + ForEach(filtered) { process in + detailRow(process) + } + } header: { + detailHeader + } + } + .frame(minWidth: visibleColumns.reduce(0) { $0 + $1.width + 12 }) + } + } + .terminationConfirmation($terminationRequest) + .sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) } + } + + private var filtered: [ProcessRecord] { + monitor.processes.filter { + searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText) + || $0.user.localizedCaseInsensitiveContains(searchText) || String($0.pid).contains(searchText) + }.sorted { lhs, rhs in + if lhs.pid == selection { return true } + if rhs.pid == selection { return false } + let result = comparison(lhs, rhs, column: sortColumn) + if result == .orderedSame { return lhs.pid < rhs.pid } + return ascending ? result == .orderedAscending : result == .orderedDescending + } + } + + private var detailHeader: some View { + HStack(spacing: 12) { + ForEach(visibleColumns) { column in + Button { + if sortColumn == column { ascending.toggle() } + else { sortColumn = column; ascending = true } + } label: { + HStack(spacing: 3) { + Text(column.title) + if sortColumn == column { Image(systemName: ascending ? "chevron.up" : "chevron.down") } + } + .frame(width: column.width, alignment: column == .name ? .leading : .trailing) + } + .buttonStyle(.plain) + } + } + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.background) + .overlay(alignment: .bottom) { Divider() } + } + + private func detailRow(_ process: ProcessRecord) -> some View { + HStack(spacing: 12) { + ForEach(visibleColumns) { column in + detailCell(process, column: column) + .frame(width: column.width, alignment: column == .name || column == .user ? .leading : .trailing) + } + } + .font(.callout) + .padding(.horizontal, 12) + .frame(height: 38) + .background(selection == process.pid ? Color.accentColor.opacity(0.18) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { selection = process.pid } + .overlay(alignment: .bottom) { Divider().opacity(0.4) } + .contextMenu { + ProcessContextMenu( + process: process, + onTerminate: { terminationRequest = $0 }, + onShowDetails: { _ in }, + onShowProperties: { inspectedProcess = $0 } + ) + } + } + + @ViewBuilder + private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View { + switch column { + case .name: Text(process.displayName).lineLimit(1) + case .pid: Text("\(process.pid)").monospacedDigit() + case .parentPID: Text("\(process.parentPID)").monospacedDigit() + case .user: Text(process.user).lineLimit(1) + case .cpu: Text(Formatters.percent(process.cpu)).monospacedDigit() + case .cpuTime: Text(Formatters.duration(process.cpuTime)).monospacedDigit() + case .memory: Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit() + case .disk: Text(Formatters.rate(monitor.processActivity[process.pid]?.diskTotal ?? 0)).monospacedDigit() + case .network: Text(Formatters.rate(monitor.processActivity[process.pid]?.networkTotal ?? 0)).monospacedDigit() + case .state: Text(process.state) + case .elapsed: Text(process.elapsed).monospacedDigit() + case .threads: Text("\(process.threadCount)").monospacedDigit() + case .handles: Text("\(process.openFileCount)").monospacedDigit() + case .architecture: Text(process.architecture) + case .priority: Text("\(process.priority)").monospacedDigit() + case .nice: Text("\(process.nice)").monospacedDigit() + } + } + + private func columnBinding(_ column: DetailColumn) -> Binding { + Binding( + get: { visibleColumns.contains(column) }, + set: { enabled in + var columns = Set(visibleColumnStorage.split(separator: ",").map(String.init)) + if enabled { columns.insert(column.rawValue) } + else { columns.remove(column.rawValue) } + visibleColumnStorage = DetailColumn.allCases.filter { columns.contains($0.rawValue) }.map(\.rawValue).joined(separator: ",") + } + ) + } + + private func comparison(_ lhs: ProcessRecord, _ rhs: ProcessRecord, column: DetailColumn) -> ComparisonResult { + func compare(_ left: T, _ right: T) -> ComparisonResult { + left == right ? .orderedSame : (left < right ? .orderedAscending : .orderedDescending) + } + return switch column { + case .name: lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + case .pid: compare(lhs.pid, rhs.pid) + case .parentPID: compare(lhs.parentPID, rhs.parentPID) + case .user: lhs.user.localizedCaseInsensitiveCompare(rhs.user) + case .cpu: compare(lhs.cpu, rhs.cpu) + case .cpuTime: compare(lhs.cpuTime, rhs.cpuTime) + case .memory: compare(lhs.residentBytes, rhs.residentBytes) + case .disk: compare(monitor.processActivity[lhs.pid]?.diskTotal ?? 0, monitor.processActivity[rhs.pid]?.diskTotal ?? 0) + case .network: compare(monitor.processActivity[lhs.pid]?.networkTotal ?? 0, monitor.processActivity[rhs.pid]?.networkTotal ?? 0) + case .state: lhs.state.localizedCaseInsensitiveCompare(rhs.state) + case .elapsed: compare(lhs.elapsed, rhs.elapsed) + case .threads: compare(lhs.threadCount, rhs.threadCount) + case .handles: compare(lhs.openFileCount, rhs.openFileCount) + case .architecture: lhs.architecture.localizedCaseInsensitiveCompare(rhs.architecture) + case .priority: compare(lhs.priority, rhs.priority) + case .nice: compare(lhs.nice, rhs.nice) + } + } +} + +struct ServicesView: View { + @Environment(SystemMonitor.self) private var monitor + let searchText: String + let onShowDetails: (ProcessRecord) -> Void + @State private var selection: String? + @State private var inspectedService: ServiceRecord? + @State private var domainFilter: ServiceDomain? + + private var services: [ServiceRecord] { + monitor.services.filter { + (domainFilter == nil || $0.domain == domainFilter) && ( + searchText.isEmpty + || $0.displayName.localizedCaseInsensitiveContains(searchText) + || $0.label.localizedCaseInsensitiveContains(searchText) + || $0.domain.rawValue.localizedCaseInsensitiveContains(searchText) + || String($0.pid ?? 0).contains(searchText) + ) + }.sorted { + if $0.isRunning != $1.isRunning { return $0.isRunning } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } + + var body: some View { + VStack(spacing: 0) { + PageHeader( + title: "Services", + subtitle: "\(monitor.services.filter(\.isRunning).count) running of \(monitor.services.count) launch services" + ) { + Picker("Scope", selection: $domainFilter) { + Text("All").tag(ServiceDomain?.none) + ForEach(ServiceDomain.allCases) { domain in + Text(domain.rawValue).tag(Optional(domain)) + } + } + .pickerStyle(.segmented) + .frame(width: 220) + } + Table(services, selection: $selection) { + TableColumn("Name") { service in + HStack(spacing: 8) { + if let pid = service.pid, let process = monitor.processes.first(where: { $0.pid == pid }) { + ProcessIcon(name: service.displayName, path: process.executablePath) + } else { + Image(systemName: service.isRunning ? "gearshape.fill" : "gearshape") + .frame(width: 30, height: 30) + } + Text(service.displayName).lineLimit(1) + } + .foregroundStyle(service.isRunning ? Color.primary : Color.secondary) + }.width(min: 150, ideal: 210) + TableColumn("PID") { service in + Text(service.pid.map(String.init) ?? "—").monospacedDigit() + }.width(70) + TableColumn("Description") { service in + Text(service.label).lineLimit(1).foregroundStyle(.secondary) + }.width(min: 220, ideal: 360) + TableColumn("Status") { service in + HStack(spacing: 6) { + Circle().fill(service.isRunning ? Color.green : Color.secondary.opacity(0.5)).frame(width: 7, height: 7) + Text(service.isRunning ? "Running" : "Stopped") + } + }.width(100) + TableColumn("Scope") { Text($0.domain.rawValue) }.width(70) + TableColumn("Group") { Text($0.publisher) }.width(min: 80, ideal: 110) + } + .contextMenu(forSelectionType: String.self) { selectedLabels in + if let id = selectedLabels.first, + let service = monitor.services.first(where: { $0.id == id }) { + serviceMenu(service) + } + } + } + .sheet(item: $inspectedService) { ServicePropertiesView(service: $0) } + } + + @ViewBuilder + private func serviceMenu(_ service: ServiceRecord) -> some View { + Button("Start", systemImage: "play.fill") { monitor.startService(service) } + .disabled(service.isRunning || !service.isControllable) + Button("Stop", systemImage: "stop.fill") { monitor.stopService(service) } + .disabled(!service.isRunning || !service.isControllable) + Button("Restart", systemImage: "arrow.clockwise") { monitor.restartService(service) } + .disabled(!service.isRunning || !service.isControllable) + if !service.isControllable { + Text("Protected system service") + } + + Divider() + + Button("Go to details", systemImage: "list.bullet.rectangle") { + guard let pid = service.pid, + let process = monitor.processes.first(where: { $0.pid == pid }) else { return } + onShowDetails(process) + } + .disabled(service.pid == nil) + + Menu("Copy", systemImage: "doc.on.doc") { + Button("Name") { copy(service.displayName) } + Button("Service identifier") { copy(service.label) } + Button("PID") { copy(service.pid.map(String.init) ?? "") } + Button("All details") { + copy("\(service.displayName)\t\(service.label)\t\(service.isRunning ? "Running" : "Stopped")\tPID \(service.pid.map(String.init) ?? "—")") + } + } + + Menu("Inspect", systemImage: "magnifyingglass") { + Button("Reveal configuration") { revealConfiguration(service) } + .disabled(service.configurationPath == nil) + Button("Search online") { searchOnline(service) } + Button("Properties") { inspectedService = service } + } + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + + private func searchOnline(_ service: ServiceRecord) { + guard let query = service.label.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://www.google.com/search?q=\(query)+macOS+launchd") else { return } + NSWorkspace.shared.open(url) + } + + private func revealConfiguration(_ service: ServiceRecord) { + guard let path = service.configurationPath else { return } + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } +} + +private struct ServicePropertiesView: View { + @Environment(\.dismiss) private var dismiss + let service: ServiceRecord + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 14) { + Image(systemName: service.isRunning ? "gearshape.fill" : "gearshape") + .font(.system(size: 28)) + .foregroundStyle(service.isRunning ? .green : .secondary) + .frame(width: 42, height: 42) + VStack(alignment: .leading, spacing: 3) { + Text(service.displayName).font(.title2.weight(.semibold)) + Text(service.isRunning ? "Running" : "Stopped").foregroundStyle(.secondary) + } + Spacer() + } + .padding(20) + Divider() + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) { + property("Identifier", service.label) + property("PID", service.pid.map(String.init) ?? "Not running") + property("Publisher", service.publisher) + property("Last exit", "\(service.lastExitStatus)") + property("Domain", service.domain.launchctlPrefix) + property("Management", service.isControllable ? "Available" : "Read-only (protected by macOS)") + property("Configuration", service.configurationPath ?? "Managed dynamically by launchd") + } + .padding(20) + Divider() + HStack { + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + } + .padding(16) + } + .frame(width: 560) + } + + 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) + } + } +} diff --git a/Sources/MacTaskManager/SystemMonitor.swift b/Sources/MacTaskManager/SystemMonitor.swift new file mode 100644 index 0000000..8e2c743 --- /dev/null +++ b/Sources/MacTaskManager/SystemMonitor.swift @@ -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? + 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? + 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[.. [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.stride) + ) + } + + let current = (0.. 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[.. 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.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.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(" (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 = [] + for index in 0.. 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]) + } +} diff --git a/Tests/MacTaskManagerTests/ModelTests.swift b/Tests/MacTaskManagerTests/ModelTests.swift new file mode 100644 index 0000000..372776d --- /dev/null +++ b/Tests/MacTaskManagerTests/ModelTests.swift @@ -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 }) + } +} diff --git a/mactaskmanager.icon/Assets/icon.png b/mactaskmanager.icon/Assets/icon.png new file mode 100644 index 0000000..8e09391 Binary files /dev/null and b/mactaskmanager.icon/Assets/icon.png differ diff --git a/mactaskmanager.icon/icon.json b/mactaskmanager.icon/icon.json new file mode 100644 index 0000000..570d147 --- /dev/null +++ b/mactaskmanager.icon/icon.json @@ -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" + } +} \ No newline at end of file diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..dbb168c --- /dev/null +++ b/scripts/build-app.sh @@ -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" diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh new file mode 100755 index 0000000..1b47286 --- /dev/null +++ b/scripts/build-dmg.sh @@ -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"