Replace the sequential for-loop with withTaskGroup running N concurrent
uploads. Each slot gets its own SMB/SFTP connection so uploads never
block each other. Default concurrency is 3 (configurable via
ConnectionStore.maxConcurrentUploads, capped at 6).
- ConnectionStore: add maxConcurrentUploads (default 3, persisted)
- BackupEngine: build pool of N connections at start, seed TaskGroup
with one task per slot, recycle slots as tasks complete
- Manifest checkpoints, pause, cancel, retry logic all preserved
- SlotResult extracted to BackupEngine extension for Sendable conformance
- Fix: remove undefined .trackScrollForTabBar() from BackupView
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>
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>
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>
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>
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>
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>
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>
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling
BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe
Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
safe to pass across task boundaries without warnings
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- BackupEngine: pre-filter assets against ManifestIndex before job.start() so
totalFiles reflects only pending items (not full gallery). Short-circuit with
allAlreadySafe() when queue is empty.
- BackupJob: add resolveSuccess() (upgrades stale .failed → .completed after
reconciliation proves needBackup==0) and allAlreadySafe() for empty-queue case.
- BackupStatusService: expose refreshAndWait(force:) async for pull-to-refresh
and post-failure reconciliation.
- BackupView: wire resolvedStatus throughout ringContentID, ringMainView,
ringColor, and actionButton; fix progress label to show pending-queue
denominator; handle .failed in onChange by reconciling and auto-resolving
to .completed when counts confirm all photos are safe.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupJob: fix finish() — failedCount > 0 now correctly sets .failed
instead of .completed in both branches.
BackupEngine: replace filename-only fileExists check with a 3-layer
dedup strategy. For each asset: (1) check ManifestIndex by localIdentifier
O(1); (2) filename fallback for bootstrap manifests; (3) NAS fileExists
for files not yet in manifest. Never overwrite an existing NAS file.
Manifest index is built once after connect and held in a local let for
the loop — Data+BackupManifest are released immediately after.
BackupView — ring color:
active (running/preparing/paused) → orange always
failed → destructive
needBackup == 0 && phoneTotal > 0 → green
default → neutral gray
Ring is no longer permanently green after .completed.
BackupView — CTA button:
Default color is always black (AppTheme.ink).
On .completed: briefly flashes green for 2.5 s then smoothly
reverts to black. ctaFlashGreen state drives the color.
BackupView — Already Safe invariant:
alreadySafeDisplayCount = min(base + uploadedFiles, phoneTotal)
during active backup; min(base, phoneTotal) otherwise.
Adding skippedFiles was removed — they are already counted in
snapshot.alreadySafe from the last reconcile.
notBackedUpDisplayCount is derived from alreadySafeDisplayCount to
keep the invariant alreadySafe + needBackup == phoneTotal.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Rename app to Kisani in all UI text and Face ID prompt
- Replace flat 2-state RootView with 5-step flow: ConnectView →
LoginView → FolderSetupView → PhotoPermissionView → MainTabView
- Remove NavigationStack from LoginView (was causing nested-stack crash);
drive navigation via ConnectionStore.isSessionActive
- Add FolderSetupView and PhotoPermissionView onboarding screens with
step indicator (2/3, 3/3), spring entrance animations, and UGreen
connection chip
- Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle
now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25)
- Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light)
- Add ConnectionStore.isSessionActive (non-persisted) and
onboardingPhotoShown (UserDefaults-persisted)
- Add os_log logging in LoginViewModel at auth start/success/failure
- Remove dead Help button from ConnectView
- UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions,
PrivacyInfo.xcprivacy, unit test target and test stubs
- AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color
- AppearanceMode enum + segmented picker in SettingsView
- BackupView: enlarged ring, removed options card and linear progress bar
- HistoryView: duration and bytes formatting, improved typography hierarchy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>