import Foundation import UserNotifications @MainActor final class BackupEngine: ObservableObject { static let shared = BackupEngine() @Published private(set) var job: BackupJob = BackupJob() private let photoService: PhotoLibraryProtocol = PhotoLibraryService() private var activeTransfer: (any NASTransferProtocol)? private var isCancelled = false private init() {} func run( connection: NASConnection, filter: BackupFilter, triggeredByLAN: Bool = false ) async throws -> BackupResult { isCancelled = false let startDate = Date() // 1. Fetch assets job.prepare() let assets = photoService.fetchAssets(filter: filter) // 2. Connect to NAS let transfer: any NASTransferProtocol switch connection.nasProtocol { case .smb: transfer = SMBService() case .sftp: transfer = SFTPService() } try await transfer.connect( to: connection.host, port: connection.port, username: connection.username, password: connection.password ) activeTransfer = transfer defer { transfer.disconnect() activeTransfer = nil } // 3. Start job tracking job.start(totalFiles: assets.count, totalBytes: 0) var uploaded = 0 var skipped = 0 var failed = 0 var totalBytes: Int64 = 0 var speedTracker = SpeedTracker() // 4. Transfer loop for asset in assets { if isCancelled { break } while case .paused = job.status { try await Task.sleep(nanoseconds: 500_000_000) } let remotePath = "\(connection.remotePath)/\(asset.filename)" // Skip if exists (newFilesOnly = true in filter) if filter.newFilesOnly, (try? await transfer.fileExists(at: remotePath)) == true { job.fileCompleted(skipped: true) skipped += 1 continue } do { let localURL = try await photoService.exportAsset(asset) defer { try? FileManager.default.removeItem(at: localURL) } let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { Int64($0) } ?? 0 try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, total in let speed = speedTracker.update(bytesSent: sent) Task { @MainActor [weak self] in self?.job.updateProgress( fileName: asset.filename, fileSize: total, bytesTransferred: totalBytes + sent, speed: speed ) } } totalBytes += fileSize job.fileCompleted(skipped: false) uploaded += 1 } catch { job.fileFailed() failed += 1 } } let duration = Date().timeIntervalSince(startDate) let result = BackupResult( uploadedCount: uploaded, skippedCount: skipped, failedCount: failed, duration: duration, totalBytes: totalBytes, date: startDate ) job.finish(result: result) // 5. Persist history let entry = BackupHistoryEntry(result: result, nasHost: connection.host, triggeredByLAN: triggeredByLAN) ConnectionStore.shared.appendHistoryEntry(entry) // 6. Push notification await sendCompletionNotification(result: result) return result } func cancel() { isCancelled = true job.cancel() } func pause() { job.pause() } func resume() { job.resume() } private func sendCompletionNotification(result: BackupResult) async { let content = UNMutableNotificationContent() content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete" content.body = "\(result.uploadedCount) uploaded · \(result.skippedCount) skipped · \(result.failedCount) errors" content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) try? await UNUserNotificationCenter.current().add(request) } } private struct SpeedTracker { private var lastBytes: Int64 = 0 private var lastTime: Date = Date() mutating func update(bytesSent: Int64) -> Double { let now = Date() let elapsed = now.timeIntervalSince(lastTime) guard elapsed > 0.1 else { return 0 } let speed = Double(bytesSent - lastBytes) / elapsed lastBytes = bytesSent lastTime = now return max(0, speed) } }