Commit Graph

98 Commits

Author SHA1 Message Date
Robin Kutesa
5751d1e36d fix(backup): align page 1 stats to same position as page 0 stats strip
Removed errant Spacer() that was pushing USED/FREE/JUNK below page 0 position.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-22 03:20:44 +03:00
Robin Kutesa
1fe24f5719 fix: remove stray character at end of NASBackupApp.swift
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-22 03:12:49 +03:00
Robin Kutesa
5049cfc7d6 feat(backup): toggleable backed-up photos in clean sheet; anchor page 1 stats to bottom
Backed-up photos row in clean sheet now toggles on/off like other rows.
Page 1 storage stats (USED/FREE/JUNK) pushed to bottom of screen via Spacer,
matching the alignment of the stats strip on the main backup page.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-22 03:08:44 +03:00
Robin Kutesa
255addd9f8 feat(backup): page 2 pull-down triggers refresh instead of clean
Pull-to-refresh on page 2 reloads device storage and cleanup data, then
calls statusService.refresh. Pages 0 and 1 retain backup and clean actions
respectively.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-22 02:19:14 +03:00
Robin Kutesa
8bd0149a60 Add Recently Deleted + screenshot sizes to cleanup page
- loadCleanupData() fetches Recently Deleted (by title) and
  Screenshots smart albums, estimates GB from asset counts
- Page 2 breakdown adds Recently Deleted row (red) and uses
  real screenshotsGB value; reordered: backed-up, deleted,
  screenshots, duplicates, large videos
