fix: NAS gallery shows backed-up files even when caches are cold

GalleryViewModel.load() now has a three-tier resolution for manifest entries:
  1. statusService.lastManifest — in-memory, freshest
  2. NASManifestCache disk cache — survives restarts
  3. Direct NAS download — fallback when both caches are cold (first launch after install,
     or before BackupStatusService reconcile has completed)

The direct download also populates NASManifestCache so subsequent opens are instant.

Also adds GalleryViewModel.bind(to:connection:) which subscribes to
statusService.$lastManifest and automatically reloads gallery items when
BackupStatusService finishes reconciling — gallery updates without manual pull-to-refresh.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 23:40:18 +03:00
parent 1afcbaff96
commit a98822d7b2
2 changed files with 58 additions and 5 deletions

View File

@@ -36,6 +36,7 @@ struct GalleryView: View {
) )
} }
.task { .task {
viewModel.bind(to: statusService, connection: store.savedConnection)
await viewModel.load(connection: store.savedConnection, statusService: statusService) await viewModel.load(connection: store.savedConnection, statusService: statusService)
} }
.refreshable { .refreshable {

View File

@@ -19,6 +19,8 @@ final class GalleryViewModel: ObservableObject {
private(set) var allItems: [GalleryItem] = [] private(set) var allItems: [GalleryItem] = []
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private weak var boundStatusService: BackupStatusService?
private var boundConnection: NASConnection?
init() { init() {
$tab $tab
@@ -33,18 +35,49 @@ final class GalleryViewModel: ObservableObject {
.store(in: &cancellables) .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 { func load(connection: NASConnection?, statusService: BackupStatusService) async {
loadTask?.cancel() loadTask?.cancel()
isLoading = true isLoading = true
nasError = nil nasError = nil
// Prefer the in-memory lastManifest (most recent reconcile), fall back to // Resolution order for manifest entries:
// disk-cached NASManifestCache (survives launches without NAS connection). // 1. statusService.lastManifest in-memory, freshest (set after each reconcile)
let manifestEntries: [ManifestEntry] // 2. NASManifestCache disk cache survives restarts without NAS connection
if let m = statusService.lastManifest { // 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 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 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 { } else {
manifestEntries = [] manifestEntries = []
} }
@@ -62,6 +95,25 @@ final class GalleryViewModel: ObservableObject {
isLoading = false 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() { func recomputeSections() {
let filtered = filteredItems() let filtered = filteredItems()
sections = buildSections(from: filtered) sections = buildSections(from: filtered)