Pull zone now shown on pages 0 and 2. Labels route to
PULL/RELEASE TO CLEAN on page 2. Releasing triggers
triggerCleanViaPull() which fires the clean confirmation dialog
with the same ripple effect as the backup pull.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
TimelineView drives a 2.4s cycle: two sin-bell chevron pulses
(primary + ghost trailing 7pt below) then a pause. Mirrors the
cascadingArrows pattern — suggests pull-down on all three pages.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
All pages now share circleSection. Page 2 shows a green arc
(freeable/backed-up fractions) with a leaf icon and GB FREEABLE
label at center, matching the anchored position of pages 0 and 1.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Three amber/gray capsule pills show the current page; a small
rounded-square button with an amber border advances to the next page.
Positioned below the status message (between heroView and the scroll
content), replacing the old left/right chevron bar at the bottom.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
9 files were present on disk but not referenced by NASBackup.xcodeproj,
causing 18 "cannot find type in scope" errors. Added to their respective
groups (Services, Models, Browse, Components) in the main app target.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Pages 0 (backup) and 1 (storage) now share a single KisaniRingView
instance. Arc values (progress, color) swap in-place via computed
properties so the ring never re-mounts or shifts position during
horizontal swipes. Page 2 (cleanup) retains its own hero layout.
Pull gesture and page-switch gesture coexist via simultaneousGesture.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Pull zone is now an .overlay(alignment: .top) offset upward by its own
height, so it appears above the ring without pushing it down. Removed the
ring's offset(y:) so it stays perfectly fixed while pulling.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupView:
- Move circle hero outside ScrollView so DragGesture no longer
competes with the ScrollView's pull-to-refresh capture
- Pull-to-refresh (native iOS gesture) now starts/resumes backup
instead of only refreshing status
- Ring diameter 240 → 270pt, ripple circle 258 → 288pt
- Restructure: heroView (fixed) + ScrollView(belowContent) + pageNav
- page1/page2 content split into heroView + belowContent dispatch
BackupStatusService:
- refreshAfterBackup now bumps nasArchiveTotal by newlyOnNAS count
(fileSize > 0 entries only), preventing the NAS stat from
flickering back to the pre-backup value while writeManifest runs
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Replace paginated ring center with full WidgetState machine (offline/checking/pending/running/safe/paused)
- Pull-down gesture on circle triggers backup with haptic + ripple animation
- Cascading TimelineView arrows, status dot pulse, live sub-label
- Add innerColor param to KisaniRingView for per-state color transitions
- Add pageSwitchGesture for horizontal swipe on pages 1 & 2
- Remove all action buttons; pull gesture is the sole backup trigger
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Adds a blue-tinted shadow glow to the outer NAS progress arc for
visibility. Darkens track circles (0.92 → 0.76) and the middle
static ring (0.78 → 0.62). Bumps outer arc lineWidth to 1.5.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Translates the React KisaniCircle design to SwiftUI: three concentric
thin rings (outer slate progress, middle static gray, inner green
progress) replace the old single ProgressRing. All ring center text
updated to ultraLight 46pt numbers with 8pt monospaced uppercase labels.
Adds ringCheckmark (22px circle + tiny checkmark) for completed/all-safe
states. Updates checking, disconnected, failed center views to match.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Replace ZStack-based cell with Color.clear.aspectRatio(1,.fit) + .overlay.
The overlay constrains all children (image, video badge, sync dot) to the
exact square frame, so scaledToFill cannot inflate the cell height in
LazyVGrid. Previously, the ZStack let the filled image push beyond the
aspectRatio constraint, causing inconsistent row heights.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
WHAT TO BACK UP section:
- Remove "New files only" / "Skip files already on NAS" toggle from Settings
- Remove BackupFilter.newFilesOnly field — duplicate protection is always active
(fileExists pre-check + hybrid filterIndex ensure NAS files are never re-uploaded)
NOTIFICATIONS section:
- Replace static decorative rows with real ToggleRow controls backed by
ConnectionStore (notifyOnStart=false, notifyOnComplete=true, notifyOnErrors=true)
- BackupEngine now gates notification calls behind these store flags
NETWORK section:
- WiFi row always shows ● On / ● Off indicator (was only shown when SSID known)
- Shows "Trusted" / "Unknown" when SSID is available, "On" / "Off" otherwise
Tests:
- makeAssets: add missing isScreenRecording parameter
- Rename test_backup_skipsExistingFiles_whenNewFilesOnly → _alwaysHardcoded,
remove filter.newFilesOnly assignment
- MockNASService: add downloadData, writeData, deleteFile to satisfy protocol
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- NASTransferProtocol: add deleteFile(at:) to enable server-side deletion
- SFTPService: implement deleteFile using Citadel sftp.remove(at:)
- SMBService: implement deleteFile using SMBClient deleteFile(path:)
- SyncView FREE UP SPACE: two new cards —
• "Clean up screenshots" — shows count of phone screenshots already
backed up to NAS, opens review sheet for bulk PHPhotoLibrary deletion
• "NAS screenshot quarantine" — identifies NAS files that match phone
screenshots, offers to move them into a Screenshots/ subfolder via
download → writeData → deleteFile per file
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Phase 1 was only checking the Kisani manifest for sync status. Phone photos
uploaded by other tools had no manifest entry, showed as orange (phone-only)
even though they were already on the NAS.
Now Phase 1 falls back to a filename lookup against the full NAS directory
listing: manifest match → green (.synced), NAS directory match → green (.synced),
no match → orange (.phone). Phase 3 skips filenames already resolved in Phase 1.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Gallery NAS was only showing Kisani-manifest-tracked files (2 of 7432). Now
fetches the full NAS directory listing on load and adds Phase 3 items for files
not in the manifest (uploaded by other tools) — all 7432 files appear in the
gallery with thumbnails loaded lazily on scroll.
Settings network section: "No Wi-Fi" → "Wi-Fi" when connected but SSID is nil
(no location permission). Added cellular status row below Wi-Fi row.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
The root cause: buildManifestIndex was discarding manifest localIdentifiers and
switching to pure filename-only matching whenever dirCount > manifest.totalCount
(which is always true when the NAS has files from other tools). If filenames didn't
match exactly, Already Safe dropped to 0 on every stale-cache reconcile.
Fix: introduce ManifestIndex(manifest:nasFilenames:totalCount:) — a hybrid that keeps
Kisani's localIdentifiers as primary keys AND adds all NAS directory filenames for
filename-fallback. Used in:
- buildManifestIndex (full reconcile)
- fast path (when dirCount > cached.entries.count, previously gated at <= 1)
- BackupEngine step 3b (so "Back up again" only uploads genuinely missing files)
Also simplify countSafe / pendingIDs / step-4 filter to always try filename fallback
(removes the isFilenameOnly gate that blocked matches when the index had IDs).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Use UIColor.secondarySystemGroupedBackground / tertiarySystemFill so
the segmented control adapts correctly to light and dark mode; darken
the shadow in dark mode so the selected pill is still visible.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Adds cellular row (antenna icon) below Wi-Fi in connection status section;
orange when active, muted when not in use
- Replaces "Retry Failed" header button in errors section with "Clear Errors"
(destructive, calls clearErrorHistory); retry action retained in error detail sheet
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Screen recordings are videos captured by iOS screen recording and are
almost never wanted in a photo backup. They are now excluded from all
counts and backup queues unless explicitly enabled.
Changes:
- BackupFilter.includeScreenRecordings (default: false) added
- PHAssetMediaSubtype.videoScreenRecording predicate applied in both
fetchAssets() and fetchResultForReconciliation() — excluded at the
PHFetchRequest level before enumeration
- LocalPhotoIndex.Record.isScreenRecording populated via makeRecord()
so the fast-path countSafe/count also respects the toggle
- LocalPhotoIndex.passes() checks includeScreenRecordings
- Settings "Screen Recordings" toggle added below Screenshots
This also resolves the transient count flash: screen recordings were
being included in the "need backup" tally on the initial fast-path
reconcile, then dropping when the filter was fully applied.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupEngine was building pendingAssets from ManifestIndex(manifest:) which
only knows about files Kisani previously uploaded. When most NAS files were
uploaded by other tools, nearly all phone photos passed the pending filter
and "Back up again" would queue and attempt to re-upload everything.
Fix: step 3b builds a filterIndex from the cached NAS directory filenames
(or a fresh listDirectory scan when cache is unavailable) when the directory
has more files than the manifest. The filename-based filterIndex correctly
identifies only photos whose filenames are absent from the NAS, so "Back up
again" queues exactly the delta — not a full library re-upload.
The baseManifest/baseIndex are unchanged and still drive the manifest write.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- buildManifestIndex now always scans the NAS directory on every full reconcile
(removed the `totalCount <= 1` guard) so NAS Archive count updates accurately
on every manual refresh, not just when the manifest is new/sparse
- The dir > manifest check still switches to filename-based matching when the
directory has more files than Kisani's manifest (uploaded by other tools)
- checkStatusRow shows NAS connection status (orange dot + "NAS Connected" /
red dot + "NAS Offline") below the timestamp line
- BackupFilter now conforms to Equatable; BackupView re-reconciles immediately
when the screenshot/video/RAW filter toggles change
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Move "X sec ago / refresh" row from inside statsStrip to directly below the
ring hero so the last-checked time is visible near the status it describes
- Remove "Checking status…" spinner from the action button — button shows
"Start backup" / "Back up again" at all times
- Remove "Checking…" spinner from the ring center (page 0) — ring always
shows the pending backup count or "All photos safe"
- Re-enable autoBackupEnabled default (true) now that already-safe counting
is accurate; auto backup will fire on LAN join when items need backup
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
When Kisani's manifest has 0–1 entries but the NAS directory contains thousands of
files uploaded by other tools, reconcile previously showed Already Safe = 1 because
countSafe only matched against the tiny manifest.
The validity check in buildManifestIndex now:
- Stores the filtered NAS filenames in NASManifestCache (directoryFilenames field)
- Returns a ManifestIndex(nasListing:) for filename-based matching against all NAS files
The fast-path reconcile now:
- Detects a sparse manifest (entries ≤ 1, dirCount > 1)
- Rebuilds ManifestIndex(nasFilenames:) from the cached filenames for O(1) filename matching
- Falls back to full NAS reconcile if cached filenames aren't available yet
Adds ManifestIndex.init(nasFilenames:) for fast-path reconstruction from cached strings.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Root cause of the alreadySafe=1 loop: after a backup where all assets are
collision-skipped (Object Name Collision), writeManifest() connects to the NAS
to write the updated manifest. If that connection fails or times out, the cache
is never updated with the 263 new entries, so the next refresh(force:true) reads
the old 1-entry cache and computes alreadySafe=1 → needBackup=263 → loop.
BackupEngine: after the upload loop, immediately merge all manifestEntries
(uploaded + collision-skipped + fileExists-skipped) into baseManifest and call
NASManifestCache.shared.update(). This happens before refreshAfterBackup so
the local truth is correct regardless of whether the NAS write later succeeds.
BackupStatusService.writeManifest: move NASManifestCache.shared.update() to
before transfer.writeData(). The local cache is now updated as soon as the
merged manifest is computed, decoupling local reconcile correctness from NAS
write success. The NAS write remains best-effort and non-blocking.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
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>
SMBClient throws ErrorResponse with NTStatus.objectNameCollision (0xC0000035)
when a file already exists on the NAS. BackupError.uploadFailed wraps this,
hiding the real cause from the retry/failure logic.
Add isAlreadyExistsError() that unwraps BackupError.uploadFailed and checks
the underlying error for "object name collision", "already exist", or POSIX
EEXIST (code 17). When detected inside the retry catch block, the asset is
immediately marked as skipped and added to manifestEntries — no 3-retry
penalty (saves up to 6s per already-existing file), no false failure count.
The post-retry fileExists fallback is retained for edge cases where the file
lands on NAS but the error wasn't a collision (e.g. connection reset after
partial upload).
Also switch BackupQueueItem.UploadError to store the underlying transport
error's domain and description instead of the BackupError wrapper text, so
the SyncView failure list shows the real reason (e.g. "Access Denied",
"IO Timeout") rather than the generic "Failed to upload <file>".
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupEngine: after all retries fail, call fileExists() before recording a
failure. If the file is on NAS (uploaded earlier but fileExists silently
threw on the pre-check), count it as skipped and add it to manifestEntries —
this ensures writeManifest includes every safe asset and prevents alreadySafe
from dropping to 1 after the post-backup reconcile.
BackupView: remove the immediate refreshAndWait from onChange(.failed). That
call raced with refreshAfterBackup's writeManifest, cancelled the manifest-
correct refresh, then reconciled against a stale 1-entry NAS manifest.
Auto-resolve (.failed → .completed when needBackup == 0) is now owned by the
coordinator so it runs after writeManifest finishes.
AutoBackupCoordinator: add backupTriggerArmed flag (set by onActive/
handleNASReachable, cleared by handleReconciliationComplete). Post-backup
reconciles from refreshAfterBackup are no longer able to re-trigger auto-
backup — only an explicit app-open or LAN-join can arm the trigger. Also
added auto-resolve: calls engine.resolveSuccess() when job is .failed but
needBackup == 0, which fires the .completed green-flash path in BackupView.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- NASManifestCache: add directoryCount field (Int?) stored independently from
manifest.entries.count; add setDirectoryCount(_:) and addToDirectoryCount(_:);
update(_:) now preserves existing directoryCount so manifest refreshes never
reset the real folder count to zero
- BackupEngine step 3: switch from "NAS if non-empty, else cache" to
max(NAS, cache) by entry count — a 1-entry NAS manifest no longer overwrites
a 264-entry cache, preventing history loss and the Already Safe = 1 regression
- BackupEngine post-upload: call addToDirectoryCount(uploaded) so NAS Archive
increments correctly (7422 → 7423) without requiring a full directory rescan
- BackupStatusService.buildManifestIndex: call setDirectoryCount(dirCount) in
both the validity-check path (manifest ≤ 1 entry) and the bootstrap path so
the real folder count survives across reconcile cycles
- BackupStatusService.writeManifest: same max(NAS, cache) base-selection logic
to fix Already Safe = 1 after backup ends (merge base was 1-entry NAS instead
of 264-entry cache)
- BackupStatusService.reconcile (fast + full path): use max(index.totalCount,
directoryCount) for nasArchiveTotal so Gallery and stats strip reflect the
real NAS folder count, not just Kisani manifest entries
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupEngine — manifest loading never poisons NASManifestCache:
Before: if the NAS manifest download failed for any reason,
BackupManifest() (0 entries) was written to NASManifestCache.
Every subsequent fast-path reconcile read the poisoned cache and
set nasArchiveTotal = 0, making every asset appear as needing backup.
Checkpoint writes then set the cache to 1 entry (first upload), which
is why NAS Archive flipped from 7422 to 1 when starting a backup.
After: NAS > cache > empty. If NAS download fails or returns an empty
manifest, the existing cache is used as the authoritative base and is
never overwritten. Only a non-empty NAS manifest replaces the cache.
BackupView — live counters stay on persistent totals:
- nasArchiveDisplayCount: snapshot.nasArchiveTotal + uploadedFiles during
active backup so the NAS Archive card grows as uploads are confirmed
instead of jumping to the session count
- alreadySafeDisplayCount: adds both uploadedFiles AND skippedFiles —
skipped means the file was already on NAS (fileExists=true) but not
yet in the manifest, so it is safe and should be counted immediately
- notBackedUpDisplayCount already derived from alreadySafeDisplayCount
so it correctly shrinks as uploads and skips are confirmed
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- BackupView: action button shows "Checking status…" with spinner and blocks
taps while coordinator.phase == .checking — prevents starting a backup
before reconciliation has determined what actually needs uploading
- BackupView: NAS status row shows statusService.refreshError (actual SMB
error reason: auth failure, timeout, host unreachable) instead of the
generic "Unreachable" string — surfaces the real diagnosis to the user
- BackupStatusService: invariant assertion after every full reconcile — logs
.error if alreadySafe + needBackup ≠ phoneTotal so filter mismatches and
off-by-ones surface immediately in Console.app
- BackupStatusService.buildManifestIndex: full diagnostic logging — remotePath,
manifest file path, download byte count, decoded entry count, bootstrap
fallback reason — all visible in Console.app filtered by "BackupStatus"
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task
and scenePhase triggers (navigation appear, rapid foreground cycles) are
no-ops; start a 60s periodic recheck timer on first active call; stop timer
and reset debounce on willResignActiveNotification so each new foreground
session always gets an immediate fresh status check
- BackupView: remove the redundant lanMonitor.nasReachable onChange that was
calling statusService.refresh() directly — coordinator already handles this
via its Combine $nasReachable subscriber, avoiding a double reconcile that
could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value
- BackupStatusService: when NAS connect fails, restore nasArchiveTotal from
NASManifestCache before writing the offline snapshot — prevents the count
from showing 0 when the NAS is temporarily unreachable but the cache is warm
- BackupStatusService: manifest validity check — if the decoded manifest has
≤ 1 entries (corrupt or newly created), scan the NAS directory and use the
real file count as the display floor for nasArchiveTotal without adding
orphan entries to the manifest (that was the 7421 regression)
- BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for
the validity check path — keeps localIdentifier matching intact while
correcting the displayed archive count
- SMBService: add os.log around connect/auth/listDirectory with actual error
reason instead of always surfacing authenticationFailed; distinguish network
errors (timeout, host unreachable) from auth errors in thrown BackupError
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- LocalPhotoIndex: add count(filter:) so fast-path reconcile uses the
filtered asset count as phoneTotal instead of the unfiltered totalCount;
fixes the broken alreadySafe + needBackup == phoneTotal invariant
- BackupStatusService: fast-path uses count(filter:) instead of totalCount;
writeManifest falls back to NASManifestCache when NAS download fails so
accumulated history is never silently discarded on a flaky connection
- ConnectionStore: autoBackupEnabled defaults false; new autoBackupOnOpen
(default false) separates LAN-join auto-backup from app-open auto-backup
- AutoBackupCoordinator: onActive(lanTriggered:) tracks trigger source;
handleReconciliationComplete gates on autoBackupEnabled for LAN joins and
autoBackupOnOpen for app-open — prevents backup starting on every launch
- SettingsView: split auto-backup into two toggles with accurate descriptions
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
The directory healing step added every media file in the NAS folder as a manifest
orphan — including files from other devices — inflating NAS Archive to 7421 and
polluting the manifest with unrelated entries. Removed entirely.
Root cause of the loop: when BackupEngine calls fileExists and skips a file already
on NAS, it never added that file to manifestEntries. The file existed on NAS but
not in the manifest, so every reconcile still showed it as pending, triggering a
new backup on every app open.
Fix: skipped files are now added to manifestEntries with their localIdentifier so
they are written to the manifest at the end of the run (and at checkpoints). Next
reconcile sees them as safe via localIdentifier match and needBackup decrements.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Root cause: ManifestIndex.isFilenameOnly is false as soon as any single entry has a
localIdentifier. All filename-only entries (orphans uploaded in prior sessions without
a saved localIdentifier) are never matched, so their assets appear pending on every
reconcile and the backup re-starts from scratch on every app open.
Fix: add unclaimedFilenames set — filenames of entries with localIdentifier: "".
Use this as a targeted fallback in countSafe / pendingIDs / BackupEngine filter:
an asset not matched by localIdentifier is still considered safe if its filename
matches an unclaimed entry. This is safe because unclaimed entries are specifically
files we know are on NAS but whose identity we can only track by name.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Two root causes:
1. Backup interrupted before loop completion = manifest write never fires,
leaving uploaded files as NAS orphans invisible to gallery and dedup.
2. Manifest only written once at loop end, so any crash loses all progress.
Fixes:
- On each backup run, list the NAS directory and merge any files not tracked
in the manifest (orphaned from prior interrupted sessions). The healed
manifest is written back to NAS and NASManifestCache before uploads start,
so the gallery reflects actual NAS contents immediately on next open.
- Incremental checkpoint writes every 25 uploads using the already-open
transfer connection — interrupted backups now preserve progress in blocks
of 25 instead of losing everything.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- writeManifest now updates NASManifestCache after every successful NAS write;
previously the fast-path reconcile would read the pre-backup cache entry
(e.g. 1 entry) and overwrite nasArchiveTotal / alreadySafe with stale values
- BackupEngine seeds NASManifestCache when it loads the manifest so the cache
is always at least as fresh as what the engine sees at backup-start time
- Remove snapshot.nasArchiveTotal += uploaded from refreshAfterBackup; the
correct total comes from the merged manifest written to NAS, not an additive
delta from a potentially-wrong baseline
- Fix JSONDecoder() → JSONDecoder.kisani throughout manifest load paths so
ISO8601 dates decode correctly instead of silently failing and returning nil
- Add retry with exponential backoff (max 3 attempts, 2 s / 4 s intervals)
to the upload loop; permanently-failed items are now marked .failed only
after all retries are exhausted
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
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>
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>
AutoBackupCoordinator (new):
- @MainActor singleton that owns all auto-backup decisions
- Phase model: idle / checking / autoBackingUp / disconnected
- idle: nothing pending or destination not set
- checking: reconciliation in progress before decision
- autoBackingUp: coordinator-triggered BackupEngine run active
- disconnected: NAS offline, pending count queued for retry
- Combine subscription on BackupStatusService.$isRefreshing fires
handleReconciliationComplete() on every reconcile completion — the
single entry point for all auto-backup decisions
- Combine subscription on LANMonitor.$nasReachable fires
handleNASReachable() which retries only when phase == .disconnected
- Guards: autoBackupEnabled, quiet hours, chargingOnlyMode + battery state
- canAutoBackup also blocks when engine.job.status.isActive to prevent
double-start from concurrent photo library change + foreground events
BackupView:
- .task + .onChange(of: scenePhase) now call coordinator.onActive() instead
of statusService.refresh() directly — coordinator decides whether to also
start a backup after reconciliation completes
- ringContentID includes coordinator phase suffix → ring animates on
checking/disconnected transitions
- ringCenterContent case 0: when engine is idle, shows:
- disconnectedRingView if phase == .disconnected and needBackup > 0
(wifi.slash icon, "Waiting for NAS", queued count)
- checkingRingView if phase == .checking (spinner, "Checking…")
- existing ringMainView otherwise
- ringColor: dim orange (0.35 opacity) when disconnected with pending items
- Pull-to-refresh reconciliation triggers auto-backup via Combine chain
ConnectionStore:
- autoBackupEnabled: Bool, default true, UserDefaults-persisted
SettingsView:
- AUTO BACKUP section at top with autoBackupEnabled toggle
BackgroundTaskManager:
- BGProcessingTask: quiet-hours guard, requiresExternalPower mirrors
chargingOnlyMode, expiration handler calls engine.cancel() for clean exit
- BGAppRefreshTask: lightweight refresh only (no upload in background refresh)
- Both tasks reschedule themselves before doing any work
AppDelegate:
- UIDevice.current.isBatteryMonitoringEnabled = true for battery checks
NASBackupApp:
- AutoBackupCoordinator.shared injected as @StateObject + environmentObject
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>