import Foundation import SwiftData import Network @preconcurrency import UserNotifications @MainActor final class BackgroundSyncCoordinator: 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 @Published private(set) var syncAllFailed = 0 @Published private(set) var syncAllSkipped = 0 @Published private(set) var isNetworkAvailable = true @Published private(set) var lastError: String? 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() private var isProcessingQueue = false private var watchDebounceTasks: [UUID: Task] = [:] private var started = false private var notificationToken: NSObjectProtocol? private let networkMonitor = NWPathMonitor() private let networkQueue = DispatchQueue(label: "net.absdev.RsyncStudio.network") private var currentTrigger: SyncRunTrigger? 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) } } networkMonitor.pathUpdateHandler = { [weak self] path in Task { @MainActor [weak self] in self?.isNetworkAvailable = path.status == .satisfied if path.status == .satisfied { self?.processQueue() } } } networkMonitor.start(queue: networkQueue) notificationToken = NotificationCenter.default.addObserver( forName: .syncJobsChanged, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.refreshConfiguration() } } } deinit { networkMonitor.cancel() if let notificationToken { NotificationCenter.default.removeObserver(notificationToken) } } func start() { guard !started else { return } started = true refreshConfiguration() pruneHistoryIfNeeded(force: true) 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 syncAllFailed = 0 syncAllSkipped = 0 for job in enabledJobs { if enqueue(jobID: job.id, trigger: .syncAll) { syncAllTotal += 1 } } isSyncAllRunning = syncAllTotal > 0 if !isSyncAllRunning { syncAllCompleted = 0 } } func cancelSyncAll() { guard isSyncAllRunning else { return } let before = pending.count pending.removeAll { item in if item.1 == .syncAll { pendingIDs.remove(item.0) return true } return false } syncAllSkipped += max(0, before - pending.count) syncAllTotal = max(syncAllCompleted + syncAllFailed + syncAllSkipped, syncAllTotal) if currentTrigger == .syncAll && runner.isRunning { runner.cancel() } if currentTrigger != .syncAll { isSyncAllRunning = false } } 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)) } saveContext("refreshing automation configuration") } 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) } } saveContext("updating scheduled jobs") } 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) } saveContext("recording file-watch activity") } @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 { if item.1 == .syncAll { syncAllSkipped += 1 } finishSyncAllItemIfNeeded(item.1, succeeded: false, skipped: true) processQueue() return } if job.isRemote && !isNetworkAvailable { pending.insert(item, at: 0) pendingIDs.insert(item.0) activity = .waiting("Network · \(job.name)") return } isProcessingQueue = true currentTrigger = item.1 activity = .running(job.name) Task { [weak self] in guard let self else { return } let succeeded = await self.execute(job: job, trigger: item.1) self.finishSyncAllItemIfNeeded(item.1, succeeded: succeeded, skipped: false) self.isProcessingQueue = false self.currentTrigger = nil self.activity = .idle self.processQueue() } } private func finishSyncAllItemIfNeeded(_ trigger: SyncRunTrigger, succeeded: Bool, skipped: Bool) { guard trigger == .syncAll else { return } if skipped { syncAllSkipped += 0 } else if succeeded { syncAllCompleted += 1 } else { syncAllFailed += 1 } if syncAllCompleted + syncAllFailed + syncAllSkipped >= syncAllTotal { isSyncAllRunning = false sendNotification(title: "Sync All complete", body: "\(syncAllCompleted) succeeded · \(syncAllFailed) failed · \(syncAllSkipped) skipped") } } private func execute(job: SyncJob, trigger: SyncRunTrigger) async -> Bool { 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) saveContext("creating run history") let automatic = trigger == .scheduled || trigger == .watched let maxAttempts = automatic ? max(1, job.retryCount + 1) : 1 var finalSuccess = false var combinedOutput = "" var combinedError = "" for attempt in 1...maxAttempts { if attempt > 1 { let delay = max(1, job.retryDelaySeconds) * (attempt - 1) combinedError += "\nRetry \(attempt - 1) of \(maxAttempts - 1) after \(delay)s…\n" try? await Task.sleep(for: .seconds(delay)) } do { let preflight = await runner.preflight(job: job) guard preflight.passed else { combinedError += preflight.checks.filter { $0.severity == .error }.map(\.detail).joined(separator: "\n") break } let result = try await runner.run(job: job, dryRun: false, trigger: trigger) combinedOutput += result.stdout combinedError += result.stderr run.exitCode = Int(result.exitCode) run.wasCancelled = result.wasCancelled if result.exitCode == 0 { finalSuccess = true break } if result.wasCancelled { break } } catch { combinedError += "\n\(error.localizedDescription)\n" run.exitCode = -1 } } run.output = SyncRun.sanitizedLog(combinedOutput) run.errorOutput = SyncRun.sanitizedLog(combinedError) run.finishedAt = Date() job.lastRunAt = run.finishedAt job.lastExitCode = run.exitCode job.updatedAt = Date() saveContext("saving run history") pruneHistoryIfNeeded() if finalSuccess, job.notifyOnSuccess { sendNotification(title: "Sync complete", body: job.name) } if !finalSuccess, job.notifyOnFailure { sendNotification(title: "Sync failed", body: job.name) } return finalSuccess } private func sendNotification(title: String, body: String) { Task { @MainActor in do { let center = UNUserNotificationCenter.current() let granted = try await center.requestAuthorization(options: [.alert, .sound]) guard granted else { return } let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: nil ) try await center.add(request) } catch { lastError = "Could not deliver notification: \(error.localizedDescription)" } } } private func pruneHistoryIfNeeded(force: Bool = false) { let now = Date() if !force, let last = UserDefaults.standard.object(forKey: "lastHistoryPruneAt") as? Date, now.timeIntervalSince(last) < 3600 { return } let configuredDays = UserDefaults.standard.integer(forKey: "historyRetentionDays") let days = configuredDays == 0 ? 90 : max(7, min(configuredDays, 3650)) let cutoff = now.addingTimeInterval(-TimeInterval(days) * 86_400) do { let runs = try modelContext.fetch(FetchDescriptor()) for run in runs where run.finishedAt != nil && run.startedAt < cutoff { modelContext.delete(run) } try modelContext.save() UserDefaults.standard.set(now, forKey: "lastHistoryPruneAt") } catch { lastError = "Could not prune run history: \(error.localizedDescription)" } } private func saveContext(_ operation: String) { do { try modelContext.save() } catch { lastError = "Database error while \(operation): \(error.localizedDescription)" } } private func fetchJobs() -> [SyncJob] { (try? modelContext.fetch(FetchDescriptor(sortBy: [SortDescriptor(\SyncJob.name)]))) ?? [] } private func fetchJob(id: UUID) -> SyncJob? { fetchJobs().first { $0.id == id } } } extension Notification.Name { static let syncJobsChanged = Notification.Name("RsyncStudio.syncJobsChanged") }