# How to sync a Mac app with its iPhone app

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

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-10-05 | Topics: [Swift](https://flaviocopes.com/tags/swift/) | Canonical: https://flaviocopes.com/mac-iphone-sync/

I want an iPhone app for [NoteRepo](https://flaviocopes.com/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:

```sql
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](https://www.sqlite.org/howtocorrupt.html) 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:

```swift
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:

```swift
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:

```swift
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](https://flaviocopes.com/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?](https://flaviocopes.com/apple-developer-free-vs-paid/).

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](https://developer.apple.com/help/account/reference/supported-capabilities-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:

1. Create an iCloud container like `iCloud.com.flaviocopes.noterepo` in your developer account.
2. Download a Developer ID provisioning profile that includes it, and copy it into the app as `Contents/embedded.provisionprofile`.
3. Sign the app with your Developer ID certificate and the iCloud entitlements, set to the production environment.
4. 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](https://flaviocopes.com/cloudflare-d1/) for the days and [R2](https://flaviocopes.com/cloudflare-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:

1. 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.
2. 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.
3. Add CloudKit sync to both apps with `CKSyncEngine`, using `Outline.merge` for 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.
