Initial Commit

This commit is contained in:
Mark Gregory
2026-09-08 14:12:59 +01:00
commit 3b95f53a25
46 changed files with 4334 additions and 0 deletions
@@ -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
}
}
+12
View File
@@ -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"
}
}
+139
View File
@@ -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)
}
}