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:
CKFetchDatabaseChangesOperationto learn which zones changed- For each changed zone,
CKFetchRecordZoneChangesOperationwith persistedCKServerChangeToken - Decode
CKRecord→Note, upsert into GRDB - On local writes, queue
CKModifyRecordsOperationto 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
pilotinvocations). - macOS gets notarized via
gymautomatically. - TestFlight requires Mac TestFlight invitation.
Next: Hardening checklist