diff --git a/Core/Models/BackupJob.swift b/Core/Models/BackupJob.swift index 25b4db7..0250c10 100644 --- a/Core/Models/BackupJob.swift +++ b/Core/Models/BackupJob.swift @@ -77,6 +77,18 @@ final class BackupJob: ObservableObject { : .failed("Completed with \(result.failedCount) error\(result.failedCount == 1 ? "" : "s")") } + /// Call after reconciliation proves needBackup == 0 — upgrades stale .failed to .completed. + func resolveSuccess() { + if case .failed = status { status = .completed } + } + + /// Used when the pending queue is empty at backup start — nothing to do. + func allAlreadySafe() { + totalFiles = 0; uploadedFiles = 0; skippedFiles = 0; failedFiles = 0 + startDate = Date(); endDate = Date() + status = .completed + } + func fail(error: BackupError) { endDate = Date() status = .failed(error.errorDescription ?? error.localizedDescription) diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 2b54cd7..4d4a2f9 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -17,11 +17,89 @@ struct BackupView: View { private let ringPageCount = 4 + // Counts are the source of truth for final display state. + // If job ended in .failed but reconciliation shows needBackup == 0, treat as .completed. + private var resolvedStatus: BackupStatus { + if case .failed = engine.job.status, + !statusService.isRefreshing, + statusService.snapshot.phoneTotal > 0, + statusService.snapshot.needBackup == 0 { + return .completed + } + return engine.job.status + } + var body: some View { ZStack(alignment: .topTrailing) { AppTheme.background.ignoresSafeArea() - // ─── Menu — aligned with kisani. wordmark ────────────────── + // ─── Scrollable content with pull-to-refresh ────────────── + GeometryReader { geo in + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + + // ─── Brand ──────────────────────────────────── + VStack(spacing: 10) { + Text("kisani.") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(AppTheme.ink) + .kerning(2) + KisaniLogoMark(size: 66) + } + .frame(maxWidth: .infinity) + .padding(.top, 16) + + Spacer(minLength: 24).frame(maxHeight: 48) + + // ─── Ring ───────────────────────────────────── + ringHero + .padding(.bottom, 44) + + // ─── Stats ──────────────────────────────────── + statsStrip + .padding(.horizontal, AppTheme.hPad) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + + Spacer(minLength: 20).frame(maxHeight: 48) + + // ─── Storage card ───────────────────────────── + nasStatusRow + .padding(.horizontal, AppTheme.hPad) + .padding(.bottom, 12) + + // ─── Destination card ────────────────────────── + destinationRow + .padding(.horizontal, AppTheme.hPad) + + Spacer(minLength: 20).frame(maxHeight: 32) + + // ─── Photos access nudge ────────────────────── + if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited { + photosAccessRow + .padding(.horizontal, AppTheme.hPad) + .padding(.bottom, 8) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + // ─── CTA ────────────────────────────────────── + actionButton + .padding(.horizontal, AppTheme.hPad) + .padding(.bottom, 24) + + Spacer(minLength: 0) + } + .frame(minHeight: geo.size.height) + } + .refreshable { + await statusService.refreshAndWait(force: true) + // After reconciliation, clear stale failure if counts say all safe + if statusService.snapshot.needBackup == 0 { + engine.resolveSuccess() + } + } + } + + // ─── Menu button — floats above scroll content ───────────── Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 14, weight: .regular)) @@ -32,63 +110,6 @@ struct BackupView: View { .accessibilityLabel("Activity menu") .padding(.top, 4) .padding(.trailing, AppTheme.hPad - 4) - - VStack(spacing: 0) { - - // ─── Brand ─────────────────────────────────────────── - VStack(spacing: 10) { - Text("kisani.") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(AppTheme.ink) - .kerning(2) - KisaniLogoMark(size: 66) - } - .frame(maxWidth: .infinity) - .padding(.top, 16) - - // flex zone — brand ↔ ring (absorbs screen-size variance) - Spacer(minLength: 24).frame(maxHeight: 48) - - // ─── Ring ───────────────────────────────────────────── - ringHero - .padding(.bottom, 44) - - // ─── Stats ──────────────────────────────────────────── - statsStrip - .padding(.horizontal, AppTheme.hPad) - .transition(.opacity.combined(with: .scale(scale: 0.97))) - - // flex zone — stats ↔ cards - Spacer(minLength: 20).frame(maxHeight: 48) - - // ─── Storage card ───────────────────────────────────── - nasStatusRow - .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 12) - - // ─── Source card ────────────────────────────────────── - destinationRow - .padding(.horizontal, AppTheme.hPad) - - // flex zone — cards ↔ CTA - Spacer(minLength: 20).frame(maxHeight: 32) - - // ─── Photos access nudge ────────────────────────────── - if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited { - photosAccessRow - .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 8) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - - // ─── CTA ────────────────────────────────────────────── - actionButton - .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 24) - - // bottom absorber — soaks up remaining height to anchor cards+CTA - Spacer(minLength: 0) - } } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) @@ -124,15 +145,24 @@ struct BackupView: View { } .onChange(of: engine.job.status) { status in if status == .completed { - // Flash green then revert to black withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true } Task { try? await Task.sleep(nanoseconds: 2_500_000_000) withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false } - // Full reconcile after flash to get accurate counts try? await Task.sleep(nanoseconds: 500_000_000) statusService.refresh(force: true) } + } else if case .failed = status { + // Reconcile — if all files landed on NAS despite the failure, auto-resolve to .completed + Task { + await statusService.refreshAndWait(force: true) + if statusService.snapshot.needBackup == 0 { + engine.resolveSuccess() + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true } + try? await Task.sleep(nanoseconds: 2_500_000_000) + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false } + } + } } } } @@ -181,7 +211,7 @@ struct BackupView: View { // ID that changes on both page swipe and status transition — drives the ring animation private var ringContentID: String { - switch engine.job.status { + switch resolvedStatus { case .idle, .cancelled: return "\(ringPage)_idle" case .preparing: return "\(ringPage)_preparing" case .running: return "\(ringPage)_running" @@ -238,7 +268,7 @@ struct BackupView: View { // Page 0 content — changes based on backup state @ViewBuilder private var ringMainView: some View { - switch engine.job.status { + switch resolvedStatus { case .idle, .cancelled: if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { VStack(spacing: 8) { @@ -279,7 +309,7 @@ struct BackupView: View { .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() - Text("\(engine.job.uploadedFiles) of \(engine.job.totalFiles) backed up") + Text("Backing up \(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkSecondary) if let eta = engine.job.eta { @@ -326,15 +356,11 @@ struct BackupView: View { } private var ringColor: Color { - // Active: always orange - if engine.job.status.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) } - // Failed: red - if case .failed = engine.job.status { return AppTheme.destructive } - // Green only when ALL phone photos are accounted for (needBackup == 0) + if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) } + if case .failed = resolvedStatus { return AppTheme.destructive } if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { return AppTheme.positive } - // Default: neutral return AppTheme.inkQuaternary.opacity(0.55) } @@ -608,10 +634,10 @@ struct BackupView: View { private var actionButton: some View { Group { - switch engine.job.status { + switch resolvedStatus { case .idle, .completed, .failed, .cancelled: Button(action: startBackup) { - Text(engine.job.status == .idle ? "Start backup" : "Back up again") + Text(resolvedStatus == .idle ? "Start backup" : "Back up again") } .buttonStyle(PrimaryButtonStyle( color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink diff --git a/Kisani.xcodeproj/project.pbxproj b/Kisani.xcodeproj/project.pbxproj index c5f750b..10accc2 100644 --- a/Kisani.xcodeproj/project.pbxproj +++ b/Kisani.xcodeproj/project.pbxproj @@ -109,7 +109,7 @@ 90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = ""; }; 966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = ""; }; 9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = ""; }; - A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = ""; }; @@ -124,7 +124,7 @@ E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = ""; }; E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = ""; }; - ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = ""; }; F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = ""; }; @@ -439,7 +439,6 @@ LastUpgradeCheck = 1500; TargetAttributes = { 009689A0ADEB4878A288991E = { - DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; 3362521ADE965E6BA3383045 = { @@ -568,6 +567,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -747,6 +747,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index 20be885..03c5d8e 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -73,7 +73,7 @@ final class BackupEngine: ObservableObject { activeTransfer = nil } - // 3. Load manifest → build index (manifest Data+struct released after this block) + // 3. Load manifest → build index (Data+struct released immediately after) let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" let index: ManifestIndex if let data = try? await transfer.downloadData(at: manifestPath), @@ -85,8 +85,23 @@ final class BackupEngine: ObservableObject { logger.info("No manifest — will check NAS file existence per asset") } - // 4. Start job tracking - job.start(totalFiles: assets.count, totalBytes: 0) + // 4. Build PENDING QUEUE — items not already confirmed in manifest. + // This is the denominator for progress; NOT the full gallery count. + let pendingAssets = assets.filter { asset in + !index.matches(localIdentifier: asset.localIdentifier) && + !(index.isFilenameOnly && index.matches(filename: asset.filename)) + } + logger.info("Pending queue: \(pendingAssets.count) of \(assets.count) total assets") + + // If nothing is pending, finish immediately — all already safe. + guard !pendingAssets.isEmpty else { + job.allAlreadySafe() + BackupStatusService.shared.refresh(force: false) + return BackupResult.empty(date: startDate) + } + + // 5. Start job with PENDING count — never the full gallery total. + job.start(totalFiles: pendingAssets.count, totalBytes: 0) var uploaded = 0 var skipped = 0 @@ -95,40 +110,24 @@ final class BackupEngine: ObservableObject { var speedTracker = SpeedTracker() var manifestEntries: [ManifestEntry] = [] - // 5. Transfer loop - for asset in assets { + // 6. Transfer loop — manifest check already done above, only NAS-exists + upload here. + for asset in pendingAssets { if isCancelled { break } while case .paused = job.status { try await Task.sleep(nanoseconds: 500_000_000) } - // ── Primary dedup: manifest localIdentifier (O(1), no network call) ── - if index.matches(localIdentifier: asset.localIdentifier) { - job.fileCompleted(skipped: true) - skipped += 1 - logger.debug("Skipped (manifest id): \(asset.filename, privacy: .public)") - continue - } - - // ── Filename fallback: bootstrap manifest has no localIdentifiers ── - if index.isFilenameOnly && index.matches(filename: asset.filename) { - job.fileCompleted(skipped: true) - skipped += 1 - logger.debug("Skipped (manifest filename): \(asset.filename, privacy: .public)") - continue - } - - // ── NAS file existence check — never overwrite ── let baseRemotePath = "\(connection.remotePath)/\(asset.filename)" - let fileAlreadyExists = (try? await transfer.fileExists(at: baseRemotePath)) == true - if fileAlreadyExists { + + // NAS file existence check — skip without overwriting legacy files + if (try? await transfer.fileExists(at: baseRemotePath)) == true { job.fileCompleted(skipped: true) skipped += 1 logger.debug("Skipped (NAS exists, no-overwrite): \(asset.filename, privacy: .public)") continue } - // ── Upload ── + // Upload do { let localURL = try await photoService.exportAsset(asset) defer { try? FileManager.default.removeItem(at: localURL) } @@ -211,6 +210,9 @@ final class BackupEngine: ObservableObject { func pause() { job.pause() } func resume() { job.resume() } + /// Upgrades a stale .failed state to .completed when reconciliation proves needBackup == 0. + func resolveSuccess() { job.resolveSuccess() } + private func sendCompletionNotification(result: BackupResult) async { let content = UNMutableNotificationContent() content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete" diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index 0ca0dd5..eda6ed4 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -60,6 +60,12 @@ final class BackupStatusService: NSObject, ObservableObject { } } + /// Awaitable reconciliation — used by pull-to-refresh and post-failure resolution. + func refreshAndWait(force: Bool = true) async { + cancelAll() + await performRefresh(force: force) + } + /// Called by BackupEngine after a completed run with newly uploaded entries. /// Applies an optimistic count update, writes the manifest in background, then reconciles. func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {