perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
import Foundation
|
|
|
|
|
import os.log
|
|
|
|
|
|
|
|
|
|
private let log = Logger(subsystem: "com.albert.nasbackup", category: "ManifestCache")
|
|
|
|
|
|
|
|
|
|
/// Persists a copy of the NAS backup manifest on-device so reconciliation can
|
|
|
|
|
/// skip the NAS network round-trip when the cache is still fresh.
|
|
|
|
|
///
|
|
|
|
|
/// Cache is updated after every successful manifest download and after every
|
|
|
|
|
/// completed backup. Stale threshold = 5 minutes. The actor serialises all
|
|
|
|
|
/// reads and writes so the cache is safe to use across tasks and threads.
|
|
|
|
|
actor NASManifestCache {
|
|
|
|
|
static let shared = NASManifestCache()
|
|
|
|
|
|
|
|
|
|
private var cached: CachedEntry?
|
|
|
|
|
private let diskURL: URL
|
fix: NAS operation timeouts, fast-path warm-up, 30-min stale threshold
Three changes that together eliminate the "Checking…" stall and excessive NAS
rescanning without requiring a database migration:
NASManifestCache: extend stale threshold from 5 min to 30 min. directoryCount
is now updated incrementally after every upload, and PHPhotoLibraryChangeObserver
keeps LocalPhotoIndex current — a full NAS roundtrip every 5 min was redundant
and forced the expensive reconcile path far more often than necessary.
BackupStatusService: add withTimeout(_:work:) using a racing ThrowingTaskGroup
so NAS operations (SMB connect, manifest download, directory listing) fail fast
instead of hanging indefinitely when the NAS is slow or unreachable. Timeouts:
15s for connect, 12s for manifest download, 12s for directory listings. On
timeout, BackupError.timeout is caught separately to preserve cached counts
instead of marking connectionState = .offline.
BackupStatusService: call LocalPhotoIndex.shared.loadOrBuild() at the start of
performRefresh before checking localIndexCount. Without this, AppDelegate's
concurrent loadOrBuild() task races with the first onActive() trigger; if it
loses, localIndexCount == 0 and the fast path is bypassed, forcing a full NAS
roundtrip on every cold launch.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 20:41:49 +03:00
|
|
|
// 30-minute window: directoryCount is updated incrementally after every upload,
|
|
|
|
|
// and PHPhotoLibraryChangeObserver keeps LocalPhotoIndex current. A full NAS
|
|
|
|
|
// roundtrip every 5 minutes was unnecessary and caused "Checking…" stalls.
|
|
|
|
|
private static let staleInterval: TimeInterval = 30 * 60
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
|
|
|
|
|
private struct CachedEntry: Codable {
|
|
|
|
|
var manifest: BackupManifest
|
|
|
|
|
var savedAt: Date
|
2026-05-18 18:03:56 +03:00
|
|
|
/// Real NAS destination folder file count from the last listDirectory scan.
|
|
|
|
|
/// Stored independently from manifest.entries.count — the folder may contain
|
|
|
|
|
/// files uploaded by other tools that are not in Kisani's manifest.
|
|
|
|
|
var directoryCount: Int?
|
2026-05-18 21:07:00 +03:00
|
|
|
/// Filtered NAS filenames from the last validity-check or bootstrap scan.
|
|
|
|
|
/// Present only when the manifest was found sparse vs. actual NAS contents.
|
|
|
|
|
/// Used by the fast-path reconcile to do filename matching without hitting NAS.
|
|
|
|
|
var directoryFilenames: [String]?
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private init() {
|
|
|
|
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
|
|
|
diskURL = caches.appendingPathComponent("kisani_manifest_cache.json")
|
|
|
|
|
if let data = try? Data(contentsOf: diskURL),
|
|
|
|
|
let entry = try? JSONDecoder.kisani.decode(CachedEntry.self, from: data) {
|
|
|
|
|
cached = entry
|
2026-05-18 18:03:56 +03:00
|
|
|
log.info("ManifestCache: loaded \(entry.manifest.entries.count) entries from disk, dirCount=\(entry.directoryCount.map(String.init) ?? "nil")")
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the cached manifest without any I/O. Never nil after first reconcile.
|
|
|
|
|
var manifest: BackupManifest? { cached?.manifest }
|
|
|
|
|
|
2026-05-18 18:03:56 +03:00
|
|
|
/// Real NAS folder file count from the last listDirectory scan.
|
|
|
|
|
/// nil until the first full reconcile completes.
|
|
|
|
|
var directoryCount: Int? { cached?.directoryCount }
|
|
|
|
|
|
2026-05-18 21:07:00 +03:00
|
|
|
/// Cached NAS filenames from the last validity-check scan.
|
|
|
|
|
/// nil until the first validity-check reconcile completes with a sparse manifest.
|
|
|
|
|
var directoryFilenames: [String]? { cached?.directoryFilenames }
|
|
|
|
|
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
/// True when the cache is absent or older than the stale threshold.
|
|
|
|
|
var isStale: Bool {
|
|
|
|
|
guard let c = cached else { return true }
|
|
|
|
|
return Date().timeIntervalSince(c.savedAt) > NASManifestCache.staleInterval
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:03:56 +03:00
|
|
|
/// Persist a freshly-downloaded manifest. Preserves any existing directoryCount.
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
func update(_ manifest: BackupManifest) {
|
2026-05-18 18:03:56 +03:00
|
|
|
cached = CachedEntry(manifest: manifest, savedAt: Date(), directoryCount: cached?.directoryCount)
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
log.info("ManifestCache: updated — \(manifest.entries.count) entries")
|
2026-05-18 18:03:56 +03:00
|
|
|
saveToDisk()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Store the real NAS destination folder file count (from listDirectory scan).
|
|
|
|
|
func setDirectoryCount(_ count: Int) {
|
|
|
|
|
if var entry = cached {
|
|
|
|
|
entry.directoryCount = count
|
|
|
|
|
cached = entry
|
|
|
|
|
} else {
|
|
|
|
|
cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: count)
|
|
|
|
|
}
|
|
|
|
|
log.info("ManifestCache: directoryCount=\(count)")
|
|
|
|
|
saveToDisk()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 21:07:00 +03:00
|
|
|
/// Store filtered NAS filenames from a validity-check or bootstrap scan.
|
|
|
|
|
/// Also updates directoryCount atomically from the filename array length.
|
|
|
|
|
func setDirectoryFilenames(_ filenames: [String]) {
|
|
|
|
|
let count = filenames.count
|
|
|
|
|
if var entry = cached {
|
|
|
|
|
entry.directoryCount = count
|
|
|
|
|
entry.directoryFilenames = filenames
|
|
|
|
|
cached = entry
|
|
|
|
|
} else {
|
|
|
|
|
cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast,
|
|
|
|
|
directoryCount: count, directoryFilenames: filenames)
|
|
|
|
|
}
|
|
|
|
|
log.info("ManifestCache: directoryFilenames=\(count)")
|
|
|
|
|
saveToDisk()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:03:56 +03:00
|
|
|
/// Increment the stored directory count after a successful upload batch.
|
|
|
|
|
func addToDirectoryCount(_ delta: Int) {
|
|
|
|
|
guard delta > 0 else { return }
|
|
|
|
|
let next = (cached?.directoryCount ?? 0) + delta
|
|
|
|
|
if var entry = cached {
|
|
|
|
|
entry.directoryCount = next
|
|
|
|
|
cached = entry
|
|
|
|
|
} else {
|
|
|
|
|
cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: next)
|
|
|
|
|
}
|
|
|
|
|
log.info("ManifestCache: directoryCount += \(delta) → \(next)")
|
|
|
|
|
saveToDisk()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func saveToDisk() {
|
|
|
|
|
guard let entry = cached else { return }
|
perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00
|
|
|
Task.detached(priority: .utility) { [entry, url = diskURL] in
|
|
|
|
|
guard let data = try? JSONEncoder.kisani.encode(entry) else { return }
|
|
|
|
|
try? data.write(to: url, options: .atomic)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: — Shared encoder/decoder
|
|
|
|
|
|
|
|
|
|
extension JSONEncoder {
|
|
|
|
|
static let kisani: JSONEncoder = {
|
|
|
|
|
let e = JSONEncoder()
|
|
|
|
|
e.dateEncodingStrategy = .iso8601
|
|
|
|
|
return e
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension JSONDecoder {
|
|
|
|
|
static let kisani: JSONDecoder = {
|
|
|
|
|
let d = JSONDecoder()
|
|
|
|
|
d.dateDecodingStrategy = .iso8601
|
|
|
|
|
return d
|
|
|
|
|
}()
|
|
|
|
|
}
|