205 lines
6.0 KiB
Markdown
205 lines
6.0 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()
|
|
observeRemoteChanges()
|
|
}
|
|
|
|
private func observeRemoteChanges() {
|
|
cloudKitSyncManager.startObserving { [weak self] in
|
|
self?.handleRemoteStoreChange()
|
|
}
|
|
}
|
|
|
|
private func handleRemoteStoreChange() {
|
|
let result = cloudKitSyncManager.processObservedRemoteChange(
|
|
modelContext: &modelContext,
|
|
modelContainer: modelContainer
|
|
)
|
|
Design.debugLog(
|
|
"Received remote store change #\(result.eventCount); " +
|
|
"rebuiltContext=\(result.didRebuildModelContext); reloading"
|
|
)
|
|
reloadEntities()
|
|
}
|
|
|
|
func refresh() {
|
|
reloadData()
|
|
}
|
|
|
|
func reloadData() {
|
|
reloadEntities()
|
|
}
|
|
|
|
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 fallbackRefreshTask: Task<Void, Never>?
|
|
@State private var macPulseTask: Task<Void, Never>?
|
|
private let macPulseIntervalSeconds: TimeInterval = 5
|
|
|
|
var body: some View {
|
|
// Ensure body observes remote reload increments.
|
|
let _ = store.dataRefreshVersion
|
|
|
|
ContentView(store: store)
|
|
.onAppear {
|
|
if scenePhase == .active {
|
|
startMacPulseLoopIfNeeded()
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .active {
|
|
startMacPulseLoopIfNeeded()
|
|
scheduleCloudKitFallbackRefresh()
|
|
} else if newPhase == .background {
|
|
fallbackRefreshTask?.cancel()
|
|
stopMacPulseLoop()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleCloudKitFallbackRefresh() {
|
|
let activationDate = Date()
|
|
fallbackRefreshTask?.cancel()
|
|
fallbackRefreshTask = Task { @MainActor in
|
|
try? await Task.sleep(for: .seconds(8))
|
|
guard !Task.isCancelled else { return }
|
|
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
|
|
store.refresh()
|
|
}
|
|
}
|
|
|
|
private func startMacPulseLoopIfNeeded() {
|
|
guard store.supportsMacCloudKitImportPulseFallback else { return }
|
|
macPulseTask?.cancel()
|
|
macPulseTask = Task { @MainActor in
|
|
while !Task.isCancelled {
|
|
guard scenePhase == .active else { return }
|
|
store.forceCloudKitImportPulse(reason: "active_loop")
|
|
try? await Task.sleep(for: .seconds(macPulseIntervalSeconds))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopMacPulseLoop() {
|
|
macPulseTask?.cancel()
|
|
macPulseTask = nil
|
|
}
|
|
}
|
|
```
|
|
|
|
## 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.
|