Integrate IndieBackup for snapshot backup and restore
Adds a local ZIP backup/restore of the iCloud document tree via the IndieBackup package, surfaced in Settings with retention controls. A restore suspends the sync observer, mirrors the files, then rebuilds the SwiftData cache; opening a shared .workoutsbackup file restores it. The engine exposes the container Documents root and a restore lifecycle (isRestoring guards a concurrent connect), and the backup file type is registered for open-in-place. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// AppBackupConfiguration.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import IndieBackup
|
||||
|
||||
/// Tells IndieBackup what to snapshot and how to bring the app back to a
|
||||
/// consistent state after a restore.
|
||||
///
|
||||
/// `backupRoot` is the ubiquity container's `Documents/` directory — the exact
|
||||
/// tree `SyncEngine`'s `DocumentFileStore` writes to: `Splits/`, `Workouts/`,
|
||||
/// and the `Stubs/` soft-delete tombstones. Backing up that whole tree,
|
||||
/// tombstones included, is deliberate — a restore that dropped the stubs could
|
||||
/// resurrect records the user had already deleted. The SwiftData store is never
|
||||
/// in the backup: it's a rebuildable cache, regenerated from the restored files
|
||||
/// by `rebuildCacheAfterRestore`.
|
||||
///
|
||||
/// This type is nonisolated on purpose — the package reads `backupRoot`
|
||||
/// synchronously off the main actor during its detached directory mirror. It
|
||||
/// holds the `@MainActor` `SyncEngine`, which is safe because a global-actor
|
||||
/// class is implicitly `Sendable`, and the three restore hooks only ever reach
|
||||
/// it via `await`.
|
||||
final class AppBackupConfiguration: BackupConfiguration {
|
||||
private let syncEngine: SyncEngine
|
||||
|
||||
init(syncEngine: SyncEngine) {
|
||||
self.syncEngine = syncEngine
|
||||
}
|
||||
|
||||
var backupFileExtension: String { "workoutsbackup" }
|
||||
var backupDisplayName: String { "Workouts Backup" }
|
||||
|
||||
/// The container's `Documents/` folder. Resolved directly from `FileManager`
|
||||
/// with the app's explicit ubiquity container id so this stays synchronous and
|
||||
/// nonisolated; it yields the identical URL `SyncEngine` exposes as
|
||||
/// `containerDocumentsURL` once connected. Backup and restore are only
|
||||
/// reachable past the iCloud gate, so the container is always resolved by the
|
||||
/// time this is read — the package's local `documentsURL` fallback is only a
|
||||
/// belt-and-suspenders never-nil guarantee.
|
||||
var backupRoot: URL {
|
||||
if let container = FileManager.default.url(
|
||||
forUbiquityContainerIdentifier: SyncEngine.containerIdentifier
|
||||
) {
|
||||
let docs = container.appendingPathComponent("Documents", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: docs, withIntermediateDirectories: true)
|
||||
return docs
|
||||
}
|
||||
return iCloudDocumentManager.shared.documentsURL
|
||||
}
|
||||
|
||||
/// After the restore has replaced the document tree on disk, rebuild the
|
||||
/// SwiftData cache from those files.
|
||||
func rebuildCacheAfterRestore(progress: @escaping (Double, String) -> Void) async throws {
|
||||
progress(0.0, "Rebuilding your library…")
|
||||
await syncEngine.rebuildCache()
|
||||
progress(1.0, "Library rebuilt")
|
||||
}
|
||||
|
||||
/// Stop the metadata observer before the restore touches disk, so the bulk
|
||||
/// file mirror isn't observed as a storm of live edits.
|
||||
func prepareForRestore() async {
|
||||
await syncEngine.beginRestore()
|
||||
}
|
||||
|
||||
/// Resume the metadata observer (fresh, re-baselined) after the restore.
|
||||
func finishRestore() async {
|
||||
await syncEngine.endRestore()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user