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