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:
@@ -132,6 +132,15 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
|
||||
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 ────────────────
|
||||
// Skips NAS connection + PHFetchResult batch enumeration entirely.
|
||||
// Triggered when: manifest cache < 5 min old AND LocalPhotoIndex has data.
|
||||
@@ -143,7 +152,7 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
ManifestIndex(manifest: cached)
|
||||
}.value
|
||||
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()
|
||||
updated.phoneTotal = phoneTotal
|
||||
updated.alreadySafe = min(safe, phoneTotal)
|
||||
@@ -152,7 +161,7 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
updated.lastCheckedAt = Date()
|
||||
snapshot = updated
|
||||
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
|
||||
}
|
||||
|
||||
@@ -322,6 +331,11 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
|
||||
func writeManifest(entries: [ManifestEntry], connection: NASConnection) async {
|
||||
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()
|
||||
do {
|
||||
try await transfer.connect(
|
||||
@@ -331,8 +345,9 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
defer { transfer.disconnect() }
|
||||
|
||||
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)
|
||||
|
||||
// Merge + JSON encode off main actor — can be slow for large manifests.
|
||||
@@ -340,7 +355,9 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
var manifest: BackupManifest
|
||||
if let data = existingData,
|
||||
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 {
|
||||
manifest = BackupManifest()
|
||||
}
|
||||
@@ -353,14 +370,12 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
|
||||
try await transfer.writeData(encoded, to: manifestPath)
|
||||
|
||||
// Update local cache immediately — the next reconcile's fast path will read
|
||||
// this fresh cache instead of re-downloading from NAS, preventing the stale-
|
||||
// cache bug where nasArchiveTotal resets to the pre-backup count.
|
||||
// Update local cache so fast-path reconcile reads fresh data.
|
||||
await NASManifestCache.shared.update(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 {
|
||||
log.error("Manifest write failed (non-fatal): \(error.localizedDescription)")
|
||||
log.error("writeManifest failed (non-fatal): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user