import Foundation @MainActor final class RsyncRunner: ObservableObject { @Published private(set) var isRunning = false @Published private(set) var currentJobID: UUID? private var process: Process? struct Result { let exitCode: Int32 let stdout: String let stderr: String let wasCancelled: Bool } func run(job: SyncJob, dryRun: Bool, trigger: SyncRunTrigger = .manual) async throws -> Result { guard !isRunning else { throw RunnerError.alreadyRunning } if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: trigger) { throw RunnerError.securityValidation(message) } let executable = RsyncLocator.locate() guard RsyncLocator.isTrustedExecutableCandidate(executable) else { throw RunnerError.invalidExecutable(executable) } let arguments = RsyncCommandBuilder.build(job: job, dryRun: dryRun, executable: executable) isRunning = true currentJobID = job.id defer { isRunning = false currentJobID = nil process = nil } let process = Process() self.process = process process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe // Keep rsync's potentially very chatty output off the MainActor. Updating a // SwiftUI String for every rsync progress chunk makes the whole window jerk. // These locked buffers collect every byte for history/export without causing // any UI invalidation while the transfer is running. let stdoutBuffer = LockedDataBuffer() let stderrBuffer = LockedDataBuffer() stdoutPipe.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData guard !data.isEmpty else { return } stdoutBuffer.append(data) } stderrPipe.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData guard !data.isEmpty else { return } stderrBuffer.append(data) } return try await withCheckedThrowingContinuation { continuation in process.terminationHandler = { proc in stdoutPipe.fileHandleForReading.readabilityHandler = nil stderrPipe.fileHandleForReading.readabilityHandler = nil // Consume anything left between the final readability callback and EOF. let remainingOut = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let remainingErr = stderrPipe.fileHandleForReading.readDataToEndOfFile() if !remainingOut.isEmpty { stdoutBuffer.append(remainingOut) } if !remainingErr.isEmpty { stderrBuffer.append(remainingErr) } continuation.resume(returning: Result( exitCode: proc.terminationStatus, stdout: stdoutBuffer.stringValue, stderr: stderrBuffer.stringValue, wasCancelled: proc.terminationReason == .uncaughtSignal )) } do { try process.run() } catch { stdoutPipe.fileHandleForReading.readabilityHandler = nil stderrPipe.fileHandleForReading.readabilityHandler = nil continuation.resume(throwing: error) } } } private func remoteDestinationLooksLikeHost(_ job: SyncJob) -> Bool { let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines) return destination == host || destination == "\(job.remoteUser)@\(host)" } func cancel() { guard let process, process.isRunning else { return } process.interrupt() } enum RunnerError: LocalizedError { case alreadyRunning case securityValidation(String) case invalidExecutable(String) var errorDescription: String? { switch self { case .alreadyRunning: return "Another rsync job is already running." case .securityValidation(let message): return message case .invalidExecutable(let path): return "The rsync executable at \(path) is unavailable or is not a trusted regular executable file." } } } } private final class LockedDataBuffer: @unchecked Sendable { private let lock = NSLock() private var data = Data() func append(_ newData: Data) { lock.lock() data.append(newData) lock.unlock() } var stringValue: String { lock.lock() let snapshot = data lock.unlock() return String(decoding: snapshot, as: UTF8.self) } }