Files
puter/Sources/Puter/ProcessDependencyInspector.swift
T

280 lines
12 KiB
Swift

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