From 712e110f57367d4d68fe81b73e1b400db824ae0f Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 17 May 2026 12:51:03 +0300 Subject: [PATCH] Add live backup status reconciliation with NAS manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir Co-Authored-By: Sentry --- App/NASBackupApp.swift | 2 + Core/Models/BackupManifest.swift | 56 ++++++ Core/Models/BackupStatusSnapshot.swift | 20 ++ Core/Protocols/NASTransferProtocol.swift | 1 + Features/Backup/BackupView.swift | 109 +++++++---- Features/Backup/BackupViewModel.swift | 33 ---- Services/BackupEngine.swift | 13 ++ Services/BackupStatusService.swift | 221 +++++++++++++++++++++++ Services/SFTPService.swift | 12 ++ Services/SMBService.swift | 15 ++ 10 files changed, 412 insertions(+), 70 deletions(-) create mode 100644 Core/Models/BackupManifest.swift create mode 100644 Core/Models/BackupStatusSnapshot.swift create mode 100644 Services/BackupStatusService.swift diff --git a/App/NASBackupApp.swift b/App/NASBackupApp.swift index 5812558..a434ad5 100644 --- a/App/NASBackupApp.swift +++ b/App/NASBackupApp.swift @@ -8,6 +8,7 @@ struct NASBackupApp: App { @StateObject private var store = ConnectionStore.shared @StateObject private var engine = BackupEngine.shared @StateObject private var lanMonitor = LANMonitor.shared + @StateObject private var statusService = BackupStatusService.shared var body: some Scene { WindowGroup { @@ -15,6 +16,7 @@ struct NASBackupApp: App { .environmentObject(store) .environmentObject(engine) .environmentObject(lanMonitor) + .environmentObject(statusService) .preferredColorScheme(store.appearanceMode.resolvedScheme) } } diff --git a/Core/Models/BackupManifest.swift b/Core/Models/BackupManifest.swift new file mode 100644 index 0000000..a43cb28 --- /dev/null +++ b/Core/Models/BackupManifest.swift @@ -0,0 +1,56 @@ +import Foundation + +struct ManifestEntry: Codable { + let localIdentifier: String // PHAsset.localIdentifier — primary key + let filename: String + let creationDate: Date? + let fileSize: Int64 + let remotePath: String + let uploadedAt: Date +} + +struct BackupManifest: Codable { + static let remoteFilename = ".kisani.json" + + var version: Int = 1 + var lastUpdated: Date = Date() + var entries: [ManifestEntry] = [] + + // Primary match: stable PHAsset localIdentifier + func contains(localIdentifier id: String) -> Bool { + guard !id.isEmpty else { return false } + return entries.contains { $0.localIdentifier == id } + } + + // Fallback: case-insensitive filename (for assets backed up before manifest existed) + func containsByFilename(_ name: String) -> Bool { + let lower = name.lowercased() + return entries.contains { $0.filename.lowercased() == lower } + } + + // Merge new entries, replacing any existing record for the same localIdentifier + mutating func merge(entries newEntries: [ManifestEntry]) { + let newIds = Set(newEntries.map { $0.localIdentifier }.filter { !$0.isEmpty }) + entries.removeAll { !$0.localIdentifier.isEmpty && newIds.contains($0.localIdentifier) } + entries.append(contentsOf: newEntries) + lastUpdated = Date() + } + + // Bootstrap from a raw NAS directory listing (no localIdentifiers known) + static func buildFromNASListing(_ items: [NASItem]) -> BackupManifest { + var manifest = BackupManifest() + manifest.entries = items + .filter { !$0.isDirectory && $0.name != remoteFilename } + .map { item in + ManifestEntry( + localIdentifier: "", + filename: item.name, + creationDate: item.modifiedDate, + fileSize: item.size, + remotePath: item.path, + uploadedAt: item.modifiedDate ?? Date() + ) + } + return manifest + } +} diff --git a/Core/Models/BackupStatusSnapshot.swift b/Core/Models/BackupStatusSnapshot.swift new file mode 100644 index 0000000..7524e38 --- /dev/null +++ b/Core/Models/BackupStatusSnapshot.swift @@ -0,0 +1,20 @@ +import Foundation + +enum NASConnectionState: String, Codable { + case connected // manifest loaded, comparison succeeded + case offline // NAS unreachable + case unknown // not yet checked +} + +struct BackupStatusSnapshot: Codable { + var phoneTotal: Int = 0 + var alreadySafe: Int = 0 + var nasArchiveTotal: Int = 0 + var lastCheckedAt: Date? + var connectionState: NASConnectionState = .unknown + + // Derived — invariant: alreadySafe + needBackup == phoneTotal (always) + var needBackup: Int { max(0, phoneTotal - alreadySafe) } + + static let empty = BackupStatusSnapshot() +} diff --git a/Core/Protocols/NASTransferProtocol.swift b/Core/Protocols/NASTransferProtocol.swift index b3c8df8..db33ae2 100644 --- a/Core/Protocols/NASTransferProtocol.swift +++ b/Core/Protocols/NASTransferProtocol.swift @@ -24,4 +24,5 @@ protocol NASTransferProtocol: AnyObject { progress: @escaping (Int64, Int64) -> Void ) async throws func downloadData(at remotePath: String) async throws -> Data + func writeData(_ data: Data, to remotePath: String) async throws } diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 149266a..2056f52 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -5,13 +5,14 @@ struct BackupView: View { @EnvironmentObject var engine: BackupEngine @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor + @EnvironmentObject var statusService: BackupStatusService @StateObject private var vm = BackupViewModel() + @Environment(\.scenePhase) private var scenePhase @State private var showMenu = false @State private var showDestinationPicker = false @State private var pickerPath: String = "/" @State private var ringPage = 0 - @State private var nasFileCount: Int? = nil private let ringPageCount = 4 @@ -95,6 +96,7 @@ struct BackupView: View { AppMenuView() .environmentObject(store) .environmentObject(lanMonitor) + .environmentObject(engine) } .sheet(isPresented: $showDestinationPicker, onDismiss: { guard pickerPath != "/", var conn = store.savedConnection else { return } @@ -108,8 +110,26 @@ struct BackupView: View { .animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed) .animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized) .task { - vm.loadPhotoCount(filter: store.backupFilter) - await loadNASAndCompare() + statusService.refresh(force: true) + } + .onChange(of: scenePhase) { phase in + if phase == .active { statusService.refresh(force: true) } + } + .onChange(of: lanMonitor.nasReachable) { reachable in + if reachable == true { statusService.refresh(force: true) } + } + .onChange(of: store.savedConnection?.remotePath) { _ in + statusService.refresh(force: true) + } + .onChange(of: engine.job.status) { status in + // After backup completes, the engine already calls refreshAfterBackup + // but also do a delayed full reconcile to catch any stragglers + if status == .completed { + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + statusService.refresh(force: true) + } + } } } @@ -202,14 +222,14 @@ struct BackupView: View { case 0: ringMainView - case 1: // Not backed up + case 1: // Need backup VStack(spacing: 4) { Text("\(notBackedUpDisplayCount)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() - Text("not backed up") + Text("need backup") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } @@ -245,8 +265,7 @@ struct BackupView: View { private var ringMainView: some View { switch engine.job.status { case .idle, .cancelled: - if vm.totalPhotoCount > 0 && vm.notBackedUpCount == 0 { - // All phone photos already exist in NAS + if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { VStack(spacing: 8) { Image(systemName: "checkmark") .font(.system(size: 30, weight: .medium)) @@ -257,12 +276,12 @@ struct BackupView: View { } } else { VStack(spacing: 6) { - Text("\(vm.notBackedUpCount)") + Text("\(statusService.snapshot.needBackup)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() - Text(vm.totalPhotoCount == 0 ? "No photos found" : "not backed up") + Text(statusService.snapshot.phoneTotal == 0 ? "No photos found" : "need backup") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } @@ -345,27 +364,26 @@ struct BackupView: View { // MARK: — Compact stats strip - // Photos from the phone set that are NOT yet in the NAS. - // Idle: derived from NAS comparison scan. - // Active: live remaining from engine job counters. + // Live "need backup" count: optimistic during active backup, service-derived otherwise. + // Invariant: needBackup + alreadySafe == phoneTotal always. private var notBackedUpDisplayCount: Int { switch engine.job.status { case .running, .paused, .preparing: return max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles) case .completed: return 0 - default: return vm.notBackedUpCount + default: return statusService.snapshot.needBackup } } - // Photos from the phone set that ARE already safe in the NAS. - // Grows during backup as uploads + engine-skipped files are confirmed present. private var alreadySafeDisplayCount: Int { switch engine.job.status { case .running, .paused, .preparing, .completed: - return vm.alreadySafeCount + engine.job.uploadedFiles + engine.job.skippedFiles + // Optimistic: base from service + newly confirmed uploads this session + return statusService.snapshot.alreadySafe + + engine.job.uploadedFiles + engine.job.skippedFiles default: - return vm.alreadySafeCount + return statusService.snapshot.alreadySafe } } @@ -373,20 +391,20 @@ struct BackupView: View { VStack(spacing: 7) { HStack(spacing: 0) { compactStat( - nasFileCount.map { "\($0)" } ?? "–", - label: "In NAS", + "\(statusService.snapshot.nasArchiveTotal)", + label: "NAS Archive", color: AppTheme.interactive ) thinDivider compactStat( - "\(vm.totalPhotoCount)", + "\(statusService.snapshot.phoneTotal)", label: "On iPhone", color: AppTheme.inkSecondary ) thinDivider compactStat( "\(notBackedUpDisplayCount)", - label: "Not Backed Up", + label: "Need Backup", color: AppTheme.inkSecondary ) thinDivider @@ -401,6 +419,39 @@ struct BackupView: View { .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) pageIndicator + + // Subtle refresh status + 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)) + .foregroundStyle(AppTheme.inkQuaternary) + + Text(" ago") + .font(.system(size: 10, 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) } } @@ -425,22 +476,6 @@ struct BackupView: View { .frame(width: 0.5, height: 18) } - private func loadNASAndCompare() async { - guard let conn = store.savedConnection else { return } - do { - let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() - try await s.connect(to: conn.host, port: conn.port, - username: conn.username, password: conn.password) - let items = try await s.listDirectory(at: conn.remotePath) - nasFileCount = items.filter { !$0.isDirectory }.count - vm.compareWithNAS(nasItems: items) - s.disconnect() - } catch { - nasFileCount = nil - // On failure: notBackedUpCount stays at totalPhotoCount (conservative — show all as pending) - } - } - // MARK: — Destination row private var destinationRow: some View { diff --git a/Features/Backup/BackupViewModel.swift b/Features/Backup/BackupViewModel.swift index 0a20d5c..f6710d2 100644 --- a/Features/Backup/BackupViewModel.swift +++ b/Features/Backup/BackupViewModel.swift @@ -3,42 +3,9 @@ import Photos @MainActor final class BackupViewModel: ObservableObject { - @Published var totalPhotoCount: Int = 0 - @Published var alreadySafeCount: Int = 0 - @Published var notBackedUpCount: Int = 0 @Published var photosAuthStatus: PHAuthorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) private let photoService = PhotoLibraryService() - private var fetchedAssets: [PhotoAsset] = [] - - func loadPhotoCount(filter: BackupFilter) { - let assets = photoService.fetchAssets(filter: filter) - fetchedAssets = assets - totalPhotoCount = assets.count - // Conservative initial state until NAS scan completes - alreadySafeCount = 0 - notBackedUpCount = assets.count - } - - // Called after NAS directory listing loads. - // Matches by lowercased filename — same heuristic BackupEngine uses for skip checks. - // This gives an accurate pre-scan "Already Safe / Not Backed Up" split before - // the user ever taps Start Backup. - func compareWithNAS(nasItems: [NASItem]) { - let nasFilenames = Set( - nasItems - .filter { !$0.isDirectory } - .map { $0.name.lowercased() } - ) - var safe = 0 - for asset in fetchedAssets { - if nasFilenames.contains(asset.filename.lowercased()) { - safe += 1 - } - } - alreadySafeCount = safe - notBackedUpCount = totalPhotoCount - safe - } func requestPhotosAccess() async { photosAuthStatus = await photoService.requestAuthorization() diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index 0d77571..5366561 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -81,6 +81,7 @@ final class BackupEngine: ObservableObject { var failed = 0 var totalBytes: Int64 = 0 var speedTracker = SpeedTracker() + var manifestEntries: [ManifestEntry] = [] // 4. Transfer loop for asset in assets { @@ -119,6 +120,15 @@ final class BackupEngine: ObservableObject { totalBytes += fileSize job.fileCompleted(skipped: false) uploaded += 1 + + manifestEntries.append(ManifestEntry( + localIdentifier: asset.localIdentifier, + filename: asset.filename, + creationDate: asset.creationDate, + fileSize: fileSize, + remotePath: remotePath, + uploadedAt: Date() + )) } catch { logger.error("Upload failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") job.fileFailed() @@ -138,6 +148,9 @@ final class BackupEngine: ObservableObject { job.finish(result: result) + // Update manifest and dashboard status + BackupStatusService.shared.refreshAfterBackup(entries: manifestEntries, connection: connection) + let entry = BackupHistoryEntry(result: result, nasHost: host, triggeredByLAN: triggeredByLAN) store.appendHistoryEntry(entry) diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift new file mode 100644 index 0000000..d2601c9 --- /dev/null +++ b/Services/BackupStatusService.swift @@ -0,0 +1,221 @@ +import Foundation +import Photos + +@MainActor +final class BackupStatusService: NSObject, ObservableObject { + static let shared = BackupStatusService() + + @Published private(set) var snapshot: BackupStatusSnapshot = .empty + @Published private(set) var isRefreshing: Bool = false + @Published private(set) var refreshError: String? + + private let photoService = PhotoLibraryService() + private let cacheKey = "backupStatusSnapshot_v2" + private var refreshTask: Task? + private var lastFullRefresh: Date? + + private override init() { + super.init() + loadCachedSnapshot() + PHPhotoLibrary.shared().register(self) + } + + // MARK: — Public + + /// Trigger a full reconciliation. `force: true` bypasses the 30-second debounce. + func refresh(force: Bool = false) { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + await self?.performRefresh(force: force) + } + } + + /// Called by BackupEngine after a completed run. + /// Applies an optimistic update immediately, then writes the manifest and reconciles in background. + func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { + let uploaded = entries.count + snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) + snapshot.nasArchiveTotal += uploaded + saveSnapshot() + Task { + await writeManifest(entries: entries, connection: connection) + refresh(force: true) + } + } + + // MARK: — Core refresh + + private func performRefresh(force: Bool) async { + guard !isRefreshing else { return } + + // Debounce: non-forced refreshes skip the NAS round-trip if we just reconciled + if !force, let last = lastFullRefresh, Date().timeIntervalSince(last) < 30 { + updatePhoneCountOnly() + return + } + + lastFullRefresh = Date() + isRefreshing = true + refreshError = nil + + do { + try await reconcile() + } catch is CancellationError { + // Swallow — a newer refresh superseded this one + } catch { + refreshError = error.localizedDescription + // Keep last cached numbers; mark NAS offline + snapshot.connectionState = .offline + snapshot.lastCheckedAt = Date() + saveSnapshot() + } + + isRefreshing = false + } + + private func reconcile() async throws { + let store = ConnectionStore.shared + guard let conn = store.savedConnection else { return } + + // 1. Count phone assets (fast, no network) + let filter = store.backupFilter + let assets = photoService.fetchAssets(filter: filter) + let phoneTotal = assets.count + + try Task.checkCancellation() + + // 2. Connect to NAS + let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() + + do { + try await transfer.connect( + to: conn.host, port: conn.port, + username: conn.username, password: conn.password + ) + } catch { + // NAS offline — update phone count only, enforce invariant on cached safe count + snapshot.phoneTotal = phoneTotal + snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) + snapshot.connectionState = .offline + snapshot.lastCheckedAt = Date() + saveSnapshot() + return + } + + defer { transfer.disconnect() } + try Task.checkCancellation() + + // 3. Load or bootstrap the manifest + let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" + let manifest: BackupManifest + + do { + let data = try await transfer.downloadData(at: manifestPath) + manifest = (try? JSONDecoder().decode(BackupManifest.self, from: data)) ?? { + let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] + return BackupManifest.buildFromNASListing(items) + }() + } catch { + // Manifest not found — build from directory listing + let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] + manifest = BackupManifest.buildFromNASListing(items) + } + + try Task.checkCancellation() + + // 4. Compare — localIdentifier first, filename fallback for legacy entries + var safe = 0 + for asset in assets { + if manifest.contains(localIdentifier: asset.localIdentifier) + || manifest.containsByFilename(asset.filename) { + safe += 1 + } + } + + // 5. NAS archive count (excludes hidden manifest file) + let nasArchive = manifest.entries.filter { !$0.filename.hasPrefix(".") }.count + + // 6. Commit — enforce invariant + var updated = BackupStatusSnapshot() + updated.phoneTotal = phoneTotal + updated.alreadySafe = min(safe, phoneTotal) // invariant: never > phoneTotal + updated.nasArchiveTotal = nasArchive + updated.connectionState = .connected + updated.lastCheckedAt = Date() + snapshot = updated + saveSnapshot() + } + + // Fast phone-only update used when debouncing NAS round-trips + private func updatePhoneCountOnly() { + let filter = ConnectionStore.shared.backupFilter + let assets = photoService.fetchAssets(filter: filter) + let phoneTotal = assets.count + if phoneTotal != snapshot.phoneTotal { + snapshot.phoneTotal = phoneTotal + snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) + saveSnapshot() + } + } + + // MARK: — Manifest write (non-blocking background operation) + + func writeManifest(entries: [ManifestEntry], connection: NASConnection) async { + guard !entries.isEmpty else { return } + + let transfer: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService() + do { + try await transfer.connect( + to: connection.host, port: connection.port, + username: connection.username, password: connection.password + ) + defer { transfer.disconnect() } + + let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" + + var manifest: BackupManifest + do { + let data = try await transfer.downloadData(at: manifestPath) + manifest = (try? JSONDecoder().decode(BackupManifest.self, from: data)) ?? BackupManifest() + } catch { + manifest = BackupManifest() + } + + manifest.merge(entries: entries) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(manifest) + try await transfer.writeData(data, to: manifestPath) + } catch { + // Manifest write failure is non-fatal — reconciliation rebuilds it next time + } + } + + // MARK: — Cache + + private func loadCachedSnapshot() { + guard let data = UserDefaults.standard.data(forKey: cacheKey), + let cached = try? JSONDecoder().decode(BackupStatusSnapshot.self, from: data) + else { return } + snapshot = cached + } + + private func saveSnapshot() { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(snapshot) { + UserDefaults.standard.set(data, forKey: cacheKey) + } + } +} + +// MARK: — PHPhotoLibraryChangeObserver + +extension BackupStatusService: PHPhotoLibraryChangeObserver { + nonisolated func photoLibraryDidChange(_ changeInstance: PHChange) { + Task { @MainActor [weak self] in + self?.refresh() // debounced — won't hit NAS more than once per 30s + } + } +} diff --git a/Services/SFTPService.swift b/Services/SFTPService.swift index f959af5..e4d6693 100644 --- a/Services/SFTPService.swift +++ b/Services/SFTPService.swift @@ -88,6 +88,18 @@ final class SFTPService: NASTransferProtocol { } } + func writeData(_ data: Data, to remotePath: String) async throws { + guard let sftp else { throw BackupError.connectionFailed("Not connected") } + do { + try await sftp.withFile(filePath: remotePath, flags: [.write, .create, .truncate]) { file in + let buffer = ByteBuffer(data: data) + try await file.write(buffer, at: 0) + } + } catch { + throw BackupError.uploadFailed(BackupManifest.remoteFilename, underlying: error) + } + } + func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws { guard let sftp else { throw BackupError.connectionFailed("Not connected") } let filename = localURL.lastPathComponent diff --git a/Services/SMBService.swift b/Services/SMBService.swift index 0b50989..6be134a 100644 --- a/Services/SMBService.swift +++ b/Services/SMBService.swift @@ -86,6 +86,21 @@ final class SMBService: NASTransferProtocol { return try await client.download(path: rel) } + func writeData(_ data: Data, to remotePath: String) async throws { + guard let client else { throw BackupError.connectionFailed("Not connected") } + let (share, rel) = splitPath(remotePath) + try await client.connectShare(share) + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try data.write(to: tmpURL) + defer { try? FileManager.default.removeItem(at: tmpURL) } + do { + try await client.upload(localPath: tmpURL, remotePath: rel) { _, _, _ in } + } catch { + throw BackupError.uploadFailed(BackupManifest.remoteFilename, underlying: error) + } + } + func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(remotePath)