Transparency

Privacy & the source code

We think the only way to really trust a backup app with your photos is to see exactly what it does. So the actual code that handles your photos is published below, word for word and unchanged. You don't have to take our word for anything — read it yourself, or ask an AI assistant like ChatGPT to read it and explain it to you.

What the app does

  • Reads your photos and videos and copies them to the USB-C drive you choose, sorted into folders by year and month.
  • When you restore, it adds photos back to your library — it can only ever add, never replace or remove.
  • Remembers the drive you picked, so it reconnects on its own the next time you plug it in.

What the app does NOT do

  • No servers of its own, no accounts, no sign-in.
  • No tracking, no analytics, no ads. There is simply no code in the app that sends information anywhere.
  • No deleting or editing the photos you already have. Backing up only reads them; restoring can only add. Nothing in the app can erase or change your library.

One honest point about the internet: the app isn't completely offline. If a photo lives in iCloud and only a small preview is on your phone, the app asks Apple to download the full-size original so it can save the real thing to your drive. That's the only time it uses the internet — and it's only ever downloading your own photo. It never uploads your photos or sends any information about you anywhere.

Ask an AI to check it for you

Not a programmer? No problem. Copy the prompt below, paste it into ChatGPT, Claude, or any AI assistant, then copy in the code shown on this page and let the AI explain, in plain language, exactly what the app does. Here's a prompt to get you started:

I'm considering an iOS app called "Very Good Photo Backup." I'll give you the Swift source for the parts that handle my photos, the backup, and the restore. Please read it carefully and tell me, in plain language: 1. Does the app ever upload my photos or any data about me to the internet or to any server? If so, where? 2. Does it ever delete, edit, or overwrite photos in my photo library? 3. Is there any tracking, analytics, or telemetry code? 4. In one sentence: what does this app actually do with my photos? Base your answer only on the code I provide, and quote the specific lines that justify each answer.

The code

Below are the three parts of the app that actually handle your photos, the backup, and the restore — these are the ones that prove what the app does with your pictures, shown exactly as they are in the app, unchanged. (The remaining parts, like the buttons and screens, aren't shown here so the page stays focused on what matters for your privacy; they're available on request.) After the code is Apple's required privacy label, explained in plain English.

About this code. It is published here for transparency and verification only. The source and the “Very Good Photo Backup” name and icon are © 2026 Agentic Labs LLC, all rights reserved. It is not open source and is not licensed for reuse, redistribution, or republication. The only official build is the one on the App Store, published by Agentic Labs LLC — any copy distributed elsewhere is not from us. You are welcome to read it, and to ask an AI assistant to read it, to confirm what the app does.

PhotoLibrary.swift

//
//  PhotoLibrary.swift
//  LittleBackup
//
//  Thin wrapper over PhotoKit. Enumerates every photo and video asset and hands
//  back the ORIGINAL, unmodified file resource for each one — no transcoding,
//  no re-encoding, HEIC stays HEIC and videos are untouched.
//
//  SAFETY — BACKUP IS READ-ONLY; RESTORE IS ADD-ONLY:
//  PhotoKit has no read-only authorization level (PHAccessLevel is only
//  .addOnly or .readWrite), so reading the library for backup requires
//  .readWrite. This file uses that capability to READ ONLY — it contains no
//  mutation calls: no PHAssetChangeRequest, no PHPhotoLibrary.performChanges,
//  no deleteAssets, no creationRequest here. The only library writes in the
//  whole app live in RestoreEngine.swift, and they are strictly ADD-ONLY:
//  restore creates NEW assets via PHAssetCreationRequest under .addOnly
//  permission, which structurally cannot delete or edit existing photos. There
//  is no deleteAssets and no change request against an existing asset anywhere
//  in the codebase (grep-verifiable). The only files this app ever removes are
//  its own throwaway temp copies inside the app sandbox.
//

import Foundation
import Photos
import UIKit

enum PhotoLibraryError: LocalizedError {
    case accessDenied
    case noOriginalResource
    case exportFailed(String)
    case timedOut

    var errorDescription: String? {
        switch self {
        case .accessDenied:
            return "Photo library access was not granted. Enable it in Settings ▸ Very Good Photo Backup ▸ Photos ▸ Full Access."
        case .noOriginalResource:
            return "No original file resource was found for this item."
        case .exportFailed(let why):
            return "Could not read the original file: \(why)"
        case .timedOut:
            return "Timed out reading the original (likely an iCloud item not downloaded)."
        }
    }
}

/// One asset's identity and the facts needed to place and dedupe it.
struct MediaItem {
    let asset: PHAsset
    let originalFilename: String
    /// The name used on the drive. Equals `originalFilename` except when two
    /// distinct assets in the same month share a filename, in which case the
    /// later ones get a `~2`, `~3` suffix so no distinct photo is lost.
    var backupFilename: String
    /// Best available capture date: EXIF creationDate from the asset.
    let captureDate: Date
    let dateSource: LogEntry.DateSource

    var month: MonthKey { MonthKey(date: captureDate) }
}

/// All the items that fall in one calendar month, oldest months first.
struct MediaMonth: Identifiable {
    let key: MonthKey
    let items: [MediaItem]
    var id: MonthKey { key }
    var count: Int { items.count }
}

struct PhotoLibrary {

    /// Requests library access. PhotoKit has no "read-only" authorization level
    /// (`PHAccessLevel` is only `.addOnly` or `.readWrite`), so reading the
    /// library requires `.readWrite`. We request it ONLY to read: this app
    /// contains no PHAssetChangeRequest / performChanges / deleteAssets calls,
    /// so it never writes to or deletes from the library. The never-delete
    /// guarantee is enforced by the code's structure (see the SAFETY note at
    /// the top of this file), not by the authorization level.
    static func requestAccess() async -> Bool {
        let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
        switch status {
        case .authorized:
            return true
        case .notDetermined:
            let granted = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
            return granted == .authorized || granted == .limited
        case .limited:
            return true   // we can still back up the subset the user allowed
        default:
            return false
        }
    }

    /// A cheap index of the whole library: the sorted list of months that
    /// contain media and how many items each has. This reads ONLY creationDate
    /// per asset — it does NOT call the expensive PHAssetResource lookup — so
    /// it stays fast even at ~18k items (seconds, not tens of seconds). The
    /// per-asset filename resolution is deferred to `items(in:)`, run only for
    /// the month actually being backed up.
    /// Count every burst frame individually (expanded). The app's item total is
    /// then slightly higher than the Photos app (which groups bursts), but every
    /// frame is backed up AND every app number (iPhone items / sync / restore)
    /// uses this same basis, so they all reconcile to one total.
    static let countBurstsIndividually = true

    static func monthIndex() -> [(key: MonthKey, count: Int)] {
        let options = PHFetchOptions()
        options.includeHiddenAssets = false
        options.includeAllBurstAssets = countBurstsIndividually
        options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]

