new updates for paddel
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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<UUID>()
|
||||
private var isProcessingQueue = false
|
||||
private var watchDebounceTasks: [UUID: Task<Void, Never>] = [:]
|
||||
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<SyncRun>())
|
||||
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<SyncJob>(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")
|
||||
}
|
||||
Reference in New Issue
Block a user