diff --git a/Core/Models/BackupManifest.swift b/Core/Models/BackupManifest.swift index 8e4b776..034802a 100644 --- a/Core/Models/BackupManifest.swift +++ b/Core/Models/BackupManifest.swift @@ -4,7 +4,8 @@ import Photos // MARK: — ManifestIndex // Lightweight comparison structure — two Sets for O(1) lookups. // Built from BackupManifest and then the manifest is released. -struct ManifestIndex { +// Sendable: all stored properties are value types — safe to pass across task boundaries. +struct ManifestIndex: Sendable { let localIdentifiers: Set // PHAsset.localIdentifier (primary key) let lowercasedFilenames: Set // filename fallback for legacy/bootstrap entries let totalCount: Int // non-hidden file count for NAS Archive stat @@ -50,7 +51,7 @@ struct ManifestIndex { // MARK: — ManifestEntry -struct ManifestEntry: Codable { +struct ManifestEntry: Codable, Sendable { let localIdentifier: String // PHAsset.localIdentifier — primary key let filename: String let creationDate: Date? diff --git a/Core/Protocols/PhotoLibraryProtocol.swift b/Core/Protocols/PhotoLibraryProtocol.swift index 3157ab6..3f65fd5 100644 --- a/Core/Protocols/PhotoLibraryProtocol.swift +++ b/Core/Protocols/PhotoLibraryProtocol.swift @@ -1,7 +1,7 @@ import Foundation import Photos -struct PhotoAsset { +struct PhotoAsset: Sendable { let localIdentifier: String let filename: String let creationDate: Date? @@ -20,7 +20,7 @@ protocol PhotoLibraryProtocol: AnyObject { func exportAsset(_ asset: PhotoAsset) async throws -> URL } -struct BackupFilter: Codable { +struct BackupFilter: Codable, Sendable { var includePhotos: Bool = true var includeVideos: Bool = true var includeScreenshots: Bool = false diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index 03c5d8e..87b6725 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -1,8 +1,10 @@ import Foundation import UserNotifications +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 { @@ -45,108 +47,137 @@ final class BackupEngine: ObservableObject { let store = ConnectionStore.shared let lan = LANMonitor.shared - // Gate: block cellular unless user opted in (never block on LAN trigger) if !triggeredByLAN && lan.isOnCellular && !store.allowCellularBackup { throw BackupError.networkUnavailable } - // Resolve effective host: use Tailscale when remote and tunnel is active let host = resolveHost(connection: connection, store: store, lan: lan) - // 1. Fetch assets + // ── 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 assets = photoService.fetchAssets(filter: filter) + 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") - // 2. Connect to NAS + 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 + to: host, port: connection.port, + username: connection.username, password: connection.password ) + signposter.endInterval("NASConnect", spConnect) activeTransfer = transfer + defer { transfer.disconnect(); activeTransfer = nil } - defer { - transfer.disconnect() - activeTransfer = nil - } + try Task.checkCancellation() - // 3. Load manifest → build index (Data+struct released immediately after) + // ── 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 index: ManifestIndex - if let data = try? await transfer.downloadData(at: manifestPath), - let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) { - index = ManifestIndex(manifest: manifest) - logger.info("Manifest loaded — \(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") - } else { - index = ManifestIndex(nasListing: []) - logger.info("No manifest — will check NAS file existence per asset") - } + let spManifest = signposter.beginInterval("ManifestLoad") + let manifestData = try? await transfer.downloadData(at: manifestPath) + let index: ManifestIndex = await Task.detached(priority: .userInitiated) { + guard let data = manifestData, + let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) + else { return ManifestIndex(nasListing: []) } + return ManifestIndex(manifest: manifest) + }.value + signposter.endInterval("ManifestLoad", spManifest, + "\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") + logger.info("Manifest: \(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") - // 4. Build PENDING QUEUE — items not already confirmed in manifest. - // 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") + 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") - // 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. + // ── 5. Upload loop ───────────────────────────────────────────────── + // The main actor is released at every `await`. Progress UI is throttled + // to 8 Hz — without throttling, one Task { @MainActor } is fired per + // progress callback which causes continuous SwiftUI full-tree re-renders. job.start(totalFiles: pendingAssets.count, totalBytes: 0) var uploaded = 0 - var skipped = 0 - var failed = 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") - // 6. Transfer loop — manifest check already done above, only NAS-exists + upload here. for asset in pendingAssets { if isCancelled { break } while case .paused = job.status { try await Task.sleep(nanoseconds: 500_000_000) } - let baseRemotePath = "\(connection.remotePath)/\(asset.filename)" + let remotePath = "\(connection.remotePath)/\(asset.filename)" - // NAS file existence check — skip without overwriting legacy files - if (try? await transfer.fileExists(at: baseRemotePath)) == true { + if (try? await transfer.fileExists(at: remotePath)) == true { job.fileCompleted(skipped: true) skipped += 1 - logger.debug("Skipped (NAS exists, no-overwrite): \(asset.filename, privacy: .public)") + logger.debug("Skipped (NAS exists): \(asset.filename, privacy: .public)") continue } - // Upload 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 + // Capture totalBytes by value so the progress closure doesn't capture + // the mutable var — avoids a data race warning and is semantically correct. + let bytesSoFar = totalBytes - try await transfer.upload(localURL: localURL, remotePath: baseRemotePath) { sent, total in + 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 self?.job.updateProgress( fileName: asset.filename, - fileSize: total, - bytesTransferred: totalBytes + sent, + fileSize: fileSize, + bytesTransferred: bytesSoFar + sent, speed: speed ) } } + signposter.endInterval("UploadFile", spFile, "\(fileSize) bytes") totalBytes += fileSize job.fileCompleted(skipped: false) uploaded += 1 @@ -156,7 +187,7 @@ final class BackupEngine: ObservableObject { filename: asset.filename, creationDate: asset.creationDate, fileSize: fileSize, - remotePath: baseRemotePath, + remotePath: remotePath, uploadedAt: Date() )) } catch { @@ -166,51 +197,42 @@ final class BackupEngine: ObservableObject { } } + 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 + skippedCount: skipped, + failedCount: failed, + duration: duration, + totalBytes: totalBytes, + date: startDate ) 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) - await sendCompletionNotification(result: result) return result } - // Returns the Tailscale host when outside the trusted network and tunnel is up, - // otherwise falls back to the saved local host. + // 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 - } + lan.isTailscaleActive else { return connection.host } return store.tailscaleHost } - func cancel() { - isCancelled = true - job.cancel() - } - + func cancel() { isCancelled = true; job.cancel() } func pause() { job.pause() } 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 { @@ -223,6 +245,8 @@ final class BackupEngine: ObservableObject { } } +// MARK: — SpeedTracker + private struct SpeedTracker { private var lastBytes: Int64 = 0 private var lastTime: Date = Date() @@ -237,3 +261,22 @@ private struct SpeedTracker { 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 + } +} diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index eda6ed4..ec8d294 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -4,6 +4,7 @@ import UIKit import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "BackupStatus") +private let signposter = OSSignposter(subsystem: "com.albert.nasbackup", category: "BackupStatus") @MainActor final class BackupStatusService: NSObject, ObservableObject { @@ -16,14 +17,13 @@ final class BackupStatusService: NSObject, ObservableObject { private let photoService = PhotoLibraryService() private let cacheKey = "backupStatusSnapshot_v2" - // One active reconciliation task at a time — cancelled before starting a new one + // One active reconciliation task at a time — cancelled before starting a new one. private var refreshTask: Task? - // Separate debounce task for photo library change events private var debounceTask: Task? private var lastFullRefresh: Date? - private static let debounceInterval: TimeInterval = 4 // seconds before photo-change refresh runs - private static let minRefreshInterval: TimeInterval = 30 // minimum between full NAS reconciliations + private static let debounceInterval: TimeInterval = 4 + private static let minRefreshInterval: TimeInterval = 30 // MARK: — Init @@ -52,7 +52,6 @@ final class BackupStatusService: NSObject, ObservableObject { // MARK: — Public API - /// Start a reconciliation. `force: true` bypasses the minimum refresh interval. func refresh(force: Bool = false) { cancelAll() refreshTask = Task { [weak self] in @@ -60,21 +59,17 @@ 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. - /// Applies an optimistic count update, writes the manifest in background, then reconciles. func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { let uploaded = entries.count snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) snapshot.nasArchiveTotal += uploaded saveSnapshot() log.info("Post-backup optimistic update: +\(uploaded) safe") - // Write manifest then force reconcile (entries captured only briefly) Task { await writeManifest(entries: entries, connection: connection) refresh(force: true) @@ -96,7 +91,6 @@ final class BackupStatusService: NSObject, ObservableObject { return } - // Minimum interval guard (skip NAS round-trip if recently refreshed) if !force, let last = lastFullRefresh, Date().timeIntervalSince(last) < Self.minRefreshInterval { log.debug("Refresh debounced — updating phone count only") @@ -128,10 +122,14 @@ final class BackupStatusService: NSObject, ObservableObject { // MARK: — Reconciliation private func reconcile() async throws { + let spReconcile = signposter.beginInterval("Reconcile") + defer { signposter.endInterval("Reconcile", spReconcile) } + let store = ConnectionStore.shared guard let conn = store.savedConnection else { return } - // ── Step 1: Phone count — metadata only, no image data, no thumbnails ── + // ── Step 1: Phone count ─────────────────────────────────────────── + // PHFetchResult.count is O(1) after the initial index build — fast on main actor. let filter = store.backupFilter let fetchResult = photoService.fetchResultForReconciliation(filter: filter) let phoneTotal = fetchResult.count @@ -139,7 +137,7 @@ final class BackupStatusService: NSObject, ObservableObject { try Task.checkCancellation() - // ── Step 2: NAS connection ── + // ── Step 2: NAS connection ──────────────────────────────────────── let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() do { try await transfer.connect( @@ -147,7 +145,6 @@ final class BackupStatusService: NSObject, ObservableObject { username: conn.username, password: conn.password ) } catch { - // NAS offline — keep cached safe count, enforce invariant snapshot.phoneTotal = phoneTotal snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal) snapshot.connectionState = .offline @@ -157,32 +154,48 @@ final class BackupStatusService: NSObject, ObservableObject { return } defer { transfer.disconnect() } + try Task.checkCancellation() - // ── Step 3: Load ManifestIndex — Data + BackupManifest released after this call ── + // ── Step 3: Load ManifestIndex ──────────────────────────────────── + // Download suspends main actor (good). JSON decode + Set construction + // is CPU-bound O(N) — runs in a background task so touch/animation remain smooth. + let spManifest = signposter.beginInterval("ManifestLoad") let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)" let index = await buildManifestIndex(transfer: transfer, path: manifestPath, conn: conn) + signposter.endInterval("ManifestLoad", spManifest, + "\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)") log.info("Manifest index built — \(index.totalCount) NAS entries, filename-only=\(index.isFilenameOnly)") try Task.checkCancellation() - // ── Step 4: Count safe assets — PHFetchResult enumerated in batches, no [PhotoAsset] kept ── - let safe = try await countSafe(in: fetchResult, against: index) + // ── Step 4: Count safe assets — off main actor ──────────────────── + // Batch enumeration of PHFetchResult is CPU-bound (up to N/150 batches). + // Running it in a detached task keeps the main thread free for the UI + // while the count is in progress. Task.yield() inside countSafe lets other + // async work interleave between batches. + let spCount = signposter.beginInterval("CountSafe") + let safe = try await Task.detached(priority: .userInitiated) { [weak self, fetchResult, index] in + guard let self else { throw CancellationError() } + return try await self.countSafe(in: fetchResult, against: index) + }.value + signposter.endInterval("CountSafe", spCount, "\(safe)/\(phoneTotal) safe") log.info("Comparison complete — \(safe)/\(phoneTotal) already safe") - // ── Step 5: Commit — enforce invariant (alreadySafe ≤ phoneTotal) ── + // ── Step 5: Commit ──────────────────────────────────────────────── var updated = BackupStatusSnapshot() - updated.phoneTotal = phoneTotal - updated.alreadySafe = min(safe, phoneTotal) + updated.phoneTotal = phoneTotal + updated.alreadySafe = min(safe, phoneTotal) updated.nasArchiveTotal = index.totalCount updated.connectionState = .connected - updated.lastCheckedAt = Date() + updated.lastCheckedAt = Date() snapshot = updated saveSnapshot() } - /// Builds a ManifestIndex from NAS. Data and BackupManifest are scoped inside - /// this function — they are released before the caller's comparison step begins. + /// Builds a ManifestIndex from NAS. + /// - The download suspends the main actor. + /// - JSON decode and Set construction run in a background task. private func buildManifestIndex( transfer: any NASTransferProtocol, path: String, @@ -190,22 +203,29 @@ final class BackupStatusService: NSObject, ObservableObject { ) async -> ManifestIndex { do { let data = try await transfer.downloadData(at: path) - if let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) { - // `data` and `manifest` released when this scope exits + // Decode and build index off main actor — JSONDecoder is not cheap for large manifests. + let index: ManifestIndex? = await Task.detached(priority: .utility) { + guard let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) + else { return nil } return ManifestIndex(manifest: manifest) - } + }.value + if let index { return index } } catch { // File not found — fall through to directory listing bootstrap } - // Bootstrap from directory listing + + // Bootstrap: list NAS directory and build filename-only index in background. let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] log.info("Manifest not found — bootstrapping from \(items.count) NAS items") - return ManifestIndex(nasListing: items) + return await Task.detached(priority: .utility) { + ManifestIndex(nasListing: items) + }.value } /// Enumerates a PHFetchResult in batches using autoreleasepool. - /// Never builds a full [PHAsset] or [PhotoAsset] array. - private func countSafe( + /// Marked nonisolated so it can be called from a background Task without + /// requiring main-actor scheduling — it accesses no @MainActor state. + private nonisolated func countSafe( in result: PHFetchResult, against index: ManifestIndex, batchSize: Int = 150 @@ -220,18 +240,14 @@ final class BackupStatusService: NSObject, ObservableObject { let batchEnd = min(processed + batchSize, total) // autoreleasepool releases PHAsset objects and PHAssetResource arrays - // created during this batch before moving to the next + // created during this batch before moving to the next. let batchSafe: Int = autoreleasepool { var count = 0 for i in processed..