Lab 6.2 — CloudKit Sync App

Goal

Build a recipe-sharing app that uses two CloudKit databases simultaneously: the private database holds your personal recipes; the public database hosts community recipes anyone can browse. Wire CKSubscription silent pushes so changes from other devices appear in real time without polling. By the end you’ll have shipped a non-trivial CloudKit-direct app and feel comfortable with the raw CKContainer/CKDatabase/CKRecord APIs that Chapter 6.3 introduced.

Time

~3 hours. Stretch goals push this to a full day.

Prerequisites

  • Xcode 16+
  • Paid Apple Developer account (required to use CloudKit on device; free tier is simulator-only)
  • An iCloud-signed-in simulator or device
  • Read Chapter 6.3

Setup

  1. New iOS app project. Name CookCloud. SwiftUI. No SwiftData/Core Data.
  2. Project → Signing & Capabilities → + Capability → iCloud → check CloudKit. Add container iCloud.com.yourname.CookCloud.
    • Capability → Background Modes → check Remote notifications.
    • Capability → Push Notifications.
  3. Make sure your simulator/device is signed into iCloud (Settings → Sign in).

Build

Step 1 — model types & DTO

Recipe.swift:

import CloudKit

struct Recipe: Identifiable, Hashable {
    let id: CKRecord.ID
    var title: String
    var ingredients: String
    var instructions: String
    var modifiedAt: Date

    init(record: CKRecord) {
        self.id = record.recordID
        self.title = record["title"] as? String ?? ""
        self.ingredients = record["ingredients"] as? String ?? ""
        self.instructions = record["instructions"] as? String ?? ""
        self.modifiedAt = record.modificationDate ?? .now
    }

    func toRecord(in zoneID: CKRecordZone.ID) -> CKRecord {
        let record = CKRecord(recordType: "Recipe", recordID: id)
        apply(to: record)
        return record
    }

    func apply(to record: CKRecord) {
        record["title"] = title as CKRecordValue
        record["ingredients"] = ingredients as CKRecordValue
        record["instructions"] = instructions as CKRecordValue
    }
}

Step 2 — repository actor

CloudKitRepository.swift:

import CloudKit
import Foundation

actor CloudKitRepository {
    enum Scope { case privateDB, publicDB }
    private let container: CKContainer
    private let zoneID = CKRecordZone.ID(zoneName: "Recipes", ownerName: CKCurrentUserDefaultName)
    private var didCreateZone = false

    init() {
        self.container = CKContainer(identifier: "iCloud.com.yourname.CookCloud")
    }

    private func database(for scope: Scope) -> CKDatabase {
        switch scope {
        case .privateDB: return container.privateCloudDatabase
        case .publicDB: return container.publicCloudDatabase
        }
    }

    func ensurePrivateZone() async throws {
        guard !didCreateZone else { return }
        let zone = CKRecordZone(zoneID: zoneID)
        _ = try await container.privateCloudDatabase.save(zone)
        didCreateZone = true
    }

    func save(_ recipe: Recipe, in scope: Scope) async throws -> Recipe {
        if scope == .privateDB { try await ensurePrivateZone() }
        let recordID = CKRecord.ID(recordName: recipe.id.recordName,
                                   zoneID: scope == .privateDB ? zoneID : .default)
        let existing = try? await database(for: scope).record(for: recordID)
        let record = existing ?? CKRecord(recordType: "Recipe", recordID: recordID)
        recipe.apply(to: record)
        let saved = try await database(for: scope).save(record)
        return Recipe(record: saved)
    }

    func fetchAll(in scope: Scope) async throws -> [Recipe] {
        let predicate = NSPredicate(value: true)
        let query = CKQuery(recordType: "Recipe", predicate: predicate)
        query.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
        let (matches, _) = try await database(for: scope).records(matching: query,
                                                                   inZoneWith: scope == .privateDB ? zoneID : nil,
                                                                   resultsLimit: 200)
        return matches.compactMap { try? Recipe(record: $0.1.get()) }
    }

    func delete(_ recipe: Recipe, in scope: Scope) async throws {
        _ = try await database(for: scope).deleteRecord(withID: recipe.id)
    }

    // MARK: subscriptions
    func subscribeToChanges() async throws {
        try await subscribe(to: .privateDB, subscriptionID: "private-recipes")
        try await subscribe(to: .publicDB, subscriptionID: "public-recipes")
    }

    private func subscribe(to scope: Scope, subscriptionID: String) async throws {
        let subscription = CKQuerySubscription(
            recordType: "Recipe",
            predicate: NSPredicate(value: true),
            subscriptionID: subscriptionID,
            options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
        )
        let info = CKSubscription.NotificationInfo()
        info.shouldSendContentAvailable = true
        subscription.notificationInfo = info
        do {
            _ = try await database(for: scope).save(subscription)
        } catch let error as CKError where error.code == .serverRejectedRequest {
            // already exists - that's fine
        }
    }
}

