Initial Commit
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user