178 lines
5.2 KiB
Markdown
178 lines
5.2 KiB
Markdown
# SwiftData to CloudKit Sync Template (Copy/Paste)
|
|
|
|
Primary source of truth lives in Bedrock:
|
|
`/Users/mattbruce/Documents/Projects/iPhone/Andromida/Bedrock/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md`
|
|
|
|
This file is an app-local quick reference and should mirror the Bedrock setup guide.
|
|
|
|
Use this as a starter for new apps that need near-real-time SwiftData sync across Apple devices.
|
|
|
|
## 1) Build Config (xcconfig)
|
|
|
|
```xcconfig
|
|
// Debug.xcconfig
|
|
#include "Base.xcconfig"
|
|
APS_ENVIRONMENT = development
|
|
|
|
// Release.xcconfig
|
|
#include "Base.xcconfig"
|
|
APS_ENVIRONMENT = production
|
|
```
|
|
|
|
Entitlements should include:
|
|
|
|
```xml
|
|
<key>aps-environment</key>
|
|
<string>$(APS_ENVIRONMENT)</string>
|
|
```
|
|
|
|
## 2) App Container Setup
|
|
|
|
```swift
|
|
import SwiftData
|
|
|
|
enum AppIdentifiers {
|
|
static let appGroupIdentifier = "group.com.example.app"
|
|
static let cloudKitContainerIdentifier = "iCloud.com.example.app"
|
|
}
|
|
|
|
func makeModelContainer(isRunningTests: Bool) throws -> ModelContainer {
|
|
let schema = Schema([
|
|
Ritual.self,
|
|
RitualArc.self,
|
|
ArcHabit.self
|
|
])
|
|
|
|
let storeURL = FileManager.default
|
|
.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
|
.appendingPathComponent("App.sqlite")
|
|
?? URL.documentsDirectory.appendingPathComponent("App.sqlite")
|
|
|
|
let config = ModelConfiguration(
|
|
schema: schema,
|
|
url: storeURL,
|
|
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
|
|
)
|
|
|
|
return try ModelContainer(for: schema, configurations: [config])
|
|
}
|
|
```
|
|
|
|
## 3) Store Pattern (Bedrock Manager + Context Refresh)
|
|
|
|
```swift
|
|
import Foundation
|
|
import Observation
|
|
import SwiftData
|
|
import Bedrock
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class AppDataStore: SwiftDataCloudKitStore {
|
|
@ObservationIgnored let modelContainer: ModelContainer
|
|
@ObservationIgnored var modelContext: ModelContext
|
|
@ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
|
|
isEnabled: true,
|
|
logIdentifier: "AppCloudKitSync"
|
|
)
|
|
|
|
private(set) var entities: [PrimaryEntity] = []
|
|
private(set) var dataRefreshVersion: Int = 0
|
|
|
|
init(modelContext: ModelContext) {
|
|
self.modelContainer = modelContext.container
|
|
self.modelContext = modelContext
|
|
reloadEntities()
|
|
startObservingCloudKitRemoteChanges()
|
|
}
|
|
|
|
func refresh() {
|
|
reloadData()
|
|
}
|
|
|
|
func reloadData() {
|
|
reloadEntities()
|
|
}
|
|
|
|
func updateEntity(_ entity: PrimaryEntity) {
|
|
// mutate entity fields...
|
|
saveAndReload()
|
|
}
|
|
|
|
func didSaveAndReloadData() {
|
|
// optional post-save side effects
|
|
}
|
|
|
|
func handleSaveAndReloadError(_ error: Error) {
|
|
Design.debugLog("AppDataStore: save failed: \(error.localizedDescription)")
|
|
}
|
|
|
|
private func reloadEntities() {
|
|
do {
|
|
entities = try modelContext.fetch(FetchDescriptor<PrimaryEntity>())
|
|
dataRefreshVersion &+= 1
|
|
} catch {
|
|
Design.debugLog("AppDataStore: reload failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## 4) Root View Pattern (Safety-Net Refresh)
|
|
|
|
```swift
|
|
import SwiftUI
|
|
|
|
struct RootView: View {
|
|
@Bindable var store: AppDataStore
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@State private var cloudKitSceneSyncCoordinator = SwiftDataCloudKitSceneSyncCoordinator(
|
|
configuration: .init(logIdentifier: "RootView.CloudKitSync")
|
|
)
|
|
|
|
var body: some View {
|
|
// Ensure body observes remote reload increments.
|
|
let _ = store.dataRefreshVersion
|
|
|
|
ContentView(store: store)
|
|
.onAppear {
|
|
if scenePhase == .active {
|
|
cloudKitSceneSyncCoordinator.handleDidBecomeActive(
|
|
store: store,
|
|
isAppActive: { scenePhase == .active },
|
|
refresh: { store.refresh() }
|
|
)
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .active {
|
|
cloudKitSceneSyncCoordinator.handleDidBecomeActive(
|
|
store: store,
|
|
isAppActive: { scenePhase == .active },
|
|
refresh: { store.refresh() }
|
|
)
|
|
} else if newPhase == .background {
|
|
cloudKitSceneSyncCoordinator.handleDidEnterBackground()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## 5) Verification Script (Manual)
|
|
|
|
Run this release gate on two real devices:
|
|
|
|
1. Single habit toggle on Device A appears on Device B with both open.
|
|
2. Rapid 4-6 toggles on Device A all appear on Device B without pull-to-refresh.
|
|
3. Background Device B, then foreground, data remains consistent.
|
|
4. Device logs show `CloudKitSync` remote-change events.
|
|
5. CloudKit Console private DB shows matching record updates.
|
|
|
|
## 6) Common Failure Modes
|
|
|
|
- Push works but UI stale: context/view invalidation issue.
|
|
- Only updates after relaunch: missing remote observer or stale long-lived context.
|
|
- Works in one build flavor only: `aps-environment` mismatch or signing/provisioning drift.
|
|
- Partial batch updates shown: render invalidation not happening for every remote merge.
|