NoteSync — Implementation Guide

Total estimated time: 50–70 hours. CloudKit sharing alone consumes a large fraction.

Day 1 — Project + capabilities

Step 1. Multi-platform Xcode project

File → New → Project → Multiplatform → App. Two targets: iOS, macOS. SwiftData not selected (we use GRDB).

Step 2. Capabilities (both targets)

  • iCloud → CloudKit, container iCloud.com.yourorg.notesync
  • Sign in with Apple
  • App Groups: group.com.yourorg.notesync (for AppIntents extension)

Step 3. Local storage

Add GRDB via SwiftPM. Build a NoteDatabase actor wrapping DatabaseQueue with schema for Note and Folder tables.

Checkpoint: create a note via UI, see it saved locally and survive relaunch.

Day 2–3 — Sync engine, Private DB only

Step 4. CloudKitSyncEngine for Private DB

Implement sync() for .privateCloudDatabase:

  • CKFetchDatabaseChangesOperation to learn which zones changed
  • For each changed zone, CKFetchRecordZoneChangesOperation with persisted CKServerChangeToken
  • Decode CKRecordNote, upsert into GRDB
  • On local writes, queue CKModifyRecordsOperation to push

Step 5. Subscriptions

Create a silent push subscription per zone so the device wakes when CloudKit has new data. CKDatabaseSubscription with notificationInfo.shouldSendContentAvailable = true.

Step 6. App delegate to handle silent push

func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable : Any],
                 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    Task {
        try? await CloudKitSyncEngine.shared.sync()
        completionHandler(.newData)
    }
}

Checkpoint: create note on device A. Note appears on device B within 30 s without manual refresh.

Day 4–6 — Shared zones & sharing flow

Step 7. “Share this note” action

@MainActor
func shareNote(_ note: Note) async throws -> CKShare {
    // 1. Create a new zone for this note
    let zoneID = CKRecordZone.ID(zoneName: "Shared:\(note.id.uuidString)", ownerName: CKCurrentUserDefaultName)
    let zone = CKRecordZone(zoneID: zoneID)
    try await container.privateCloudDatabase.save(zone)

    // 2. Move the note record to the new zone
    let noteRecord = try CKRecord(note: note, zoneID: zoneID)

    // 3. Create the share
    let share = CKShare(rootRecord: noteRecord)
    share[CKShare.SystemFieldKey.title] = note.title as CKRecordValue
    share.publicPermission = .none

    // 4. Save both
    let _ = try await container.privateCloudDatabase.modifyRecords(
        saving: [noteRecord, share], deleting: []
    )
    return share
}

Step 8. Present UICloudSharingController (iOS)

struct ShareSheet: UIViewControllerRepresentable {
    let share: CKShare
    let container: CKContainer
    func makeUIViewController(context: Context) -> UICloudSharingController {
        UICloudSharingController(share: share, container: container)
    }
    func updateUIViewController(_ ui: UICloudSharingController, context: Context) {}
}

Step 9. macOS sharing

Use NSSharingService(named: .cloudSharing) (deprecated path) or build a custom view that shows the share URL via share.url.

Step 10. Handle CKShare.Metadata on the recipient

func application(_ application: UIApplication, userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) {
    Task {
        let op = CKAcceptSharesOperation(shareMetadatas: [metadata])
        op.qualityOfService = .userInitiated
        try await container.accept(op)
        try await CloudKitSyncEngine.shared.sync()
    }
}

For SwiftUI app lifecycle, hook this via App.handlesExternalEvents and URL parsing for the share URL.

Checkpoint: device A shares a note with iCloud account B (different device). B accepts. The note appears in B’s “Shared with me” within 10 s. B edits it. A sees the edit within 10 s.

Day 7 — Conflict resolution

Step 11. Detect serverRecordChanged

In your CloudKit modify response:

catch let error as CKError where error.code == .serverRecordChanged {
    let serverRecord = error.serverRecord
    let clientRecord = error.clientRecord
    let ancestor = error.ancestorRecord  // last seen
    // Run 3-way merge; if mergeable, retry the save; if not, store as conflict
}

Step 12. Three-way merge for body

import struct Foundation.CollectionDifference

func mergeText(ancestor: String, local: String, server: String) -> String? {
    let ancestorLines = ancestor.split(separator: "\n").map(String.init)
    let localLines = local.split(separator: "\n").map(String.init)
    let serverLines = server.split(separator: "\n").map(String.init)
    let localDiff = localLines.difference(from: ancestorLines)
    let serverDiff = serverLines.difference(from: ancestorLines)
    // If diffs don't overlap, apply both
    // Detect overlap by comparing changed indices
    // ...
    return mergedText
}

If mergeable, save and continue. If not, store the divergent versions as ConflictRecord(noteID, localBody, serverBody) and surface to the user with a conflict resolution UI.

Checkpoint: airplane mode both devices. Edit different paragraphs. Reconnect. Both edits appear. Now edit the same paragraph on both. Reconnect. UI shows “1 conflict — resolve.”

Day 8 — AppIntents

Step 13. Add AppIntents Extension

File → New → Target → Intents Extension. Move CreateNoteIntent, AppendToNoteIntent, FindNotesIntent into the extension’s package.

Step 14. EntityQuery for note picker

Per architecture.md. Returns up to 50 most recently modified notes for the Shortcuts picker.

Step 15. Spotlight indexing

In NoteStore.save(_:):

import CoreSpotlight

let attributeSet = CSSearchableItemAttributeSet(itemContentType: UTType.text.identifier)
attributeSet.title = note.title
attributeSet.contentDescription = String(note.bodyMarkdown.prefix(200))
let item = CSSearchableItem(uniqueIdentifier: note.id.uuidString,
                            domainIdentifier: "notes",
                            attributeSet: attributeSet)
try await CSSearchableIndex.default().indexSearchableItems([item])

Handle Spotlight tap via onContinueUserActivity(CSSearchableItemActionType).

Checkpoint: open Shortcuts → “+ Action” → NoteSync → “Create Note” appears with parameters. Run it; note is created. Open Siri: “Hey Siri, create a note saying hello.” Works without launching the app.

Day 9 — macOS shell

Step 16. macOS sidebar layout

NavigationSplitView with sidebar (Folders | Shared | Tags), content list, detail. Add Toolbar items for “New note,” “Share.”

Step 17. CommandMenus

@main struct NoteSyncApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
            .commands {
                CommandMenu("Note") {
                    Button("New Note") { /* ... */ }.keyboardShortcut("n")
                    Button("Search") { /* ... */ }.keyboardShortcut("f")
                }
            }
    }
}

Checkpoint: open on Mac. ⌘N creates a note. ⌘F focuses search. Sidebar selection works.

Day 10–14 — Polish + TestFlight

  • Run hardening-checklist.md.
  • Fastlane lanes for iOS and macOS (separate pilot invocations).
  • macOS gets notarized via gym automatically.
  • TestFlight requires Mac TestFlight invitation.

Next: Hardening checklist