Fix backup progress state machine: pending queue, resolvedStatus, failure resolution
- 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>
This commit is contained in:
@@ -77,6 +77,18 @@ final class BackupJob: ObservableObject {
|
|||||||
: .failed("Completed with \(result.failedCount) error\(result.failedCount == 1 ? "" : "s")")
|
: .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) {
|
func fail(error: BackupError) {
|
||||||
endDate = Date()
|
endDate = Date()
|
||||||
status = .failed(error.errorDescription ?? error.localizedDescription)
|
status = .failed(error.errorDescription ?? error.localizedDescription)
|
||||||
|
|||||||
@@ -17,25 +17,28 @@ struct BackupView: View {
|
|||||||
|
|
||||||
private let ringPageCount = 4
|
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 {
|
var body: some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
AppTheme.background.ignoresSafeArea()
|
AppTheme.background.ignoresSafeArea()
|
||||||
|
|
||||||
// ─── Menu — aligned with kisani. wordmark ──────────────────
|
// ─── Scrollable content with pull-to-refresh ──────────────
|
||||||
Button { showMenu = true } label: {
|
GeometryReader { geo in
|
||||||
Image(systemName: "line.3.horizontal")
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
.font(.system(size: 14, weight: .regular))
|
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Activity menu")
|
|
||||||
.padding(.top, 4)
|
|
||||||
.padding(.trailing, AppTheme.hPad - 4)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
// ─── Brand ───────────────────────────────────────────
|
// ─── Brand ────────────────────────────────────
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Text("kisani.")
|
Text("kisani.")
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
@@ -46,34 +49,31 @@ struct BackupView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
|
|
||||||
// flex zone — brand ↔ ring (absorbs screen-size variance)
|
|
||||||
Spacer(minLength: 24).frame(maxHeight: 48)
|
Spacer(minLength: 24).frame(maxHeight: 48)
|
||||||
|
|
||||||
// ─── Ring ─────────────────────────────────────────────
|
// ─── Ring ─────────────────────────────────────
|
||||||
ringHero
|
ringHero
|
||||||
.padding(.bottom, 44)
|
.padding(.bottom, 44)
|
||||||
|
|
||||||
// ─── Stats ────────────────────────────────────────────
|
// ─── Stats ────────────────────────────────────
|
||||||
statsStrip
|
statsStrip
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
||||||
|
|
||||||
// flex zone — stats ↔ cards
|
|
||||||
Spacer(minLength: 20).frame(maxHeight: 48)
|
Spacer(minLength: 20).frame(maxHeight: 48)
|
||||||
|
|
||||||
// ─── Storage card ─────────────────────────────────────
|
// ─── Storage card ─────────────────────────────
|
||||||
nasStatusRow
|
nasStatusRow
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
// ─── Source card ──────────────────────────────────────
|
// ─── Destination card ──────────────────────────
|
||||||
destinationRow
|
destinationRow
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
|
|
||||||
// flex zone — cards ↔ CTA
|
|
||||||
Spacer(minLength: 20).frame(maxHeight: 32)
|
Spacer(minLength: 20).frame(maxHeight: 32)
|
||||||
|
|
||||||
// ─── Photos access nudge ──────────────────────────────
|
// ─── Photos access nudge ──────────────────────
|
||||||
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
|
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
|
||||||
photosAccessRow
|
photosAccessRow
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
@@ -81,14 +81,35 @@ struct BackupView: View {
|
|||||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CTA ──────────────────────────────────────────────
|
// ─── CTA ──────────────────────────────────────
|
||||||
actionButton
|
actionButton
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
|
|
||||||
// bottom absorber — soaks up remaining height to anchor cards+CTA
|
|
||||||
Spacer(minLength: 0)
|
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))
|
||||||
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Activity menu")
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.trailing, AppTheme.hPad - 4)
|
||||||
}
|
}
|
||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -124,15 +145,24 @@ struct BackupView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: engine.job.status) { status in
|
.onChange(of: engine.job.status) { status in
|
||||||
if status == .completed {
|
if status == .completed {
|
||||||
// Flash green then revert to black
|
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
|
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)
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
statusService.refresh(force: true)
|
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
|
// ID that changes on both page swipe and status transition — drives the ring animation
|
||||||
private var ringContentID: String {
|
private var ringContentID: String {
|
||||||
switch engine.job.status {
|
switch resolvedStatus {
|
||||||
case .idle, .cancelled: return "\(ringPage)_idle"
|
case .idle, .cancelled: return "\(ringPage)_idle"
|
||||||
case .preparing: return "\(ringPage)_preparing"
|
case .preparing: return "\(ringPage)_preparing"
|
||||||
case .running: return "\(ringPage)_running"
|
case .running: return "\(ringPage)_running"
|
||||||
@@ -238,7 +268,7 @@ struct BackupView: View {
|
|||||||
// Page 0 content — changes based on backup state
|
// Page 0 content — changes based on backup state
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var ringMainView: some View {
|
private var ringMainView: some View {
|
||||||
switch engine.job.status {
|
switch resolvedStatus {
|
||||||
case .idle, .cancelled:
|
case .idle, .cancelled:
|
||||||
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
@@ -279,7 +309,7 @@ struct BackupView: View {
|
|||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
.monospacedDigit()
|
.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())
|
.font(AppTheme.caption())
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
if let eta = engine.job.eta {
|
if let eta = engine.job.eta {
|
||||||
@@ -326,15 +356,11 @@ struct BackupView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var ringColor: Color {
|
private var ringColor: Color {
|
||||||
// Active: always orange
|
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
||||||
if engine.job.status.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
if case .failed = resolvedStatus { return AppTheme.destructive }
|
||||||
// Failed: red
|
|
||||||
if case .failed = engine.job.status { return AppTheme.destructive }
|
|
||||||
// Green only when ALL phone photos are accounted for (needBackup == 0)
|
|
||||||
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
||||||
return AppTheme.positive
|
return AppTheme.positive
|
||||||
}
|
}
|
||||||
// Default: neutral
|
|
||||||
return AppTheme.inkQuaternary.opacity(0.55)
|
return AppTheme.inkQuaternary.opacity(0.55)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,10 +634,10 @@ struct BackupView: View {
|
|||||||
|
|
||||||
private var actionButton: some View {
|
private var actionButton: some View {
|
||||||
Group {
|
Group {
|
||||||
switch engine.job.status {
|
switch resolvedStatus {
|
||||||
case .idle, .completed, .failed, .cancelled:
|
case .idle, .completed, .failed, .cancelled:
|
||||||
Button(action: startBackup) {
|
Button(action: startBackup) {
|
||||||
Text(engine.job.status == .idle ? "Start backup" : "Back up again")
|
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
|
||||||
}
|
}
|
||||||
.buttonStyle(PrimaryButtonStyle(
|
.buttonStyle(PrimaryButtonStyle(
|
||||||
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
|
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
|
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
|
||||||
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
|
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
|
||||||
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
||||||
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||||
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||||
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
|
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
|
||||||
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
|
||||||
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = "<group>"; };
|
F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = "<group>"; };
|
||||||
@@ -439,7 +439,6 @@
|
|||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
009689A0ADEB4878A288991E = {
|
009689A0ADEB4878A288991E = {
|
||||||
DevelopmentTeam = "";
|
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
3362521ADE965E6BA3383045 = {
|
3362521ADE965E6BA3383045 = {
|
||||||
@@ -568,6 +567,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -747,6 +747,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ final class BackupEngine: ObservableObject {
|
|||||||
activeTransfer = nil
|
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 manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
|
||||||
let index: ManifestIndex
|
let index: ManifestIndex
|
||||||
if let data = try? await transfer.downloadData(at: manifestPath),
|
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")
|
logger.info("No manifest — will check NAS file existence per asset")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Start job tracking
|
// 4. Build PENDING QUEUE — items not already confirmed in manifest.
|
||||||
job.start(totalFiles: assets.count, totalBytes: 0)
|
// 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 uploaded = 0
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
@@ -95,40 +110,24 @@ final class BackupEngine: ObservableObject {
|
|||||||
var speedTracker = SpeedTracker()
|
var speedTracker = SpeedTracker()
|
||||||
var manifestEntries: [ManifestEntry] = []
|
var manifestEntries: [ManifestEntry] = []
|
||||||
|
|
||||||
// 5. Transfer loop
|
// 6. Transfer loop — manifest check already done above, only NAS-exists + upload here.
|
||||||
for asset in assets {
|
for asset in pendingAssets {
|
||||||
if isCancelled { break }
|
if isCancelled { break }
|
||||||
while case .paused = job.status {
|
while case .paused = job.status {
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
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 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)
|
job.fileCompleted(skipped: true)
|
||||||
skipped += 1
|
skipped += 1
|
||||||
logger.debug("Skipped (NAS exists, no-overwrite): \(asset.filename, privacy: .public)")
|
logger.debug("Skipped (NAS exists, no-overwrite): \(asset.filename, privacy: .public)")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Upload ──
|
// Upload
|
||||||
do {
|
do {
|
||||||
let localURL = try await photoService.exportAsset(asset)
|
let localURL = try await photoService.exportAsset(asset)
|
||||||
defer { try? FileManager.default.removeItem(at: localURL) }
|
defer { try? FileManager.default.removeItem(at: localURL) }
|
||||||
@@ -211,6 +210,9 @@ final class BackupEngine: ObservableObject {
|
|||||||
func pause() { job.pause() }
|
func pause() { job.pause() }
|
||||||
func resume() { job.resume() }
|
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 {
|
private func sendCompletionNotification(result: BackupResult) async {
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete"
|
content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete"
|
||||||
|
|||||||
@@ -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.
|
/// Called by BackupEngine after a completed run with newly uploaded entries.
|
||||||
/// Applies an optimistic count update, writes the manifest in background, then reconciles.
|
/// Applies an optimistic count update, writes the manifest in background, then reconciles.
|
||||||
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {
|
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {
|
||||||
|
|||||||
Reference in New Issue
Block a user