diff --git a/Features/Browse/GalleryView.swift b/Features/Browse/GalleryView.swift index 06db221..d96cd8b 100644 --- a/Features/Browse/GalleryView.swift +++ b/Features/Browse/GalleryView.swift @@ -36,6 +36,7 @@ struct GalleryView: View { ) } .task { + viewModel.bind(to: statusService, connection: store.savedConnection) await viewModel.load(connection: store.savedConnection, statusService: statusService) } .refreshable { diff --git a/Features/Browse/GalleryViewModel.swift b/Features/Browse/GalleryViewModel.swift index 1cdcb38..96a9aed 100644 --- a/Features/Browse/GalleryViewModel.swift +++ b/Features/Browse/GalleryViewModel.swift @@ -19,6 +19,8 @@ final class GalleryViewModel: ObservableObject { private(set) var allItems: [GalleryItem] = [] private var loadTask: Task? private var cancellables = Set() + private weak var boundStatusService: BackupStatusService? + private var boundConnection: NASConnection? init() { $tab @@ -33,18 +35,49 @@ final class GalleryViewModel: ObservableObject { .store(in: &cancellables) } + /// Call once to wire up reactive reloading when the manifest arrives. + func bind(to statusService: BackupStatusService, connection: NASConnection?) { + boundStatusService = statusService + boundConnection = connection + // When BackupStatusService finishes reconciling, reload if our items are stale. + statusService.$lastManifest + .dropFirst() + .compactMap { $0 } + .filter { !$0.entries.isEmpty } + .receive(on: RunLoop.main) + .sink { [weak self] manifest in + guard let self else { return } + // Only reload if the manifest has MORE items than what we're showing. + let currentNASCount = self.allItems.filter { + if case .nas = $0.source { return true } + if case .synced = $0.source { return true } + return false + }.count + if manifest.entries.count > currentNASCount { + Task { await self.load(connection: connection, statusService: statusService) } + } + } + .store(in: &cancellables) + } + func load(connection: NASConnection?, statusService: BackupStatusService) async { loadTask?.cancel() isLoading = true nasError = nil - // Prefer the in-memory lastManifest (most recent reconcile), fall back to - // disk-cached NASManifestCache (survives launches without NAS connection). - let manifestEntries: [ManifestEntry] - if let m = statusService.lastManifest { + // Resolution order for manifest entries: + // 1. statusService.lastManifest — in-memory, freshest (set after each reconcile) + // 2. NASManifestCache disk cache — survives restarts without NAS connection + // 3. Direct NAS download — used when both caches are cold (first launch after install) + var manifestEntries: [ManifestEntry] + if let m = statusService.lastManifest, !m.entries.isEmpty { manifestEntries = m.entries - } else if let cached = await NASManifestCache.shared.manifest { + } else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty { manifestEntries = cached.entries + } else if let conn = connection { + // Both caches are cold — download the manifest directly so the gallery + // isn't blank while BackupStatusService reconcile is still pending. + manifestEntries = await Self.fetchManifestDirect(conn: conn) } else { manifestEntries = [] } @@ -62,6 +95,25 @@ final class GalleryViewModel: ObservableObject { isLoading = false } + /// Downloads the NAS manifest directly when local caches are empty. + /// Caches the result so subsequent opens are instant. + private nonisolated static func fetchManifestDirect(conn: NASConnection) async -> [ManifestEntry] { + do { + let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() + try await transfer.connect(to: conn.host, port: conn.port, + username: conn.username, password: conn.password) + let path = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" + let data = try await transfer.downloadData(at: path) + transfer.disconnect() + let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) + if let manifest { + await NASManifestCache.shared.update(manifest) + return manifest.entries + } + } catch {} + return [] + } + func recomputeSections() { let filtered = filteredItems() sections = buildSections(from: filtered)