        let result = PHAsset.fetchAssets(with: options)
        var counts: [MonthKey: Int] = [:]
        result.enumerateObjects { asset, _, _ in
            guard asset.mediaType == .image || asset.mediaType == .video else { return }
            let date = asset.creationDate ?? asset.modificationDate ?? Date()
            counts[MonthKey(date: date), default: 0] += 1
        }
        return counts.keys.sorted().map { ($0, counts[$0] ?? 0) }
    }

    /// The single canonical "iPhone item count" (collapsed) — the one total the
    /// whole app displays and partitions. Everything (Sync, Restore, the drive
    /// card) anchors to this so the numbers always reconcile.
    static func libraryItemCount() -> Int {
        monthIndex().reduce(0) { $0 + $1.count }
    }

    /// How many of the library items are extra burst sub-frames — i.e. the gap
    /// between our expanded count and the way the Photos app groups bursts.
    /// Shown in the drive card so the user understands why our total is a bit
    /// higher than what Photos shows. Counts the whole library two ways.
    static func burstFrameExtra() -> Int {
        func total(_ allBursts: Bool) -> Int {
            let o = PHFetchOptions()
            o.includeHiddenAssets = false
            o.includeAllBurstAssets = allBursts
            let r = PHAsset.fetchAssets(with: o)
            var n = 0
            r.enumerateObjects { a, _, _ in
                if a.mediaType == .image || a.mediaType == .video { n += 1 }
            }
            return n
        }
        return max(0, total(true) - total(false))
    }

    /// Cheap per-month "fingerprint" of the library used to detect, without the
    /// expensive per-asset resource lookup, whether a month has changed since
    /// the last Sync. For every media asset it reads ONLY properties PhotoKit
    /// serves cheaply — `localIdentifier` and `modificationDate` — and folds
    /// them into a stable per-month hash plus a count. It never calls
    /// `PHAssetResource.assetResources(for:)` (the slow step), so it runs in the
    /// same time tier as `monthIndex()` (seconds at ~18k items), not the tens of
    /// seconds a full filename resolution of every month would take.
    ///
    /// The fingerprint changes if ANY asset in the month is added, removed, or
    /// edited (an edit bumps `modificationDate`), which is exactly the signal a
    /// count comparison misses — e.g. one photo deleted and another added to the
    /// same month leaves the count equal but changes the set. Sync compares this
    /// against a value cached on the drive and only does the full, expensive
    /// pass for months whose fingerprint differs.
    ///
    /// Returns, per month: the item count (so Sync can still count every item it
    /// considered, even for skipped months) and the fingerprint string.
    static func monthFingerprints() -> [MonthKey: (count: Int, fingerprint: String)] {
        let options = PHFetchOptions()
        options.includeHiddenAssets = false
        options.includeAllBurstAssets = countBurstsIndividually
        options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]

        let result = PHAsset.fetchAssets(with: options)

        // Accumulate a per-month, order-independent hash. We XOR each asset's
        // own hash into the month's accumulator so enumeration order can't
        // change the result, and keep a count. Each asset contributes a hash of
        // its stable localIdentifier plus its modificationDate (to the second).
        struct Acc { var count = 0; var hash: UInt64 = 0 }
        var byMonth: [MonthKey: Acc] = [:]

        result.enumerateObjects { asset, _, _ in
            guard asset.mediaType == .image || asset.mediaType == .video else { return }
            let date = asset.creationDate ?? asset.modificationDate ?? Date()
            let key = MonthKey(date: date)
            // modificationDate to whole seconds (nil → 0); stable across runs.
            let modSecs = Int64((asset.modificationDate ?? date).timeIntervalSince1970.rounded())
            let assetHash = fnv1a("\(asset.localIdentifier)|\(modSecs)")
            var acc = byMonth[key] ?? Acc()
            acc.count += 1
            acc.hash ^= assetHash
            byMonth[key] = acc
        }

        var out: [MonthKey: (count: Int, fingerprint: String)] = [:]
        for (key, acc) in byMonth {
            // Fold the count into the final string too, so a pure add+remove
            // that somehow XOR-cancelled (astronomically unlikely) still differs
            // when the count changes.
            out[key] = (acc.count, String(format: "%d-%016llx", acc.count, acc.hash))
        }
        return out
    }

    /// FNV-1a 64-bit hash of a string. Small, fast, dependency-free, and stable
    /// across launches (unlike Swift's built-in `Hasher`, which is seeded per
    /// process and therefore NOT safe to persist and compare across runs).
    static func fnv1a(_ s: String) -> UInt64 {
        var hash: UInt64 = 0xcbf29ce484222325
        for byte in s.utf8 {
            hash ^= UInt64(byte)
            hash = hash &* 0x100000001b3
        }
        return hash
    }

    /// Fetches the media items for ONE month, resolving each asset's original
    /// filename (the expensive part) only for this month. Oldest first.
    static func items(in month: MonthKey) -> [MediaItem] {
        // Bound the fetch to [monthStart, nextMonthStart) so PhotoKit only
        // returns this month's assets — we never materialize the whole library.
        var comps = DateComponents()
        comps.year = month.year
        comps.month = month.month
        comps.day = 1
        let cal = Calendar.current
        guard let start = cal.date(from: comps),
              let end = cal.date(byAdding: .month, value: 1, to: start) else { return [] }

        let options = PHFetchOptions()
        options.includeHiddenAssets = false
        options.includeAllBurstAssets = true
        options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
        options.predicate = NSPredicate(
            format: "creationDate >= %@ AND creationDate < %@",
            start as NSDate, end as NSDate)

        let result = PHAsset.fetchAssets(with: options)

        // Pass 1: collect distinct assets (deduped by localIdentifier) with
        // their original filename. We DON'T assign backup names yet — that must
        // be deterministic, not enumeration-order dependent.
        struct Raw { let asset: PHAsset; let filename: String; let date: Date; let source: LogEntry.DateSource }
        var raws: [Raw] = []
        raws.reserveCapacity(result.count)
        var seenAssetIDs = Set<String>()
        result.enumerateObjects { asset, _, _ in
            guard asset.mediaType == .image || asset.mediaType == .video else { return }
            guard seenAssetIDs.insert(asset.localIdentifier).inserted else { return }
            let filename = originalFilename(for: asset)
            let date = asset.creationDate ?? asset.modificationDate ?? Date()
            let source: LogEntry.DateSource = asset.creationDate != nil ? .exif : .fileCreation
            raws.append(Raw(asset: asset, filename: filename, date: date, source: source))
        }

        // Pass 2: assign backup names DETERMINISTICALLY. For each filename, if
        // multiple distinct assets share it, sort those assets by their stable
        // localIdentifier and give the 2nd, 3rd… a ~2, ~3 suffix. The same
        // assets always sort the same way, so every run (Sync AND Verify)
        // computes the identical name — fixing the "ghost missing" mismatch.
        // Group case-insensitively so IMG.jpg and IMG.JPG are treated as the
        // same name for collision purposes (the drive filesystem is
        // case-insensitive).
        let byName = Dictionary(grouping: raws, by: { $0.filename.lowercased() })
        var backupNameByAssetID: [String: String] = [:]
        for (_, group) in byName {
            if group.count == 1 {
                // Keep the original filename exactly (preserve its case).
                backupNameByAssetID[group[0].asset.localIdentifier] = group[0].filename
            } else {
                // Deterministic order by stable id; first keeps original name,
                // the rest get ~2, ~3 based on that first name's stem/ext.
                let sorted = group.sorted { $0.asset.localIdentifier < $1.asset.localIdentifier }
                let base = sorted[0].filename
                let ext = (base as NSString).pathExtension
                let stem = (base as NSString).deletingPathExtension
                for (i, r) in sorted.enumerated() {
                    let name: String
                    if i == 0 {
                        name = r.filename   // original, original case
                    } else {
                        name = ext.isEmpty ? "\(stem)~\(i + 1)" : "\(stem)~\(i + 1).\(ext)"
                    }
                    backupNameByAssetID[r.asset.localIdentifier] = name
                }
            }
        }

        // Build the final items in creationDate order (raws already came in
        // ascending order from the fetch).
        return raws.map { r in
            MediaItem(asset: r.asset,
                      originalFilename: r.filename,
                      backupFilename: backupNameByAssetID[r.asset.localIdentifier] ?? r.filename,
                      captureDate: r.date,
                      dateSource: r.source)
        }
    }

    /// The original on-disk filename (e.g. IMG_0001.HEIC). Falls back to a
    /// synthesized name using the asset's local identifier if none is exposed.
    static func originalFilename(for asset: PHAsset) -> String {
        let resources = PHAssetResource.assetResources(for: asset)
        // Prefer the primary photo/video resource's original filename.
        let preferredTypes: [PHAssetResourceType] = [.photo, .video, .fullSizePhoto, .fullSizeVideo]
        for type in preferredTypes {
            if let r = resources.first(where: { $0.type == type }) {
                return r.originalFilename
            }
        }
        if let first = resources.first {
            return first.originalFilename
        }
        // Last-ditch unique name so nothing silently collides.
        let safeId = asset.localIdentifier.replacingOccurrences(of: "/", with: "_")
        let ext = asset.mediaType == .video ? "mov" : "heic"
        return "asset_\(safeId).\(ext)"
    }

    /// Default give-up threshold for a single read. Normal Sync uses this;
    /// Retry Failed passes a longer value (it's deliberately patient, since
    /// those items already failed once and we're trying hard to recover them).
    static let defaultReadTimeout: Duration = .seconds(5)
    static let retryReadTimeout: Duration = .seconds(20)

    /// Writes the ORIGINAL file bytes for an asset to `destinationURL`.
    /// Uses PHAssetResourceManager with `.network` allowed so iCloud-optimized
    /// originals are fetched on demand. No conversion is performed. Throws
    /// `PhotoLibraryError.timedOut` if the read exceeds `readTimeout`.
    ///
    /// Returns `true` if a NETWORK (iCloud) download occurred during the read —
    /// detected via the request's progressHandler firing, which only happens
    /// when iOS has to fetch the original from iCloud. A purely local original
    /// completes without any progress callback, so this is a factual signal of
    /// local-vs-iCloud rather than a guess from timing.
    @discardableResult
    static func writeOriginal(of item: MediaItem, to destinationURL: URL,
                              timeout: Duration = defaultReadTimeout) async throws -> Bool {
        let resources = PHAssetResource.assetResources(for: item.asset)
        let originalResource = pickOriginalResource(resources, mediaType: item.asset.mediaType)
        guard let resource = originalResource else {
            throw PhotoLibraryError.noOriginalResource
        }

        let options = PHAssetResourceRequestOptions()
        options.isNetworkAccessAllowed = true   // pull from iCloud if needed

        // Observed via the progress handler: set only if iOS reports download
        // progress (i.e. it fetched from iCloud). Using a class box so the
        // @Sendable handler can mutate it safely.
        // Tracks whether a network (iCloud) download happened, and when we last
        // saw activity. The timeout below is a STALL timeout keyed off this.
        final class State: @unchecked Sendable {
            var didDownload = false
            var lastActivity = Date()
        }
        let flag = State()
        options.progressHandler = { progress in
            if progress > 0 { flag.didDownload = true }
            flag.lastActivity = Date()   // any progress tick resets the stall clock
        }

        try await withThrowingTaskGroup(of: Void.self) { group in
            group.addTask {
                try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
                    PHAssetResourceManager.default().writeData(
                        for: resource, toFile: destinationURL, options: options
                    ) { error in
                        if let error {
                            cont.resume(throwing: PhotoLibraryError.exportFailed(error.localizedDescription))
                        } else {
                            cont.resume()
                        }
                    }
                }
            }
            group.addTask {
                // Stall watchdog: only give up if there's been NO activity for
                // a full `timeout` interval. A local read completes before the
                // first interval elapses; an actively-downloading iCloud
                // original keeps resetting lastActivity, so it runs as long as
                // it needs; a truly hung item (no progress, no completion)
                // still fails after one idle interval.
                let timeoutSeconds = Double(timeout.components.seconds)
                while true {
                    try await Task.sleep(for: timeout)
                    if Date().timeIntervalSince(flag.lastActivity) >= timeoutSeconds {
                        throw PhotoLibraryError.timedOut
                    }
                }
            }
            defer { group.cancelAll() }
            try await group.next()
        }
        return flag.didDownload
    }

    /// Prefer the true original resource over edited/adjusted renditions.
    private static func pickOriginalResource(_ resources: [PHAssetResource],
                                             mediaType: PHAssetMediaType) -> PHAssetResource? {
        let order: [PHAssetResourceType] = mediaType == .video
            ? [.video, .fullSizeVideo, .pairedVideo]
            : [.photo, .fullSizePhoto, .alternatePhoto]
        for type in order {
            if let r = resources.first(where: { $0.type == type }) {
                return r
            }
        }
        return resources.first
    }

    // MARK: - Restore support

    /// A stable-per-install identifier for THIS phone. Written into the drive's
    /// device marker at backup time and compared at restore time so we can warn
    /// if the drive's backup was made by a different phone. `identifierForVendor`
    /// is stable while the app stays installed; it resets on uninstall, which is
    /// acceptable — the marker also records the device folder name, and the
    /// restore warning is advisory (the user can override). Must be read on the
    /// main actor.
    @MainActor
    static func deviceIdentifier() -> String {
        UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
    }

    /// Requests ADD-ONLY write access, used only when Restore first runs. Backup
    /// never needs this (it only reads). Add-only lets us create new assets and
    /// a "Restored" album WITHOUT the ability to delete or edit existing photos —
    /// the strongest guarantee iOS offers that restore can only ever ADD.
    /// Returns true if we can add.
    static func requestAddAccess() async -> Bool {
        let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
        switch status {
        case .authorized, .limited:
            return true
        case .notDetermined:
            let granted = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
            return granted == .authorized || granted == .limited
        default:
            return false
        }
    }

    // NOTE: Restore dedupe is done by filename, scoped per month, in
    // RestoreEngine.missingDriveFiles — the same reliable match Sync/Verify use.
    // An earlier filename+timestamp key produced false "missing" hits because
    // the drive filesystem (ExFAT) rounds modification times to 2-second
    // granularity, so it was removed.
}

