feat: automatic backup on launch, foreground, and photo library change
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 <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -7,6 +7,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
BackgroundTaskManager.registerTasks()
|
BackgroundTaskManager.registerTasks()
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
setupLANMonitor()
|
setupLANMonitor()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct NASBackupApp: App {
|
|||||||
@StateObject private var engine = BackupEngine.shared
|
@StateObject private var engine = BackupEngine.shared
|
||||||
@StateObject private var lanMonitor = LANMonitor.shared
|
@StateObject private var lanMonitor = LANMonitor.shared
|
||||||
@StateObject private var statusService = BackupStatusService.shared
|
@StateObject private var statusService = BackupStatusService.shared
|
||||||
|
@StateObject private var coordinator = AutoBackupCoordinator.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@@ -17,6 +18,7 @@ struct NASBackupApp: App {
|
|||||||
.environmentObject(engine)
|
.environmentObject(engine)
|
||||||
.environmentObject(lanMonitor)
|
.environmentObject(lanMonitor)
|
||||||
.environmentObject(statusService)
|
.environmentObject(statusService)
|
||||||
|
.environmentObject(coordinator)
|
||||||
.preferredColorScheme(store.appearanceMode.resolvedScheme)
|
.preferredColorScheme(store.appearanceMode.resolvedScheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct BackupView: View {
|
|||||||
@EnvironmentObject var store: ConnectionStore
|
@EnvironmentObject var store: ConnectionStore
|
||||||
@EnvironmentObject var lanMonitor: LANMonitor
|
@EnvironmentObject var lanMonitor: LANMonitor
|
||||||
@EnvironmentObject var statusService: BackupStatusService
|
@EnvironmentObject var statusService: BackupStatusService
|
||||||
|
@EnvironmentObject var coordinator: AutoBackupCoordinator
|
||||||
@StateObject private var vm = BackupViewModel()
|
@StateObject private var vm = BackupViewModel()
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -92,10 +93,11 @@ struct BackupView: View {
|
|||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await statusService.refreshAndWait(force: true)
|
await statusService.refreshAndWait(force: true)
|
||||||
// After reconciliation, clear stale failure if counts say all safe
|
|
||||||
if statusService.snapshot.needBackup == 0 {
|
if statusService.snapshot.needBackup == 0 {
|
||||||
engine.resolveSuccess()
|
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: engine.job.status == .completed)
|
||||||
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
|
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
|
||||||
.task {
|
.task {
|
||||||
statusService.refresh(force: true)
|
// Kick off reconciliation and auto-backup on first appear.
|
||||||
|
coordinator.onActive()
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.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
|
.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
|
.onChange(of: store.savedConnection?.remotePath) { _ in
|
||||||
statusService.refresh(force: true)
|
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
|
// ID that changes on both page swipe and status transition — drives the ring animation
|
||||||
private var ringContentID: String {
|
private var ringContentID: String {
|
||||||
|
let enginePart: String
|
||||||
switch resolvedStatus {
|
switch resolvedStatus {
|
||||||
case .idle, .cancelled: return "\(ringPage)_idle"
|
case .idle, .cancelled: enginePart = "idle"
|
||||||
case .preparing: return "\(ringPage)_preparing"
|
case .preparing: enginePart = "prep"
|
||||||
case .running: return "\(ringPage)_running"
|
case .running: enginePart = "run"
|
||||||
case .paused: return "\(ringPage)_paused"
|
case .paused: enginePart = "pause"
|
||||||
case .completed: return "\(ringPage)_done"
|
case .completed: enginePart = "done"
|
||||||
case .failed: return "\(ringPage)_failed"
|
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
|
@ViewBuilder
|
||||||
private var ringCenterContent: some View {
|
private var ringCenterContent: some View {
|
||||||
switch ringPage {
|
switch ringPage {
|
||||||
case 0:
|
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
|
case 1: // Need backup
|
||||||
VStack(spacing: 4) {
|
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 {
|
private var ringColor: Color {
|
||||||
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
||||||
if case .failed = resolvedStatus { return AppTheme.destructive }
|
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 {
|
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
||||||
return AppTheme.positive
|
return AppTheme.positive
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct SettingsView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: AppTheme.sectionGap) {
|
VStack(spacing: AppTheme.sectionGap) {
|
||||||
appearanceSection
|
appearanceSection
|
||||||
|
autoBackupSection
|
||||||
LANTriggerSection()
|
LANTriggerSection()
|
||||||
networkStatusSection
|
networkStatusSection
|
||||||
mediaSection
|
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
|
// MARK: — Appearance
|
||||||
|
|
||||||
private var appearanceSection: some View {
|
private var appearanceSection: some View {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
01610BD782FA7F00787E8CB5 /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF503BCE3AEDC25F4D35083 /* NASConnection.swift */; };
|
01610BD782FA7F00787E8CB5 /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF503BCE3AEDC25F4D35083 /* NASConnection.swift */; };
|
||||||
0555485F0FD9D1D724B98B0D /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D2B8E1C4BBB260BC1EBA2B /* PhotoLibraryProtocol.swift */; };
|
0555485F0FD9D1D724B98B0D /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35D2B8E1C4BBB260BC1EBA2B /* PhotoLibraryProtocol.swift */; };
|
||||||
06C876CCC3F66517F663B410 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6004FE7B107CC60894EDC434 /* BrowseView.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 */; };
|
0E446A91CD30C2905B8E215F /* BackupManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966927456571CE45600BEF75 /* BackupManifest.swift */; };
|
||||||
0E4948C8E3326DF859341942 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F052EE8B0A757532E4BCBCCD /* LoginView.swift */; };
|
0E4948C8E3326DF859341942 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F052EE8B0A757532E4BCBCCD /* LoginView.swift */; };
|
||||||
0F80FDE64AF87518C6563CEC /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354D31602CFEC56D0D219F41 /* SceneDelegate.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 = "<group>"; };
|
84D7C31A6C73BBF5B8CBDCF2 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = "<group>"; };
|
||||||
86B66D4FB8A7E26BEE807C44 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
86B66D4FB8A7E26BEE807C44 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
8AC6E57C1203C70B43C3A789 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
|
8AC6E57C1203C70B43C3A789 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
|
||||||
|
909C89562F7A0F4D4ECAFD7E /* AutoBackupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBackupCoordinator.swift; sourceTree = "<group>"; };
|
||||||
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
|
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
|
||||||
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
|
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
|
||||||
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
||||||
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||||
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
||||||
@@ -124,7 +126,7 @@
|
|||||||
E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||||
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
|
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
|
||||||
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
|
||||||
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = "<group>"; };
|
F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = "<group>"; };
|
||||||
@@ -246,6 +248,7 @@
|
|||||||
5C5F3DAEDF1D5405A8D0FEA8 /* Services */ = {
|
5C5F3DAEDF1D5405A8D0FEA8 /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
909C89562F7A0F4D4ECAFD7E /* AutoBackupCoordinator.swift */,
|
||||||
8AC6E57C1203C70B43C3A789 /* BackgroundTaskManager.swift */,
|
8AC6E57C1203C70B43C3A789 /* BackgroundTaskManager.swift */,
|
||||||
75FEBDDA82656772FE30609D /* BackupEngine.swift */,
|
75FEBDDA82656772FE30609D /* BackupEngine.swift */,
|
||||||
FC516E4CBDFA09B6D04937D6 /* BackupStatusService.swift */,
|
FC516E4CBDFA09B6D04937D6 /* BackupStatusService.swift */,
|
||||||
@@ -439,6 +442,7 @@
|
|||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
009689A0ADEB4878A288991E = {
|
009689A0ADEB4878A288991E = {
|
||||||
|
DevelopmentTeam = "";
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
3362521ADE965E6BA3383045 = {
|
3362521ADE965E6BA3383045 = {
|
||||||
@@ -490,6 +494,7 @@
|
|||||||
A43FF80A5C7FC09E07294B60 /* AppDelegate.swift in Sources */,
|
A43FF80A5C7FC09E07294B60 /* AppDelegate.swift in Sources */,
|
||||||
17DBE498E2505814F5F40D6E /* AppMenuView.swift in Sources */,
|
17DBE498E2505814F5F40D6E /* AppMenuView.swift in Sources */,
|
||||||
97E28F8EF7418C7D7A4797B1 /* AppTheme.swift in Sources */,
|
97E28F8EF7418C7D7A4797B1 /* AppTheme.swift in Sources */,
|
||||||
|
0DAD4787CA9EA8E7EFA4E65E /* AutoBackupCoordinator.swift in Sources */,
|
||||||
957976696AD0F01A7BA054DE /* BackgroundTaskManager.swift in Sources */,
|
957976696AD0F01A7BA054DE /* BackgroundTaskManager.swift in Sources */,
|
||||||
C0709000E6CAE98224DE4D98 /* BackupEngine.swift in Sources */,
|
C0709000E6CAE98224DE4D98 /* BackupEngine.swift in Sources */,
|
||||||
67149DE0D19696D9327AEBAB /* BackupError.swift in Sources */,
|
67149DE0D19696D9327AEBAB /* BackupError.swift in Sources */,
|
||||||
@@ -567,7 +572,6 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -747,7 +751,6 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|||||||
167
Services/AutoBackupCoordinator.swift
Normal file
167
Services/AutoBackupCoordinator.swift
Normal file
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.albert.nasbackup", category: "BGTask")
|
||||||
|
|
||||||
final class BackgroundTaskManager {
|
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 let refreshTaskID = "com.albert.nasbackup.refresh"
|
||||||
|
|
||||||
static func registerTasks() {
|
static func registerTasks() {
|
||||||
@@ -26,12 +29,13 @@ final class BackgroundTaskManager {
|
|||||||
static func scheduleBackup(delay: TimeInterval = 30) {
|
static func scheduleBackup(delay: TimeInterval = 30) {
|
||||||
let request = BGProcessingTaskRequest(identifier: backupTaskID)
|
let request = BGProcessingTaskRequest(identifier: backupTaskID)
|
||||||
request.requiresNetworkConnectivity = true
|
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)
|
request.earliestBeginDate = Date(timeIntervalSinceNow: delay)
|
||||||
do {
|
do {
|
||||||
try BGTaskScheduler.shared.submit(request)
|
try BGTaskScheduler.shared.submit(request)
|
||||||
} catch {
|
} catch {
|
||||||
print("BGTask schedule failed: \(error)")
|
log.warning("BGProcessingTask schedule failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,31 +45,71 @@ final class BackgroundTaskManager {
|
|||||||
do {
|
do {
|
||||||
try BGTaskScheduler.shared.submit(request)
|
try BGTaskScheduler.shared.submit(request)
|
||||||
} catch {
|
} 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) {
|
private static func handleBackupTask(_ task: BGProcessingTask) {
|
||||||
scheduleBackup()
|
scheduleBackup() // Reschedule before doing any work so we always get a next shot
|
||||||
task.expirationHandler = { Task { await BackupEngine.shared.cancel() } }
|
|
||||||
|
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
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let store = ConnectionStore.shared
|
log.info("BGBackup starting")
|
||||||
guard let connection = store.savedConnection else {
|
_ = try await BackupEngine.shared.run(
|
||||||
task.setTaskCompleted(success: false)
|
connection: connection,
|
||||||
return
|
filter: store.backupFilter,
|
||||||
}
|
triggeredByLAN: true
|
||||||
let filter = store.backupFilter
|
)
|
||||||
_ = try await BackupEngine.shared.run(connection: connection, filter: filter, triggeredByLAN: true)
|
log.info("BGBackup completed")
|
||||||
task.setTaskCompleted(success: true)
|
task.setTaskCompleted(success: true)
|
||||||
} catch {
|
} catch {
|
||||||
|
log.error("BGBackup failed: \(error.localizedDescription)")
|
||||||
task.setTaskCompleted(success: false)
|
task.setTaskCompleted(success: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: — BGAppRefreshTask: lightweight status check only
|
||||||
|
|
||||||
private static func handleRefreshTask(_ task: BGAppRefreshTask) {
|
private static func handleRefreshTask(_ task: BGAppRefreshTask) {
|
||||||
scheduleAppRefresh()
|
scheduleAppRefresh() // Always reschedule
|
||||||
task.setTaskCompleted(success: true)
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ final class ConnectionStore: ObservableObject {
|
|||||||
didSet { UserDefaults.standard.set(localRetentionDays, forKey: "localRetentionDays") }
|
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
|
// Cellular & remote access
|
||||||
@Published var allowCellularBackup: Bool {
|
@Published var allowCellularBackup: Bool {
|
||||||
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
|
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
|
||||||
@@ -89,6 +94,7 @@ final class ConnectionStore: ObservableObject {
|
|||||||
onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown")
|
onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown")
|
||||||
appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system
|
appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system
|
||||||
localRetentionDays = UserDefaults.standard.object(forKey: "localRetentionDays") as? Int ?? 0
|
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
|
allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false
|
||||||
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
|
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
|
||||||
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""
|
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""
|
||||||
|
|||||||
Reference in New Issue
Block a user