Files
puter/Sources/Puter/SecondaryViews.swift
T

1284 lines
57 KiB
Swift

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<String> = []
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<String>) -> [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<String> = []
var items: [StartupItem] = []
let flags = UInt32(kLSSharedFileListNoUserInteraction | kLSSharedFileListDoNotMountVolumes)
for index in 0..<CFArrayGetCount(snapshot) {
let raw = CFArrayGetValueAtIndex(snapshot, index)
let item = unsafeBitCast(raw, to: LSSharedFileListItem.self)
guard let unmanagedURL = LSSharedFileListItemCopyResolvedURL(item, flags, nil) else { continue }
let url = unmanagedURL.takeRetainedValue() as URL
let path = url.standardizedFileURL.path
guard seen.insert(path).inserted else { continue }
let bundle = Bundle(url: url)
let identifier = bundle?.bundleIdentifier ?? path
let name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String)
?? url.deletingPathExtension().lastPathComponent
items.append(StartupItem(
id: "login:\(path)",
name: name,
publisher: publisherName(label: identifier, command: path),
sourcePath: path,
executablePath: path,
serviceIdentifier: identifier,
kind: .loginItem,
isDirectlyManageable: false,
enabled: true
))
}
return items
}
private static func disabledServices() -> Set<String> {
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<Bool> {
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<T: Comparable>(_ 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)
}
}
}