Andromida/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md

5.8 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 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.