142 lines
5.0 KiB
Swift
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)
|
|
}
|
|
}
|