Step 3 — @Observable store

import Observation

@Observable
@MainActor
final class RecipeStore {
    private let repo = CloudKitRepository()
    var privateRecipes: [Recipe] = []
    var publicRecipes: [Recipe] = []
    var error: String?

    func bootstrap() async {
        do {
            try await repo.subscribeToChanges()
            await refresh()
        } catch {
            self.error = "Bootstrap: \(error.localizedDescription)"
        }
    }

    func refresh() async {
        async let privates = try? await repo.fetchAll(in: .privateDB) ?? []
        async let publics = try? await repo.fetchAll(in: .publicDB) ?? []
        privateRecipes = await privates ?? []
        publicRecipes = await publics ?? []
    }

    func save(_ recipe: Recipe, public isPublic: Bool) async {
        do {
            let saved = try await repo.save(recipe, in: isPublic ? .publicDB : .privateDB)
            if isPublic { upsert(saved, into: &publicRecipes) }
            else { upsert(saved, into: &privateRecipes) }
        } catch {
            self.error = error.localizedDescription
        }
    }

    private func upsert(_ recipe: Recipe, into list: inout [Recipe]) {
        if let i = list.firstIndex(where: { $0.id == recipe.id }) {
            list[i] = recipe
        } else {
            list.insert(recipe, at: 0)
        }
    }

    func delete(_ recipe: Recipe, public isPublic: Bool) async {
        do {
            try await repo.delete(recipe, in: isPublic ? .publicDB : .privateDB)
            if isPublic { publicRecipes.removeAll { $0.id == recipe.id } }
            else { privateRecipes.removeAll { $0.id == recipe.id } }
        } catch {
            self.error = error.localizedDescription
        }
    }
}

Step 4 — handle silent pushes (AppDelegate)

import UIKit
import CloudKit

final class AppDelegate: NSObject, UIApplicationDelegate {
    static var refreshHandler: (() async -> Void)?

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        application.registerForRemoteNotifications()
        return true
    }

    func application(_ application: UIApplication,
                     didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                     fetchCompletionHandler completion: @escaping (UIBackgroundFetchResult) -> Void) {
        guard CKNotification(fromRemoteNotificationDictionary: userInfo) != nil else {
            completion(.noData); return
        }
        Task {
            await Self.refreshHandler?()
            completion(.newData)
        }
    }
}

@main
struct CookCloudApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
    @State private var store = RecipeStore()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(store)
                .task {
                    await store.bootstrap()
                    AppDelegate.refreshHandler = { @MainActor in await store.refresh() }
                }
        }
    }
}

Step 5 — UI

struct RootView: View {
    @Environment(RecipeStore.self) private var store
    var body: some View {
        TabView {
            RecipeListView(scopeIsPublic: false)
                .tabItem { Label("My Recipes", systemImage: "person.crop.circle") }
            RecipeListView(scopeIsPublic: true)
                .tabItem { Label("Community", systemImage: "globe") }
        }
    }
}

struct RecipeListView: View {
    let scopeIsPublic: Bool
    @Environment(RecipeStore.self) private var store
    @State private var showingNew = false

