5.2 KiB
5.2 KiB
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)
// Debug.xcconfig
#include "Base.xcconfig"
APS_ENVIRONMENT = development
// Release.xcconfig
#include "Base.xcconfig"
APS_ENVIRONMENT = production
Entitlements should include:
<key>aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>
2) App Container Setup
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)
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)
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:
- Single habit toggle on Device A appears on Device B with both open.
- Rapid 4-6 toggles on Device A all appear on Device B without pull-to-refresh.
- Background Device B, then foreground, data remains consistent.
- Device logs show
CloudKitSyncremote-change events. - 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-environmentmismatch or signing/provisioning drift. - Partial batch updates shown: render invalidation not happening for every remote merge.