- AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task and scenePhase triggers (navigation appear, rapid foreground cycles) are no-ops; start a 60s periodic recheck timer on first active call; stop timer and reset debounce on willResignActiveNotification so each new foreground session always gets an immediate fresh status check - BackupView: remove the redundant lanMonitor.nasReachable onChange that was calling statusService.refresh() directly — coordinator already handles this via its Combine $nasReachable subscriber, avoiding a double reconcile that could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value - BackupStatusService: when NAS connect fails, restore nasArchiveTotal from NASManifestCache before writing the offline snapshot — prevents the count from showing 0 when the NAS is temporarily unreachable but the cache is warm - BackupStatusService: manifest validity check — if the decoded manifest has ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the real file count as the display floor for nasArchiveTotal without adding orphan entries to the manifest (that was the 7421 regression) - BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for the validity check path — keeps localIdentifier matching intact while correcting the displayed archive count - SMBService: add os.log around connect/auth/listDirectory with actual error reason instead of always surfacing authenticationFailed; distinguish network errors (timeout, host unreachable) from auth errors in thrown BackupError Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
225 lines
8.0 KiB
Swift
225 lines
8.0 KiB
Swift
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 var lastTriggerWasLAN = false
|
|
private var lastAutoCheckAt: Date = .distantPast
|
|
private var recheckTimer: AnyCancellable?
|
|
|
|
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()
|
|
setupAppLifecycle()
|
|
}
|
|
|
|
private func setupAppLifecycle() {
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appWillResignActive),
|
|
name: UIApplication.willResignActiveNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
// Stop the timer and reset the debounce window on every background transition.
|
|
// This ensures the next foreground session always gets an immediate status check.
|
|
@objc private nonisolated func appWillResignActive() {
|
|
Task { @MainActor [weak self] in
|
|
self?.recheckTimer = nil
|
|
self?.lastAutoCheckAt = .distantPast
|
|
log.debug("App resigned — recheck timer stopped, debounce reset")
|
|
}
|
|
}
|
|
|
|
private func startPeriodicRecheckIfNeeded() {
|
|
guard recheckTimer == nil else { return }
|
|
recheckTimer = Timer.publish(every: 60, on: .main, in: .common)
|
|
.autoconnect()
|
|
.sink { [weak self] _ in
|
|
log.debug("Periodic 60s recheck fired")
|
|
self?.onActive()
|
|
}
|
|
}
|
|
|
|
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 — debounce prevents redundant reconciliation within 55s.
|
|
func onActive(lanTriggered: Bool = false) {
|
|
lastTriggerWasLAN = lanTriggered
|
|
guard !engine.job.status.isActive else { return }
|
|
guard store.savedConnection != nil else { return }
|
|
guard phase == .idle else { return }
|
|
|
|
// Non-LAN triggers are debounced: .task {} fires on every navigation appear,
|
|
// and scenePhase fires on every foreground. Only allow one check per 55 seconds
|
|
// so the user can background+foreground without restarting a new backup job.
|
|
if !lanTriggered {
|
|
let elapsed = Date().timeIntervalSince(lastAutoCheckAt)
|
|
guard elapsed > 55 else {
|
|
log.debug("onActive debounced — \(Int(elapsed))s since last check (need >55s)")
|
|
return
|
|
}
|
|
}
|
|
|
|
lastAutoCheckAt = Date()
|
|
phase = .checking
|
|
startPeriodicRecheckIfNeeded()
|
|
log.info("onActive: trigger=\(lanTriggered ? "LAN" : "appOpen") — status check starting")
|
|
statusService.refresh(force: false)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Gate auto-backup on trigger source:
|
|
// LAN join → autoBackupEnabled (lanTriggerEnabled guards the SSID check in LANMonitor)
|
|
// App open → autoBackupOnOpen (off by default; requires explicit opt-in)
|
|
let autoAllowed = lastTriggerWasLAN
|
|
? store.autoBackupEnabled
|
|
: store.autoBackupOnOpen
|
|
|
|
guard autoAllowed else {
|
|
phase = .idle
|
|
log.info("Auto-backup gated — trigger=\(self.lastTriggerWasLAN ? "LAN" : "appOpen") enabled=\(autoAllowed)")
|
|
return
|
|
}
|
|
|
|
triggerAutoBackupIfNeeded(conn: conn)
|
|
}
|
|
|
|
private func handleNASReachable() {
|
|
guard case .disconnected = phase else { return }
|
|
log.info("NAS reachable again — rescheduling auto-check (LAN trigger)")
|
|
onActive(lanTriggered: true)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|