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.


6. Native — AppDelegate.swift Bridge

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


7. Dart Wrapper (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 / catch returning a "neutral" value (null / false). This is intentional: sync must never crash the app. If iCloud isn't there, the user keeps editing locally.


8. Stable IDs — Why UUIDs Are Non-Negotiable

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


9. Auto-Sync Orchestration (in 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);

Pull procedure (_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;
  }
}

Push procedure (_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;
  }
}

Wiring the timers

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.


10. Conflict Resolution — Per-Memo Last-Writer-Wins

File: lib/services/memo_cloud_merge.dart. Pure Dart, no Flutter deps, fully unit-testable.

The idea:

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:


11. JSON Payload Schema

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:


12. Failure Modes & Fallback

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.


13. Recipe: Add This to Another App (Step-by-Step)

For an agent porting this into, say, MyTasksApp:

  1. Decide names (write them in your spec doc):

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

  3. Add uuid dep and convert every entity ID to UUID v4 (Section 8). This is the change you can't shortcut.

  4. Add the entitlements files (Section 5) — one for iOS, two for macOS. Container ID matches step 1.

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

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

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

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

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

  10. QA checklist:


14. Troubleshooting Cheatsheet

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

15. Reference Files in This Repo

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

Why this design is good

When NOT to use this design

— End of blueprint —