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 = [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 = [] var seenLibraries: Set = [] var seenFiles: Set = [] 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[..