diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index 8c2eb8a..8370fcb 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -50,11 +50,18 @@ struct SettingsView: View { VStack(spacing: 0) { ToggleRow( - icon: "arrow.clockwise.icloud", - title: "Auto backup", - subtitle: "Back up new photos automatically when the app opens", + icon: "wifi", + title: "Auto-backup on home network", + subtitle: "Start backup automatically when joining a trusted Wi-Fi network", 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) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) diff --git a/Services/AutoBackupCoordinator.swift b/Services/AutoBackupCoordinator.swift index dfa1614..fa06303 100644 --- a/Services/AutoBackupCoordinator.swift +++ b/Services/AutoBackupCoordinator.swift @@ -29,6 +29,8 @@ final class AutoBackupCoordinator: ObservableObject { @Published private(set) var phase: Phase = .idle @Published private(set) var pendingAtDisconnect: Int = 0 + private var lastTriggerWasLAN = false + private let engine = BackupEngine.shared private let statusService = BackupStatusService.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. /// 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 store.savedConnection != nil else { return } guard phase == .idle else { return } // Don't interrupt an in-progress check 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) } @@ -105,9 +105,16 @@ final class AutoBackupCoordinator: ObservableObject { return } - // Auto-backup setting is off — show count but don't start - guard store.autoBackupEnabled else { + // 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 } @@ -116,8 +123,8 @@ final class AutoBackupCoordinator: ObservableObject { private func handleNASReachable() { guard case .disconnected = phase else { return } - log.info("NAS reachable again — rescheduling auto-check") - onActive() + log.info("NAS reachable again — rescheduling auto-check (LAN trigger)") + onActive(lanTriggered: true) } // MARK: — Auto-backup trigger diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index dfc9bc8..3981090 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -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)") } } diff --git a/Services/LocalPhotoIndex.swift b/Services/LocalPhotoIndex.swift index c4be159..e6d20ac 100644 --- a/Services/LocalPhotoIndex.swift +++ b/Services/LocalPhotoIndex.swift @@ -50,6 +50,10 @@ actor LocalPhotoIndex { 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 countSafe(against index: ManifestIndex, filter: BackupFilter) -> Int { diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift index 33b4dc2..e25ff32 100644 --- a/Shared/Persistence/ConnectionStore.swift +++ b/Shared/Persistence/ConnectionStore.swift @@ -63,11 +63,16 @@ final class ConnectionStore: ObservableObject { 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 { 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 @Published var allowCellularBackup: Bool { didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") } @@ -94,7 +99,8 @@ final class ConnectionStore: ObservableObject { onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown") appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system 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 useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""