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) async throws -> Result { guard !isRunning else { throw RunnerError.alreadyRunning } guard !job.sourcePath.isEmpty, !job.destinationPath.isEmpty else { throw RunnerError.invalidPaths } if job.isRemote && job.remoteHost.isEmpty { throw RunnerError.missingRemoteHost } if job.isRemote && remoteDestinationLooksLikeHost(job) { throw RunnerError.invalidRemoteDestination } if job.isRemote && job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".pub") { let privatePath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath) if privatePath.hasSuffix(".pub") { throw RunnerError.publicSSHKeySelected } } let executable = RsyncLocator.locate() 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, invalidPaths, missingRemoteHost, invalidRemoteDestination, publicSSHKeySelected var errorDescription: String? { switch self { case .alreadyRunning: return "Another rsync job is already running." case .invalidPaths: return "Source and destination are required." case .missingRemoteHost: return "Remote host is required for an SSH job." case .invalidRemoteDestination: return "Remote destination must be a path on the remote machine, for example /home/mag/backup or ~/backup — not the host address." case .publicSSHKeySelected: return "The SSH key must be the private key (for example ~/.ssh/id_rsa or ~/.ssh/id_ed25519), not the .pub 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) } }