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:
@@ -19,6 +19,8 @@ final class GalleryViewModel: ObservableObject {
|
||||
private(set) var allItems: [GalleryItem] = []
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user