Fix backup loop, phone count invariant, and auto-start behaviour

- LocalPhotoIndex: add count(filter:) so fast-path reconcile uses the
  filtered asset count as phoneTotal instead of the unfiltered totalCount;
  fixes the broken alreadySafe + needBackup == phoneTotal invariant

- BackupStatusService: fast-path uses count(filter:) instead of totalCount;
  writeManifest falls back to NASManifestCache when NAS download fails so
  accumulated history is never silently discarded on a flaky connection

- ConnectionStore: autoBackupEnabled defaults false; new autoBackupOnOpen
  (default false) separates LAN-join auto-backup from app-open auto-backup

- AutoBackupCoordinator: onActive(lanTriggered:) tracks trigger source;
  handleReconciliationComplete gates on autoBackupEnabled for LAN joins and
  autoBackupOnOpen for app-open — prevents backup starting on every launch

- SettingsView: split auto-backup into two toggles with accurate descriptions

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 10:01:09 +03:00
parent b18a2aad14
commit 3beea88fab
5 changed files with 61 additions and 22 deletions

View File

@@ -50,11 +50,18 @@ struct SettingsView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ToggleRow( ToggleRow(
icon: "arrow.clockwise.icloud", icon: "wifi",
title: "Auto backup", title: "Auto-backup on home network",
subtitle: "Back up new photos automatically when the app opens", subtitle: "Start backup automatically when joining a trusted Wi-Fi network",
isOn: $store.autoBackupEnabled isOn: $store.autoBackupEnabled
) )
Divider().padding(.leading, 52)
ToggleRow(
icon: "arrow.clockwise.icloud",
title: "Auto-backup on app open",
subtitle: "Start backup every time the app is opened (off by default)",
isOn: $store.autoBackupOnOpen
)
} }
.background(AppTheme.surfaceRaised) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))

View File

