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 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) } } }