Initial MacTaskManager release
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum DependencySection: String, CaseIterable, Identifiable {
|
||||
case relationships = "Relationships"
|
||||
case connections = "Connections"
|
||||
case libraries = "Libraries"
|
||||
case files = "Open files"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private struct ProcessDependencySnapshot: Sendable {
|
||||
var connections: [String] = []
|
||||
var libraries: [String] = []
|
||||
var files: [String] = []
|
||||
var handleCount = 0
|
||||
var error: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum ProcessDependencyInspectorController {
|
||||
private static var controllers: [Int32: NSWindowController] = [:]
|
||||
|
||||
static func open(process: ProcessRecord, allProcesses: [ProcessRecord]) {
|
||||
if let controller = controllers[process.pid], let window = controller.window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let view = ProcessDependencyInspectorView(process: process, allProcesses: allProcesses)
|
||||
let window = NSWindow(contentViewController: NSHostingController(rootView: view))
|
||||
window.title = "Analyze dependencies — \(process.displayName)"
|
||||
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||
window.setContentSize(NSSize(width: 780, height: 600))
|
||||
window.minSize = NSSize(width: 660, height: 480)
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
let controller = NSWindowController(window: window)
|
||||
controllers[process.pid] = controller
|
||||
controller.showWindow(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessDependencyInspectorView: View {
|
||||
let process: ProcessRecord
|
||||
let allProcesses: [ProcessRecord]
|
||||
@State private var section: DependencySection = .relationships
|
||||
@State private var snapshot = ProcessDependencySnapshot()
|
||||
@State private var isLoading = true
|
||||
@State private var refreshID = UUID()
|
||||
|
||||
private var processByPID: [Int32: ProcessRecord] {
|
||||
Dictionary(uniqueKeysWithValues: allProcesses.map { ($0.pid, $0) })
|
||||
}
|
||||
|
||||
private var parentChain: [ProcessRecord] {
|
||||
var result: [ProcessRecord] = []
|
||||
var currentPID = process.parentPID
|
||||
var visited: Set<Int32> = [process.pid]
|
||||
while currentPID > 0, !visited.contains(currentPID), let parent = processByPID[currentPID] {
|
||||
result.append(parent)
|
||||
visited.insert(currentPID)
|
||||
currentPID = parent.parentPID
|
||||
}
|
||||
return result.reversed()
|
||||
}
|
||||
|
||||
private var children: [ProcessRecord] {
|
||||
allProcesses.filter { $0.parentPID == process.pid }.sorted { $0.displayName < $1.displayName }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
Picker("Dependency category", selection: $section) {
|
||||
ForEach(DependencySection.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Group {
|
||||
switch section {
|
||||
case .relationships: relationships
|
||||
case .connections: itemList(snapshot.connections, icon: "network", empty: "No open network or local socket connections")
|
||||
case .libraries: itemList(snapshot.libraries, icon: "shippingbox", empty: "No loaded libraries could be read")
|
||||
case .files: itemList(snapshot.files, icon: "doc", empty: "No open regular files could be read")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.task(id: refreshID) { await refresh() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 14) {
|
||||
HStack(spacing: 14) {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
.scaleEffect(1.25)
|
||||
.frame(width: 40, height: 40)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Analyze dependencies").font(.title2.weight(.semibold))
|
||||
Text("\(process.displayName) • PID \(process.pid)").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if isLoading { ProgressView().controlSize(.small) }
|
||||
Button("Refresh", systemImage: "arrow.clockwise") { refreshID = UUID() }
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
stat("Parents", "\(parentChain.count)", "arrow.up.left")
|
||||
stat("Children", "\(children.count)", "arrow.down.right")
|
||||
stat("Connections", "\(snapshot.connections.count)", "network")
|
||||
stat("Open handles", "\(snapshot.handleCount)", "link")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private func stat(_ title: String, _ value: String, _ icon: String) -> some View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: icon).foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(value).font(.headline.monospacedDigit())
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.65))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var relationships: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
relationshipSection("Parent chain", items: parentChain, icon: "arrow.turn.down.right")
|
||||
relationshipSection("Selected process", items: [process], icon: "scope", highlighted: true)
|
||||
relationshipSection("Direct children", items: children, icon: "arrow.turn.down.right")
|
||||
if let error = snapshot.error {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func relationshipSection(_ title: String, items: [ProcessRecord], icon: String, highlighted: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title).font(.headline)
|
||||
if items.isEmpty {
|
||||
Text("None").foregroundStyle(.secondary).padding(.vertical, 8)
|
||||
} else {
|
||||
ForEach(items) { item in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon).foregroundStyle(highlighted ? Color.accentColor : Color.secondary)
|
||||
ProcessIcon(name: item.displayName, path: item.executablePath)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.displayName).fontWeight(highlighted ? .semibold : .regular)
|
||||
Text(item.executablePath).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text("PID \(item.pid)").monospacedDigit().foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(highlighted ? Color.accentColor.opacity(0.12) : Color(nsColor: .controlBackgroundColor).opacity(0.45))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func itemList(_ items: [String], icon: String, empty: String) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if isLoading && items.isEmpty {
|
||||
ProgressView("Reading process dependencies…").padding(30)
|
||||
} else if items.isEmpty {
|
||||
EmptyState(icon: icon, title: empty, message: snapshot.error ?? "The process may not expose this information to the current user.")
|
||||
.padding(.top, 50)
|
||||
} else {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon).foregroundStyle(.secondary).frame(width: 20)
|
||||
Text(item).textSelection(.enabled).lineLimit(2)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.vertical, 9)
|
||||
Divider().padding(.leading, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() async {
|
||||
isLoading = true
|
||||
snapshot = await Task.detached(priority: .userInitiated) {
|
||||
ProcessDependencyScanner.capture(pid: process.pid)
|
||||
}.value
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private enum ProcessDependencyScanner {
|
||||
static func capture(pid: Int32) -> ProcessDependencySnapshot {
|
||||
let task = Process()
|
||||
let output = Pipe()
|
||||
let errors = Pipe()
|
||||
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
|
||||
task.arguments = ["-n", "-P", "-a", "-p", String(pid)]
|
||||
task.standardOutput = output
|
||||
task.standardError = errors
|
||||
do {
|
||||
try task.run()
|
||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorData = errors.fileHandleForReading.readDataToEndOfFile()
|
||||
task.waitUntilExit()
|
||||
let text = String(decoding: data, as: UTF8.self)
|
||||
var result = parse(text)
|
||||
if task.terminationStatus != 0 || text.isEmpty {
|
||||
let message = String(decoding: errorData, as: UTF8.self)
|
||||
.split(separator: "\n")
|
||||
.first(where: { !$0.contains("WARNING") })
|
||||
.map(String.init)
|
||||
result.error = message ?? "Process information is unavailable or permission was denied."
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return ProcessDependencySnapshot(error: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parse(_ output: String) -> ProcessDependencySnapshot {
|
||||
var result = ProcessDependencySnapshot()
|
||||
var seenConnections: Set<String> = []
|
||||
var seenLibraries: Set<String> = []
|
||||
var seenFiles: Set<String> = []
|
||||
for line in output.split(separator: "\n").dropFirst() {
|
||||
let fields = line.split(maxSplits: 8, whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
guard fields.count == 9 else { continue }
|
||||
result.handleCount += 1
|
||||
let type = fields[4]
|
||||
let name = fields[8]
|
||||
if ["IPv4", "IPv6", "unix"].contains(type) {
|
||||
seenConnections.insert(name)
|
||||
} else if type == "REG", name.hasPrefix("/") {
|
||||
if let library = libraryIdentity(for: name) {
|
||||
seenLibraries.insert(library)
|
||||
} else {
|
||||
seenFiles.insert(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.connections = seenConnections.sorted()
|
||||
result.libraries = seenLibraries.sorted()
|
||||
result.files = seenFiles.sorted()
|
||||
return result
|
||||
}
|
||||
|
||||
private static func libraryIdentity(for path: String) -> String? {
|
||||
let lower = path.lowercased()
|
||||
if lower.hasSuffix(".dylib") { return path }
|
||||
guard let range = lower.range(of: ".framework/") else {
|
||||
return lower.hasSuffix(".framework") ? path : nil
|
||||
}
|
||||
return String(path[..<range.lowerBound]) + ".framework"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user