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:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user