- totalFreeableGB includes backed-up + deleted + screenshots
- Clean sheet adds toggleable Recently Deleted row with trash
  icon; unchecked by default, turns red when selected

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 15:44:37 +03:00
Robin Kutesa
5a55931233 Replace clean confirm dialog with multi-option sheet
Pull-to-clean on pages 1 and 2 now opens a half-sheet with:
- Backed-up photos row (always selected, shows ~GB)
- Screenshots section with 7 / 21 / 30 / 60 day toggle buttons
- CLEAN NOW button proceeds to final confirmation

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 15:06:43 +03:00
Robin Kutesa
02feba092c Add pull-to-clean on page 1 (storage) — pull zone now on all pages
Pages 1 and 2 both show PULL/RELEASE TO CLEAN. Only page 0
triggers backup; all others trigger the clean confirmation.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 15:00:32 +03:00
Robin Kutesa
1a40d4c1b6 Extend swipe and pull gestures to full screen
Moved pullGesture and pageSwitchGesture from the ring ZStack to
the outermost ZStack via simultaneousGesture. Both gestures now
fire from anywhere on the screen without blocking scroll.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:54:23 +03:00
Robin Kutesa
c33bf08b5e Remove DONE button from cleanup page
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:52:09 +03:00
Robin Kutesa
7a03afaea1 Reduce stat values 30% — 30pt → 21pt light
Applies to both compactStat (NAS/iPhone/Pending/Safe) and
storageStat (USED/FREE/JUNK).

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:48:46 +03:00
Robin Kutesa
5f98dfc15c Match storage stats to stats strip — plain inline, no card background
Removed gray card background/clipShape. storageStat now uses
30pt light monospacedDigit with statDividers, identical to
compactStat on page 0. Consistent design across all pages.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:44:38 +03:00
Robin Kutesa
54b8ac59be Match storage card font weight to light — consistent with stats strip
36pt semibold rounded → 36pt light monospacedDigit, matching
the thin numeral design language used across all widgets.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:41:00 +03:00
Robin Kutesa
91879f2411 Revert stats strip to light weight — 30pt light monospacedDigit
Semibold rounded was wrong. Values are now 30pt light matching
the thin numeral style shown in the design screenshot.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:36:28 +03:00
Robin Kutesa
fa75e00326 Match stats strip to design — 32pt rounded semibold values
compactStat: 15pt light monospaced → 32pt semibold rounded.
Labels: 8pt monospaced caps → 11pt regular mixed-case.
statDivider height 22→44pt to match taller cells.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:30:29 +03:00
Robin Kutesa
e8cb48b6d0 Scale storage stat card values to match design — 36pt rounded
26pt semibold monospaced → 36pt semibold rounded, matching
the screenshot proportions. Removed monospacedDigit, increased
vertical cell padding to 22pt.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:20:33 +03:00
Robin Kutesa
76c1189412 Add pull-to-clean on page 2 — mirrors pull-to-backup on page 0
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>
2026-05-21 14:12:54 +03:00
Robin Kutesa
a877f34e7d Animate page indicator button with cascading pull-down hint
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>
2026-05-21 14:01:16 +03:00
Robin Kutesa
3674e5fb06 Anchor ring to all 3 pages — page 2 cleanup uses same circle
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>
2026-05-21 13:58:50 +03:00
Robin Kutesa
5361d03a5c Remove HOLD TO CLEAN button from page 1 — pull-down only
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 13:55:24 +03:00
Robin Kutesa
93682cb72c Match page 1 storage layout to design — larger card, black button
Stats card: 26pt semibold values, 18pt corners, full-width gray card
with hairline dividers. Legend centered. HOLD TO CLEAN button is
full-width black (58pt tall, 18pt radius) replacing PrimaryButtonStyle.
storageStat padding/font updated to match screenshot proportions.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 13:51:38 +03:00
Robin Kutesa
0fd025de3e Replace page nav arrows with segmented indicator + square chevron
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>
2026-05-21 13:44:24 +03:00
Robin Kutesa
e8f47fd734 Add missing source files to NASBackup.xcodeproj
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>
2026-05-21 11:57:31 +03:00
Robin Kutesa
eb028d32b6 Lock circle during page swipes — unified ring for pages 0 and 1
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>
2026-05-21 11:31:01 +03:00
Robin Kutesa
1d7cf63409 Anchor circle — pull zone floats above ring via overlay, no layout shift
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>
2026-05-21 00:07:01 +03:00
Robin Kutesa
13806a2089 Fix pull gesture, ring size, and manifest NAS count consistency
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>
2026-05-20 20:31:43 +03:00
Robin Kutesa
ca333918a6 Remove destinationRow from backup page — not in HTML prototype
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-20 20:12:51 +03:00
Robin Kutesa
92541b8d7e Match ring tracks and stats to HTML prototype exactly
- Ring tracks: #c2c2c2 → #ebebeb (hairline, nearly invisible like the prototype)
- Middle ring: #9e9e9e → #c8c8c8 (lighter, matches HTML)
- Outer arc: lineWidth 1.5 → 1, glow softened to match prototype subtlety
- Stats: remove card background, inline plain layout with #ebebeb dividers
- Stats value: .semibold → .light (font-weight:300), letter-spacing tightened
- Stats label: size 9 → 8pt SF Mono, tracking 1.0 (matches HTML 0.12em)

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-20 19:55:23 +03:00
Robin Kutesa
c96f28cd06 Fix stats labels/colors and ring center content to match HTML prototype
- Stats: NAS / IPHONE / PENDING (amber) / SAFE — shorter labels, correct colors
- Running state: ring center counts down remaining files with "BACKING UP" label
- Safe state: ring center shows total photo count in green + checkmark
- Remove unused percentText computed property

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-20 19:39:51 +03:00
Robin Kutesa
da6f1926d2 Rewrite BackupView circle widget with 6-state pull gesture UX
- 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>
2026-05-20 19:15:42 +03:00
Robin Kutesa
f59f79bad6 style: glow on NAS arc, darken ring tracks
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>
2026-05-19 18:34:33 +03:00
Robin Kutesa
99ca4a9e21 chore: increase ring diameter from 210 to 240
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-19 18:31:53 +03:00
Robin Kutesa
5f6d1a676b feat: add iPhone Storage and Cleanup pages to ring carousel
Replaces the 4-page in-ring stat cycling with 3 full-page views:
- Page 0 (BACKUP): existing ring + status + NAS cards + CTA
- Page 1 (IPHONE STORAGE): StorageRingView with orange used/green
  safe-to-delete arcs, 3-col GB stats, legend, "HOLD TO CLEAN" button
