import Foundation /// Thread-safe String-Accumulator. readabilityHandler werden auf einem /// background-thread aufgerufen, daher dürfen captured-vars nicht direkt /// mutiert werden (Swift-6-concurrency-rule). final class LineBuffer: @unchecked Sendable { private var _value = "" private let lock = NSLock() func append(_ s: String) { lock.lock(); defer { lock.unlock() } _value += s } var value: String { lock.lock(); defer { lock.unlock() } return _value } } enum ProcessRunnerError: Error, LocalizedError { case binaryNotFound(String) case nonZeroExit(Int32, String) var errorDescription: String? { switch self { case .binaryNotFound(let path): return "Binary nicht gefunden: \(path)" case .nonZeroExit(let code, let out): return "Exit \(code): \(out)" } } } /// Spawnt einen child-process und sammelt stdout+stderr. /// Streamt zeilenweise via AsyncStream wenn `stream: true` — sonst nur Final-Result. enum ProcessRunner { struct Result { let exitCode: Int32 let stdout: String let stderr: String } static func run( _ executable: String, arguments: [String] = [], environment: [String: String]? = nil, currentDirectoryPath: String? = nil ) async throws -> Result { guard FileManager.default.isExecutableFile(atPath: executable) else { throw ProcessRunnerError.binaryNotFound(executable) } let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments if let environment = environment { process.environment = environment } if let cwd = currentDirectoryPath { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } let outPipe = Pipe() let errPipe = Pipe() process.standardOutput = outPipe process.standardError = errPipe try process.run() process.waitUntilExit() let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" return Result(exitCode: process.terminationStatus, stdout: stdout, stderr: stderr) } /// Streamt stdout zeilenweise. Caller bekommt jeden Line via onLine-Callback. /// stderr wird parallel gesammelt + im Final-Result returned. @MainActor static func stream( _ executable: String, arguments: [String] = [], environment: [String: String]? = nil, onLine: @escaping (String) -> Void ) async throws -> Result { guard FileManager.default.isExecutableFile(atPath: executable) else { throw ProcessRunnerError.binaryNotFound(executable) } let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments if let environment = environment { process.environment = environment } let outPipe = Pipe() let errPipe = Pipe() process.standardOutput = outPipe process.standardError = errPipe let stdoutBuf = LineBuffer() let stderrBuf = LineBuffer() let outHandle = outPipe.fileHandleForReading let errHandle = errPipe.fileHandleForReading outHandle.readabilityHandler = { handle in let data = handle.availableData guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return } stdoutBuf.append(chunk) for line in chunk.split(separator: "\n", omittingEmptySubsequences: false) { let s = String(line) if !s.isEmpty { Task { @MainActor in onLine(s) } } } } errHandle.readabilityHandler = { handle in let data = handle.availableData guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return } stderrBuf.append(chunk) for line in chunk.split(separator: "\n", omittingEmptySubsequences: false) { let s = String(line) if !s.isEmpty { Task { @MainActor in onLine("[stderr] " + s) } } } } try process.run() await withCheckedContinuation { (continuation: CheckedContinuation) in process.terminationHandler = { _ in continuation.resume() } } outHandle.readabilityHandler = nil errHandle.readabilityHandler = nil return Result(exitCode: process.terminationStatus, stdout: stdoutBuf.value, stderr: stderrBuf.value) } }