import Foundation import BackgroundTasks import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "BGTask") final class BackgroundTaskManager { static let backupTaskID = "com.albert.nasbackup.backup" static let refreshTaskID = "com.albert.nasbackup.refresh" static func registerTasks() { BGTaskScheduler.shared.register( forTaskWithIdentifier: backupTaskID, using: nil ) { task in guard let task = task as? BGProcessingTask else { return } BackgroundTaskManager.handleBackupTask(task) } BGTaskScheduler.shared.register( forTaskWithIdentifier: refreshTaskID, using: nil ) { task in guard let task = task as? BGAppRefreshTask else { return } BackgroundTaskManager.handleRefreshTask(task) } } static func scheduleBackup(delay: TimeInterval = 30) { let request = BGProcessingTaskRequest(identifier: backupTaskID) request.requiresNetworkConnectivity = true // Respect chargingOnlyMode: if set, only run when plugged in. request.requiresExternalPower = ConnectionStore.shared.chargingOnlyMode request.earliestBeginDate = Date(timeIntervalSinceNow: delay) do { try BGTaskScheduler.shared.submit(request) } catch { log.warning("BGProcessingTask schedule failed: \(error.localizedDescription)") } } static func scheduleAppRefresh() { let request = BGAppRefreshTaskRequest(identifier: refreshTaskID) request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) do { try BGTaskScheduler.shared.submit(request) } catch { log.warning("BGAppRefreshTask schedule failed: \(error.localizedDescription)") } } // MARK: — BGProcessingTask: best-effort full backup private static func handleBackupTask(_ task: BGProcessingTask) { scheduleBackup() // Reschedule before doing any work so we always get a next shot let store = ConnectionStore.shared // Quiet hours: skip silently — next scheduled slot will try again guard !store.isInQuietHours else { log.info("BGBackup skipped — quiet hours") task.setTaskCompleted(success: true) return } guard let connection = store.savedConnection, connection.remotePath != "/" else { log.info("BGBackup skipped — no destination configured") task.setTaskCompleted(success: false) return } // Expiration handler: cancel current upload cleanly. // BackupEngine.cancel() sets isCancelled = true; the upload loop checks this // between files and exits. defer { transfer.disconnect() } in engine.run() // ensures the NAS connection is always closed before iOS reclaims the slot. task.expirationHandler = { log.warning("BGBackup expiring — cancelling upload") Task { @MainActor in BackupEngine.shared.cancel() } } Task { @MainActor in do { log.info("BGBackup starting") _ = try await BackupEngine.shared.run( connection: connection, filter: store.backupFilter, triggeredByLAN: true ) log.info("BGBackup completed") task.setTaskCompleted(success: true) } catch { log.error("BGBackup failed: \(error.localizedDescription)") task.setTaskCompleted(success: false) } } } // MARK: — BGAppRefreshTask: lightweight status check only private static func handleRefreshTask(_ task: BGAppRefreshTask) { scheduleAppRefresh() // Always reschedule task.expirationHandler = { task.setTaskCompleted(success: false) } Task { @MainActor in // Light metadata-only refresh — no NAS upload, just phone count update. // This keeps the cached status fresh so the dashboard shows accurate data // when the user opens the app. BackupStatusService.shared.refresh(force: false) task.setTaskCompleted(success: true) } } }