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:
@@ -25,6 +25,19 @@ final class SyncEngine {
|
||||
private(set) var iCloudStatus: ICloudStatus = .checking
|
||||
private(set) var isSyncing = false
|
||||
|
||||
/// Resolved `Documents/` directory inside the ubiquity container — the root of
|
||||
/// the on-disk JSON document tree (`Splits/`, `Workouts/`, `Stubs/`) and the
|
||||
/// backup layer's `backupRoot`. Set once the container resolves in `connect()`;
|
||||
/// nil until then. Read-only for callers.
|
||||
private(set) var containerDocumentsURL: URL?
|
||||
|
||||
/// True while a backup restore is replacing the document tree. The restore
|
||||
/// suspends the metadata observer itself (`beginRestore`/`endRestore`); this
|
||||
/// flag additionally vetoes a concurrent `connect()`/reconcile (e.g. a
|
||||
/// scenePhase foreground-return) so the two can't fight over the cache while a
|
||||
/// bulk file mirror is in flight.
|
||||
private(set) var isRestoring = false
|
||||
|
||||
/// Last non-fatal sync failure, surfaced for the UI to render. Cleared on the
|
||||
/// next successful write or full reconcile.
|
||||
private(set) var lastSyncError: String?
|
||||
@@ -75,6 +88,10 @@ final class SyncEngine {
|
||||
// MARK: - Connection (deferred, patient)
|
||||
|
||||
func connect() async {
|
||||
// Never reconnect/reconcile mid-restore: the backup layer is rewriting the
|
||||
// whole document tree and rebuilding the cache itself. Balanced by the
|
||||
// restore clearing `isRestoring` in `endRestore()`.
|
||||
guard !isRestoring else { log.info("connect: skipped — restore in progress"); return }
|
||||
guard iCloudStatus != .available else { return }
|
||||
connectAttempt += 1
|
||||
let attempt = connectAttempt
|
||||
@@ -142,6 +159,7 @@ final class SyncEngine {
|
||||
guard attempt == connectAttempt else { return }
|
||||
|
||||
self.store = store
|
||||
self.containerDocumentsURL = store.rootURL
|
||||
self.tombstones = TombstoneStore(store: store)
|
||||
iCloudStatus = .available
|
||||
log.info("connect[\(attempt)]: directories ready → available")
|
||||
@@ -164,6 +182,43 @@ final class SyncEngine {
|
||||
log.info("connect: abandoned by user → unavailable")
|
||||
}
|
||||
|
||||
// MARK: - Backup restore lifecycle
|
||||
|
||||
/// Rebuild the SwiftData cache from whatever documents are currently on disk.
|
||||
/// The cache is a pure read-through projection of the files, so a full
|
||||
/// `reconcile()` — import every live file, prune every entity with no backing
|
||||
/// file — *is* a rebuild. Called by the backup layer after a restore has
|
||||
/// replaced the document tree; safe whenever the engine is connected (a nil
|
||||
/// store, i.e. not yet connected, no-ops and the next `connect()` rebuilds).
|
||||
func rebuildCache() async {
|
||||
await reconcile()
|
||||
}
|
||||
|
||||
/// Suspend live file-watching for the duration of a restore. A restore mirrors
|
||||
/// many files at once; without this the observer would replay each as a live
|
||||
/// edit. Sets `isRestoring` (so a concurrent `connect()` bails) and tears down
|
||||
/// the metadata observer. Always balanced by `endRestore()`.
|
||||
func beginRestore() {
|
||||
isRestoring = true
|
||||
monitorTask?.cancel()
|
||||
monitorTask = nil
|
||||
monitor?.stop()
|
||||
monitor = nil
|
||||
log.info("restore: began — metadata observer suspended")
|
||||
}
|
||||
|
||||
/// Resume live file-watching after a restore. Starts a FRESH metadata observer
|
||||
/// (which re-baselines its known paths, so the just-restored files don't replay
|
||||
/// as a flood of add/remove events) and clears `isRestoring`. Balanced with
|
||||
/// `beginRestore()`; the backup layer calls this on both success and failure.
|
||||
func endRestore() {
|
||||
isRestoring = false
|
||||
if let store {
|
||||
startMonitoring(documentsURL: store.rootURL)
|
||||
}
|
||||
log.info("restore: ended — metadata observer resumed")
|
||||
}
|
||||
|
||||
// MARK: - Monitoring
|
||||
|
||||
private func startMonitoring(documentsURL: URL) {
|
||||
|
||||
Reference in New Issue
Block a user