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>
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>
Introduce ConnectionState enum (idle → authenticating → authenticated
→ destinationSelected → failed) replacing the old Bool isConnected flag.
The entire UI renders reactively from this single state value.
Authentication: vm.authenticate() drives the state, haptic feedback
fires on success, any field edit resets back to idle so the user always
knows their current auth is stale after editing.
Connected banner: slides in below the credentials card after auth
showing "Connected to [host]" with a green dot and a Disconnect button
that resets state without clearing credentials.
Destination row: always visible at 40% opacity with a lock icon and
placeholder text "Connect to your NAS first" — activates to full opacity
with chevron and browsable state after auth. Green border + subtitle
"Backup destination ready" appears after selection. Taps blocked via
allowsHitTesting until authenticated.
CTA progression:
idle/failed → "Connect" (enabled when fields filled)
authenticating → spinner, disabled
authenticated → "Choose Destination" (opens folder browser)
destinationSelected → "Get Started" in green → navigates to dashboard
After folder browser dismissal, confirmDestination() writes the path
to ConnectionStore and fires a light haptic. Navigation to MainTabView
happens only when state == .destinationSelected.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Remove slide-in animation — destination row is always visible but
dimmed (opacity 0.45, taps blocked) until credentials are verified.
On successful auth it becomes fully active, green border appears
once a valid path is chosen.
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>
Replace full [PhotoAsset] array enumeration with PHFetchResult batch
enumeration (autoreleasepool + Task.yield per batch) to keep memory
bounded during reconciliation. Introduce ManifestIndex with two O(1)
Set<String> lookups (localIdentifier + lowercased filename) instead of
O(n×m) linear scans. Scope manifest Data+struct inside buildManifestIndex
so they are released by ARC before the comparison loop begins. Add
UIApplication.didReceiveMemoryWarningNotification handler to cancel
in-flight tasks under memory pressure. Separate debounceTask from
refreshTask to avoid cancelling long-running reconciles on every photo
library event. fetchResultForReconciliation returns PHFetchResult
directly — no [PhotoAsset] array is ever allocated during reconciliation.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Swift does not allow async expressions inside ?? closures.
Replaced with explicit if-let branch so await is at statement level.
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>
Generated at 9 sizes for iPhone home screen and App Store (1024px).
White server.rack SF Symbol on black rounded bg — matches in-app logo.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- safeAreaInset pins Connect/Continue button to screen bottom,
eliminating dead space below the form
- UGreenCompatibilityBadge now takes selectedProtocol param:
green dot + green SMB label when SMB selected,
grey dot + grey SFTP label when SFTP selected
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>
Asset fix:
- nas_logo_clean.imageset/Contents.json now registers the 512px PNG at
all three scales (1x/2x/3x) so iOS uses it on every device type.
Previously only 1x was wired causing the image to be missing on Retina.
- Logo rendered with thin AppTheme.inkQuaternary ring overlay so its
dark-background edge stays legible against any app background color.
LoginView overhaul:
- New hierarchy: Kisani title → 90pt logo → NAS displayName → username
- Staggered spring entrance (0.06–0.32s delays, asymmetric ease)
- "Switch User" replaces "More options" — calls store.signOut() to
clear connection/session/onboarding and return to ConnectView.
- MoreOptionsSheet removed (no longer needed).
ConnectionStore:
- Added lock() — ends session but preserves saved connection (returns to
LoginView for Face ID re-auth without losing NAS credentials).
- signOut() now clearly documented as clearing everything.
- Removed duplicate sectionLabel() from SettingsView (canonical version
lives in LANTriggerSection.swift as a top-level free function).
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>
UGreen UGOS exposes personal folders as named SMB shares (e.g.
'personal_folder'). Third-party apps connect via SMB with the
UGOS username/password — no proprietary protocol involved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Standalone IP field with hint labels below (matches UGreen connect pattern)
- Combined credentials card: username + hairline + password with show/hide eye
- Path field shows full absolute path (UGreen uses POSIX paths via SFTP)
- Default protocol changed from SMB → SFTP (UGreen absolute paths are SFTP-native)
- surfaceSunken background, protocol picker stays as secondary control
- Help button stub at bottom
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap in a ViewModifier that guards with #available(iOS 16.4, *),
keeping the rounded sheet on 16.4+ while compiling clean on 16.0+.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Re-ran xcodegen so ButtonStyles.swift is included (fixes PrimaryButtonStyle
and ScaleButtonStyle not in scope in LoginView and LANTriggerSection)
- BackgroundTaskManager: access BackupEngine.shared inside @MainActor Task
instead of nonisolated context
- SFTPService: change var buffer → let (never mutated)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>