How to sync a Mac app with its iPhone app
By Flavio Copes
How to sync a Mac app and its iPhone companion: CloudKit, what the Apple Developer Program unlocks, iCloud for apps outside the App Store, and alternatives.
I want an iPhone app for NoteRepo, my daily notes app for macOS. I write on the Mac most of the time, but I’d like to read today’s note on my phone and add a bullet when I’m away from the desk.
The phone app is easy to picture. The hard part is getting the notes from the Mac to the phone and back. Do I need iCloud? Can I use it without paying Apple? I release my Mac apps on GitHub, not on the Mac App Store, so does iCloud even work for them? And what are the options if I skip iCloud entirely?
Where the notes live today
NoteRepo keeps everything in one SQLite file, ~/Library/Application Support/NoteRepo/notes.sqlite3. Each day is one row, keyed by its date:
CREATE TABLE notes (
date TEXT PRIMARY KEY,
content TEXT NOT NULL DEFAULT '',
updated_at TEXT
)
The content is the day’s list as Markdown text. Screenshots live in a separate images table, and each image is identified by a checksum of its bytes, so an image never changes once it’s saved.
There’s also a noterepo command line tool that reads and writes the same file, so my coding agents can add things to a day. The app picks up those changes within a second.
This shape is good news for sync. Days are small, independent records with a natural ID, and images are write-once. What needs to travel between devices is rows, not the database file.
Don’t sync the SQLite file
The tempting shortcut is to move notes.sqlite3 into iCloud Drive or Dropbox and let it sync like any other file. Don’t do it.
NoteRepo runs SQLite in WAL mode, so the database is three files on disk: notes.sqlite3, notes.sqlite3-wal and notes.sqlite3-shm. Recent writes sit in the -wal file until SQLite moves them into the main file. A sync service uploads each file on its own schedule, often in the middle of a write, and the other device ends up with a database from one moment and a log from another.
SQLite’s own page on how to corrupt an SQLite database file lists these exact cases: copying a database while a transaction is in progress, and copying the database without its journal. On top of that, when both devices write at the same time, the sync service can’t merge two binary files. You get a “conflicted copy” next to the database, and the app only sees one side’s changes.
So each device keeps its own local database, and we sync the records.
iCloud, the Apple way
Apple gives apps three ways to store data in iCloud:
- key-value storage, for small settings like a theme choice
- iCloud Documents, for files in an app folder in iCloud Drive
- CloudKit, a database of records
CloudKit is the one for notes. Every user gets a private database inside their own iCloud account. The data counts against their iCloud storage, not against anything I pay for, and there’s no server for me to run. People don’t create an account either, because CloudKit uses the Apple ID they’re already signed in with. That matters for NoteRepo, since having no accounts is one of its features.
In CloudKit, one day becomes one record. The record name is the date, so both devices agree on the ID without talking to each other:
import CloudKit
let zone = CKRecordZone(zoneName: "Notes")
let id = CKRecord.ID(recordName: "2026-10-03", zoneID: zone.zoneID)
let day = CKRecord(recordType: "Day", recordID: id)
day.encryptedValues["content"] = "- Buy the Apple Developer membership"
Writing to encryptedValues makes CloudKit encrypt the field on the device before uploading it. With Advanced Data Protection turned on, only the owner of the record has the keys. Images become CKAsset files, which CloudKit encrypts by default, and since they never change they upload once.
Let CKSyncEngine do the sync loop
Writing a sync loop by hand is the painful part. You track what changed, batch the uploads, listen for changes from other devices, and retry when the network drops. Since macOS 14 and iOS 17, CKSyncEngine does that work for you. NoteRepo already requires macOS 14, so it fits.
You create the engine at launch and point it at the private database:
let container = CKContainer(identifier: "iCloud.com.flaviocopes.noterepo")
let configuration = CKSyncEngine.Configuration(
database: container.privateCloudDatabase,
stateSerialization: savedState,
delegate: syncDelegate
)
let engine = CKSyncEngine(configuration)
savedState is the engine’s own bookkeeping. The engine sends you updates to it as events, and you save them to disk so the next launch picks up where the last one left off.
Every time a day changes, you tell the engine:
engine.state.add(pendingRecordZoneChanges: [.saveRecord(id)])
The engine schedules an upload, asks your delegate for the records in batches of up to 250, and sends them. Changes made on other devices arrive as a silent push notification, and the engine fetches them and hands them to your delegate.
What it doesn’t do is resolve conflicts. If both devices edited the same day before syncing, the save fails with a serverRecordChanged error, and the app decides what the merged day looks like.
NoteRepo already has that piece. When the CLI changes a day while you’re typing in it, Outline.merge(base:mine:theirs:) compares both versions line by line against the last version both sides had seen, and combines what each side added or removed. For sync, base is the last version that came from iCloud, mine is the day on this device, and theirs is the day on the server.
What you can do without paying Apple
A free Apple ID lets you install your own app on your own iPhone from Xcode. The limits:
- the app stops opening after 7 days, until you run it from Xcode again
- you can have at most 3 apps installed this way
- there’s no iCloud, no TestFlight and no App Store, so nobody else can install it
The Mac app had the same iCloud problem. NoteRepo releases were ad-hoc signed up to version 2.1, and an ad-hoc signed app can’t use iCloud either.
The Apple Developer Program costs $99 a year. It unlocks iCloud, TestFlight and the App Store, and apps you install from Xcode keep working for a year instead of 7 days. It also gives you a Developer ID certificate to sign and notarize Mac apps. I went through everything the free and paid accounts can do in Should you pay for the Apple Developer Program?.
So I paid. The iPhone app needs the membership anyway, and NoteRepo for Mac got something out of it right away. Up to version 2.1, macOS said it “could not verify NoteRepo is free of malware” on the first launch, and you had to click Open Anyway in System Settings. NoteRepo 2.2 is signed with my Developer ID and notarized, so it opens with no warning.
Can a Mac app outside the App Store use iCloud?
Yes. Apple’s table of supported capabilities for macOS has a column for apps signed with a Developer ID certificate, and it includes CloudKit, iCloud Documents, iCloud key-value storage and push notifications. CloudKit needs push to hear about changes from other devices, so that one matters too. Developer ID apps can’t use In-App Purchase, Game Center or Sign in with Apple, but a notes app doesn’t need any of those.
There’s some setup, especially if you don’t build with Xcode. NoteRepo builds with Swift Package Manager and a shell script, so the script has to do what Xcode would do for you:
- Create an iCloud container like
iCloud.com.flaviocopes.noterepoin your developer account. - Download a Developer ID provisioning profile that includes it, and copy it into the app as
Contents/embedded.provisionprofile. - Sign the app with your Developer ID certificate and the iCloud entitlements, set to the production environment.
- Send the app to Apple’s notary service, then staple the ticket to it.
Be careful with that production environment. CloudKit has two: builds you run from Xcode use development, while App Store and Developer ID builds use production. You design the record types in development, then deploy the schema to production in the CloudKit Console before the first release. If you skip that step, sync works on your Mac and fails for everyone who downloads the app.
The iPhone side
iOS has no equivalent of a zip file on GitHub. To get an app on other people’s iPhones you have:
- the App Store
- TestFlight, for betas, with up to 10,000 testers and builds that expire after 90 days
- Ad Hoc installs on up to 100 iPhones you register by hand, per membership year
In the EU there are also alternative app marketplaces, but apps there still need the paid membership and Apple’s notarization.
For my own phone, I’ll install from Xcode. For everyone else, it’s the App Store.
The Mac app on GitHub and the iPhone app on the App Store can still share the same iCloud container. Containers belong to the developer account, not to a store, so the notes show up on both no matter how each app was installed.
Alternatives to iCloud
None of these need the iCloud entitlement, so none of them need the paid membership for sync. Keep in mind they only solve sync. Without the membership, the iPhone app still stops opening every 7 days.
Sync over the local network
The Mac app runs a small server, and the iPhone finds it with Bonjour when both are on the same Wi-Fi. Each side sends the days that changed since the last sync. There are no accounts, and the notes never leave your devices. With Tailscale, the phone can reach the Mac from anywhere.
The catch is that the Mac has to be awake, with the app running. When the Mac is your main device, that’s a fair trade. If you write on the phone while you’re out, the phone keeps the changes and sends them when it reaches the Mac again.
A folder in iCloud Drive
The Mac app writes one Markdown file per day into a folder in iCloud Drive. A Mac app that isn’t sandboxed can write there like in any other folder. The iPhone app asks you to pick that folder once in the Files picker, and iOS lets it keep access. Neither app needs the iCloud entitlement, and the same approach works with a Dropbox or Google Drive folder.
The cost is two copies of the truth: the SQLite database and the files. The app has to keep them in step. iOS may not have downloaded the latest version of a file yet, and the app only finds out about changes when it looks.
Your own server
A Cloudflare Worker with a D1 database for the days and R2 for the images. Each app pushes the days it changed, with their updated_at, and asks for everything that changed since its last sync. It works anywhere, on any platform, Android included.
Now you’re hosting other people’s private notes, though. You’d want to encrypt them on the device, add sign-in (so NoteRepo would get accounts after all), and keep a service running and paid for.
| Option | Paid membership | Server to run | Works away from home |
|---|---|---|---|
| CloudKit | Yes | No | Yes |
| Local network | No | No | With Tailscale |
| iCloud Drive folder | No | No | Yes |
| Own server | No | Yes | Yes |
What I’m going to do
Since I pay for the membership anyway, CloudKit is the simplest option. There’s no server, no accounts, and CKSyncEngine handles most of the sync work.
This is the order I’m going to follow:
- Sign NoteRepo for Mac with Developer ID and notarize it. That’s done: NoteRepo 2.2 is notarized, releases stay on GitHub, and 2.1 updates itself to it like to any other version, because the updater only checks that the new version’s signature is valid.
- Build the iPhone app. It reuses
NoteRepoCore, the part of the Mac app with the outline model, the SQLite store and the link handling. The editor is new, since the Mac one is built on AppKit. The first version saves notes on the phone, with no sync. - Add CloudKit sync to both apps with
CKSyncEngine, usingOutline.mergefor conflicts.
The noterepo CLI stays as it is. It keeps writing to the SQLite file, the app notices the change like it does today, and the app uploads it. A command line tool can’t easily carry the iCloud entitlements, so sync lives in the app.
Want me to talk about your product? You can sponsor this site.
Related posts about swift: