1091 lines
48 KiB
Swift
1091 lines
48 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 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<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 {
|
|
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<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 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<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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|