Files

142 lines
5.0 KiB
Swift

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)
}
}