- Page 2 (CLEANUP): freed GB hero, category breakdown bars (backed-up
  photos, duplicates, screenshots, large videos), "DONE" button

Adds ← PAGE NAME → navigation bar replacing the dot indicator.
SwipeGesture now shared via pageSwitchGesture applied per page hero.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-19 18:24:55 +03:00
Robin Kutesa
2eaf6620f1 feat: replace ProgressRing with KisaniRingView, update ring typography
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>
2026-05-19 17:56:23 +03:00
Robin Kutesa
b9b91bbb35 Replace static NAS status label with live sync status indicator
Swap dot + "NAS Connected/Offline" for an icon + text that reflects
the actual engine/coordinator state:

  Running   → ↻ arrow.triangle.2.circlepath  (orange)
  Checking  → 🔍 magnifyingglass              (gray)
  Paused    → ⏸ pause.fill                   (orange)
  Offline   → wifi.slash                      (red)
  Stopped   → ■ stop.fill                     (gray)

Animates crossfade between states. Nothing else changed.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-19 10:05:47 +03:00
Robin Kutesa
0bcb8f849c Force uniform 1:1 square crop on all gallery cells
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>
2026-05-19 09:53:42 +03:00
Robin Kutesa
47867f2ef3 Remove newFilesOnly toggle; add functional notifications; fix WiFi status
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>
2026-05-19 01:33:36 +03:00
Robin Kutesa
089c9dfd67 Add screenshot cleanup and NAS quarantine features
- 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>
2026-05-19 01:19:43 +03:00
Robin Kutesa
20b2b8eeb8 Gallery: fix sync dot colors — match phone photos against NAS directory
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>
2026-05-19 00:58:17 +03:00
Robin Kutesa
fae13eba9f Gallery: show all NAS files; Settings: fix WiFi label + cellular row
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>
2026-05-19 00:47:28 +03:00
Robin Kutesa
6ae567d008 Fix Already Safe = 0: hybrid ManifestIndex preserves localIdentifiers + NAS filenames
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>
2026-05-19 00:32:03 +03:00
Robin Kutesa
d5b1f13434 Settings: fix appearance picker in dark mode
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>
2026-05-18 23:54:34 +03:00
Robin Kutesa
777024e9c7 Activity menu: add cellular row, replace Retry header with Clear Errors
- 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>
2026-05-18 23:42:43 +03:00
Robin Kutesa
9cb1bc29ca feat: add screen recordings filter, exclude by default
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>
2026-05-18 23:33:47 +03:00
Robin Kutesa
fb6ecc10be fix: pending queue uses NAS directory filenames when manifest is sparse
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>
2026-05-18 23:24:46 +03:00
Robin Kutesa
6c38893374 fix: always refresh NAS dir count, show connection status, fix screenshot filter reactivity
- 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>
2026-05-18 23:15:51 +03:00
Robin Kutesa
78a6b377f2 feat: move check status below ring, remove checking spinner, re-enable auto-backup
- 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>
2026-05-18 21:12:35 +03:00
Robin Kutesa
2384a74517 fix: use NAS directory filenames for already-safe matching when manifest is sparse
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>
2026-05-18 21:07:00 +03:00
Robin Kutesa
809929e0ed fix: update local manifest cache immediately after upload, before NAS write
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>
2026-05-18 20:52:40 +03:00
Robin Kutesa
0e65a548f2 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
Robin Kutesa
788262502d fix: detect SMB Object Name Collision as already-safe, not failure
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>
2026-05-18 19:33:26 +03:00