BackupEngine.swift

//
//  BackupEngine.swift
//  LittleBackup
//
//  Orchestrates a run month by month (batched so the phone isn't asked to copy
//  the whole library at once). For each asset it decides whether the original
//  is already on the drive, otherwise copies the ORIGINAL bytes into the right
//  YYYY/YYYY-MM folder. Incremental, resumable, cancellable, CSV-logged. The
//  Photos library is only ever read.
//

import Foundation
import Photos

enum BackupMode: Equatable {
    /// Walk every month; within each, copy what's missing. (First-time backup.)
    case full
    /// Skip months already fully present on the drive. For the newest month
    /// that IS present, still re-scan it for newly-added photos (the delta).
    case sync
    /// Re-attempt only the items currently in the failures store.
    case retryFailed
    /// Process ONLY the given months (used by "Back up missing" after a Verify,
    /// so it targets exactly the flagged months instead of scanning everything).
    case onlyMonths(Set<MonthKey>)
}

actor BackupEngine {

    private var cancelled = false
    func cancel() { cancelled = true }
    func resetCancellation() { cancelled = false }

    /// Adaptive concurrency for this run (set in `run`). The level starts low
    /// and the controller tunes it live from throughput + failure rate.
    private var controller = ConcurrencyController()

    /// Runs a backup to `destination` in the given `mode`.
    /// - Parameters:
    ///   - limit: if non-nil, stop after this many items (across all months,
    ///     oldest first). Used by Test mode.
    ///   - pinnedConcurrency: if non-nil, disable adaptation and hold this
    ///     fixed concurrency (the manual-override safety hatch).
    func run(mode: BackupMode,
             to destination: DriveDestination,
             limit: Int? = nil,
             pinnedConcurrency: Int? = nil,
             progress: BackupProgress) async {
        resetCancellation()
        controller = ConcurrencyController(start: 4, min: 2, max: 16,
                                           pinned: pinnedConcurrency)
        let startLevel = controller.level
        await MainActor.run { progress.concurrency = startLevel }

        // 1. Access.
        guard await PhotoLibrary.requestAccess() else {
            AppLog.backup.error("ACCESS DENIED: photo library authorization not granted")
            await MainActor.run {
                progress.phase = .failed
                progress.errorMessage = PhotoLibraryError.accessDenied.localizedDescription
            }
            return
        }

        let runStart = Date()
        let concDesc = controller.isPinned ? "pinned=\(controller.level)" : "adaptive(start=\(controller.level),max=\(controller.maxLevel))"
        AppLog.backup.notice("RUN START mode=\(String(describing: mode)) limit=\(limit.map(String.init) ?? "none") device=\(destination.deviceFolder) concurrency=\(concDesc)")

        // Stamp the drive with this phone's identity so Restore can later verify
        // the backup belongs to this device. Read the id on the main actor.
        let deviceID = await MainActor.run { PhotoLibrary.deviceIdentifier() }
        destination.writeDeviceMarker(deviceID: deviceID)

        // 2. Build the CHEAP month index AND a per-month fingerprint in a single
        //    enumeration (dates + localIdentifier + modificationDate only — no
        //    per-asset resource lookup). This stays fast at ~18k items; the
        //    expensive filename resolution is deferred to each month inside the
        //    loop below, and in Sync mode is SKIPPED entirely for months whose
        //    fingerprint is unchanged since the last successful Sync.
        await MainActor.run { progress.phase = .enumerating }
        let scanStart = Date()
        let fingerprintsByKey = PhotoLibrary.monthFingerprints()
        // Derive the same shape `monthIndex()` returned (sorted key+count), so
        // the rest of the planning code is unchanged.
        let index = fingerprintsByKey
            .map { (key: $0.key, count: $0.value.count) }
            .sorted { $0.key < $1.key }
        let totalInLibrary = index.reduce(0) { $0 + $1.count }
        AppLog.backup.notice("INDEXED library: \(totalInLibrary) items across \(index.count) months in \(String(format: "%.1f", Date().timeIntervalSince(scanStart)))s")

        // Fingerprint cache on the drive (used only by Sync). We load the stored
        // map once, update it in memory as months are processed, and flush it at
        // checkpoints + at the end. A month is only recorded here after it is
        // fully processed with no failures.
        let fingerprintStore = FingerprintStore(destination: destination)
        var storedFingerprints = (mode == .sync) ? fingerprintStore.load() : [:]

        // 3. Decide which months to process, in order (oldest first).
        let existing = destination.existingMonths()
        let newestExisting = existing.max()
        let failuresForPlan = FailureStore(destination: destination).load()
        let failedMonths = Set(failuresForPlan.map(\.month))   // "YYYY-MM" strings
        // Filenames that still need retrying, grouped by month.
        let retryByMonth: [String: Set<String>] = Dictionary(grouping: failuresForPlan, by: \.month)
            .mapValues { Set($0.map(\.filename)) }

        // On-drive file count per month (cheap: directory listing), for the
        // count-first Sync decision.
        let indexCountByKey = Dictionary(uniqueKeysWithValues: index.map { ($0.key, $0.count) })
        let plannedKeys: [MonthKey] = index.map(\.key).filter { key in
            switch mode {
            case .full:
                return true
            case .sync:
                // Sync is the THOROUGH operation: open every month and
                // name-check each item against the drive, copying anything
                // missing. We no longer gate on counts — this guarantees that
                // even a photo added to a PAST month (common: saving an older
                // photo from Messages keeps its original date) or a
                // same-count-different-file case is caught by name. Already-
                // present items are fast-skipped per item, so the cost is
                // enumeration time, not re-copying.
                return true
            case .retryFailed:
                return failedMonths.contains(key.description)
            case .onlyMonths(let months):
                // Targeted: process just the months Verify flagged.
                return months.contains(key)
            }
        }
        _ = newestExisting   // retained for potential future use

        // Estimated total for the progress bar (from the cheap index). In test
        // mode we cap it to `limit`.
        let estimatedTotal = min(
            plannedKeys.reduce(0) { $0 + (indexCountByKey[$1] ?? 0) },
            limit ?? .max)
        AppLog.backup.notice("PLAN: \(plannedKeys.count) months to consider, ~\(estimatedTotal) items (existing months on drive: \(existing.count))")
        await MainActor.run {
            progress.reset(total: estimatedTotal)
            progress.totalMonths = plannedKeys.count
            progress.monthsDone = 0
            progress.phase = .copying
        }

        // 4. Log + durable failure store.
        let stamp = Self.timestamp()
        var csvLines: [String] = ["action,month,filename,date,date_source,size,message"]
        try? destination.ensureLogFolder()
        let failures = FailureStore(destination: destination)

        var budget = limit ?? .max   // remaining items to process in test mode
        // Retry is deliberately patient (items already failed once); normal
        // Sync/Full fails fast to keep momentum.
        let readTimeout: Duration = (mode == .retryFailed)
            ? PhotoLibrary.retryReadTimeout : PhotoLibrary.defaultReadTimeout

        // 5. Month-by-month: enumerate THIS month, copy it, checkpoint, next.
        for monthKey in plannedKeys {
            if cancelled { await finishCancelled(progress, csvLines, destination, stamp); return }
            if budget <= 0 { break }

            await MainActor.run { progress.currentMonth = monthKey.description }

            // FINGERPRINT FAST-SKIP (Sync only): if this month's cheap
            // fingerprint matches the one cached from the last successful Sync,
            // the set of assets is unchanged, so it's already fully on the drive
            // — skip the expensive filename resolution AND the copy loop. We
            // still COUNT every item in the month toward the run total (so the
            // progress reflects that every item was considered), honoring the
            // test-mode budget. Fail-safe: no cached fingerprint, a mismatch, or
            // a month with recorded failures all fall through to the full pass.
            // Note: months WITH known failures are still eligible for the
            // fast-skip now. Sync no longer re-attempts failed items (only
            // "Retry failed" does), so if the library month is unchanged since
            // the last successful Sync (fingerprint match), there's genuinely
            // nothing for Sync to do — the same new items are present and the
            // same items are still failed. Fast-skipping is therefore correct
            // and stops those months (e.g. the unrecoverable old-migration
            // videos) from dragging every Sync.
            let liveFingerprint = fingerprintsByKey[monthKey]?.fingerprint
            if mode == .sync,
               let live = liveFingerprint,
               let cached = storedFingerprints[monthKey.description],
               live == cached {
                let monthCount = fingerprintsByKey[monthKey]?.count ?? 0
                let counted = min(monthCount, budget)
                AppLog.backup.notice("MONTH \(monthKey.description): unchanged (fingerprint match) — skipped \(counted) item(s) without scan")
                // Record the whole month as skipped in one update so the total
                // reflects every considered item, without any file read.
                await MainActor.run {
                    progress.recordSkippedBulk(counted)
                    progress.monthsDone += 1
                }
                budget -= counted
                continue
            }

            // Enumerate just this month (resolves filenames only here).
            let monthScan = Date()
            var monthItems = PhotoLibrary.items(in: monthKey)
            // Retry mode: keep only the items still recorded as failed, and at
            // most ONE library item per failed filename. Filenames are not
            // guaranteed unique (shared videos and Live Photo components can
            // collide), so a plain filter can match several assets for the same
            // recorded failure and process it multiple times. De-dupe by name.
            if mode == .retryFailed, let wanted = retryByMonth[monthKey.description] {
                // Failures are recorded under the on-drive (backup) name, which
                // is what `wanted` holds. Match on that, and keep at most one
                // item per name.
                var seen = Set<String>()
                monthItems = monthItems.filter { item in
                    guard wanted.contains(item.backupFilename),
                          !seen.contains(item.backupFilename) else { return false }
                    seen.insert(item.backupFilename)
                    return true
                }
            }

            // SYNC skips known-failed items. A normal Sync should be fast and
            // never get bogged down re-attempting items that already failed
            // (e.g. unrecoverable old-migration originals that time out every
            // time). Those are only re-attempted by the explicit "Retry failed"
            // button (mode == .retryFailed). We still COUNT them as skipped so
            // the total reflects every item considered. On success via Retry
            // the item clears from the failed list and a later Sync treats it
            // normally again.
            var preSkippedFailed = 0
            if mode == .sync, let failedNames = retryByMonth[monthKey.description], !failedNames.isEmpty {
                let before = monthItems.count
                monthItems = monthItems.filter { !failedNames.contains($0.backupFilename) }
                preSkippedFailed = before - monthItems.count
                if preSkippedFailed > 0 {
                    AppLog.backup.notice("MONTH \(monthKey.description): skipping \(preSkippedFailed) known-failed item(s) (use Retry failed to re-attempt)")
                }
            }
            if monthItems.count > budget { monthItems = Array(monthItems.prefix(budget)) }

            // Clean up any ".lbpart" temp files left by a previously
            // interrupted run of this month before we snapshot it.
            let swept = destination.cleanupPartials(in: monthKey)
            if swept > 0 { AppLog.backup.notice("CLEANED \(swept) leftover partial file(s) in \(monthKey.description)") }

            // Snapshot of what's already in this month's folder (for fast
            // dedupe), keyed by LOWERCASE name — the drive filesystem is
            // case-insensitive, so IMG.HEIC and img.heic are the same file.
            let present = Dictionary(
                destination.existingFiles(in: monthKey).map { ($0.key.lowercased(), $0.value) },
                uniquingKeysWith: { a, _ in a })
            AppLog.backup.notice("MONTH \(monthKey.description): \(monthItems.count) items enumerated in \(String(format: "%.2f", Date().timeIntervalSince(monthScan)))s (\(present.count) already present)")

            let month = MediaMonth(key: monthKey, items: monthItems)

            // Track per-month failures: we only cache a month's fingerprint
            // (so a future Sync can skip it) if EVERY item in it was handled
            // without failure, i.e. the whole set is confirmed on the drive.
            var monthFailureCount = 0

            // Copy this month's items with an ADAPTIVE concurrency window. The
            // window size is `controller.level`, which the controller raises or
            // lowers between completions based on measured throughput and the
            // failure rate (see ConcurrencyController). Results come back as
            // each finishes; progress/log are updated here on the actor.
            await withTaskGroup(of: LogEntry.self) { group in
                var index = 0
                var inFlight = 0

                @discardableResult
                func addNext() -> Bool {
                    guard index < month.items.count, !cancelled else { return false }
                    let item = month.items[index]
                    index += 1
                    inFlight += 1
                    group.addTask { [self] in
                        await copyOne(item, month: month.key,
                                      to: destination, knownPresent: present,
                                      readTimeout: readTimeout)
                    }
                    return true
                }

                // Prime the window to the current level (at least 1).
                let primeTo = max(1, controller.level)
                while inFlight < primeTo { if !addNext() { break } }

                // Drain: for each completion, process it, then top the window
                // back up to the (possibly adjusted) level.
                while inFlight > 0 {
                    guard let entry = await group.next() else { break }
                    inFlight -= 1

                    csvLines.append(Self.csvLine(for: entry, month: month.key))
                    switch entry.outcome {
                    case .failed:
                        monthFailureCount += 1
                        failures.recordFailure(month: month.key.description,
                                               filename: entry.filename,
                                               reason: entry.message,
                                               captureISO: ISO8601DateFormatter().string(from: entry.date))
                    case .copied, .skipped:
                        failures.clear(month: month.key.description, filename: entry.filename)
                    }
                    if let change = controller.record(success: entry.outcome != .failed) {
                        AppLog.backup.notice(change)
                        let nowLevel = controller.level
                        await MainActor.run { progress.concurrency = nowLevel }
                    }
                    await MainActor.run {
                        progress.currentItem = entry.filename
                        progress.record(entry)
                    }
                    if csvLines.count % 250 == 0 {
                        await flushLog(csvLines, to: destination, stamp: stamp)
                    }

                    if cancelled {
                        group.cancelAll()
                    } else {
                        let target = max(1, controller.level)
                        while inFlight < target { if !addNext() { break } }
                    }
                }
            }
            AppLog.backup.notice("MONTH \(monthKey.description) done")

            if cancelled { await finishCancelled(progress, csvLines, destination, stamp); return }
            budget -= month.items.count   // test-mode item budget consumed
            // Count the items we deliberately skipped (known-failed) so the
            // run total still reflects every item considered.
            if preSkippedFailed > 0 {
                let n = preSkippedFailed
                await MainActor.run { progress.recordSkippedBulk(n) }
            }
            await MainActor.run { progress.monthsDone += 1 }

            // Cache this month's fingerprint so the NEXT Sync can skip it.
            // Conditions: a real (non-test) Sync, and no NEW failures occurred
            // this run (monthFailureCount == 0). Pre-skipped KNOWN failures do
            // NOT block caching — Sync intentionally skips them, so the month is
            // "done" from Sync's point of view, and the fingerprint lets the
            // next Sync fast-skip it. If the user later fixes a failed item via
            // "Retry failed", that run clears it from the failed list and
            // rewrites state, so Sync re-engages the month normally.
            if mode == .sync, limit == nil, monthFailureCount == 0,
               let live = liveFingerprint {
                storedFingerprints[monthKey.description] = live
            }

            // Checkpoint: flush the log + fingerprint cache at each month
            // boundary so an interruption always leaves a clean, up-to-date
            // record (and the fast-skip cache reflects completed months).
            await flushLog(csvLines, to: destination, stamp: stamp)
            if mode == .sync { fingerprintStore.save(storedFingerprints) }
        }

        await flushLog(csvLines, to: destination, stamp: stamp)
        if mode == .sync { fingerprintStore.save(storedFingerprints) }
        await MainActor.run {
            progress.phase = .finished
            progress.currentItem = ""
            progress.currentMonth = ""
            let elapsed = Date().timeIntervalSince(runStart)
            let mbps = elapsed > 0 ? (Double(progress.copiedBytes) / 1_048_576.0) / elapsed : 0
            AppLog.backup.notice(String(
                format: "RUN DONE: copied=%d skipped=%d failed=%d bytes=%d in %.1fs (%.1f MB/s avg) log=%@",
                progress.copied, progress.skipped, progress.failed,
                progress.copiedBytes, elapsed, mbps,
                destination.logFileURL(stamp: stamp).lastPathComponent))
        }
    }

    // MARK: - One item

    /// NONISOLATED on purpose: this does the heavy per-item I/O (reading the
    /// original, writing to the drive) and must run OFF the engine actor so the
    /// TaskGroup's children actually run concurrently. If it were actor-isolated
    /// the children would serialize on the actor that is busy awaiting them —
    /// a self-deadlock. It touches only its parameters and thread-safe system
    /// APIs (PhotoLibrary, FileManager), no mutable actor state.
    nonisolated private func copyOne(_ item: MediaItem,
                                     month: MonthKey,
                                     to destination: DriveDestination,
                                     knownPresent: [String: Int64],
                                     readTimeout: Duration) async -> LogEntry {
        // FAST SKIP (no read): if the month-folder snapshot already has a
        // COMPLETE file with this exact name, treat it as backed up without
        // reading the original. This is safe because a file only ever reaches
        // its final name via an atomic rename of a fully-written ".lbpart"
        // temp — so a present final-name file is guaranteed complete, and any
        // partial from a crash carries the ".lbpart" suffix (excluded from
        // knownPresent and cleaned up before the month runs).
        // All drive-side operations use backupFilename (the collision-resolved
        // name); originalFilename is only for display. For items with no
        // collision these are identical.
        let driveName = item.backupFilename
        if let presentSize = knownPresent[driveName.lowercased()] {
            AppLog.backup.info("SKIP(fast) \(driveName): present in month folder, no read")
            return LogEntry(filename: driveName, outcome: .skipped,
                            date: item.captureDate, dateSource: item.dateSource,
                            bytes: presentSize, message: "")
        }

        // We write straight onto the drive, into a ".lbpart" temp, then rename.
        // Ensure the destination month folder exists up front.
        do { try destination.ensureMonthFolder(month) }
        catch {
            AppLog.backup.error("FAILED \(driveName): cannot create month folder: \(error.localizedDescription)")
            return LogEntry(filename: driveName, outcome: .failed,
                            date: item.captureDate, dateSource: item.dateSource,
                            bytes: 0, message: error.localizedDescription)
        }

        // Unique .lbpart path on the drive so concurrent writers never collide.
        let plannedFinal = destination.plannedURL(filename: driveName, month: month)
        let partURL = plannedFinal
            .deletingLastPathComponent()
            .appendingPathComponent(UUID().uuidString + "-" + driveName)
            .appendingPathExtension("lbpart")
        // If anything below fails or is interrupted, the .lbpart is cleaned up
        // (here on failure, or by cleanupPartials on the next run).
        var renamed = false
        defer { if !renamed { try? FileManager.default.removeItem(at: partURL) } }

        do {
            // Phase 1: read the original out of Photos (may fetch from iCloud)
            // directly into the on-drive .lbpart temp. A 3s timeout inside
            // writeOriginal gives up on stalled items; we DON'T retry here —
            // a failure is logged, recorded to the failures store by the
            // caller, and we move on. The user retries later from that list.
            let tRead0 = Date()
            let fromICloud = try await PhotoLibrary.writeOriginal(of: item, to: partURL, timeout: readTimeout)
            let readSecs = Date().timeIntervalSince(tRead0)
            let size = destination.fileSize(at: partURL) ?? 0

            switch destination.placement(filename: driveName,
                                         month: month, expectedSize: size) {
            case .alreadyBackedUp:
                AppLog.backup.info("SKIP(size) \(driveName): same size already on drive")
                return LogEntry(filename: driveName, outcome: .skipped,
                                date: item.captureDate, dateSource: item.dateSource,
                                bytes: size, message: "")
            case .copy(let finalURL):
                // Phase 2: atomically move the fully-written .lbpart into its
                // final name. On the same volume this is an atomic rename, so a
                // file at the final path is NEVER partial.
                let tWrite0 = Date()
                do {
                    try FileManager.default.moveItem(at: partURL, to: finalURL)
                } catch {
                    // Final name may have appeared concurrently; fall back to a
                    // replace, then verify size.
                    _ = try? FileManager.default.replaceItemAt(finalURL, withItemAt: partURL)
                }
                renamed = true
                // Verify the landed file matches the expected size; if not,
                // remove it so the next run re-copies rather than trusting it.
                let landed = destination.fileSize(at: finalURL)
                guard landed == size else {
                    try? FileManager.default.removeItem(at: finalURL)
                    let msg = "size mismatch after move (got \(landed ?? -1), expected \(size))"
                    AppLog.backup.error("FAILED \(driveName): \(msg)")
                    return LogEntry(filename: driveName, outcome: .failed,
                                    date: item.captureDate, dateSource: item.dateSource,
                                    bytes: 0, message: msg)
                }
                try? FileManager.default.setAttributes(
                    [.modificationDate: item.captureDate],
                    ofItemAtPath: finalURL.path)
                let writeSecs = Date().timeIntervalSince(tWrite0)
                let mbps = size > 0 && (readSecs + writeSecs) > 0
                    ? (Double(size) / 1_048_576.0) / (readSecs + writeSecs) : 0
                let renamedNote = driveName == item.originalFilename ? "" : " (was \(item.originalFilename))"
                AppLog.backup.notice(String(
                    format: "COPIED %@%@ %.1fMB read=%.2fs write=%.2fs (%.1f MB/s) source=%@",
                    driveName, renamedNote, Double(size) / 1_048_576.0,
                    readSecs, writeSecs, mbps, fromICloud ? "iCloud" : "local"))
                return LogEntry(filename: driveName, outcome: .copied,
                                date: item.captureDate, dateSource: item.dateSource,
                                bytes: size, message: "")
            }
        } catch {
            AppLog.backup.error("FAILED \(driveName): \(error.localizedDescription)")
            return LogEntry(filename: driveName, outcome: .failed,
                            date: item.captureDate, dateSource: item.dateSource,
                            bytes: 0, message: error.localizedDescription)
        }
    }

    // MARK: - Helpers

    private func finishCancelled(_ progress: BackupProgress,
                                 _ lines: [String],
                                 _ destination: DriveDestination,
                                 _ stamp: String) async {
        await flushLog(lines, to: destination, stamp: stamp)
        AppLog.backup.notice("RUN CANCELLED by user")
        await MainActor.run { progress.phase = .cancelled }
    }

    private func flushLog(_ lines: [String], to destination: DriveDestination, stamp: String) async {
        let url = destination.logFileURL(stamp: stamp)
        let text = lines.joined(separator: "\n") + "\n"
        try? text.data(using: .utf8)?.write(to: url, options: .atomic)
    }

    private static func csvLine(for e: LogEntry, month: MonthKey) -> String {
        let fields = [
            e.outcome.rawValue,
            month.description,
            e.filename,
            Self.dateFormatter.string(from: e.date),
            e.dateSource.rawValue,
            String(e.bytes),
            e.message
        ]
        return fields.map(Self.csvEscape).joined(separator: ",")
    }

    private static func csvEscape(_ s: String) -> String {
        if s.contains(",") || s.contains("\"") || s.contains("\n") {
            return "\"" + s.replacingOccurrences(of: "\"", with: "\"\"") + "\""
        }
        return s
    }

    private static let dateFormatter: DateFormatter = {
        let f = DateFormatter()
        f.dateFormat = "yyyy-MM-dd HH:mm:ss"
        f.locale = Locale(identifier: "en_US_POSIX")
        return f
    }()

    private static func timestamp() -> String {
        let f = DateFormatter()
        f.dateFormat = "yyyyMMdd-HHmmss"
        f.locale = Locale(identifier: "en_US_POSIX")
        return f.string(from: Date())
    }
}

