From 653cc65e0eddd5899e55bb4a80667cfe06b10ffb Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 8 Jul 2026 08:03:30 -0400 Subject: [PATCH] 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 --- Workouts/AppServices.swift | 11 ++- Workouts/Backup/AppBackupConfiguration.swift | 73 ++++++++++++++++++++ Workouts/Resources/Info-iOS.plist | 37 ++++++++++ Workouts/Sync/SyncEngine.swift | 55 +++++++++++++++ Workouts/Views/Settings/SettingsView.swift | 4 ++ Workouts/WorkoutsApp.swift | 7 ++ project.yml | 4 ++ 7 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 Workouts/Backup/AppBackupConfiguration.swift diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift index b3fa85e..45b1c89 100644 --- a/Workouts/AppServices.swift +++ b/Workouts/AppServices.swift @@ -1,6 +1,7 @@ import Foundation import Observation import SwiftData +import IndieBackup /// Composition root for the iOS app. Owns the SwiftData cache container and the /// iCloud sync engine, and drives the one-shot launch sequence. Injected into the @@ -10,6 +11,10 @@ import SwiftData final class AppServices { let container: ModelContainer let syncEngine: SyncEngine + /// Local ZIP backup/restore of the iCloud document tree (`backupRoot` = the + /// container's `Documents/`). Restores rebuild the SwiftData cache via + /// `SyncEngine.rebuildCache()`; see `AppBackupConfiguration`. + let backupController: BackupController let watchBridge: PhoneConnectivityBridge let workoutLauncher = WorkoutLauncher() let workoutHealthDeleter = WorkoutHealthDeleter() @@ -26,7 +31,11 @@ final class AppServices { init() { let container = WorkoutsModelContainer.make() self.container = container - self.syncEngine = SyncEngine(container: container) + let syncEngine = SyncEngine(container: container) + self.syncEngine = syncEngine + self.backupController = BackupController( + configuration: AppBackupConfiguration(syncEngine: syncEngine) + ) let liveRunState = LiveRunState() self.liveRunState = liveRunState self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState) diff --git a/Workouts/Backup/AppBackupConfiguration.swift b/Workouts/Backup/AppBackupConfiguration.swift new file mode 100644 index 0000000..fb3269f --- /dev/null +++ b/Workouts/Backup/AppBackupConfiguration.swift @@ -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() + } +} diff --git a/Workouts/Resources/Info-iOS.plist b/Workouts/Resources/Info-iOS.plist index b6bf3bd..5c1dd42 100644 --- a/Workouts/Resources/Info-iOS.plist +++ b/Workouts/Resources/Info-iOS.plist @@ -57,5 +57,42 @@ Any + LSSupportsOpeningDocumentsInPlace + + CFBundleDocumentTypes + + + CFBundleTypeName + Workouts Backup + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + dev.rzen.indie.workoutsbackup + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + dev.rzen.indie.workoutsbackup + UTTypeDescription + Workouts Backup + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + + workoutsbackup + + + + diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index 7ade136..de0e46e 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -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) { diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index 6f4fe71..ce4d2f9 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -8,6 +8,7 @@ import SwiftUI import SwiftData import IndieAbout +import IndieBackup struct SettingsView: View { @Environment(SyncEngine.self) private var sync @@ -132,6 +133,9 @@ struct SettingsView: View { Text("iCloud Sync") } + // MARK: - Backups Section + BackupsSectionView(controller: services.backupController) + // MARK: - Developer Section (debug builds only) #if DEBUG Section { diff --git a/Workouts/WorkoutsApp.swift b/Workouts/WorkoutsApp.swift index e5352d3..ca066ea 100644 --- a/Workouts/WorkoutsApp.swift +++ b/Workouts/WorkoutsApp.swift @@ -41,5 +41,12 @@ struct WorkoutsApp: App { .environment(services.liveRunState) .modelContainer(services.container) .task { await services.bootstrap() } + // Tap a shared ".workoutsbackup" file (Files, AirDrop, share sheet) to + // restore it. The controller stops the sync observer for the restore and + // rebuilds the cache afterward (see AppBackupConfiguration). + .onOpenURL { url in + guard url.pathExtension == "workoutsbackup" else { return } + Task { try? await services.backupController.restoreBackup(from: url) } + } } } diff --git a/project.yml b/project.yml index 1d808e7..4856f5d 100644 --- a/project.yml +++ b/project.yml @@ -22,6 +22,9 @@ packages: IndieSync: url: https://git.rzen.dev/rzen/indie-sync.git from: "0.3.0" + IndieBackup: + url: https://git.rzen.dev/rzen/indie-backup.git + from: "2.0.0" # Shared post-build phase — stamps CFBundleVersion (git commit count), BuildDate, and # BuildHash into each target's (and dSYM's) Info.plist. Aliased into every code-bearing @@ -62,6 +65,7 @@ targets: dependencies: - package: IndieAbout - package: IndieSync + - package: IndieBackup - target: Workouts Watch App - target: Workouts Widget embed: true