import Foundation import Photos import os.log private let logger = Logger(subsystem: "com.albert.nasbackup", category: "BackupEngine") private let signposter = OSSignposter(subsystem: "com.albert.nasbackup", category: "BackupEngine") @MainActor final class BackupEngine: ObservableObject { static let shared = BackupEngine() @Published private(set) var job: BackupJob = BackupJob() @Published private(set) var queueItems: [BackupQueueItem] = [] private let photoService: PhotoLibraryProtocol private let transferFactory: (NASProtocol) -> any NASTransferProtocol private var activeTransfer: (any NASTransferProtocol)? private var isCancelled = false private static let queueCacheURL: URL = { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] return caches.appendingPathComponent("kisani_last_queue.json") }() private init( photos: PhotoLibraryProtocol = PhotoLibraryService(), transferFactory: @escaping (NASProtocol) -> any NASTransferProtocol = { proto in switch proto { case .smb: return SMBService() case .sftp: return SFTPService() } } ) { self.photoService = photos self.transferFactory = transferFactory // Load last session's queue so SyncView has data to show on next launch. if let data = try? Data(contentsOf: Self.queueCacheURL), let items = try? JSONDecoder.kisani.decode([BackupQueueItem].self, from: data) { self.queueItems = items } } static func testInstance( transfer: any NASTransferProtocol, photos: PhotoLibraryProtocol ) -> BackupEngine { BackupEngine(photos: photos, transferFactory: { _ in transfer }) } func run( connection: NASConnection, filter: BackupFilter, triggeredByLAN: Bool = false ) async throws -> BackupResult { isCancelled = false let startDate = Date() let store = ConnectionStore.shared let lan = LANMonitor.shared if !triggeredByLAN && lan.isOnCellular && !store.allowCellularBackup { throw BackupError.networkUnavailable } let host = resolveHost(connection: connection, store: store, lan: lan) // ── 1. Fetch assets off the main actor ───────────────────────────── // fetchAssets() enumerates every PHAsset and builds a [PhotoAsset] array. // For a library of 10 k+ assets this is 100–500 ms of synchronous work. // Running it in a detached task keeps the main thread free for animations. job.prepare() let photoSvc = self.photoService let spFetch = signposter.beginInterval("FetchAssets") let assets: [PhotoAsset] = await Task.detached(priority: .userInitiated) { photoSvc.fetchAssets(filter: filter) }.value signposter.endInterval("FetchAssets", spFetch, "\(assets.count) assets") logger.info("Photo fetch: \(assets.count) assets in library") try Task.checkCancellation() // ── 2. Connect to NAS ─────────────────────────────────────────────── // Network I/O suspends the main actor — UI stays live the whole time. let transfer = transferFactory(connection.nasProtocol) let spConnect = signposter.beginInterval("NASConnect") try await transfer.connect( to: host, port: connection.port, username: connection.username, password: connection.password ) signposter.endInterval("NASConnect", spConnect) activeTransfer = transfer defer { transfer.disconnect(); activeTransfer = nil } try Task.checkCancellation() // ── 3. Load manifest — parse off the main actor ──────────────────── // The download suspends the main actor (good). JSON decode + Set // construction for a large manifest is O(N) CPU work — run it in a // detached task so we don't block touch handling or animations. let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" let spManifest = signposter.beginInterval("ManifestLoad") let manifestData = try? await transfer.downloadData(at: manifestPath) let (index, loadedManifest): (ManifestIndex, BackupManifest?) = await Task.detached(priority: .userInitiated) { guard let data = manifestData, let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) else { return (ManifestIndex(nasListing: []), nil) } return (ManifestIndex(manifest: manifest), manifest) }.value signposter.endInterval("ManifestLoad", spManifest, "\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") logger.info("Manifest: \(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") // Seed the local cache so BackupStatusService's fast-path reconcile reflects // the current NAS state before the upload loop begins. if let m = loadedManifest { await NASManifestCache.shared.update(m) } try Task.checkCancellation() // ── 4. Build pending queue off the main actor ────────────────────── // Filtering a large [PhotoAsset] is O(N) with two Set lookups per item. // Safe to run in background: both assets and index are value-type safe. let spFilter = signposter.beginInterval("BuildPendingQueue") let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) { assets.filter { asset in !index.matches(localIdentifier: asset.localIdentifier) && !(index.isFilenameOnly && index.matches(filename: asset.filename)) } }.value signposter.endInterval("BuildPendingQueue", spFilter, "\(pendingAssets.count) pending") logger.info("Pending queue: \(pendingAssets.count) of \(assets.count) total") guard !pendingAssets.isEmpty else { job.allAlreadySafe() BackupStatusService.shared.refresh(force: false) return BackupResult.empty(date: startDate) } // ── 5. Upload loop ───────────────────────────────────────────────── job.start(totalFiles: pendingAssets.count, totalBytes: 0) NotificationService.notifyBackupStarted(count: pendingAssets.count) // Build initial queue (all queued) for SyncView queueItems = pendingAssets.map { asset in BackupQueueItem( id: asset.localIdentifier, filename: asset.filename, nasPath: "\(connection.remotePath)/\(asset.filename)", status: .queued, bytesUploaded: 0, totalBytes: 0, speedBytesPerSec: 0, error: nil, retryCount: 0, queuedAt: Date(), completedAt: nil ) } var uploaded = 0 var skipped = 0 var failed = 0 var totalBytes: Int64 = 0 var speedTracker = SpeedTracker() var throttle = ProgressThrottle(hz: 8) var manifestEntries: [ManifestEntry] = [] manifestEntries.reserveCapacity(pendingAssets.count) let spUpload = signposter.beginInterval("UploadLoop", "\(pendingAssets.count) files") let maxRetries = 3 for (assetIdx, asset) in pendingAssets.enumerated() { if isCancelled { break } while case .paused = job.status { try await Task.sleep(nanoseconds: 500_000_000) } let remotePath = "\(connection.remotePath)/\(asset.filename)" if assetIdx < queueItems.count { queueItems[assetIdx].status = .uploading } if (try? await transfer.fileExists(at: remotePath)) == true { job.fileCompleted(skipped: true) skipped += 1 if assetIdx < queueItems.count { queueItems[assetIdx].status = .skipped queueItems[assetIdx].completedAt = Date() } logger.debug("Skipped (NAS exists): \(asset.filename, privacy: .public)") continue } var uploadSucceeded = false var lastUploadError: Error? for attempt in 0.. 0 { // Exponential backoff: 2s, 4s before retries 2 and 3 let backoffNs = UInt64(pow(2.0, Double(attempt)) * 1_000_000_000) try? await Task.sleep(nanoseconds: backoffNs) if assetIdx < queueItems.count { queueItems[assetIdx].retryCount = attempt queueItems[assetIdx].status = .uploading } logger.info("Retry \(attempt)/\(maxRetries - 1) for \(asset.filename, privacy: .public)") } do { let spFile = signposter.beginInterval("UploadFile", "\(asset.filename, privacy: .public)") 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 let bytesSoFar = totalBytes let capturedIdx = assetIdx if capturedIdx < queueItems.count { queueItems[capturedIdx].totalBytes = fileSize queueItems[capturedIdx].bytesUploaded = 0 } try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, _ in let speed = speedTracker.update(bytesSent: sent) guard throttle.shouldUpdate() else { return } Task { @MainActor [weak self] in guard let self else { return } self.job.updateProgress( fileName: asset.filename, fileSize: fileSize, bytesTransferred: bytesSoFar + sent, speed: speed ) if capturedIdx < self.queueItems.count { self.queueItems[capturedIdx].bytesUploaded = sent self.queueItems[capturedIdx].speedBytesPerSec = speed } } } signposter.endInterval("UploadFile", spFile, "\(fileSize) bytes") totalBytes += fileSize job.fileCompleted(skipped: false) uploaded += 1 uploadSucceeded = true if assetIdx < queueItems.count { queueItems[assetIdx].status = .uploaded queueItems[assetIdx].bytesUploaded = fileSize queueItems[assetIdx].completedAt = Date() } manifestEntries.append(ManifestEntry( localIdentifier: asset.localIdentifier, filename: asset.filename, creationDate: asset.creationDate, fileSize: fileSize, remotePath: remotePath, uploadedAt: Date() )) } catch { lastUploadError = error logger.warning("Upload attempt \(attempt + 1)/\(maxRetries) failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") } if uploadSucceeded { break } } if !uploadSucceeded, let error = lastUploadError { logger.error("Upload permanently failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") job.fileFailed() failed += 1 if assetIdx < queueItems.count { queueItems[assetIdx].status = .failed queueItems[assetIdx].completedAt = Date() queueItems[assetIdx].error = BackupQueueItem.UploadError( code: (error as NSError).domain, message: error.localizedDescription, timestamp: Date() ) } } } signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed") let duration = Date().timeIntervalSince(startDate) let result = BackupResult( uploadedCount: uploaded, skippedCount: skipped, failedCount: failed, duration: duration, totalBytes: totalBytes, date: startDate ) job.finish(result: result) BackupStatusService.shared.refreshAfterBackup(entries: manifestEntries, connection: connection) let entry = BackupHistoryEntry(result: result, nasHost: host, triggeredByLAN: triggeredByLAN) store.appendHistoryEntry(entry) // Notifications if result.failedCount > 0 { NotificationService.notifyBackupFailed(count: result.failedCount) } else { NotificationService.notifyBackupCompleted( uploaded: result.uploadedCount, total: BackupStatusService.shared.snapshot.phoneTotal ) } // Persist queue so SyncView shows last session on next launch let snapshot = queueItems Task.detached(priority: .utility) { [snapshot, url = Self.queueCacheURL] in guard let data = try? JSONEncoder.kisani.encode(snapshot) else { return } try? data.write(to: url, options: .atomic) } return result } // Returns Tailscale host when off trusted LAN and tunnel is active. private func resolveHost(connection: NASConnection, store: ConnectionStore, lan: LANMonitor) -> String { let onTrustedLAN = store.trustedSSIDs.contains(lan.currentSSID ?? "") guard !onTrustedLAN, store.useTailscaleWhenRemote, !store.tailscaleHost.isEmpty, lan.isTailscaleActive else { return connection.host } return store.tailscaleHost } func cancel() { isCancelled = true; job.cancel() } func pause() { job.pause() } func resume() { job.resume() } func resolveSuccess() { job.resolveSuccess() } } // MARK: — SpeedTracker 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) } } // MARK: — ProgressThrottle /// Limits the rate at which progress callbacks trigger @MainActor UI updates. /// Without this, SMB/SFTP stacks fire hundreds of callbacks per second, causing /// SwiftUI to redraw the full view tree on every tick. private struct ProgressThrottle { private var lastUpdate: Date = .distantPast private let minInterval: TimeInterval init(hz: Double = 8) { minInterval = 1.0 / max(1, hz) } mutating func shouldUpdate() -> Bool { let now = Date() guard now.timeIntervalSince(lastUpdate) >= minInterval else { return false } lastUpdate = now return true } }