Files
meetingnotes/meetingnotes/ProcessTap/AudioProcessController.swift
T

240 lines
7.2 KiB
Swift

import SwiftUI
import AudioToolbox
import OSLog
import Combine
struct AudioProcess: Identifiable, Hashable, Sendable {
enum Kind: String, Sendable {
case process
case app
}
var id: pid_t
var kind: Kind
var name: String
var audioActive: Bool
var bundleID: String?
var bundleURL: URL?
var objectID: AudioObjectID
}
struct AudioProcessGroup: Identifiable, Hashable, Sendable {
var id: String
var title: String
var processes: [AudioProcess]
}
extension AudioProcess.Kind {
var defaultIcon: NSImage {
switch self {
case .process: NSWorkspace.shared.icon(for: .unixExecutable)
case .app: NSWorkspace.shared.icon(for: .applicationBundle)
}
}
}
extension AudioProcess {
var icon: NSImage {
guard let bundleURL else { return kind.defaultIcon }
let image = NSWorkspace.shared.icon(forFile: bundleURL.path)
image.size = NSSize(width: 32, height: 32)
return image
}
}
extension String: @retroactive LocalizedError {
public var errorDescription: String? { self }
}
@MainActor
@Observable
final class AudioProcessController {
private let logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: String(describing: AudioProcessController.self))
private(set) var processes = [AudioProcess]() {
didSet {
guard processes != oldValue else { return }
processGroups = AudioProcessGroup.groups(with: processes)
}
}
private(set) var processGroups = [AudioProcessGroup]()
private var cancellables = Set<AnyCancellable>()
func activate() {
logger.debug(#function)
NSWorkspace.shared
.publisher(for: \.runningApplications, options: [.initial, .new])
.map { $0.filter({ $0.processIdentifier != ProcessInfo.processInfo.processIdentifier }) }
.sink { [weak self] apps in
guard let self else { return }
self.reload(apps: apps)
}
.store(in: &cancellables)
}
fileprivate func reload(apps: [NSRunningApplication]) {
logger.debug(#function)
do {
let objectIdentifiers = try AudioObjectID.readProcessList()
let updatedProcesses: [AudioProcess] = objectIdentifiers.compactMap { objectID in
do {
let proc = try AudioProcess(objectID: objectID, runningApplications: apps)
#if DEBUG
if UserDefaults.standard.bool(forKey: "ACDumpProcessInfo") {
logger.debug("[PROCESS] \(String(describing: proc))")
}
#endif
return proc
} catch {
logger.warning("Failed to initialize process with object ID #\(objectID, privacy: .public): \(error, privacy: .public)")
return nil
}
}
self.processes = updatedProcesses
.sorted {
if $0.name.localizedStandardCompare($1.name) == .orderedAscending {
$1.audioActive && !$0.audioActive ? false : true
} else {
$0.audioActive && !$1.audioActive ? true : false
}
}
} catch {
logger.error("Error reading process list: \(error, privacy: .public)")
}
}
}
private extension AudioProcess {
init(app: NSRunningApplication, objectID: AudioObjectID) {
let name = app.localizedName ?? app.bundleURL?.deletingPathExtension().lastPathComponent ?? app.bundleIdentifier?.components(separatedBy: ".").last ?? "Unknown \(app.processIdentifier)"
self.init(
id: app.processIdentifier,
kind: .app,
name: name,
audioActive: objectID.readProcessIsRunning(),
bundleID: app.bundleIdentifier,
bundleURL: app.bundleURL,
objectID: objectID
)
}
init(objectID: AudioObjectID, runningApplications apps: [NSRunningApplication]) throws {
let pid: pid_t = try objectID.read(kAudioProcessPropertyPID, defaultValue: -1)
if let app = apps.first(where: { $0.processIdentifier == pid }) {
self.init(app: app, objectID: objectID)
} else {
try self.init(objectID: objectID, pid: pid)
}
}
init(objectID: AudioObjectID, pid: pid_t) throws {
let bundleID = objectID.readProcessBundleID()
let bundleURL: URL?
let name: String
(name, bundleURL) = if let info = processInfo(for: pid) {
(info.name, URL(fileURLWithPath: info.path).parentBundleURL())
} else if let id = bundleID?.lastReverseDNSComponent {
(id, nil)
} else {
("Unknown (\(pid))", nil)
}
self.init(
id: pid,
kind: bundleURL?.isApp == true ? .app : .process,
name: name,
audioActive: objectID.readProcessIsRunning(),
bundleID: bundleID.flatMap { $0.isEmpty ? nil : $0 },
bundleURL: bundleURL,
objectID: objectID
)
}
}
extension AudioProcessGroup {
static func groups(with processes: [AudioProcess]) -> [AudioProcessGroup] {
var byKind = [AudioProcess.Kind: AudioProcessGroup]()
for process in processes {
byKind[process.kind, default: .init(for: process.kind)].processes.append(process)
}
return byKind.values.sorted(by: { $0.title.localizedStandardCompare($1.title) == .orderedAscending })
}
}
extension AudioProcessGroup {
init(for kind: AudioProcess.Kind) {
self.init(id: kind.rawValue, title: kind.groupTitle, processes: [])
}
}
extension AudioProcess.Kind {
var groupTitle: String {
switch self {
case .process: "Processes"
case .app: "Apps"
}
}
}
private func processInfo(for pid: pid_t) -> (name: String, path: String)? {
let nameBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
let pathBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
defer {
nameBuffer.deallocate()
pathBuffer.deallocate()
}
let nameLength = proc_name(pid, nameBuffer, UInt32(MAXPATHLEN))
let pathLength = proc_pidpath(pid, pathBuffer, UInt32(MAXPATHLEN))
guard nameLength > 0, pathLength > 0 else {
return nil
}
let name = String(cString: nameBuffer)
let path = String(cString: pathBuffer)
return (name, path)
}
private extension String {
var lastReverseDNSComponent: String? {
components(separatedBy: ".").last.flatMap { $0.isEmpty ? nil : $0 }
}
}
private extension URL {
func parentBundleURL(maxDepth: Int = 8) -> URL? {
var depth = 0
var url = deletingLastPathComponent()
while depth < maxDepth, !url.isBundle {
url = url.deletingLastPathComponent()
depth += 1
}
return url.isBundle ? url : nil
}
var isBundle: Bool {
(try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .bundle) == true
}
var isApp: Bool {
(try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .application) == true
}
}