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:
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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") ?? ""
|
||||||
|
|||||||
Reference in New Issue
Block a user