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:
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import IndieBackup
|
||||||
|
|
||||||
/// Composition root for the iOS app. Owns the SwiftData cache container and the
|
/// 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
|
/// iCloud sync engine, and drives the one-shot launch sequence. Injected into the
|
||||||
@@ -10,6 +11,10 @@ import SwiftData
|
|||||||
final class AppServices {
|
final class AppServices {
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
let syncEngine: SyncEngine
|
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 watchBridge: PhoneConnectivityBridge
|
||||||
let workoutLauncher = WorkoutLauncher()
|
let workoutLauncher = WorkoutLauncher()
|
||||||
let workoutHealthDeleter = WorkoutHealthDeleter()
|
let workoutHealthDeleter = WorkoutHealthDeleter()
|
||||||
@@ -26,7 +31,11 @@ final class AppServices {
|
|||||||
init() {
|
init() {
|
||||||
let container = WorkoutsModelContainer.make()
|
let container = WorkoutsModelContainer.make()
|
||||||
self.container = container
|
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()
|
let liveRunState = LiveRunState()
|
||||||
self.liveRunState = liveRunState
|
self.liveRunState = liveRunState
|
||||||
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState)
|
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,5 +57,42 @@
|
|||||||
<string>Any</string>
|
<string>Any</string>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>Workouts Backup</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>LSHandlerRank</key>
|
||||||
|
<string>Owner</string>
|
||||||
|
<key>LSItemContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>dev.rzen.indie.workoutsbackup</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>UTExportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>dev.rzen.indie.workoutsbackup</string>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>Workouts Backup</string>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.data</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict>
|
||||||
|
<key>public.filename-extension</key>
|
||||||
|
<array>
|
||||||
|
<string>workoutsbackup</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ final class SyncEngine {
|
|||||||
private(set) var iCloudStatus: ICloudStatus = .checking
|
private(set) var iCloudStatus: ICloudStatus = .checking
|
||||||
private(set) var isSyncing = false
|
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
|
/// Last non-fatal sync failure, surfaced for the UI to render. Cleared on the
|
||||||
/// next successful write or full reconcile.
|
/// next successful write or full reconcile.
|
||||||
private(set) var lastSyncError: String?
|
private(set) var lastSyncError: String?
|
||||||
@@ -75,6 +88,10 @@ final class SyncEngine {
|
|||||||
// MARK: - Connection (deferred, patient)
|
// MARK: - Connection (deferred, patient)
|
||||||
|
|
||||||
func connect() async {
|
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 }
|
guard iCloudStatus != .available else { return }
|
||||||
connectAttempt += 1
|
connectAttempt += 1
|
||||||
let attempt = connectAttempt
|
let attempt = connectAttempt
|
||||||
@@ -142,6 +159,7 @@ final class SyncEngine {
|
|||||||
guard attempt == connectAttempt else { return }
|
guard attempt == connectAttempt else { return }
|
||||||
|
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self.containerDocumentsURL = store.rootURL
|
||||||
self.tombstones = TombstoneStore(store: store)
|
self.tombstones = TombstoneStore(store: store)
|
||||||
iCloudStatus = .available
|
iCloudStatus = .available
|
||||||
log.info("connect[\(attempt)]: directories ready → available")
|
log.info("connect[\(attempt)]: directories ready → available")
|
||||||
@@ -164,6 +182,43 @@ final class SyncEngine {
|
|||||||
log.info("connect: abandoned by user → unavailable")
|
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
|
// MARK: - Monitoring
|
||||||
|
|
||||||
private func startMonitoring(documentsURL: URL) {
|
private func startMonitoring(documentsURL: URL) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import IndieAbout
|
import IndieAbout
|
||||||
|
import IndieBackup
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(SyncEngine.self) private var sync
|
@Environment(SyncEngine.self) private var sync
|
||||||
@@ -132,6 +133,9 @@ struct SettingsView: View {
|
|||||||
Text("iCloud Sync")
|
Text("iCloud Sync")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Backups Section
|
||||||
|
BackupsSectionView(controller: services.backupController)
|
||||||
|
|
||||||
// MARK: - Developer Section (debug builds only)
|
// MARK: - Developer Section (debug builds only)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
@@ -41,5 +41,12 @@ struct WorkoutsApp: App {
|
|||||||
.environment(services.liveRunState)
|
.environment(services.liveRunState)
|
||||||
.modelContainer(services.container)
|
.modelContainer(services.container)
|
||||||
.task { await services.bootstrap() }
|
.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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ packages:
|
|||||||
IndieSync:
|
IndieSync:
|
||||||
url: https://git.rzen.dev/rzen/indie-sync.git
|
url: https://git.rzen.dev/rzen/indie-sync.git
|
||||||
from: "0.3.0"
|
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
|
# 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
|
# BuildHash into each target's (and dSYM's) Info.plist. Aliased into every code-bearing
|
||||||
@@ -62,6 +65,7 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- package: IndieAbout
|
- package: IndieAbout
|
||||||
- package: IndieSync
|
- package: IndieSync
|
||||||
|
- package: IndieBackup
|
||||||
- target: Workouts Watch App
|
- target: Workouts Watch App
|
||||||
- target: Workouts Widget
|
- target: Workouts Widget
|
||||||
embed: true
|
embed: true
|
||||||
|
|||||||
Reference in New Issue
Block a user