The reference implementation lives in this repo (MemoSquare). Every section > below points to the concrete file(s) that prove the design works in > production. --- ## 0. TL;DR — What This System Is and Isn't **What it IS:** - A *single JSON blob* (`${APP_FILE_NAME}.json`) stored inside the app's own iCloud Drive **ubiquity container**. - Auto-synchronised across the user's iPhone, iPad, and Mac by **the OS** — the app never talks to a custom server. - Merged with a **per-record last-writer-wins** algorithm (using stable UUIDs + `updatedAt` timestamps), so a stale device can't clobber a newer edit. - An *opt-in*, automatic mirror — if iCloud is off (free Apple ID, "> The reference implementation lives in this repo (MemoSquare). Every section > below points to the concrete file(s) that prove the design works in > production. --- ## 0. TL;DR — What This System Is and Isn't **What it IS:** - A *single JSON blob* (`${APP_FILE_NAME}.json`) stored inside the app's own iCloud Drive **ubiquity container**. - Auto-synchronised across the user's iPhone, iPad, and Mac by **the OS** — the app never talks to a custom server. - Merged with a **per-record last-writer-wins** algorithm (using stable UUIDs + `updatedAt` timestamps), so a stale device can't clobber a newer edit. - An *opt-in*, automatic mirror — if iCloud is off (free Apple ID, "> The reference implementation lives in this repo (MemoSquare). Every section > below points to the concrete file(s) that prove the design works in > production. --- ## 0. TL;DR — What This System Is and Isn't **What it IS:** - A *single JSON blob* (`${APP_FILE_NAME}.json`) stored inside the app's own iCloud Drive **ubiquity container**. - Auto-synchronised across the user's iPhone, iPad, and Mac by **the OS** — the app never talks to a custom server. - Merged with a **per-record last-writer-wins** algorithm (using stable UUIDs + `updatedAt` timestamps), so a stale device can't clobber a newer edit. - An *opt-in*, automatic mirror — if iCloud is off (free Apple ID, ">
# iCloud Drive Sync Blueprint (iOS + macOS)
A **portable specification** for adding "Apple Notes–style" auto-sync to a
Flutter app. Hand this file to another agent / developer and they should be
able to recreate the exact same backup-and-merge system in any single-user app
(notes, tasks, journals, todos, etc).
> The reference implementation lives in this repo (MemoSquare). Every section
> below points to the concrete file(s) that prove the design works in
> production.
---
## 0. TL;DR — What This System Is and Isn't
**What it IS:**
- A *single JSON blob* (`${APP_FILE_NAME}.json`) stored inside the app's own
iCloud Drive **ubiquity container**.
- Auto-synchronised across the user's iPhone, iPad, and Mac by **the OS** —
the app never talks to a custom server.
- Merged with a **per-record last-writer-wins** algorithm (using stable UUIDs
+ `updatedAt` timestamps), so a stale device can't clobber a newer edit.
- An *opt-in*, automatic mirror — if iCloud is off (free Apple ID, no signin,
airplane mode, container unavailable), the app falls back to a manual
*Send / Receive* clipboard share. Nothing breaks.
**What it is NOT:**
- **Not CloudKit.** No `CKDatabase`, no record types, no schema. We use iCloud
*Drive*, which is just a sandboxed folder that Apple syncs in the
background.
- **Not multi-user / shared.** Each Apple ID has its own private copy. No
collaboration features.
- **Not a real-time stream.** Pull cadence is ~135 s; pushes are
debounced ~18 s after edits. Good enough for personal notes.
- **Not "free Apple ID".** Apple only issues iCloud containers to **paid
Apple Developer Program** members ($99/yr). Without it the wizard hides
iCloud and the app uses Send/Receive only.
---
## 1. Mental Model
┌─────────────────── iPhone (UserApp.app) ───────────────────┐ │ Flutter UI ──► Dart "${AppICloudSync}" service │ │ │ │ │ MethodChannel(${CHANNEL_NAME}) │ │ │ │ │ AppDelegate.swift ──► FileManager.url( │ │ forUbiquityContainerIdentifier: │ │ nil)/Documents/board.json │ └────────────────────────────┬───────────────────────────────┘ │ ← Apple's iCloud daemon │ uploads/downloads atomically │ in the background ▼ ╔═══════════════════════════════════╗ ║ iCloud Drive (Apple's servers) ║ ║ iCloud.${BUNDLE_ID}/Documents/ ║ ║ └─ ${APP_FILE_NAME}.json ║ ╚═══════════════════════════════════╝ ▲ │ ┌─────────────────── Mac (UserApp.app) ──────────────────────┐ │ Same FileManager path: │ │ ~/Library/Mobile Documents/iCloud~${BUNDLE_ID}/Documents/ │ │ Same Dart service, same merge. │ └────────────────────────────────────────────────────────────┘
The OS guarantees: when device A writes the file, devices B & C eventually
see the same byte-for-byte file on disk (modulo offline). The **app does
nothing** to make that happen — it just reads and writes the file like any
local file. All sync infrastructure is Apple's.
The app's only job: on read, *merge* the remote bytes with local state.
---
## 2. The Pieces (Where Each File Lives)
${REPO_ROOT}/ ├── ios/Runner/ │ ├── Runner.entitlements ← iCloud capability + container ID │ ├── AppDelegate.swift ← MethodChannel ↔ FileManager │ └── Info.plist ← (no special keys; container is in entitlements) │ ├── macos/Runner/ │ ├── DebugProfile.entitlements ← same iCloud capability (debug builds) │ ├── Release.entitlements ← same iCloud capability (release builds) │ └── AppDelegate.swift ← mirror of iOS handler │ ├── lib/ │ ├── services/ │ │ ├── apple_icloud_board_sync.dart ← Dart wrapper for MethodChannel │ │ └── memo_cloud_merge.dart ← Pure-Dart merge algorithm │ ├── data/ │ │ └── models.dart ← UUIDs (uuid v4) for every record │ └── main.dart ← Timer-driven pull (135s) + debounce push │ └── pubspec.yaml ← uuid: ^4.5.3
Naming conventions used below:
| Placeholder | Example in MemoSquare |
|---|---|
| `${BUNDLE_ID}` | `com.rogan.memosquare` |
| `${ICLOUD_CONTAINER_ID}` | `iCloud.com.rogan.memosquare` |
| `${APP_FILE_NAME}` | `memo_squares_board.json` |
| `${CHANNEL_NAME}` | `appmemo/icloud_board` |
| `${TEAM_ID}` | `V3J8MR637G` |
Apple's convention: the iCloud container ID is the bundle ID prefixed with
`iCloud.`. Keep it that way — it makes everything elsewhere "just work".
---
## 3. One-Time Setup (Apple Developer Portal)
Required: paid Apple Developer Program account ($99/yr).
1. Sign in to <https://developer.apple.com/account/resources/identifiers>.
2. **iCloud Containers** → `+` → `Description: ${app name} iCloud` →
`ID: ${ICLOUD_CONTAINER_ID}`. Save.
3. **Identifiers** → register an App ID for `${BUNDLE_ID}` (Multiplatform).
Under Capabilities, tick **iCloud** with these options:
- `Include CloudKit support`: **off** (we only use iCloud Drive)
- Click **Configure** → check the container you just made
➜ Save.
If you have an existing App ID, just edit it and add the iCloud capability +
container. No new App ID is needed.
> 💡 **Pro tip:** you can skip step 2 and step 3 entirely if Xcode is signed
> in to your developer Apple ID: tick the iCloud capability in Xcode → Signing
> & Capabilities, and Xcode will silently create the container in the portal
> for you on first build. The portal click-through above is the fallback when
> the Xcode flow fails (corporate proxy, weird Apple ID setup, etc).
---
## 4. Xcode One-Time Setup (per developer Mac)
Apple **cannot be scripted around** for this — every dev Mac must do it once.
1. Open Xcode → `Cmd + ,` → **Accounts** tab.
2. `+` → **Apple ID** → sign in with the Apple ID that owns / belongs to
team `${TEAM_ID}`.
3. Wait for the team name to appear on the right.
4. Quit Xcode (`Cmd + Q`).
After this:
- Every `xcodebuild -allowProvisioningUpdates ...` works headlessly.
- New iCloud containers are auto-created on first build.
- The dev no longer needs to touch Xcode.
---
## 5. Native — `*.entitlements` (iOS + macOS)
iOS (`ios/Runner/Runner.entitlements`):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "<http://www.apple.com/DTDs/PropertyList-1.0.dtd>">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array><string>${ICLOUD_CONTAINER_ID}</string></array>
<key>com.apple.developer.icloud-services</key>
<array><string>CloudDocuments</string></array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array><string>${ICLOUD_CONTAINER_ID}</string></array>
</dict>
</plist>
macOS — two files, identical content but each ALSO keeps the sandbox keys
Flutter scaffolds. macos/Runner/DebugProfile.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "<http://www.apple.com/DTDs/PropertyList-1.0.dtd>">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.network.server</key><true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array><string>${ICLOUD_CONTAINER_ID}</string></array>
<key>com.apple.developer.icloud-services</key>
<array><string>CloudDocuments</string></array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array><string>${ICLOUD_CONTAINER_ID}</string></array>
</dict>
</plist>
Release.entitlements is the same minus the cs.allow-jit and
network.server flags (release builds don't need debug-time perms).
Why three keys for the same container? Apple historically split them:
ubiquity-container-identifiers= file storage (iCloud Drive),icloud-container-identifiers= the generic container slot,icloud-services = CloudDocuments= "yes I want documents API". Modern apps need all three present.
AppDelegate.swift BridgeBoth iOS and macOS implement the same 4-method bridge over a single
MethodChannel(${CHANNEL_NAME}). The two files differ only in the Flutter
host setup boilerplate; the iCloud handler is identical.
ios/Runner/AppDelegate.swift)import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var iCloudBoardChannel: FlutterMethodChannel?
private static let boardFileName = "${APP_FILE_NAME}" // e.g. notes.json
func didInitializeImplicitFlutterEngine(_ bridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: bridge.pluginRegistry)
let messenger = bridge.applicationRegistrar.messenger()
let ch = FlutterMethodChannel(name: "${CHANNEL_NAME}",
binaryMessenger: messenger)
ch.setMethodCallHandler { [weak self] call, result in
self?.handleICloudBoardCall(call, result: result)
}
iCloudBoardChannel = ch
}
// ── core: resolve <iCloud container>/Documents/<file> ───────────────
private func memoBoardFileURL() -> URL? {
guard let base = FileManager.default.url(
forUbiquityContainerIdentifier: nil // nil = first entitled container
) else { return nil }
let docs = base.appendingPathComponent("Documents", isDirectory: true)
try? FileManager.default.createDirectory(at: docs,
withIntermediateDirectories: true)
return docs.appendingPathComponent(Self.boardFileName)
}
private func handleICloudBoardCall(_ call: FlutterMethodCall,
result: @escaping FlutterResult) {
switch call.method {
case "isAvailable":
result(memoBoardFileURL() != nil)
case "lastModifiedMillis":
guard let url = memoBoardFileURL(),
FileManager.default.fileExists(atPath: url.path) else {
result(-1); return
}
let vals = try? url.resourceValues(forKeys: [.contentModificationDateKey])
if let d = vals?.contentModificationDate {
result(Int64(d.timeIntervalSince1970 * 1000))
} else { result(-1) }
case "readText":
guard let url = memoBoardFileURL() else {
result(FlutterError(code: "no_container", message: nil, details: nil)); return
}
if !FileManager.default.fileExists(atPath: url.path) {
result(nil); return // first launch on this Apple ID
}
// CRITICAL: nudge the OS to materialise the latest bytes before we read.
try? FileManager.default.startDownloadingUbiquitousItem(at: url)
do {
result(try String(contentsOf: url, encoding: .utf8))
} catch {
result(FlutterError(code: "read_failed",
message: error.localizedDescription, details: nil))
}
case "writeText":
guard let text = call.arguments as? String,
let url = memoBoardFileURL() else { result(false); return }
do {
try text.write(to: url, atomically: true, encoding: .utf8)
result(true)
} catch { result(false) }
default: result(FlutterMethodNotImplemented)
}
}
}
macos/Runner/AppDelegate.swift)The handler body is byte-for-byte identical — copy the same
memoBoardFileURL() + handleICloudBoardCall(...) methods. The only
differences are the Cocoa/FlutterMacOS host boilerplate:
import Cocoa
import FlutterMacOS
@main
class AppDelegate: FlutterAppDelegate {
private var iCloudBoardChannel: FlutterMethodChannel?
private static let boardFileName = "${APP_FILE_NAME}"
override func applicationDidFinishLaunching(_ n: Notification) {
if let vc = mainFlutterWindow?.contentViewController as? FlutterViewController {
let ch = FlutterMethodChannel(name: "${CHANNEL_NAME}",
binaryMessenger: vc.engine.binaryMessenger)
ch.setMethodCallHandler { [weak self] call, result in
self?.handleICloudBoardCall(call, result: result)
}
iCloudBoardChannel = ch
}
super.applicationDidFinishLaunching(n)
}
// memoBoardFileURL() + handleICloudBoardCall() copied verbatim from iOS.
}
Why pass nil to forUbiquityContainerIdentifier? It tells the OS
"give me my app's primary container", which is auto-resolved from the
entitlements. This way the same Swift code works regardless of bundle ID
changes — you only touch the entitlements file.
lib/services/${app}_icloud_sync.dart)import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class AppleICloudBoardSync {
AppleICloudBoardSync._();
static const MethodChannel _channel = MethodChannel('${CHANNEL_NAME}');
static bool get supported =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS);
static Future<bool> isAvailable() async {
if (!supported) return false;
try {
return (await _channel.invokeMethod<bool>('isAvailable')) == true;
} on MissingPluginException { return false; }
catch (_) { return false; }
}
/// Remote file's content mtime, or null if missing.
static Future<int?> lastModifiedMillis() async {
if (!supported) return null;
try {
final v = await _channel.invokeMethod<int>('lastModifiedMillis');
return (v == null || v < 0) ? null : v;
} catch (_) { return null; }
}
static Future<String?> readBoardJson() async {
if (!supported) return null;
try {
final v = await _channel.invokeMethod<String>('readText');
return (v == null || v.isEmpty) ? null : v;
} catch (_) { return null; }
}
static Future<bool> writeBoardJson(String text) async {
if (!supported) return false;
try {
return (await _channel.invokeMethod<bool>('writeText', text)) == true;
} catch (_) { return false; }
}
}
Every method is wrapped in
try / catchreturning a "neutral" value (null/false). This is intentional: sync must never crash the app. If iCloud isn't there, the user keeps editing locally.
This is the part everyone gets wrong on first attempt.
Bad idea: use DateTime.now().microsecondsSinceEpoch.toString() as a record
ID. Renaming a title on Device A creates a new ID on the next save, the merge
sees two records, and you end up with duplicates.
Good idea: every entity has a stable UUID v4 assigned once at creation and never changed:
// lib/data/models.dart
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
String memoGenerateId() => _uuid.v4();
class MemoSquare {
final String id; // UUID v4, never changes
String title; // editable, doesn't affect id
String body;
String updatedAtIso; // ISO-8601 UTC, bumped on every edit
MemoSquare.newMemo({String? id, ...})
: id = id ?? memoGenerateId();
factory MemoSquare.fromJson(Map<String, dynamic> j) =>
MemoSquare._(id: j['id'] as String? ?? memoGenerateId(), ...);
}
pubspec.yaml:
dependencies:
uuid: ^4.5.3
The merge algorithm in Section 10 keys off these UUIDs, so renames, body edits, and tab reordering are all just "updates to the same record".
main.dart)Three trigger sources, two directions:
┌───────────────────────────────────────────┐
│ Triggers (any of these fires "PUSH") │
├───────────────────────────────────────────┤
PUSH (local→cloud): │
1) Right after every successful local save │
→ debounced 18 s (so a burst of typing = 1 upload) │
2) Periodic safety net: every 60 s │
│
PULL (cloud→local): │
3) Periodic: every 135 s │
4) On app resume / re-foreground │
┴───────────────────────────────────────────┘
Reference constants (lib/main.dart):
static const Duration _drivePullInterval = Duration(seconds: 135);
static const Duration _driveUploadAfterSaveDebounce = Duration(seconds: 18);
_tryPullMergeFromICloud)Future<void> _tryPullMergeFromICloud() async {
if (_isLoading || !mounted) return;
if (!_icloudAutoSyncEnabled ||
!_icloudContainerAvailable ||
!AppleICloudBoardSync.supported) return;
if (_hasDirtyChanges) return; // never overwrite unsaved edits
if (_isICloudPullBusy || _isICloudUploadBusy) return; // serialise
_isICloudPullBusy = true;
try {
final ms = await AppleICloudBoardSync.lastModifiedMillis();
final remoteMod = ms == null
? null
: DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true);
// Skip if we've already seen this or a newer remote.
if (remoteMod != null &&
_lastRemoteICloudModified != null &&
!remoteMod.isAfter(_lastRemoteICloudModified!)) return;
final raw = await AppleICloudBoardSync.readBoardJson();
if (raw == null) return;
await _mergeRemoteBoardPayload(raw, remoteMod: remoteMod,
onRememberRemote: (d) => _lastRemoteICloudModified = d);
} catch (_) {
// Silent: offline / empty container / first launch.
} finally {
_isICloudPullBusy = false;
}
}
_syncToICloudBoard)Future<void> _syncToICloudBoard({bool silent = false}) async {
if (!AppleICloudBoardSync.supported ||
!_icloudAutoSyncEnabled ||
!_icloudContainerAvailable) return;
if (_isICloudUploadBusy) return;
_isICloudUploadBusy = true;
try {
final stamp = DateTime.now().toUtc();
final payload = _createBackupPayload(exportStamp: stamp); // section 11
final ok = await AppleICloudBoardSync.writeBoardJson(payload);
if (ok) {
_icloudLastSyncedAt = DateTime.now();
await _saveICloudSyncPrefs();
if (!silent) _showSnack('Saved to iCloud Drive.');
} else if (!silent) {
_showSnack('Could not write to iCloud Drive.');
}
} finally {
_isICloudUploadBusy = false;
}
}
void _restartICloudTimers() {
_icloudPullTimer?.cancel();
_icloudPeriodicUploadTimer?.cancel();
if (!_icloudAutoSyncEnabled || !_icloudContainerAvailable) return;
_icloudPeriodicUploadTimer = Timer.periodic(const Duration(minutes: 1),
(_) => unawaited(_syncToICloudBoard(silent: true)));
_icloudPullTimer = Timer.periodic(_drivePullInterval,
(_) => unawaited(_tryPullMergeFromICloud()));
}
void _scheduleICloudSyncAfterSave() {
if (!_icloudAutoSyncEnabled || !_icloudContainerAvailable) return;
_icloudAfterSaveTimer?.cancel();
_icloudAfterSaveTimer = Timer(_driveUploadAfterSaveDebounce,
() => unawaited(_syncToICloudBoard(silent: true)));
}
_scheduleICloudSyncAfterSave() gets called at the end of every persist
operation. _restartICloudTimers() is called once at app launch (after
deciding availability) and whenever the user toggles iCloud on/off.
File: lib/services/memo_cloud_merge.dart. Pure Dart, no Flutter deps, fully
unit-testable.
The idea:
id (UUID) and updatedAtIso (UTC ISO-8601).deletedAt timestamps.local ∪ remote ∪ localTrash ∪ remoteTrash:
class MemoCloudMerge {
static MemoCloudMergeResult merge({
required List<MemoSquare> localMemos,
required List<MemoSquare> remoteMemos,
required List<MemoTrashEntry> localTrash,
required List<MemoTrashEntry> remoteTrash,
/* … windowStates, settings, order — all merged similarly … */
}) {
final localBoard = { for (final m in localMemos) m.id: m };
final remoteBoard = { for (final m in remoteMemos) m.id: m };
final ids = {
...localBoard.keys, ...remoteBoard.keys,
for (final e in localTrash) e.memo.id,
for (final e in remoteTrash) e.memo.id,
};
final mergedMemos = <MemoSquare>[];
final mergedTrash = <String, MemoTrashEntry>{};
for (final id in ids) {
final boardTime = _newest([
localBoard[id]?.updatedAt, remoteBoard[id]?.updatedAt,
]);
final trashTime = _newest([
_findTrash(localTrash, id)?.deletedAt,
_findTrash(remoteTrash, id)?.deletedAt,
]);
final hasBoard = localBoard[id] != null || remoteBoard[id] != null;
final hasTrash = trashTime != null;
if (hasTrash && (!hasBoard || trashTime.isAfter(boardTime))) {
mergedTrash[id] = _pickNewer(localTrash, remoteTrash, id);
} else if (hasBoard) {
mergedMemos.add(_pickNewer(localBoard[id], remoteBoard[id]));
}
}
return MemoCloudMergeResult(memos: mergedMemos,
trash: mergedTrash.values.toList(), /* … */);
}
}
(Full version is 150 lines and also merges window positions, ordering, and
UI settings. See lib/services/memo_cloud_merge.dart in this repo.)
Important guarantees:
id, newer updatedAtIso wins.ids, no conflict).The single file on iCloud is the full app state. Treat it as one snapshot — it's small (~tens of KB even for 1000 notes) so we don't bother diffing.
{
"version": 2,
"exportedAtIso": "2026-05-11T22:34:00.000Z",
"memos": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Shopping list",
"tabs": [
{ "id": "uuid-tab-1", "name": "Groceries", "body": "..." }
],
"createdAtIso": "2026-05-01T...",
"updatedAtIso": "2026-05-11T...",
"color": "#FFD166"
}
],
"trash": [
{ "memo": { "id": "...", ... }, "deletedAtIso": "2026-05-10T..." }
],
"windowOrder": [ "id-1", "id-2" ],
"windowStates": {
"id-1": { "x": 120, "y": 80, "w": 320, "h": 240, "minimised": false }
},
"settings": { "accentColor": "#4F46E5", "compactMode": false }
}
Rules:
version — bump only on schema breaks. Add forward-compatible fields
freely.id fields are UUID v4. Never reuse, never re-issue.trash — soft-deleted records with a 30-day retention. The merge
needs these to honour deletes across devices.| Situation | Effect | What the user sees |
|---|---|---|
| Apple ID not signed in to OS | isAvailable() returns false |
Wizard step 1: "Sign in to iCloud Drive" |
| iCloud Drive turned off on this device | Same as above | Same |
| Free (non-paid) Apple ID | Provisioning fails at build time | Wizard hides itself, only Send/Receive shown |
| Offline | readText / writeText may succeed against the local copy; OS resumes sync when back online |
Sync pill says "Last synced 2 m ago" |
| Container is empty (first launch) | readBoardJson() returns null |
App treats remote as empty, merges nothing |
| iCloud quota full | writeText returns false |
Snackbar: "Could not write to iCloud Drive." |
| Corrupted JSON | jsonDecode throws, merge silently skipped |
Local copy keeps working, no data loss |
The fallback is always: Send / Receive via clipboard. The user copies a
short code on Device A, pastes on Device B, and the same MemoCloudMerge
runs. This works on any device, any OS, no Apple ID.
For an agent porting this into, say, MyTasksApp:
Decide names (write them in your spec doc):
BUNDLE_ID = com.yourname.mytasksICLOUD_CONTAINER_ID = iCloud.com.yourname.mytasksAPP_FILE_NAME = mytasks_board.jsonCHANNEL_NAME = mytasks/icloud_boardApple Developer Portal: create the iCloud container and tick the iCloud capability on the App ID (Section 3). Free Apple IDs: skip — your app uses Send/Receive only.
Add uuid dep and convert every entity ID to UUID v4 (Section 8).
This is the change you can't shortcut.
Add the entitlements files (Section 5) — one for iOS, two for macOS. Container ID matches step 1.
Copy AppDelegate.swift snippets (Section 6) into both
ios/Runner/AppDelegate.swift and macos/Runner/AppDelegate.swift.
Adjust boardFileName and the MethodChannel name to match step 1.
Copy apple_icloud_board_sync.dart (Section 7) into
lib/services/. Rename the class if you want; change the channel
constant to match step 5.
Write your MyTasksCloudMerge following the recipe in Section 10.
The shape of "board" / "trash" can be whatever your app needs — the
timestamps and UUID-keyed maps are what makes it work.
Wire timers in main.dart (Section 9). The cadence numbers
(135 s pull, 18 s debounce, 60 s safety) are good defaults; tune only
if you have heavy users.
Sign Xcode in once on each dev Mac (Section 4) and build. Xcode auto-creates the container on first build if the developer portal step was skipped.
QA checklist:
| Symptom | Likely cause | Fix |
|---|---|---|
Build error: No profiles for '${BUNDLE_ID}' |
Xcode not signed in to dev Apple ID | Section 4 |
Build error: No Accounts: Add a new account |
Same | Section 4 |
isAvailable() always false |
iCloud Drive off on device, or container ID typo in entitlements | Check Settings → Apple ID → iCloud → iCloud Drive ON; verify entitlement matches Identifiers portal entry |
| Sync pill stays orange forever | Wrong container ID across iOS/macOS entitlements (must match exactly) | Diff Runner.entitlements, DebugProfile.entitlements, Release.entitlements |
| Remote always wins | Local updatedAtIso not being bumped on edit |
Update updatedAtIso = DateTime.now().toUtc().toIso8601String() in every mutation |
| Two copies of the same note | IDs aren't stable (still using microsecondsSinceEpoch) |
Section 8 |
startDownloadingUbiquitousItem throws |
File is local already | Wrap in try? — non-fatal |
| File | What it proves |
|---|---|
ios/Runner/AppDelegate.swift |
The 4-method Swift bridge |
macos/Runner/AppDelegate.swift |
macOS mirror |
ios/Runner/Runner.entitlements |
iCloud capability declaration |
macos/Runner/{Debug,Release}.entitlements |
macOS capability |
lib/services/apple_icloud_board_sync.dart |
Dart MethodChannel wrapper |
lib/services/memo_cloud_merge.dart |
Per-record last-writer-wins merge |
lib/data/models.dart |
UUID v4 ID generation for every entity |
lib/main.dart (around line 932, 1041, 992) |
Pull / push / timer orchestration |
pubspec.yaml |
uuid: ^4.5.3 dependency |
AppDelegate.swift to macOS.— End of blueprint —