Files
2026-09-08 20:06:58 +01:00

261 lines
11 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
let activityToken = ProcessInfo.processInfo.beginActivity(
options: [.userInitiated, .idleSystemSleepDisabled],
reason: "Rsync Studio is synchronising \(job.name)"
)
defer {
ProcessInfo.processInfo.endActivity(activityToken)
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 preflight(job: SyncJob) async -> JobPreflightReport {
var checks: [JobPreflightCheck] = []
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: .manual) {
checks.append(.init(title: "Configuration", detail: message, severity: .error))
return JobPreflightReport(checks: checks)
}
checks.append(.init(title: "Configuration", detail: "Security and argument validation passed.", severity: .success))
let executable = RsyncLocator.locate()
if RsyncLocator.isTrustedExecutableCandidate(executable) {
checks.append(.init(title: "Rsync", detail: executable, severity: .success))
} else {
checks.append(.init(title: "Rsync", detail: "No trusted rsync executable is available.", severity: .error))
}
if job.isRemote {
let ssh = "/usr/bin/ssh"
guard FileManager.default.isExecutableFile(atPath: ssh) else {
checks.append(.init(title: "SSH", detail: "The system SSH client is unavailable.", severity: .error))
return JobPreflightReport(checks: checks)
}
let userHost = job.remoteUser.isEmpty ? job.remoteHost : "\(job.remoteUser)@\(job.remoteHost)"
var args = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=1", "-p", String(job.remotePort)]
let key = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath)
if !key.isEmpty { args += ["-o", "IdentitiesOnly=yes", "-i", key] }
let remotePath = RsyncCommandBuilder.shellEscape(job.destinationPath)
args += [userHost, "command -v rsync >/dev/null 2>&1 && test -d \(remotePath)"]
let result = await Self.runProbe(executable: ssh, arguments: args)
if result == 0 {
checks.append(.init(title: "Remote connection", detail: "SSH authentication, remote rsync and destination directory are available.", severity: .success))
} else {
checks.append(.init(title: "Remote connection", detail: "Could not authenticate, find remote rsync, or access the destination directory.", severity: .error))
}
} else {
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: (job.sourcePath as NSString).expandingTildeInPath, isDirectory: &isDir) {
checks.append(.init(title: "Source", detail: "Source exists and is reachable.", severity: .success))
} else {
checks.append(.init(title: "Source", detail: "Source does not exist or is not reachable.", severity: .error))
}
let destination = (job.destinationPath as NSString).expandingTildeInPath
let volume = SyncVolumeInspector.info(forPath: destination)
if let volume {
checks.append(.init(title: "Destination volume", detail: "\(volume.name) · \(volume.freeSpaceDescription) free", severity: volume.freeBytes >= Int64(job.minimumFreeSpaceMB) * 1_048_576 ? .success : .error))
if job.requireExpectedVolume && !job.expectedVolumeIdentifier.isEmpty && volume.identifier != job.expectedVolumeIdentifier {
checks.append(.init(title: "Volume identity", detail: "Expected \(job.expectedVolumeIdentifier), found \(volume.identifier).", severity: .error))
} else if job.requireExpectedVolume {
checks.append(.init(title: "Volume identity", detail: "Expected destination volume is mounted.", severity: .success))
}
} else {
checks.append(.init(title: "Destination volume", detail: "Could not identify the destination volume.", severity: .error))
}
}
return JobPreflightReport(checks: checks)
}
private static func runProbe(executable: String, arguments: [String]) async -> Int32 {
await withCheckedContinuation { continuation in
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) }
do { try process.run() } catch { continuation.resume(returning: -1) }
}
}
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)
}
}
enum JobPreflightSeverity: Int, Comparable {
case success = 0, warning = 1, error = 2
static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue }
}
struct JobPreflightCheck: Identifiable {
let id = UUID()
let title: String
let detail: String
let severity: JobPreflightSeverity
}
struct JobPreflightReport {
let checks: [JobPreflightCheck]
var severity: JobPreflightSeverity { checks.map(\.severity).max() ?? .success }
var passed: Bool { severity != .error }
var title: String { passed ? (severity == .warning ? "Ready with warnings" : "Ready") : "Needs attention" }
}
struct SyncVolumeInfo {
let identifier: String
let name: String
let freeBytes: Int64
var freeSpaceDescription: String { ByteCountFormatter.string(fromByteCount: freeBytes, countStyle: .file) }
}
enum SyncVolumeInspector {
static func info(forPath path: String) -> SyncVolumeInfo? {
var url = URL(fileURLWithPath: path)
while !FileManager.default.fileExists(atPath: url.path), url.path != "/" { url.deleteLastPathComponent() }
do {
let values = try url.resourceValues(forKeys: [.volumeIdentifierKey, .volumeNameKey, .volumeAvailableCapacityForImportantUsageKey])
guard let rawID = values.volumeIdentifier else { return nil }
return SyncVolumeInfo(
identifier: String(describing: rawID),
name: values.volumeName ?? url.path,
freeBytes: values.volumeAvailableCapacityForImportantUsage ?? 0
)
} catch { return nil }
}
}