    private var recipes: [Recipe] {
        scopeIsPublic ? store.publicRecipes : store.privateRecipes
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(recipes) { recipe in
                    NavigationLink(value: recipe) {
                        VStack(alignment: .leading) {
                            Text(recipe.title).font(.headline)
                            Text(recipe.modifiedAt, style: .relative)
                                .font(.caption).foregroundStyle(.secondary)
                        }
                    }
                }
                .onDelete { offsets in
                    Task {
                        for i in offsets { await store.delete(recipes[i], public: scopeIsPublic) }
                    }
                }
            }
            .navigationTitle(scopeIsPublic ? "Community" : "My Recipes")
            .toolbar {
                Button("New", systemImage: "plus") { showingNew = true }
            }
            .sheet(isPresented: $showingNew) {
                RecipeEditorSheet(scopeIsPublic: scopeIsPublic)
            }
            .navigationDestination(for: Recipe.self) { RecipeDetailView(recipe: $0) }
            .refreshable { await store.refresh() }
        }
    }
}

struct RecipeEditorSheet: View {
    let scopeIsPublic: Bool
    @Environment(RecipeStore.self) private var store
    @Environment(\.dismiss) private var dismiss
    @State private var title = ""
    @State private var ingredients = ""
    @State private var instructions = ""

    var body: some View {
        NavigationStack {
            Form {
                TextField("Title", text: $title)
                Section("Ingredients") { TextEditor(text: $ingredients).frame(minHeight: 120) }
                Section("Instructions") { TextEditor(text: $instructions).frame(minHeight: 120) }
            }
            .navigationTitle("New Recipe")
            .toolbar {
                ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Save") {
                        let recipe = Recipe(
                            record: {
                                let r = CKRecord(recordType: "Recipe")
                                r["title"] = title as CKRecordValue
                                r["ingredients"] = ingredients as CKRecordValue
                                r["instructions"] = instructions as CKRecordValue
                                return r
                            }())
                        Task { await store.save(recipe, public: scopeIsPublic); dismiss() }
                    }.disabled(title.isEmpty)
                }
            }
        }
    }
}

struct RecipeDetailView: View {
    let recipe: Recipe
    var body: some View {
        Form {
            Section("Ingredients") { Text(recipe.ingredients) }
            Section("Instructions") { Text(recipe.instructions) }
        }
        .navigationTitle(recipe.title)
    }
}

Step 6 — verify

  1. Run on a simulator signed into iCloud. Create a recipe. Confirm it appears.
  2. Open the CloudKit Dashboard → your container → Records → choose the appropriate database. You should see the record.
  3. Run a second simulator (different scheme target or different model) signed into the same iCloud account for the private DB test, or any iCloud account for the public DB test. Confirm changes propagate (give the silent push a few seconds).
  4. Delete a recipe on Device A. Confirm it disappears on Device B.

Stretch

  • Stretch 1 — delta sync: switch from full fetchAll to CKFetchRecordZoneChangesOperation with persisted CKServerChangeToken. Massive speedup once the dataset grows.
  • Stretch 2 — assets: add a photo to each recipe via CKAsset. Store local cache of fetched assets; don’t re-download on every refresh.
  • Stretch 3 — sharing: enable CKShare for collaborative editing of a private recipe with another iCloud user. Implement the application(_:userDidAcceptCloudKitShareWith:) flow.
  • Stretch 4 — account changes: handle CKAccountChanged notification by clearing caches and re-bootstrapping when the user signs out/in.

Notes & gotchas

  • Public database queries need indexes in Production. Sort and filter fields must be marked queryable in the dashboard. Development environment auto-indexes; Production does not.
  • Silent pushes don’t deliver to apps that were force-quit. The user must launch the app at least once after install for push registration to take effect.
  • The default zone in the private database doesn’t support fetchRecordZoneChanges — you must use a custom zone (we created “Recipes”). The public database uses the default zone and a different sync strategy (last-modified queries).
  • Schema must be deployed to Production before App Store builds can use new record types or fields. Test on TestFlight with Production environment, not Development.
  • Container identifier must match the bundle ID convention Apple expects: iCloud. + your bundle ID. Use a different one and you’ll burn an afternoon.

Next: Lab 6.3 — Production Network Layer