diff --git a/Core/Protocols/PhotoLibraryProtocol.swift b/Core/Protocols/PhotoLibraryProtocol.swift index 3f65fd5..0ce1c9a 100644 --- a/Core/Protocols/PhotoLibraryProtocol.swift +++ b/Core/Protocols/PhotoLibraryProtocol.swift @@ -20,7 +20,7 @@ protocol PhotoLibraryProtocol: AnyObject { func exportAsset(_ asset: PhotoAsset) async throws -> URL } -struct BackupFilter: Codable, Sendable { +struct BackupFilter: Codable, Sendable, Equatable { var includePhotos: Bool = true var includeVideos: Bool = true var includeScreenshots: Bool = false diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 582c3e4..0e92f12 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -149,6 +149,9 @@ struct BackupView: View { .onChange(of: store.savedConnection?.remotePath) { _ in statusService.refresh(force: true) } + .onChange(of: store.backupFilter) { _ in + statusService.refresh(force: true) + } .onChange(of: engine.job.status) { status in if status == .completed { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true } @@ -492,37 +495,60 @@ struct BackupView: View { } private var checkStatusRow: some View { - HStack(spacing: 5) { - if statusService.isRefreshing { - ProgressView() - .scaleEffect(0.5) - .tint(AppTheme.inkQuaternary) - Text("Checking…") - .font(.system(size: 10, weight: .regular)) - .foregroundStyle(AppTheme.inkQuaternary) - } else if let checked = statusService.snapshot.lastCheckedAt { - Image(systemName: statusService.snapshot.connectionState == .offline - ? "wifi.slash" : "checkmark.circle") - .font(.system(size: 9, weight: .regular)) - .foregroundStyle(statusService.snapshot.connectionState == .offline - ? .orange : AppTheme.inkQuaternary) - Text(checked, style: .relative) - .font(.system(size: 10, weight: .regular)) - .foregroundColor(AppTheme.inkQuaternary) - + Text(" ago") - .font(.system(size: 10, weight: .regular)) - .foregroundColor(AppTheme.inkQuaternary) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 5) { + if statusService.isRefreshing { + ProgressView() + .scaleEffect(0.5) + .tint(AppTheme.inkQuaternary) + Text("Checking…") + .font(.system(size: 10, weight: .regular)) + .foregroundStyle(AppTheme.inkQuaternary) + } else if let checked = statusService.snapshot.lastCheckedAt { + Image(systemName: statusService.snapshot.connectionState == .offline + ? "wifi.slash" : "checkmark.circle") + .font(.system(size: 9, weight: .regular)) + .foregroundStyle(statusService.snapshot.connectionState == .offline + ? .orange : AppTheme.inkQuaternary) + Text(checked, style: .relative) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(AppTheme.inkQuaternary) + + Text(" ago") + .font(.system(size: 10, weight: .regular)) + .foregroundColor(AppTheme.inkQuaternary) + } + Spacer() + Button { + statusService.refresh(force: true) + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(AppTheme.inkQuaternary) + } } - Spacer() - Button { - statusService.refresh(force: true) - } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 11, weight: .regular)) - .foregroundStyle(AppTheme.inkQuaternary) + .frame(height: 16) + + if statusService.snapshot.connectionState != .unknown { + HStack(spacing: 4) { + Circle() + .fill(nasConnectionStatusColor) + .frame(width: 5, height: 5) + Text(nasConnectionStatusLabel) + .font(.system(size: 9, weight: .regular)) + .foregroundStyle(nasConnectionStatusColor) + } } } - .frame(height: 16) + } + + private var nasConnectionStatusColor: Color { + statusService.snapshot.connectionState == .offline + ? AppTheme.destructive + : Color(red: 1.0, green: 0.58, blue: 0.0) + } + + private var nasConnectionStatusLabel: String { + statusService.snapshot.connectionState == .offline ? "NAS Offline" : "NAS Connected" } private func compactStat(_ value: String, label: String, color: Color) -> some View { diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index 7106ed6..84bb80b 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -308,25 +308,26 @@ final class BackupStatusService: NSObject, ObservableObject { lastManifest = manifest Task { await NASManifestCache.shared.update(manifest) } - // Validity check: if the manifest has ≤ 1 entries it may be corrupt or newly - // created. Scan the directory to get the real NAS contents. When the directory - // is larger than the manifest, return a filename-based index so countSafe - // matches against the actual NAS files, not just the sparse manifest entries. - if index.totalCount <= 1 { - let items = (try? await Self.withTimeout(12) { try await transfer.listDirectory(at: conn.remotePath) }) ?? [] - let filteredItems = items.filter { - !$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename - } - let dirCount = filteredItems.count - log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — filename matching") - if dirCount > index.totalCount { - await NASManifestCache.shared.setDirectoryFilenames(filteredItems.map { $0.name }) - return await Task.detached(priority: .utility) { - ManifestIndex(nasListing: items) - }.value - } + // Always scan directory so the NAS Archive count is accurate on every + // full reconcile (not just when the manifest is new/sparse). + // When the directory has more files than the manifest (uploaded by other + // tools), switch to filename-based matching so countSafe reflects real + // NAS coverage instead of only Kisani-tracked files. + let items = (try? await Self.withTimeout(12) { try await transfer.listDirectory(at: conn.remotePath) }) ?? [] + let filteredItems = items.filter { + !$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename + } + let dirCount = filteredItems.count + log.info("buildManifestIndex: dir=\(dirCount) manifest=\(index.totalCount)") + if dirCount > index.totalCount { + await NASManifestCache.shared.setDirectoryFilenames(filteredItems.map { $0.name }) + return await Task.detached(priority: .utility) { + ManifestIndex(nasListing: items) + }.value + } else { + await NASManifestCache.shared.setDirectoryCount(dirCount) + return index } - return index } log.warning("buildManifestIndex: manifest downloaded but failed to decode (\(data.count) bytes)") } catch {