- 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>
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>
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>
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>
- 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>
Replace the SF Symbol server.rack with the custom KisaniLogoMark
extracted into a shared component used by both ConnectView and BackupView.
Introduce ConnectStep enum (credentials → folder → ready) in
ConnectViewModel. The primary CTA changes label and colour at each
step: "Connect" → "Select Destination" → "Get Started". The folder
row animates in only after successful authentication; its border goes
green once a valid path is chosen. Status message updates live with
the current step. Submitting the password field triggers verify().
Rename project.yml name to Kisani so xcodegen produces Kisani.xcodeproj.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
foregroundStyle(_:) on Text with a ShapeStyle requires iOS 17;
replace with foregroundColor for the two Text-concatenation nodes
in the refresh status row to stay compatible with the iOS 16 target.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Section order: Connection Status → Current Session (live, when active) →
Errors → Last Successful Backup → Sync Activity → History → About
Connection Status:
- NAS row shows remotePath as third subtitle line (destination trust signal)
- Wi-Fi Offline badge now orange (was muted grey)
Current Session (new):
- Shows only when backup is active (preparing/running/paused)
- Progress bar, file count, live upload speed, ETA, current filename
- Badge colors: blue for uploading, orange for paused/scanning
Last Successful Backup (new):
- Trust signal always visible — shows date + total photos safe
- Empty state if no clean backup yet
Errors section:
- "Retry Failed" button in header (disabled while active or NAS unreachable)
- Error rows show count + uploaded count as context
- Error detail sheet: Retry Failed Uploads button disabled with reason when NAS is down
Sync Activity:
- Clear button opens confirmationDialog: Clear Completed Logs / Clear Errors / Clear Everything
- Labels: "Backups completed" / "New uploads" / "Already safe" / "Errors"
- ConnectionStore: clearCompletedHistory() and clearErrorHistory() added
History rows:
- "skipped" replaced with human-readable text:
"X already backed up", "X new · Y already safe", "X photos backed up"
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- NAS reachable → green "Connected" (TCP ping = usable, not a warning state)
- NAS offline → orange "Offline" (matches color rule: orange = not reachable)
- NAS icon changed from externaldrive to server.rack
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Wi-Fi row: title always "Wi-Fi", subtitle shows SSID or Cellular/Not connected;
badge is Connected (green) / Cellular (amber) / Offline (muted) — no more
contradictory "No Wi-Fi" title with "Wi-Fi connected" subtitle
- NAS badge: Reachable now amber (TCP ping only, not auth-confirmed), Offline red,
Unknown muted — removes misleading green on unverified NAS connectivity
- Error rows are now tappable; chevron affordance + ScaleButtonStyle; opens detail
sheet with date, counts, duration, NAS host, trigger type, and suggested fix steps
- Sync Activity section: Clear button with confirmation alert (warns about error count
if errors exist), calls store.clearHistory()
- BackupViewModel: compareWithNAS() computes accurate pre-scan alreadySafeCount /
notBackedUpCount by matching lowercased filenames against NAS directory listing
- BackupView: ring shows state-based content (idle: not-backed-up count, running: %,
completed: checkmark), pill-style page indicator, display counts update live during
backup run, loadNASAndCompare() called on appear
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Light mode: white disk icon on black bg
Dark mode: black disk icon on white bg
No green — uses AppTheme.ink + AppTheme.inkInverse throughout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SF Symbol 'externaldrive.fill' renders reliably vs sparse PNG.
White icon on UGREEN green (#1FA462) background — crisp at all sizes.
kisani. weight: medium -> bold on login and connect screens.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The ugreen_logo.png is a black outline on transparent background.
Rendering it as .template (white) over UGREEN brand green (#1FA462)
makes it visible and on-brand on all three screens:
- LoginView: 90pt logo below kisani. wordmark
- ConnectView: 72pt logo replaces old Kisani title
- BackupView toolbar: 22pt chip with matching treatment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reduce vertical padding from 10→6pt, drop large icons, shrink font,
value color now matches stat type (green/red/grey). Strip is ~40% shorter.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drop com.apple.developer.networking.wifi-info from project.yml entitlements
and NSLocationWhenInUseUsageDescription from Info.plist
- SSID detection gracefully returns nil without the capability; all UI handles
nil SSID already via optional chaining
feat: branding, ring polish, sign-out UX, version/support
- LoginView: replace title with "kisani." monospaced wordmark + UGREEN logo,
show username then NAS display name after login
- BackupView: "kisani." + UGREEN logo chip in toolbar; ring color is orange
(in-progress), green (completed), destructive (failed), dim (idle)
- BackupJob: cap progress at min(1.0, ...) to prevent >100% display
- SettingsView sign-out dialog: two actions — "Sign Out" (lock only, keeps NAS)
and "Sign Out & Forget NAS" (full clear); clearer message
- SettingsView ACCOUNT section: App Version v1.0 + Support mailto rows
- AppMenuView: About section with kisani. wordmark, version, support link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>