RestoreEngine.swift

//
//  RestoreEngine.swift
//  LittleBackup
//
//  Restores photos/videos FROM the USB-C drive back INTO the phone's library.
//
//  SAFETY — RESTORE ONLY EVER ADDS:
//  • It reads files from the drive (never writes to or deletes from the drive).
//  • It creates NEW assets in the library via PHAssetCreationRequest only.
//  • It contains NO deleteAssets and NO change request against any EXISTING
//    asset — so it structurally cannot delete or edit a photo already on the
//    phone.
//  • Before importing a file it checks a dedupe key (filename + capture second)
//    against every asset already in the library and SKIPS anything that matches,
//    so it won't re-add a photo you still have. The check is conservative toward
//    skipping: a false match skips a restore (safe) and never causes a delete.
//  • Imports land in a dedicated "Restored by Very Good Photo Backup" album as a
//    visible safety net, so anything added by a restore is easy to find (and, if
//    ever unwanted, remove by hand).
//
//  Resumable: each run re-scans the drive and re-dedupes against the current
//  library, so re-running after an interruption simply skips what already landed
//  and imports the rest. There is no fragile checkpoint to corrupt.
//

import Foundation
import Photos

enum RestoreError: LocalizedError {
    case addAccessDenied
    case importFailed(String)

