import Foundation /// A cooperative cancellation token shared between a job and the external /// processes it spawns. Converters run off the main thread, so every mutation /// is lock protected. final class Cancellation { private let lock = NSLock() private var cancelled = false private var processes: [Process] = [] var isCancelled: Bool { lock.lock() defer { lock.unlock() } return cancelled } func cancel() { lock.lock() cancelled = true let running = processes lock.unlock() for process in running where process.isRunning { process.terminate() } } func register(_ process: Process) { lock.lock() let alreadyCancelled = cancelled if !alreadyCancelled { processes.append(process) } lock.unlock() if alreadyCancelled, process.isRunning { process.terminate() } } func check() throws { if isCancelled { throw CancellationError() } } } struct ShellError: LocalizedError { let command: String let status: Int32 let stderr: String var errorDescription: String? { let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) if detail.isEmpty { return "\(command) exited with status \(status)." } // External tools can be very chatty; keep the first meaningful line. let line = detail .split(separator: "\n") .map { $0.trimmingCharacters(in: .whitespaces) } .last { !$0.isEmpty } ?? detail return "\(command): \(line.prefix(400))" } } /// Thin wrapper around `Process` with deadlock-free pipe draining. enum Shell { struct Result { let status: Int32 let stdout: String let stderr: String } private final class Box { var value: T init(_ value: T) { self.value = value } } /// Locate an executable by name, searching `extraPaths` first and then /// `$PATH`. Returns the last match so Homebrew's copies win over system /// stubs (e.g. the `/usr/bin/ffmpeg` shim that only opens the App Store). static func find(_ name: String, extraPaths: [String] = []) -> String? { var search = extraPaths if let path = ProcessInfo.processInfo.environment["PATH"] { search += path.split(separator: ":").map(String.init) } var found: String? for directory in search { let full = directory.hasSuffix("/") ? directory + name : directory + "/" + name if FileManager.default.isExecutableFile(atPath: full) { found = full } } return found } @discardableResult static func run( _ executable: String, _ arguments: [String], currentDirectory: URL? = nil, cancellation: Cancellation? = nil ) throws -> Result { try cancellation?.check() let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments if let currentDirectory { process.currentDirectoryURL = currentDirectory } let outPipe = Pipe() let errPipe = Pipe() process.standardOutput = outPipe process.standardError = errPipe process.standardInput = FileHandle.nullDevice do { try process.run() } catch { throw ShellError( command: (arguments.first ?? executable), status: -1, stderr: error.localizedDescription ) } cancellation?.register(process) // Drain both pipes on separate queues: reading them in sequence can // deadlock when a tool fills the buffer we are not reading yet. let outBox = Box(Data()) let errBox = Box(Data()) let group = DispatchGroup() DispatchQueue.global(qos: .utility).async(group: group) { outBox.value = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() } DispatchQueue.global(qos: .utility).async(group: group) { errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data() } group.wait() process.waitUntilExit() let result = Result( status: process.terminationStatus, stdout: String(data: outBox.value, encoding: .utf8) ?? "", stderr: String(data: errBox.value, encoding: .utf8) ?? "" ) try cancellation?.check() if result.status != 0 { throw ShellError( command: (arguments.first ?? executable), status: result.status, stderr: result.stderr.isEmpty ? result.stdout : result.stderr ) } return result } /// Best-effort variant used for optional probes (`ffprobe`, `mdls`, …). static func output(_ executable: String, _ arguments: [String]) -> String? { (try? run(executable, arguments))?.stdout } /// Runs a tool whose stdout is binary (compressed streams) and returns it. static func runData( _ executable: String, _ arguments: [String], cancellation: Cancellation? = nil ) throws -> Data { try cancellation?.check() let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments let outPipe = Pipe() let errPipe = Pipe() process.standardOutput = outPipe process.standardError = errPipe process.standardInput = FileHandle.nullDevice do { try process.run() } catch { throw ShellError(command: arguments.first ?? executable, status: -1, stderr: error.localizedDescription) } cancellation?.register(process) let outBox = Box(Data()) let errBox = Box(Data()) let group = DispatchGroup() DispatchQueue.global(qos: .utility).async(group: group) { outBox.value = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() } DispatchQueue.global(qos: .utility).async(group: group) { errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data() } group.wait() process.waitUntilExit() let stderr = String(data: errBox.value, encoding: .utf8) ?? "" try cancellation?.check() if process.terminationStatus != 0 { throw ShellError(command: arguments.first ?? executable, status: process.terminationStatus, stderr: stderr) } return outBox.value } /// Runs a tool and reports progress parsed from `key=value` lines on /// stdout (what `ffmpeg -progress pipe:1` emits). static func runReportingProgress( _ executable: String, _ arguments: [String], cancellation: Cancellation? = nil, onProgress: @escaping (Double, String) -> Void ) throws { try cancellation?.check() let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments let outPipe = Pipe() let errPipe = Pipe() process.standardOutput = outPipe process.standardError = errPipe process.standardInput = FileHandle.nullDevice let errBox = Box(Data()) DispatchQueue.global(qos: .utility).async { errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data() } let buffer = Box(Data()) let lock = NSLock() outPipe.fileHandleForReading.readabilityHandler = { handle in let chunk = handle.availableData guard !chunk.isEmpty else { return } lock.lock() buffer.value.append(chunk) let text = String(data: buffer.value, encoding: .utf8) ?? "" var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) let remainder = lines.popLast() ?? "" buffer.value = Data(remainder.utf8) lock.unlock() for line in lines { onProgress(0, line) } } do { try process.run() } catch { outPipe.fileHandleForReading.readabilityHandler = nil throw ShellError(command: arguments.first ?? executable, status: -1, stderr: error.localizedDescription) } cancellation?.register(process) process.waitUntilExit() outPipe.fileHandleForReading.readabilityHandler = nil let stderr = String(data: errBox.value, encoding: .utf8) ?? "" try cancellation?.check() if process.terminationStatus != 0 { throw ShellError(command: arguments.first ?? executable, status: process.terminationStatus, stderr: stderr) } } }