@@ -29,6 +29,8 @@ final class AutoBackupCoordinator: ObservableObject {
@Published private(set) var phase: Phase = .idle @Published private(set) var phase: Phase = .idle
@Published private(set) var pendingAtDisconnect: Int = 0 @Published private(set) var pendingAtDisconnect: Int = 0
private var lastTriggerWasLAN = false
private let engine = BackupEngine.shared private let engine = BackupEngine.shared
private let statusService = BackupStatusService.shared private let statusService = BackupStatusService.shared
private let store = ConnectionStore.shared private let store = ConnectionStore.shared
@@ -68,14 +70,12 @@ final class AutoBackupCoordinator: ObservableObject {
/// Call when the app becomes active: on first launch, foreground resume, or session unlock. /// Call when the app becomes active: on first launch, foreground resume, or session unlock.
/// Safe to call repeatedly guards prevent redundant reconciliation. /// Safe to call repeatedly guards prevent redundant reconciliation.
func onActive() { func onActive(lanTriggered: Bool = false) {
lastTriggerWasLAN = lanTriggered
guard !engine.job.status.isActive else { return } guard !engine.job.status.isActive else { return }
guard store.savedConnection != nil else { return } guard store.savedConnection != nil else { return }
guard phase == .idle else { return } // Don't interrupt an in-progress check guard phase == .idle else { return } // Don't interrupt an in-progress check
phase = .checking phase = .checking
// force: false BackupStatusService's 30-second debounce prevents expensive
// rescans on rapid foreground/background cycles. The cached snapshot is shown
// immediately; reconciliation only runs when truly stale.
statusService.refresh(force: false) statusService.refresh(force: false)
} }
@@ -105,9 +105,16 @@ final class AutoBackupCoordinator: ObservableObject {
return return
} }
// Auto-backup setting is off show count but don't start // Gate auto-backup on trigger source:
guard store.autoBackupEnabled else { // 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 phase = .idle
log.info("Auto-backup gated — trigger=\(self.lastTriggerWasLAN ? "LAN" : "appOpen") enabled=\(autoAllowed)")
return return
} }
@@ -116,8 +123,8 @@ final class AutoBackupCoordinator: ObservableObject {
private func handleNASReachable() { private func handleNASReachable() {
guard case .disconnected = phase else { return } guard case .disconnected = phase else { return }
log.info("NAS reachable again — rescheduling auto-check") log.info("NAS reachable again — rescheduling auto-check (LAN trigger)")
onActive() onActive(lanTriggered: true)
} }
// MARK: Auto-backup trigger // MARK: Auto-backup trigger

View File

@@ -132,6 +132,15 @@ final class BackupStatusService: NSObject, ObservableObject {
let filter = store.backupFilter let filter = store.backupFilter
log.info("""
[reconcile] host=\(conn.host, privacy: .public) remotePath=\(conn.remotePath, privacy: .public) \
manifestPath=\(conn.remotePath, privacy: .public)/\(BackupManifest.remoteFilename, privacy: .public) \
filter=photos:\(filter.includePhotos) videos:\(filter.includeVideos) \
screenshots:\(filter.includeScreenshots) raw:\(filter.includeRAW) \
autoBackupEnabled=\(store.autoBackupEnabled) autoBackupOnOpen=\(store.autoBackupOnOpen)
""")
// Fast path: use local caches when both are warm // Fast path: use local caches when both are warm
// Skips NAS connection + PHFetchResult batch enumeration entirely. // Skips NAS connection + PHFetchResult batch enumeration entirely.
// Triggered when: manifest cache < 5 min old AND LocalPhotoIndex has data. // Triggered when: manifest cache < 5 min old AND LocalPhotoIndex has data.
@@ -143,7 +152,7 @@ final class BackupStatusService: NSObject, ObservableObject {
ManifestIndex(manifest: cached) ManifestIndex(manifest: cached)
}.value }.value
let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter) let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
let phoneTotal = await LocalPhotoIndex.shared.totalCount let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter)
var updated = BackupStatusSnapshot() var updated = BackupStatusSnapshot()
updated.phoneTotal = phoneTotal updated.phoneTotal = phoneTotal
updated.alreadySafe = min(safe, phoneTotal) updated.alreadySafe = min(safe, phoneTotal)
@@ -152,7 +161,7 @@ final class BackupStatusService: NSObject, ObservableObject {
updated.lastCheckedAt = Date() updated.lastCheckedAt = Date()
snapshot = updated snapshot = updated
saveSnapshot() saveSnapshot()
log.info("Reconcile (cached): \(phoneTotal) phone, \(safe) safe, \(index.totalCount) NAS") log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(index.totalCount) needBackup=\(phoneTotal - min(safe, phoneTotal))")
return return
} }
@@ -322,6 +331,11 @@ final class BackupStatusService: NSObject, ObservableObject {
func writeManifest(entries: [ManifestEntry], connection: NASConnection) async { func writeManifest(entries: [ManifestEntry], connection: NASConnection) async {
guard !entries.isEmpty else { return } guard !entries.isEmpty else { return }
// Capture cache before connecting used as fallback if NAS download fails.
// This prevents discarding accumulated history when the connection is flaky.
let cacheBase = await NASManifestCache.shared.manifest
let transfer: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService() let transfer: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService()
do { do {
try await transfer.connect( try await transfer.connect(
@@ -331,8 +345,9 @@ final class BackupStatusService: NSObject, ObservableObject {
defer { transfer.disconnect() } defer { transfer.disconnect() }
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
log.info("writeManifest: path=\(manifestPath, privacy: .public) entries=\(entries.count)")
// Download existing manifest (async, main actor suspended). // Try NAS download authoritative if successful.
let existingData = try? await transfer.downloadData(at: manifestPath) let existingData = try? await transfer.downloadData(at: manifestPath)
// Merge + JSON encode off main actor can be slow for large manifests. // Merge + JSON encode off main actor can be slow for large manifests.
@@ -340,7 +355,9 @@ final class BackupStatusService: NSObject, ObservableObject {
var manifest: BackupManifest var manifest: BackupManifest
if let data = existingData, if let data = existingData,
let existing = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) { let existing = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) {
manifest = existing manifest = existing // NAS is authoritative when readable
} else if let base = cacheBase {
manifest = base // NAS download failed preserve history from cache
} else { } else {
manifest = BackupManifest() manifest = BackupManifest()
} }
@@ -353,14 +370,12 @@ final class BackupStatusService: NSObject, ObservableObject {
try await transfer.writeData(encoded, to: manifestPath) try await transfer.writeData(encoded, to: manifestPath)
// Update local cache immediately the next reconcile's fast path will read // Update local cache so fast-path reconcile reads fresh data.
// this fresh cache instead of re-downloading from NAS, preventing the stale-
// cache bug where nasArchiveTotal resets to the pre-backup count.
await NASManifestCache.shared.update(mergedManifest) await NASManifestCache.shared.update(mergedManifest)
lastManifest = mergedManifest lastManifest = mergedManifest
log.info("Manifest written\(entries.count) new entries, total \(mergedManifest.entries.count)") log.info("writeManifest: done\(entries.count) new, total=\(mergedManifest.entries.count)")
} catch { } catch {
log.error("Manifest write failed (non-fatal): \(error.localizedDescription)") log.error("writeManifest failed (non-fatal): \(error.localizedDescription)")
} }
} }

View File

@@ -50,6 +50,10 @@ actor LocalPhotoIndex {
var totalCount: Int { records.count } var totalCount: Int { records.count }
func count(filter: BackupFilter) -> Int {
records.values.filter { passes($0, filter: filter) }.count
}
func allRecords() -> [Record] { Array(records.values) } func allRecords() -> [Record] { Array(records.values) }
func countSafe(against index: ManifestIndex, filter: BackupFilter) -> Int { func countSafe(against index: ManifestIndex, filter: BackupFilter) -> Int {

View File

@@ -63,11 +63,16 @@ 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 // Auto-backup via LAN trigger (trusted Wi-Fi join)
@Published var autoBackupEnabled: Bool { @Published var autoBackupEnabled: Bool {
didSet { UserDefaults.standard.set(autoBackupEnabled, forKey: "autoBackupEnabled") } didSet { UserDefaults.standard.set(autoBackupEnabled, forKey: "autoBackupEnabled") }
} }
// Auto-backup on every app open off by default; requires explicit opt-in
@Published var autoBackupOnOpen: Bool {
didSet { UserDefaults.standard.set(autoBackupOnOpen, forKey: "autoBackupOnOpen") }
}
// 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") }
@@ -94,7 +99,8 @@ 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 autoBackupEnabled = UserDefaults.standard.object(forKey: "autoBackupEnabled") as? Bool ?? false
autoBackupOnOpen = UserDefaults.standard.object(forKey: "autoBackupOnOpen") as? Bool ?? false
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") ?? ""