From 5b68fde62fceaa9e8dd4030d55387241acfdae64 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 17 May 2026 17:28:11 +0300 Subject: [PATCH] feat: automatic backup on launch, foreground, and photo library change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoBackupCoordinator (new): - @MainActor singleton that owns all auto-backup decisions - Phase model: idle / checking / autoBackingUp / disconnected - idle: nothing pending or destination not set - checking: reconciliation in progress before decision - autoBackingUp: coordinator-triggered BackupEngine run active - disconnected: NAS offline, pending count queued for retry - Combine subscription on BackupStatusService.$isRefreshing fires handleReconciliationComplete() on every reconcile completion — the single entry point for all auto-backup decisions - Combine subscription on LANMonitor.$nasReachable fires handleNASReachable() which retries only when phase == .disconnected - Guards: autoBackupEnabled, quiet hours, chargingOnlyMode + battery state - canAutoBackup also blocks when engine.job.status.isActive to prevent double-start from concurrent photo library change + foreground events BackupView: - .task + .onChange(of: scenePhase) now call coordinator.onActive() instead of statusService.refresh() directly — coordinator decides whether to also start a backup after reconciliation completes - ringContentID includes coordinator phase suffix → ring animates on checking/disconnected transitions - ringCenterContent case 0: when engine is idle, shows: - disconnectedRingView if phase == .disconnected and needBackup > 0 (wifi.slash icon, "Waiting for NAS", queued count) - checkingRingView if phase == .checking (spinner, "Checking…") - existing ringMainView otherwise - ringColor: dim orange (0.35 opacity) when disconnected with pending items - Pull-to-refresh reconciliation triggers auto-backup via Combine chain ConnectionStore: - autoBackupEnabled: Bool, default true, UserDefaults-persisted SettingsView: - AUTO BACKUP section at top with autoBackupEnabled toggle BackgroundTaskManager: - BGProcessingTask: quiet-hours guard, requiresExternalPower mirrors chargingOnlyMode, expiration handler calls engine.cancel() for clean exit - BGAppRefreshTask: lightweight refresh only (no upload in background refresh) - Both tasks reschedule themselves before doing any work AppDelegate: - UIDevice.current.isBatteryMonitoringEnabled = true for battery checks NASBackupApp: - AutoBackupCoordinator.shared injected as @StateObject + environmentObject Co-Authored-By: Kutesir Co-Authored-By: Sentry --- App/AppDelegate.swift | 1 + App/NASBackupApp.swift | 2 + Features/Backup/BackupView.swift | 85 ++++++++++-- Features/Settings/SettingsView.swift | 21 +++ Kisani.xcodeproj/project.pbxproj | 11 +- Services/AutoBackupCoordinator.swift | 167 +++++++++++++++++++++++ Services/BackgroundTaskManager.swift | 74 ++++++++-- Shared/Persistence/ConnectionStore.swift | 6 + 8 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 Services/AutoBackupCoordinator.swift diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index fa6f740..615d57d 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -7,6 +7,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + UIDevice.current.isBatteryMonitoringEnabled = true BackgroundTaskManager.registerTasks() requestNotificationPermission() setupLANMonitor() diff --git a/App/NASBackupApp.swift b/App/NASBackupApp.swift index a434ad5..b505072 100644 --- a/App/NASBackupApp.swift +++ b/App/NASBackupApp.swift @@ -9,6 +9,7 @@ struct NASBackupApp: App { @StateObject private var engine = BackupEngine.shared @StateObject private var lanMonitor = LANMonitor.shared @StateObject private var statusService = BackupStatusService.shared + @StateObject private var coordinator = AutoBackupCoordinator.shared var body: some Scene { WindowGroup { @@ -17,6 +18,7 @@ struct NASBackupApp: App { .environmentObject(engine) .environmentObject(lanMonitor) .environmentObject(statusService) + .environmentObject(coordinator) .preferredColorScheme(store.appearanceMode.resolvedScheme) } } diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 4d4a2f9..96a1847 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -6,6 +6,7 @@ struct BackupView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @EnvironmentObject var statusService: BackupStatusService + @EnvironmentObject var coordinator: AutoBackupCoordinator @StateObject private var vm = BackupViewModel() @Environment(\.scenePhase) private var scenePhase @@ -92,10 +93,11 @@ struct BackupView: View { } .refreshable { await statusService.refreshAndWait(force: true) - // After reconciliation, clear stale failure if counts say all safe if statusService.snapshot.needBackup == 0 { engine.resolveSuccess() } + // handleReconciliationComplete() fires via Combine and will + // start auto-backup if needed — no explicit call required here. } } @@ -132,13 +134,18 @@ struct BackupView: View { .animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed) .animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized) .task { - statusService.refresh(force: true) + // Kick off reconciliation and auto-backup on first appear. + coordinator.onActive() } .onChange(of: scenePhase) { phase in - if phase == .active { statusService.refresh(force: true) } + // Re-check every time the app returns to the foreground. + if phase == .active { coordinator.onActive() } } .onChange(of: lanMonitor.nasReachable) { reachable in - if reachable == true { statusService.refresh(force: true) } + // Belt-and-suspenders: coordinator handles this via Combine, + // but an explicit refresh here ensures the stats strip updates + // even when the coordinator's phase guard is not .disconnected. + if reachable == true { statusService.refresh(force: false) } } .onChange(of: store.savedConnection?.remotePath) { _ in statusService.refresh(force: true) @@ -211,21 +218,41 @@ struct BackupView: View { // ID that changes on both page swipe and status transition — drives the ring animation private var ringContentID: String { + let enginePart: String switch resolvedStatus { - case .idle, .cancelled: return "\(ringPage)_idle" - case .preparing: return "\(ringPage)_preparing" - case .running: return "\(ringPage)_running" - case .paused: return "\(ringPage)_paused" - case .completed: return "\(ringPage)_done" - case .failed: return "\(ringPage)_failed" + case .idle, .cancelled: enginePart = "idle" + case .preparing: enginePart = "prep" + case .running: enginePart = "run" + case .paused: enginePart = "pause" + case .completed: enginePart = "done" + case .failed: enginePart = "fail" } + // Include coordinator phase so ring animates on checking/disconnected transitions + let coordSuffix: String + switch coordinator.phase { + case .checking: coordSuffix = "_chk" + case .disconnected: coordSuffix = "_disc" + case .autoBackingUp: coordSuffix = "_auto" + case .idle: coordSuffix = "" + } + return "\(ringPage)_\(enginePart)\(coordSuffix)" } @ViewBuilder private var ringCenterContent: some View { switch ringPage { case 0: - ringMainView + // Engine activity always takes priority. + // Coordinator states are shown only when the engine is idle. + if engine.job.status.isActive { + ringMainView + } else if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 { + disconnectedRingView + } else if coordinator.phase == .checking { + checkingRingView + } else { + ringMainView + } case 1: // Need backup VStack(spacing: 4) { @@ -355,9 +382,45 @@ struct BackupView: View { } } + // MARK: — Coordinator ring states (shown when engine is idle) + + @ViewBuilder + private var checkingRingView: some View { + VStack(spacing: 10) { + ProgressView() + .scaleEffect(0.85) + .tint(AppTheme.inkTertiary) + Text("Checking…") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + } + } + + @ViewBuilder + private var disconnectedRingView: some View { + VStack(spacing: 8) { + Image(systemName: "wifi.slash") + .font(.system(size: 26, weight: .light)) + .foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.75)) + Text("Waiting for NAS") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary) + if coordinator.pendingAtDisconnect > 0 { + Text("\(coordinator.pendingAtDisconnect) queued") + .font(AppTheme.micro(11)) + .foregroundStyle(AppTheme.inkTertiary) + .contentTransition(.numericText()) + } + } + } + private var ringColor: Color { if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) } if case .failed = resolvedStatus { return AppTheme.destructive } + // Disconnected with pending items: dim orange pulse + if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 { + return Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.35) + } if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { return AppTheme.positive } diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index 54f1c33..8c2eb8a 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -12,6 +12,7 @@ struct SettingsView: View { ScrollView { VStack(spacing: AppTheme.sectionGap) { appearanceSection + autoBackupSection LANTriggerSection() networkStatusSection mediaSection @@ -41,6 +42,26 @@ struct SettingsView: View { } } + // MARK: — Auto Backup + + private var autoBackupSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("AUTO BACKUP") + + VStack(spacing: 0) { + ToggleRow( + icon: "arrow.clockwise.icloud", + title: "Auto backup", + subtitle: "Back up new photos automatically when the app opens", + isOn: $store.autoBackupEnabled + ) + } + .background(AppTheme.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + } + } + // MARK: — Appearance private var appearanceSection: some View { diff --git a/Kisani.xcodeproj/project.pbxproj b/Kisani.xcodeproj/project.pbxproj index 10accc2..e1ab870 100644 --- a/Kisani.xcodeproj/project.pbxproj +++ b/Kisani.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 01610BD782FA7F00787E8CB5 /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF503BCE3AEDC25F4D35083 /* NASConnection.swift */; }; 0555485F0FD9D1D724B98B0D /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D2B8E1C4BBB260BC1EBA2B /* PhotoLibraryProtocol.swift */; }; 06C876CCC3F66517F663B410 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6004FE7B107CC60894EDC434 /* BrowseView.swift */; }; + 0DAD4787CA9EA8E7EFA4E65E /* AutoBackupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 909C89562F7A0F4D4ECAFD7E /* AutoBackupCoordinator.swift */; }; 0E446A91CD30C2905B8E215F /* BackupManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966927456571CE45600BEF75 /* BackupManifest.swift */; }; 0E4948C8E3326DF859341942 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F052EE8B0A757532E4BCBCCD /* LoginView.swift */; }; 0F80FDE64AF87518C6563CEC /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354D31602CFEC56D0D219F41 /* SceneDelegate.swift */; }; @@ -106,10 +107,11 @@ 84D7C31A6C73BBF5B8CBDCF2 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = ""; }; 86B66D4FB8A7E26BEE807C44 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8AC6E57C1203C70B43C3A789 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; }; + 909C89562F7A0F4D4ECAFD7E /* AutoBackupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBackupCoordinator.swift; sourceTree = ""; }; 90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = ""; }; 966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = ""; }; 9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = ""; }; - A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = ""; }; @@ -124,7 +126,7 @@ E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = ""; }; E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = ""; }; - ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = ""; }; F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = ""; }; @@ -246,6 +248,7 @@ 5C5F3DAEDF1D5405A8D0FEA8 /* Services */ = { isa = PBXGroup; children = ( + 909C89562F7A0F4D4ECAFD7E /* AutoBackupCoordinator.swift */, 8AC6E57C1203C70B43C3A789 /* BackgroundTaskManager.swift */, 75FEBDDA82656772FE30609D /* BackupEngine.swift */, FC516E4CBDFA09B6D04937D6 /* BackupStatusService.swift */, @@ -439,6 +442,7 @@ LastUpgradeCheck = 1500; TargetAttributes = { 009689A0ADEB4878A288991E = { + DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; 3362521ADE965E6BA3383045 = { @@ -490,6 +494,7 @@ A43FF80A5C7FC09E07294B60 /* AppDelegate.swift in Sources */, 17DBE498E2505814F5F40D6E /* AppMenuView.swift in Sources */, 97E28F8EF7418C7D7A4797B1 /* AppTheme.swift in Sources */, + 0DAD4787CA9EA8E7EFA4E65E /* AutoBackupCoordinator.swift in Sources */, 957976696AD0F01A7BA054DE /* BackgroundTaskManager.swift in Sources */, C0709000E6CAE98224DE4D98 /* BackupEngine.swift in Sources */, 67149DE0D19696D9327AEBAB /* BackupError.swift in Sources */, @@ -567,7 +572,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -747,7 +751,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Services/AutoBackupCoordinator.swift b/Services/AutoBackupCoordinator.swift new file mode 100644 index 0000000..3244d76 --- /dev/null +++ b/Services/AutoBackupCoordinator.swift @@ -0,0 +1,167 @@ +import Foundation +import Combine +import UIKit +import os.log + +private let log = Logger(subsystem: "com.albert.nasbackup", category: "AutoBackup") + +/// Decides when to start backup automatically and tracks the high-level app phase. +/// +/// Phase model: +/// idle — no pending work, or destination not configured +/// checking — reconciliation running, awaiting the pending-queue size +/// autoBackingUp — coordinator-triggered backup running +/// disconnected — NAS offline while items are queued; will retry when reachable +/// +/// Active-backup states (running/paused/completed/failed) are owned by BackupEngine.job +/// and are read directly by BackupView — the coordinator does not duplicate them. +@MainActor +final class AutoBackupCoordinator: ObservableObject { + static let shared = AutoBackupCoordinator() + + enum Phase: Equatable { + case idle + case checking + case autoBackingUp + case disconnected + } + + @Published private(set) var phase: Phase = .idle + @Published private(set) var pendingAtDisconnect: Int = 0 + + private let engine = BackupEngine.shared + private let statusService = BackupStatusService.shared + private let store = ConnectionStore.shared + private let lanMonitor = LANMonitor.shared + + private var cancellables = Set() + + // MARK: — Init + + private init() { + setupObservation() + } + + private func setupObservation() { + // Fires when reconciliation transitions from running → done. + // dropFirst() skips the initial subscription value so we don't fire on + // startup before the first real reconciliation has run. + statusService.$isRefreshing + .dropFirst() + .filter { !$0 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.handleReconciliationComplete() } + .store(in: &cancellables) + + // Fires when NAS transitions to reachable — only acts when in .disconnected phase + // so we don't duplicate the trigger on initial startup or during active backup. + lanMonitor.$nasReachable + .dropFirst() + .compactMap { $0 } + .filter { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.handleNASReachable() } + .store(in: &cancellables) + } + + // MARK: — External triggers + + /// Call when the app becomes active: on first launch, foreground resume, or session unlock. + /// Safe to call repeatedly — guards prevent redundant reconciliation. + func onActive() { + guard !engine.job.status.isActive else { return } + guard store.savedConnection != nil else { return } + phase = .checking + statusService.refresh(force: true) + // handleReconciliationComplete() fires via Combine when the refresh settles + } + + // MARK: — Combine handlers + + private func handleReconciliationComplete() { + let snapshot = statusService.snapshot + + // No destination configured yet + guard let conn = store.savedConnection, conn.remotePath != "/" else { + phase = .idle + return + } + + // Nothing to back up — clear any stale state + guard snapshot.needBackup > 0 else { + phase = .idle + pendingAtDisconnect = 0 + return + } + + // NAS offline — queue pending count and wait for reachability + if snapshot.connectionState == .offline { + pendingAtDisconnect = snapshot.needBackup + phase = .disconnected + log.info("NAS offline — \(snapshot.needBackup) items queued") + return + } + + // Auto-backup setting is off — show count but don't start + guard store.autoBackupEnabled else { + phase = .idle + return + } + + triggerAutoBackupIfNeeded(conn: conn) + } + + private func handleNASReachable() { + guard case .disconnected = phase else { return } + log.info("NAS reachable again — rescheduling auto-check") + onActive() + } + + // MARK: — Auto-backup trigger + + private func triggerAutoBackupIfNeeded(conn: NASConnection) { + guard canAutoBackup else { return } + + let pending = statusService.snapshot.needBackup + log.info("Auto-backup triggered: \(pending) pending items → \(conn.remotePath)") + + phase = .autoBackingUp + pendingAtDisconnect = 0 + + Task { + do { + _ = try await engine.run(connection: conn, filter: store.backupFilter) + } catch { + log.error("Auto-backup error: \(error.localizedDescription)") + } + // engine.run() calls refreshAfterBackup() which triggers reconciliation. + // handleReconciliationComplete() will fire again and reset phase. + if case .autoBackingUp = phase { phase = .idle } + } + } + + // MARK: — Guards + + private var canAutoBackup: Bool { + // Already running + guard !engine.job.status.isActive else { return false } + // Quiet hours + guard !store.isInQuietHours else { + log.info("Auto-backup skipped — quiet hours active") + return false + } + // Charging-only mode + guard isBatteryOK else { + log.info("Auto-backup skipped — not charging (chargingOnlyMode enabled)") + return false + } + return true + } + + private var isBatteryOK: Bool { + guard store.chargingOnlyMode else { return true } + let state = UIDevice.current.batteryState + // .unknown covers simulator and unavailable reads — allow in those cases + return state == .charging || state == .full || state == .unknown + } +} diff --git a/Services/BackgroundTaskManager.swift b/Services/BackgroundTaskManager.swift index 92f7538..29cfd8d 100644 --- a/Services/BackgroundTaskManager.swift +++ b/Services/BackgroundTaskManager.swift @@ -1,8 +1,11 @@ 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 backupTaskID = "com.albert.nasbackup.backup" static let refreshTaskID = "com.albert.nasbackup.refresh" static func registerTasks() { @@ -26,12 +29,13 @@ final class BackgroundTaskManager { static func scheduleBackup(delay: TimeInterval = 30) { let request = BGProcessingTaskRequest(identifier: backupTaskID) request.requiresNetworkConnectivity = true - request.requiresExternalPower = false + // 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 { - print("BGTask schedule failed: \(error)") + log.warning("BGProcessingTask schedule failed: \(error.localizedDescription)") } } @@ -41,31 +45,71 @@ final class BackgroundTaskManager { do { try BGTaskScheduler.shared.submit(request) } catch { - print("BGRefresh schedule failed: \(error)") + log.warning("BGAppRefreshTask schedule failed: \(error.localizedDescription)") } } + // MARK: — BGProcessingTask: best-effort full backup + private static func handleBackupTask(_ task: BGProcessingTask) { - scheduleBackup() - task.expirationHandler = { Task { await BackupEngine.shared.cancel() } } + 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 { - let store = ConnectionStore.shared - guard let connection = store.savedConnection else { - task.setTaskCompleted(success: false) - return - } - let filter = store.backupFilter - _ = try await BackupEngine.shared.run(connection: connection, filter: filter, triggeredByLAN: true) + 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() - task.setTaskCompleted(success: true) + 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) + } } } diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift index 9c8993d..33b4dc2 100644 --- a/Shared/Persistence/ConnectionStore.swift +++ b/Shared/Persistence/ConnectionStore.swift @@ -63,6 +63,11 @@ final class ConnectionStore: ObservableObject { didSet { UserDefaults.standard.set(localRetentionDays, forKey: "localRetentionDays") } } + // Auto-backup: default on after first destination is configured + @Published var autoBackupEnabled: Bool { + didSet { UserDefaults.standard.set(autoBackupEnabled, forKey: "autoBackupEnabled") } + } + // Cellular & remote access @Published var allowCellularBackup: Bool { didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") } @@ -89,6 +94,7 @@ final class ConnectionStore: ObservableObject { onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown") appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system localRetentionDays = UserDefaults.standard.object(forKey: "localRetentionDays") as? Int ?? 0 + autoBackupEnabled = UserDefaults.standard.object(forKey: "autoBackupEnabled") as? Bool ?? true allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""