    var errorDescription: String? {
        switch self {
        case .addAccessDenied:
            return "Permission to add photos was not granted. Enable it in Settings ▸ Very Good Photo Backup ▸ Photos."
        case .importFailed(let why):
            return "Couldn't add a photo to your library: \(why)"
        }
    }
}

actor RestoreEngine {

    /// Name of the album every restored item is added to.
    static let albumName = "Restored by Very Good Photo Backup"

    private var cancelled = false
    func cancel() { cancelled = true }
    func resetCancellation() { cancelled = false }

    // MARK: - Device identity check

    /// Compare the drive's device marker to THIS phone. Runs off any run so the
    /// UI can warn before the user commits.
    func checkDevice(destination: DriveDestination) async -> DeviceMatch {
        let thisID = await MainActor.run { PhotoLibrary.deviceIdentifier() }
        guard let marker = destination.readDeviceMarker() else {
            return .unknown
        }
        if marker.deviceID == thisID {
            return .match(deviceFolder: marker.deviceFolder)
        }
        return .mismatch(markerFolder: marker.deviceFolder,
                         thisFolder: destination.deviceFolder)
    }

    // MARK: - Dedupe (which drive files are missing from the library)

    /// The drive files NOT already in the library, decided by FILENAME, scoped
    /// per month — the same reliable match Sync and Verify use.
    ///
    /// Why filename, not filename+timestamp: the drive stores capture time in
    /// the file's modification date, but the drive filesystem (ExFAT/FAT) rounds
    /// mtime to a 2-second granularity, and backup vs library dates can drift by
    /// a second. A timestamp-based key therefore produced false "missing" hits
    /// for a scattered subset of items (e.g. 106 of ~17.9k) even though the
    /// photos were present. Filenames are already made collision-free and
    /// deterministic by backup (IMG_1234.HEIC, with ~2/~3 for genuine clashes),
    /// so per-month filename matching is both correct and strict enough: a drive
    /// file counts as "present" iff the library has an asset whose backup name
    /// for that month equals it (case-insensitively — the drive FS is
    /// case-insensitive). This is conservative toward SKIPPING (safe: never
    /// deletes, never re-adds a photo you have), and no longer over-reports.
    /// - Parameters:
    ///   - driveFiles: the already-listed drive files (so we don't scan the
    ///     drive twice). Pass `destination.allBackedUpFiles()`.
    ///   - progress: updated live (month being checked, running count) so the
    ///     scan shows motion instead of appearing stuck.
    private func missingDriveFiles(driveFiles: [DriveFile],
                                   progress: RestoreProgress) async -> [DriveFile] {
        // Group drive files by month so we only resolve library names per month,
        // oldest month first for predictable progress.
        let driveByMonth = Dictionary(grouping: driveFiles, by: { $0.month })
        let months = driveByMonth.keys.sorted()

        await MainActor.run {
            progress.scanTotal = driveFiles.count
            progress.scanChecked = 0
            progress.scanFoundMissing = 0
        }

        var missing: [DriveFile] = []
        var checked = 0
        for month in months {
            if cancelled { break }
            let files = driveByMonth[month] ?? []
            await MainActor.run { progress.scanMonth = month.description }

            // The library's backup filenames for this month (lowercased). This
            // is the expensive per-asset resolution; doing it per month keeps
            // memory flat and lets us report progress between months.
            let libNames = Set(PhotoLibrary.items(in: month)
                .map { $0.backupFilename.lowercased() })
            for f in files where !libNames.contains(f.filename.lowercased()) {
                missing.append(f)
            }
            checked += files.count
            let checkedNow = checked
            let foundNow = missing.count
            await MainActor.run {
                progress.scanChecked = checkedNow
                progress.scanFoundMissing = foundNow
            }
        }
        // Oldest first, stable order.
        return missing.sorted {
            $0.month != $1.month ? $0.month < $1.month
                                 : $0.filename.lowercased() < $1.filename.lowercased()
        }
    }

    // MARK: - Plan (dry-run count)

    /// Scan the drive and the library and report how many files WOULD be added
    /// vs are already present. No library changes are made. This is what the
    /// confirmation dialog shows before the user commits.
    func plan(destination: DriveDestination,
              progress: RestoreProgress) async -> RestorePlan {
        await MainActor.run {
            progress.phase = .scanning
            progress.scanMonth = ""
        }

        let driveFiles = destination.allBackedUpFiles()
        let totalOnDrive = driveFiles.count
        let missing = await missingDriveFiles(driveFiles: driveFiles, progress: progress)
        let bytesToAdd = missing.reduce(Int64(0)) { $0 + $1.size }
        let toAdd = missing.count
        let present = totalOnDrive - toAdd

        AppLog.restore.notice("PLAN: drive=\(totalOnDrive) toAdd=\(toAdd) present=\(present)")
        await MainActor.run { progress.phase = .idle }
        return RestorePlan(toAdd: toAdd, alreadyPresent: present,
                           totalOnDrive: totalOnDrive, bytesToAdd: bytesToAdd,
                           missing: missing)
    }

    // MARK: - Full restore

    /// Restore missing files from the drive into the library. Re-scans and
    /// re-dedupes against the current library (so it's resumable and never
    /// re-adds). If `limit` is non-nil, stops after adding that many (used by
    /// the single-item test restore).
    /// - Parameter preScanned: the exact files to add, from a prior `plan()`.
    ///   When provided, we skip re-scanning the drive (that double-scan was the
    ///   lag on Add new). Pass nil only for a cold restore with no prior scan.
    func restore(destination: DriveDestination,
                 preScanned: [DriveFile]? = nil,
                 limit: Int? = nil,
                 progress: RestoreProgress) async {
        resetCancellation()

        // Add-only authorization (requested lazily, only for restore).
        guard await PhotoLibrary.requestAddAccess() else {
            AppLog.restore.error("ADD ACCESS DENIED")
            await MainActor.run {
                progress.phase = .failed
                progress.errorMessage = RestoreError.addAccessDenied.localizedDescription
            }
            return
        }

        let runStart = Date()
        var missing: [DriveFile]
        if let preScanned {
            // Reuse the scan result — no re-walk of the drive.
            missing = preScanned
        } else {
            await MainActor.run {
                progress.phase = .scanning
                progress.scanMonth = ""
            }
            let driveFiles = destination.allBackedUpFiles()
            missing = await missingDriveFiles(driveFiles: driveFiles, progress: progress)
        }
        if let limit { missing = Array(missing.prefix(limit)) }

        AppLog.restore.notice("RESTORE START missing=\(missing.count) preScanned=\(preScanned != nil) limit=\(limit.map(String.init) ?? "none")")

        await MainActor.run {
            progress.reset(total: missing.count)
            progress.phase = .importing
        }

        if missing.isEmpty {
            await MainActor.run {
                progress.phase = .finished
                AppLog.restore.notice("RESTORE DONE: nothing to add")
            }
            return
        }

        // Resolve (creating if needed) the Restored album once.
        let album = await resolveAlbum()

        // Import one at a time: each is an atomic performChanges, so a failure
        // never leaves a half-added asset, and we can report per-item results.
        for file in missing {
            if cancelled { break }
            await MainActor.run { progress.currentItem = file.filename }
            let entry = await importOne(file, into: album)
            await MainActor.run { progress.record(entry) }
        }

        let wasCancelled = cancelled
        await MainActor.run {
            if wasCancelled {
                progress.phase = .cancelled
                AppLog.restore.notice("RESTORE CANCELLED added=\(progress.added) failed=\(progress.failed)")
            } else {
                progress.phase = .finished
                let elapsed = Date().timeIntervalSince(runStart)
                AppLog.restore.notice(String(
                    format: "RESTORE DONE: added=%d skipped=%d failed=%d bytes=%d in %.1fs",
                    progress.added, progress.skipped, progress.failed,
                    progress.addedBytes, elapsed))
            }
            progress.currentItem = ""
        }
    }

    // MARK: - One item

    /// Create ONE new asset from a drive file and add it to the album.
    ///
    /// IMPORTANT — why we copy to a local temp first: PhotoKit performs the
    /// import OUT OF PROCESS (in the Photos agent), which has NO access to our
    /// app's security-scoped drive URL. Handing it the drive URL directly makes
    /// `performChanges` hang forever waiting on a file it can't read. So we copy
    /// the file into our own sandbox temp (which we CAN read via the security
    /// scope), then give PhotoKit that local URL with `shouldMoveFile = true` so
    /// it takes ownership and cleans it up. The drive file itself is only read,
    /// never moved or deleted.
    private func importOne(_ file: DriveFile,
                           into album: PHAssetCollection?) async -> RestoreLogEntry {
        let ext = file.filename.split(separator: ".").last.map { String($0).lowercased() } ?? ""
        let videoExts: Set<String> = ["mov", "mp4", "m4v", "avi", "3gp", "mpg", "mpeg"]
        let resourceType: PHAssetResourceType = videoExts.contains(ext) ? .video : .photo

        // 1. Copy the drive file into a local sandbox temp PhotoKit can read.
        let tempURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("restore-\(UUID().uuidString)-\(file.filename)")
        do {
            // Reading the drive file requires the active security scope, which
            // DriveStore holds for the connected drive while the app is running.
            if FileManager.default.fileExists(atPath: tempURL.path) {
                try? FileManager.default.removeItem(at: tempURL)
            }
            try FileManager.default.copyItem(at: file.url, to: tempURL)
        } catch {
            AppLog.restore.error("FAILED \(file.filename): couldn't stage from drive: \(error.localizedDescription)")
            return RestoreLogEntry(filename: file.filename, outcome: .failed,
                                   bytes: 0, message: "Couldn't read from the drive: \(error.localizedDescription)")
        }
        // Ensure the temp is gone if PhotoKit didn't consume it (it should, via
        // shouldMoveFile, but belt-and-suspenders on every failure path).
        defer { try? FileManager.default.removeItem(at: tempURL) }

        // 2. Import the LOCAL temp into Photos, with a watchdog timeout so a
        //    stuck import surfaces as a failure instead of hanging forever.
        do {
            try await withImportTimeout(.seconds(60)) {
                try await PHPhotoLibrary.shared().performChanges {
                    let creation = PHAssetCreationRequest.forAsset()
                    let options = PHAssetResourceCreationOptions()
                    options.shouldMoveFile = true   // PhotoKit owns + removes the temp
                    options.originalFilename = file.filename
                    creation.addResource(with: resourceType, fileURL: tempURL, options: options)
                    // Preserve the capture date so the restored asset sorts into
                    // the right place in the library timeline.
                    creation.creationDate = file.modificationDate
                    if let album,
                       let placeholder = creation.placeholderForCreatedAsset {
                        let addReq = PHAssetCollectionChangeRequest(for: album)
                        addReq?.addAssets([placeholder] as NSArray)
                    }
                }
            }
            AppLog.restore.notice("ADDED \(file.filename) \(String(format: "%.1f", Double(file.size) / 1_048_576.0))MB")
            return RestoreLogEntry(filename: file.filename, outcome: .added,
                                   bytes: file.size, message: "", date: file.modificationDate)
        } catch {
            AppLog.restore.error("FAILED \(file.filename): \(error.localizedDescription)")
            return RestoreLogEntry(filename: file.filename, outcome: .failed,
                                   bytes: 0, message: error.localizedDescription)
        }
    }

    /// Run an import with a timeout. If it doesn't finish in `timeout`, throw so
    /// the caller records a failure and moves on instead of hanging the restore.
    private func withImportTimeout(_ timeout: Duration,
                                   _ operation: @escaping @Sendable () async throws -> Void) async throws {
        try await withThrowingTaskGroup(of: Void.self) { group in
            group.addTask { try await operation() }
            group.addTask {
                try await Task.sleep(for: timeout)
                throw RestoreError.importFailed("timed out after \(timeout.components.seconds)s (the import did not complete)")
            }
            defer { group.cancelAll() }
            try await group.next()
        }
    }

    // MARK: - Album

    /// Find the Restored album, creating it if it doesn't exist. Returns nil if
    /// it couldn't be created (in which case items are still added to the
    /// library, just not grouped into the album).
    private func resolveAlbum() async -> PHAssetCollection? {
        if let existing = Self.findAlbum(named: Self.albumName) {
            return existing
        }
        do {
            try await PHPhotoLibrary.shared().performChanges {
                PHAssetCollectionChangeRequest.creationRequestForAssetCollection(
                    withTitle: Self.albumName)
            }
        } catch {
            AppLog.restore.error("Couldn't create album: \(error.localizedDescription)")
            return nil
        }
        return Self.findAlbum(named: Self.albumName)
    }

    private static func findAlbum(named title: String) -> PHAssetCollection? {
        let options = PHFetchOptions()
        options.predicate = NSPredicate(format: "title = %@", title)
        let result = PHAssetCollection.fetchAssetCollections(
            with: .album, subtype: .albumRegular, options: options)
        return result.firstObject
    }
}

