import AppKit import CoreServices import Darwin import SwiftUI struct AppHistoryView: View { @Environment(SystemMonitor.self) private var monitor let searchText: String let onShowDetails: (ProcessRecord) -> 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 Puter 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: [StartupItem] = [] @State private var isLoading = true @State private var inspectedItem: StartupItem? var body: some View { VStack(spacing: 0) { PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") { HStack { Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() } .disabled(isLoading) Button("Open Login Items") { openLoginItemsSettings() } } } if isLoading && items.isEmpty { VStack(spacing: 12) { ProgressView() Text("Scanning login items…").foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityElement(children: .combine) .accessibilityLabel("Scanning login items") } else if items.isEmpty { EmptyState( icon: "rectangle.stack.badge.play", title: "No startup items found", 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)) } .task { refreshItems() } } private func openLoginItemsSettings() { if let url = URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension") { NSWorkspace.shared.open(url) } } private func refreshItems() { guard !isLoading || items.isEmpty else { return } isLoading = true Task { items = await Task.detached(priority: .utility) { StartupScanner.scan() }.value isLoading = false } } private func startupImpact(for item: StartupItem) -> StartupImpact { let candidates = monitor.processes.filter { process in guard !item.executablePath.isEmpty else { return false } 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: Sendable { case loginItem case launchAgent var title: String { self == .loginItem ? "Login item" : "Launch agent" } } private struct StartupItem: Identifiable, Sendable { 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 defaultWidth: CGFloat { switch self { case .name: 260 case .user: 140 case .architecture, .state: 105 case .cpuTime, .memory, .disk, .network, .elapsed: 112 case .parentPID: 92 default: 78 } } var minimumWidth: CGFloat { switch self { case .name: 170 case .user: 90 case .cpuTime, .memory, .disk, .network, .elapsed: 82 default: 62 } } var maximumWidth: CGFloat { self == .name ? 620 : 300 } var textAlignment: Alignment { switch self { case .name, .user, .state, .architecture: .leading default: .trailing } } var defaultVisible: Bool { [.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("detailVisibleColumnsV2") private var visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",") @AppStorage("detailColumnWidthsV2") private var columnWidthsStorage = "" @State private var terminationRequest: TerminationRequest? @State private var inspectedProcess: ProcessRecord? @State private var columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) }) @AppStorage("detailSortColumn") private var sortColumn: DetailColumn = .pid @AppStorage("detailSortAscending") private var ascending = true 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") { Button("Properties", systemImage: "info.circle") { inspectedProcess = selectedProcess } .disabled(selectedProcess == nil) Menu("Columns", systemImage: "rectangle.split.3x1") { ForEach(DetailColumn.allCases) { column in Toggle(column.title, isOn: columnBinding(column)) .disabled(column == .name || column == .pid) } Divider() Button("Reset columns") { visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",") columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) }) persistColumnWidths() } } } ResourceSummaryBar(snapshot: monitor.snapshot) GeometryReader { proxy in ScrollView([.horizontal, .vertical]) { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { Section { ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in detailRow(process, index: index) } } header: { detailHeader } } .frame(width: max(tableWidth, proxy.size.width), alignment: .leading) } } .frame(maxWidth: .infinity, maxHeight: .infinity) selectionBar } .onAppear(perform: restoreColumnWidths) .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 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: 0) { ForEach(visibleColumns) { column in detailHeaderCell(column) } } .frame(maxWidth: .infinity, alignment: .leading) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) .background(.regularMaterial) .overlay(alignment: .bottom) { Divider() } } private func detailRow(_ process: ProcessRecord, index: Int) -> some View { HStack(spacing: 0) { ForEach(visibleColumns) { column in detailCell(process, column: column) .padding(.horizontal, 10) .frame(width: width(column), alignment: column.textAlignment) .clipped() .overlay(alignment: .trailing) { Divider().opacity(0.18) } } } .frame(maxWidth: .infinity, alignment: .leading) .font(.callout) .frame(minHeight: 42) .background(rowBackground(process: process, index: index)) .contentShape(Rectangle()) .onTapGesture { selection = process.pid } .focusable() .onKeyPress(.return) { selection = process.pid return .handled } .accessibilityElement(children: .combine) .accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user), \(readableState(process.state))") .accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))), \(process.threadCount) threads") .accessibilityAddTraits(selection == process.pid ? .isSelected : []) .accessibilityAction { selection = process.pid } .overlay(alignment: .bottom) { Divider().opacity(0.4) } .contextMenu { ProcessContextMenu( process: process, onTerminate: { terminationRequest = $0 }, onShowDetails: { _ in }, onShowProperties: { inspectedProcess = $0 } ) } } @ViewBuilder private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View { switch column { case .name: HStack(spacing: 8) { ProcessIcon(name: process.displayName, path: process.executablePath) Text(process.displayName).lineLimit(1).truncationMode(.tail) } case .pid: Text("\(process.pid)").monospacedDigit() case .parentPID: Text("\(process.parentPID)").monospacedDigit() case .user: Text(process.user).lineLimit(1) 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(readableState(process.state)) .font(.caption.weight(.medium)) .foregroundStyle(process.state.hasPrefix("R") ? Color.green : Color.secondary) case .elapsed: Text(process.elapsed).monospacedDigit() case .threads: Text("\(process.threadCount)").monospacedDigit() case .handles: Text("\(process.openFileCount)").monospacedDigit() case .architecture: Text(process.architecture) case .priority: Text("\(process.priority)").monospacedDigit() case .nice: Text("\(process.nice)").monospacedDigit() } } private func detailHeaderCell(_ column: DetailColumn) -> some View { HStack(spacing: 0) { Button { if sortColumn == column { ascending.toggle() } else { sortColumn = column; ascending = true } } label: { HStack(spacing: 5) { Text(column.title).lineLimit(1) Spacer(minLength: 4) if sortColumn == column { Image(systemName: ascending ? "chevron.up" : "chevron.down") .font(.caption2.weight(.semibold)) } } .padding(.horizontal, 10) .frame(width: width(column), height: 36, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("Sort by \(column.title)") .accessibilityValue(sortColumn == column ? (ascending ? "Ascending" : "Descending") : "Not sorted") } .frame(width: width(column), height: 36) .overlay(alignment: .trailing) { Divider().opacity(0.45) DetailResizeHandle( width: Binding(get: { width(column) }, set: { columnWidths[column] = $0 }), minimumWidth: column.minimumWidth, maximumWidth: column.maximumWidth, onEnded: persistColumnWidths ) .offset(x: 5) } } private var selectionBar: some View { VStack(spacing: 0) { Divider() HStack(spacing: 10) { if let process = selectedProcess { ProcessIcon(name: process.displayName, path: process.executablePath) Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1) Text("PID \(process.pid)").foregroundStyle(.secondary).monospacedDigit() Text("•").foregroundStyle(.tertiary) Text(Formatters.percent(process.cpu) + " CPU").monospacedDigit() Text("•").foregroundStyle(.tertiary) Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit() Spacer() Button("End task") { terminationRequest = .init(process: process, kind: .normal) } Button("Properties") { inspectedProcess = process }.buttonStyle(.borderedProminent) } else { Text("\(filtered.count) processes").foregroundStyle(.secondary) Spacer() Text("Select a process for actions and live details").foregroundStyle(.tertiary) } } .font(.caption) .padding(.horizontal, 14) .frame(minHeight: 48) } .background(.bar) } private var selectedProcess: ProcessRecord? { guard let selection else { return nil } return monitor.processes.first { $0.pid == selection } } private var tableWidth: CGFloat { visibleColumns.reduce(0) { $0 + width($1) } } private func width(_ column: DetailColumn) -> CGFloat { columnWidths[column] ?? column.defaultWidth } private func rowBackground(process: ProcessRecord, index: Int) -> Color { if selection == process.pid { return Color.accentColor.opacity(0.18) } return index.isMultiple(of: 2) ? Color.clear : Color.secondary.opacity(0.035) } private func readableState(_ state: String) -> String { if state.hasPrefix("R") { return "Running" } if state.hasPrefix("S") { return "Sleeping" } if state.hasPrefix("Z") { return "Zombie" } if state.hasPrefix("T") { return "Stopped" } return state } private func restoreColumnWidths() { guard let data = columnWidthsStorage.data(using: .utf8), let stored = try? JSONDecoder().decode([String: Double].self, from: data) else { return } for column in DetailColumn.allCases { if let value = stored[column.rawValue] { columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, value)) } } } private func persistColumnWidths() { let stored = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0.rawValue, Double(width($0))) }) guard let data = try? JSONEncoder().encode(stored), let value = String(data: data, encoding: .utf8) else { return } columnWidthsStorage = value } private func columnBinding(_ column: DetailColumn) -> Binding { 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) } } } private struct DetailResizeHandle: View { @Binding var width: CGFloat let minimumWidth: CGFloat let maximumWidth: CGFloat let onEnded: () -> Void @State private var startWidth: CGFloat? @State private var proposedWidth: CGFloat? @State private var hovered = false var body: some View { Rectangle() .fill(hovered || startWidth != nil ? Color.accentColor : Color.clear) .frame(width: 2, height: 24) .frame(width: 10, height: 36) .offset(x: (proposedWidth ?? width) - width) .contentShape(Rectangle()) .onHover { value in hovered = value (value ? NSCursor.resizeLeftRight : NSCursor.arrow).set() } .highPriorityGesture( DragGesture(minimumDistance: 0) .onChanged { value in if startWidth == nil { startWidth = width } proposedWidth = min(maximumWidth, max(minimumWidth, (startWidth ?? width) + value.translation.width)) } .onEnded { _ in if let proposedWidth { width = proposedWidth } proposedWidth = nil startWidth = nil onEnded() } ) .help("Drag to resize column") } } struct ServicesView: View { @Environment(SystemMonitor.self) private var monitor let searchText: String 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) } } }