Initial Commit
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class BackgroundSyncManager: ObservableObject {
|
||||
enum Activity: Equatable {
|
||||
case idle
|
||||
case waiting(String)
|
||||
case running(String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .idle: return "Idle"
|
||||
case .waiting(let name): return "Waiting · \(name)"
|
||||
case .running(let name): return "Running · \(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var activity: Activity = .idle
|
||||
@Published private(set) var watchedJobCount = 0
|
||||
@Published private(set) var scheduledJobCount = 0
|
||||
@Published private(set) var isSyncAllRunning = false
|
||||
@Published private(set) var syncAllCompleted = 0
|
||||
@Published private(set) var syncAllTotal = 0
|
||||
|
||||
var activityDisplayTitle: String {
|
||||
if isSyncAllRunning {
|
||||
return "Sync All · \(min(syncAllCompleted + 1, syncAllTotal)) of \(syncAllTotal)"
|
||||
}
|
||||
return activity.title
|
||||
}
|
||||
|
||||
private let modelContext: ModelContext
|
||||
private let runner: RsyncRunner
|
||||
private let watcher = FileWatchService()
|
||||
private var timer: Timer?
|
||||
private var pending: [(UUID, SyncRunTrigger)] = []
|
||||
private var pendingIDs = Set<UUID>()
|
||||
private var isProcessingQueue = false
|
||||
private var watchDebounceTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var started = false
|
||||
private var notificationToken: NSObjectProtocol?
|
||||
|
||||
init(modelContext: ModelContext, runner: RsyncRunner) {
|
||||
self.modelContext = modelContext
|
||||
self.runner = runner
|
||||
watcher.onChange = { [weak self] jobID, path in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.fileChanged(jobID: jobID, path: path)
|
||||
}
|
||||
}
|
||||
notificationToken = NotificationCenter.default.addObserver(
|
||||
forName: .syncJobsChanged,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.refreshConfiguration() }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let notificationToken { NotificationCenter.default.removeObserver(notificationToken) }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
refreshConfiguration()
|
||||
checkSchedules()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.refreshConfiguration()
|
||||
self?.checkSchedules()
|
||||
self?.processQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
watcher.stop()
|
||||
watchDebounceTasks.values.forEach { $0.cancel() }
|
||||
watchDebounceTasks.removeAll()
|
||||
started = false
|
||||
}
|
||||
|
||||
func syncAllEnabledJobs() {
|
||||
guard !isSyncAllRunning else { return }
|
||||
|
||||
let enabledJobs = fetchJobs().filter(\.isEnabled)
|
||||
syncAllCompleted = 0
|
||||
syncAllTotal = 0
|
||||
|
||||
for job in enabledJobs {
|
||||
if enqueue(jobID: job.id, trigger: .syncAll) {
|
||||
syncAllTotal += 1
|
||||
}
|
||||
}
|
||||
|
||||
isSyncAllRunning = syncAllTotal > 0
|
||||
if !isSyncAllRunning {
|
||||
syncAllCompleted = 0
|
||||
}
|
||||
}
|
||||
|
||||
func refreshConfiguration() {
|
||||
let jobs = fetchJobs()
|
||||
scheduledJobCount = jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual }.count
|
||||
watchedJobCount = jobs.filter { $0.isEnabled && $0.watchEnabled }.count
|
||||
|
||||
if UserDefaults.standard.object(forKey: "fileWatchingEnabled") as? Bool ?? true {
|
||||
watcher.update(jobs: jobs)
|
||||
} else {
|
||||
watcher.update(jobs: [])
|
||||
}
|
||||
|
||||
for job in jobs where job.scheduleEnabled && job.scheduleKind != .manual && job.nextRunAt == nil {
|
||||
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
private func checkSchedules() {
|
||||
guard UserDefaults.standard.object(forKey: "backgroundSchedulesEnabled") as? Bool ?? true else { return }
|
||||
let now = Date()
|
||||
for job in fetchJobs() where job.isEnabled && job.scheduleEnabled && job.scheduleKind != .manual {
|
||||
guard let next = job.nextRunAt, next <= now else { continue }
|
||||
enqueue(jobID: job.id, trigger: .scheduled)
|
||||
|
||||
if job.scheduleKind == .once {
|
||||
job.scheduleEnabled = false
|
||||
job.nextRunAt = nil
|
||||
} else {
|
||||
job.nextRunAt = job.calculateNextRun(after: now)
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
private func fileChanged(jobID: UUID, path: String) {
|
||||
guard UserDefaults.standard.object(forKey: "fileWatchingEnabled") as? Bool ?? true else { return }
|
||||
guard let job = fetchJob(id: jobID), job.isEnabled, job.watchEnabled else { return }
|
||||
job.lastWatchEventAt = Date()
|
||||
watchDebounceTasks[jobID]?.cancel()
|
||||
let delay = max(1, job.watchDebounceSeconds)
|
||||
watchDebounceTasks[jobID] = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
_ = self.enqueue(jobID: jobID, trigger: .watched)
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func enqueue(jobID: UUID, trigger: SyncRunTrigger) -> Bool {
|
||||
guard !pendingIDs.contains(jobID), runner.currentJobID != jobID else { return false }
|
||||
pending.append((jobID, trigger))
|
||||
pendingIDs.insert(jobID)
|
||||
processQueue()
|
||||
return true
|
||||
}
|
||||
|
||||
private func processQueue() {
|
||||
guard !isProcessingQueue, !runner.isRunning, !pending.isEmpty else {
|
||||
if (isProcessingQueue || runner.isRunning), let first = pending.first, let job = fetchJob(id: first.0) {
|
||||
activity = .waiting(job.name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let item = pending.removeFirst()
|
||||
pendingIDs.remove(item.0)
|
||||
guard let job = fetchJob(id: item.0), job.isEnabled else {
|
||||
finishSyncAllItemIfNeeded(item.1)
|
||||
processQueue()
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingQueue = true
|
||||
activity = .running(job.name)
|
||||
Task { [weak self] in
|
||||
await self?.execute(job: job, trigger: item.1)
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.finishSyncAllItemIfNeeded(item.1)
|
||||
self.isProcessingQueue = false
|
||||
self.activity = .idle
|
||||
self.processQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishSyncAllItemIfNeeded(_ trigger: SyncRunTrigger) {
|
||||
switch trigger {
|
||||
case .syncAll:
|
||||
syncAllCompleted += 1
|
||||
if syncAllCompleted >= syncAllTotal {
|
||||
isSyncAllRunning = false
|
||||
}
|
||||
case .manual, .scheduled, .watched:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func execute(job: SyncJob, trigger: SyncRunTrigger) async {
|
||||
let executable = RsyncLocator.locate()
|
||||
let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: false, executable: executable)
|
||||
let run = SyncRun(job: job, command: command, wasDryRun: false, trigger: trigger)
|
||||
modelContext.insert(run)
|
||||
try? modelContext.save()
|
||||
|
||||
do {
|
||||
let result = try await runner.run(job: job, dryRun: false)
|
||||
run.output = result.stdout
|
||||
run.errorOutput = result.stderr
|
||||
run.exitCode = Int(result.exitCode)
|
||||
run.wasCancelled = result.wasCancelled
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = Int(result.exitCode)
|
||||
job.updatedAt = Date()
|
||||
} catch {
|
||||
run.errorOutput = error.localizedDescription
|
||||
run.exitCode = -1
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = -1
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
private func fetchJobs() -> [SyncJob] {
|
||||
(try? modelContext.fetch(FetchDescriptor<SyncJob>(sortBy: [SortDescriptor(\SyncJob.name)]))) ?? []
|
||||
}
|
||||
|
||||
private func fetchJob(id: UUID) -> SyncJob? {
|
||||
fetchJobs().first { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let syncJobsChanged = Notification.Name("RsyncManager.syncJobsChanged")
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
|
||||
final class FileWatchService {
|
||||
typealias ChangeHandler = @Sendable (UUID, String) -> Void
|
||||
|
||||
private struct WatchTarget: Equatable {
|
||||
let targetPath: String
|
||||
let streamRoot: String
|
||||
let isDirectory: Bool
|
||||
}
|
||||
|
||||
private var stream: FSEventStreamRef?
|
||||
private var watchedTargets: [UUID: WatchTarget] = [:]
|
||||
private let queue = DispatchQueue(label: "net.absdev.RsyncManager.fsevents", qos: .utility)
|
||||
var onChange: ChangeHandler?
|
||||
|
||||
deinit { stop() }
|
||||
|
||||
func update(jobs: [SyncJob]) {
|
||||
var targets: [UUID: WatchTarget] = [:]
|
||||
for job in jobs where job.isEnabled && job.watchEnabled {
|
||||
let path = (job.effectiveWatchPath as NSString).expandingTildeInPath
|
||||
guard !path.isEmpty else { continue }
|
||||
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
|
||||
let directory = exists ? isDirectory.boolValue : true
|
||||
let streamRoot = directory ? path : (path as NSString).deletingLastPathComponent
|
||||
targets[job.id] = WatchTarget(targetPath: path, streamRoot: streamRoot, isDirectory: directory)
|
||||
}
|
||||
guard targets != watchedTargets else { return }
|
||||
watchedTargets = targets
|
||||
rebuild()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let stream {
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
self.stream = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuild() {
|
||||
stop()
|
||||
let paths = Array(Set(watchedTargets.values.map(\.streamRoot)))
|
||||
guard !paths.isEmpty else { return }
|
||||
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: Unmanaged.passUnretained(self).toOpaque(),
|
||||
retain: nil,
|
||||
release: nil,
|
||||
copyDescription: nil
|
||||
)
|
||||
|
||||
let callback: FSEventStreamCallback = { _, info, count, eventPaths, _, _ in
|
||||
guard let info else { return }
|
||||
let service = Unmanaged<FileWatchService>.fromOpaque(info).takeUnretainedValue()
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? []
|
||||
for index in 0..<min(count, paths.count) {
|
||||
service.handle(path: paths[index])
|
||||
}
|
||||
}
|
||||
|
||||
let flags = FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
|
||||
guard let stream = FSEventStreamCreate(
|
||||
nil,
|
||||
callback,
|
||||
&context,
|
||||
paths as CFArray,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.4,
|
||||
flags
|
||||
) else { return }
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, queue)
|
||||
FSEventStreamStart(stream)
|
||||
}
|
||||
|
||||
private func handle(path: String) {
|
||||
let normalized = (path as NSString).standardizingPath
|
||||
for (jobID, target) in watchedTargets {
|
||||
let targetPath = (target.targetPath as NSString).standardizingPath
|
||||
let matches = target.isDirectory
|
||||
? (normalized == targetPath || normalized.hasPrefix(targetPath + "/"))
|
||||
: normalized == targetPath
|
||||
if matches { onChange?(jobID, normalized) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
|
||||
struct RsyncCommandBuilder {
|
||||
static func build(job: SyncJob, dryRun: Bool, executable: String) -> [String] {
|
||||
var args: [String] = []
|
||||
|
||||
if job.archive {
|
||||
args.append("-a")
|
||||
} else if job.preservePermissions {
|
||||
args.append("-p")
|
||||
}
|
||||
|
||||
if job.compress { args.append("-z") }
|
||||
|
||||
// Apple's bundled /usr/bin/rsync uses the Apple-specific -E flag for
|
||||
// extended attributes. That flag is not compatible with a standard
|
||||
// rsync server over SSH (for example Linux rsync 3.x), because Apple
|
||||
// rsync expands it to --extended-attributes on the remote side.
|
||||
//
|
||||
// Therefore:
|
||||
// • Apple rsync + local job -> -E
|
||||
// • Apple rsync + SSH job -> omit xattr flag
|
||||
// • Modern/Homebrew rsync -> -X (standard rsync xattrs)
|
||||
if job.preserveExtendedAttributes {
|
||||
if isAppleRsync(executable) {
|
||||
if !job.isRemote {
|
||||
args.append("-E")
|
||||
}
|
||||
} else {
|
||||
args.append("-X")
|
||||
}
|
||||
}
|
||||
|
||||
if job.deleteExtraneous { args.append("--delete") }
|
||||
|
||||
// --info=progress2 is not supported by Apple's bundled rsync 2.6.9.
|
||||
// --progress works on both Apple's rsync and modern Homebrew rsync.
|
||||
if job.showProgress { args.append("--progress") }
|
||||
|
||||
if dryRun { args.append("--dry-run") }
|
||||
|
||||
args += ["--human-readable", "--stats"]
|
||||
|
||||
for exclusion in job.exclusions {
|
||||
args.append("--exclude=\(exclusion)")
|
||||
}
|
||||
|
||||
if !job.additionalArguments.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args += shellSplit(job.additionalArguments)
|
||||
}
|
||||
|
||||
if job.isRemote {
|
||||
var sshParts = ["ssh", "-p", String(job.remotePort)]
|
||||
let keyPath = normalizedPrivateKeyPath(job.sshKeyPath)
|
||||
if !keyPath.isEmpty {
|
||||
sshParts += ["-i", keyPath]
|
||||
}
|
||||
args += ["-e", sshParts.map(shellEscape).joined(separator: " ")]
|
||||
}
|
||||
|
||||
args.append(normalizeSource(job.sourcePath))
|
||||
args.append(job.destinationDisplay)
|
||||
return args
|
||||
}
|
||||
|
||||
static func displayCommand(job: SyncJob, dryRun: Bool, executable: String) -> String {
|
||||
([executable] + build(job: job, dryRun: dryRun, executable: executable))
|
||||
.map(shellEscape)
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func normalizedPrivateKeyPath(_ path: String) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasSuffix(".pub") else { return trimmed }
|
||||
|
||||
let privatePath = String(trimmed.dropLast(4))
|
||||
if FileManager.default.fileExists(atPath: privatePath) {
|
||||
return privatePath
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func isAppleRsync(_ executable: String) -> Bool {
|
||||
executable == "/usr/bin/rsync"
|
||||
}
|
||||
|
||||
private static func normalizeSource(_ path: String) -> String {
|
||||
path
|
||||
}
|
||||
|
||||
static func shellEscape(_ value: String) -> String {
|
||||
if value.isEmpty { return "''" }
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_./:@%+=,"))
|
||||
if value.unicodeScalars.allSatisfy({ allowed.contains($0) }) { return value }
|
||||
return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
|
||||
static func shellSplit(_ string: String) -> [String] {
|
||||
var result: [String] = []
|
||||
var current = ""
|
||||
var quote: Character?
|
||||
var escaping = false
|
||||
|
||||
for ch in string {
|
||||
if escaping {
|
||||
current.append(ch)
|
||||
escaping = false
|
||||
continue
|
||||
}
|
||||
if ch == "\\" {
|
||||
escaping = true
|
||||
continue
|
||||
}
|
||||
if let q = quote {
|
||||
if ch == q { quote = nil } else { current.append(ch) }
|
||||
continue
|
||||
}
|
||||
if ch == "\"" || ch == "'" {
|
||||
quote = ch
|
||||
} else if ch.isWhitespace {
|
||||
if !current.isEmpty {
|
||||
result.append(current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current.append(ch)
|
||||
}
|
||||
}
|
||||
if !current.isEmpty { result.append(current) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum RsyncLocator {
|
||||
static func locate() -> String {
|
||||
let candidates = [
|
||||
"/opt/homebrew/bin/rsync",
|
||||
"/usr/local/bin/rsync",
|
||||
"/usr/bin/rsync"
|
||||
]
|
||||
return candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) ?? "/usr/bin/rsync"
|
||||
}
|
||||
}
|
||||
@@ -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