The rest of the app

The three files above are the ones that touch your photos, the drive, and the network — they are what prove the privacy claims, so they are shown in full. The remaining files (the app's buttons, screens, and the bits that remember your settings on your own device) ship inside the App Store build and are available on request; they are left off this page so it stays focused on the code that actually matters for privacy.

Apple's privacy label

Apple requires every app to include a short file that formally declares what data it collects and whether it tracks you — a bit like the nutrition label on food. Apple checks it. For Very Good Photo Backup, the important boxes are all marked “no”:

  • Tracks you? No.
  • Talks to any tracking or advertising companies? No — the list is empty.
  • Collects any data about you? No — the list is empty.

The only things it does list are ordinary, behind-the-scenes items every app has to declare — checking how much free space a drive has, reading a file's date, and saving your own settings on your device. None of that is information about you, and none of it leaves your phone. The exact file Apple receives is below.

Apple privacy label file (PrivacyInfo.xcprivacy)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>NSPrivacyTracking</key>
	<false/>
	<key>NSPrivacyTrackingDomains</key>
	<array/>
	<key>NSPrivacyCollectedDataTypes</key>
	<array/>
	<key>NSPrivacyAccessedAPITypes</key>
	<array>
		<dict>
			<key>NSPrivacyAccessedAPIType</key>
			<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
			<key>NSPrivacyAccessedAPITypeReasons</key>
			<array>
				<string>E174.1</string>
			</array>
		</dict>
		<dict>
			<key>NSPrivacyAccessedAPIType</key>
			<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
			<key>NSPrivacyAccessedAPITypeReasons</key>
			<array>
				<string>C617.1</string>
			</array>
		</dict>
		<dict>
			<key>NSPrivacyAccessedAPIType</key>
			<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
			<key>NSPrivacyAccessedAPITypeReasons</key>
			<array>
				<string>CA92.1</string>
			</array>
		</dict>
	</array>
</dict>
</plist>