# AppleSyncKit > A Swift kit for building agent-friendly, end-to-end encrypted sync tools — Apple-native and cross-platform. Bring your own Codable types, deploy one Cloudflare D1 Worker, and ship a self-hosted backend in minutes. AppleSyncKit is a small, auditable Swift library (MIT-licensed) that owns the sync layer so you don't have to write one. Conform your own `Codable` records to the kit and it seals every record on device with AES-GCM-256 (record-bound AAD), then reconciles changes last-write-wins over a cursor — you write zero sync code. The bundled Cloudflare D1 Worker deploys with a single `wrangler deploy`; you own the database and the encryption keys, and the Worker only ever stores ciphertext. EventKit and Apple platforms come first, but the same codebase runs on Linux over local SQLite. Composable Swift APIs and scriptable CLIs give LLM agents and automations clean structured I/O with no hidden state. How it works, in three moves: (1) define your entities as your own `Codable & Sendable` types — the read layer is yours, the sync engine is shared; (2) encrypt each record on device with the kit's AES-GCM-256 service, then push/pull through the engine; (3) deploy one bundled D1 Worker with a single command — no servers, no lock-in. ## Install Add AppleSyncKit to your Swift package. Paste this line into the `dependencies` array of your `Package.swift`: ```swift .package(url: "https://github.com/FradSer/apple-sync-kit.git", from: "0.4.1") ``` Then add the product to your target's dependencies: ```swift .product(name: "AppleSyncKit", package: "apple-sync-kit") ``` Or in Xcode: File → Add Package Dependencies → `https://github.com/FradSer/apple-sync-kit`. ## Agent guide A copy-adaptable guide for building an app that syncs your own Codable records end-to-end encrypted across Apple platforms (macOS/iOS) and Linux through a self-hosted Cloudflare D1 Worker. The Worker only ever stores ciphertext. Architecture (one round trip): ``` device A ──encrypt──▶ POST /api/v1//push ──▶ Worker ──▶ D1 (ciphertext) device B ◀──decrypt── GET /api/v1//pull?device= ◀── Worker ◀── D1 ``` ### Mental model (read this first) - No protocol to conform to. Your record type just needs to be `Codable & Sendable`. Entities are string-addressed (`"notes"`, `"myrecords"`, …). The kit is generic. - Encryption is caller-driven, not automatic. The kit never encrypts for you. You encrypt before push and decrypt after pull. The Worker stores opaque blobs. - Envelope fields are the kit's. `id`, `last_modified`, `deleted`, `source_device`, `seq` are managed by the kit/Worker — do NOT put them on your payload. Your payload is the `data` column. - A device never pulls its own writes (`excludeOwnWrites: true`), so sync is idempotent per device. - Conflict resolution is last-write-wins on `last_modified`; ties broken by the Worker's monotonic `seq` cursor. ### Prerequisites - Swift 6.2+ (built in Swift 6 language mode) - A Cloudflare account (free tier works) for the D1 Worker - `openssl` for generating the encryption key and API token ### Step 1 — Deploy the Worker (once, ~2 min) The Worker is bundled in the `apple-sync-kit` repo. It is entity-agnostic: it serves any table you list in `ENTITIES` and create a migration for. ```sh git clone https://github.com/FradSer/apple-sync-kit.git cd apple-sync-kit/worker pnpm install wrangler login wrangler d1 create apple-sync # copy the printed database_id cp wrangler.toml.example wrangler.toml # paste database_id into it wrangler secret put API_TOKEN # paste: openssl rand -base64 32 pnpm run db:migrate:remote # applies migrations/all to your D1 pnpm run deploy # → https://.workers.dev ``` The bundled migrations create `notes`, `note_folders`, `reminders`, `calendar_events`, `reminder_lists`. For your own entity, add a migration (e.g. `worker/migrations/all/0005_myrecords.sql`) using the generic schema, and add the table name to `ENTITIES` in `wrangler.toml`: ```sql CREATE TABLE IF NOT EXISTS myrecords ( id TEXT PRIMARY KEY, data TEXT NOT NULL, last_modified TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')), source_device TEXT, seq INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_myrecords_seq ON myrecords (seq, id); ``` Re-run `pnpm run db:migrate:remote && pnpm run deploy`. Endpoints: `POST /api/v1/:entity/push` (≤500 items), `GET /api/v1/:entity/pull` (cursor, excludes own writes), `DELETE /api/v1/:entity/:id` (soft delete), `GET /health`. ### Step 2 — Add the package See the Install section above: add the `.package(...)` line and `.product(name: "AppleSyncKit", package: "apple-sync-kit")` to your target. ### Step 3 — Configure each device (env vars) Pick a per-app prefix (here `MYAPP`). The kit reads config env-first, falling back to `~/.config/myapp-sync/config.json`. Set on every device: ```sh export MYAPP_SYNC_API_URL=https://.workers.dev # must be HTTPS export MYAPP_SYNC_API_TOKEN= export MYAPP_SYNC_DEVICE_ID=$(hostname) # or any stable id export MYAPP_ENCRYPTION_KEY=$(openssl rand -base64 32) # 32-byte AES key ``` The encryption key must be the **same on every device** that shares data (it is the E2E key). The kit does not name this var for you — pass the name to `EncryptionService.keyFromEnvironment("MYAPP_ENCRYPTION_KEY")`. ### Step 4 — The code (copy-and-adapt) A complete, runnable sync run for one entity. Pushes local records (encrypted) and pulls records other devices wrote (decrypted). ```swift import AppleSyncKit import Foundation // 1. Your data model — Codable & Sendable is all the kit requires. struct Record: Codable, Sendable, Identifiable { let id: String var title: String var body: String } // 2. The encrypted envelope stored as the Worker's `data` column. struct EncryptedRecord: Codable, Sendable { let id: String let ciphertext: String let iv: String } // 3. One sync run for one entity. func syncMyRecords(local: [Record], entity: String = "myrecords") async throws { let store = ConfigStore(namespace: "myapp-sync", prefix: "MYAPP") let config = try store.loadConfig() // reads MYAPP_SYNC_* env let key = try EncryptionService.keyFromEnvironment("MYAPP_ENCRYPTION_KEY") let enc = EncryptionService(key: key) try await D1SyncClient.withClient(config: config) { client in // PUSH — encrypt, then send ciphertext + the timestamp used as AAD. // Use syncISO8601 (fractional seconds, UTC): the Worker normalizes // last_modified via Date(ts).toISOString(); a non-fractional string would be // rewritten to "...:28.000Z" and break the AAD on decrypt. let now = ISO8601DateFormatter.syncISO8601.string(from: Date()) var lastModified: [String: String] = [:] var envelopes: [EncryptedRecord] = [] for rec in local { let (ct, iv) = try await enc.encrypt(rec, recordId: rec.id, modifiedDate: now) lastModified[rec.id] = now envelopes.append(EncryptedRecord(id: rec.id, ciphertext: ct, iv: iv)) } if !envelopes.isEmpty { _ = try await client.push( entity: entity, items: envelopes, id: { $0.id }, lastModifiedByRemoteId: lastModified) // ≤500 per batch } // PULL — incremental cursor; excludes this device's own writes. var cursor: String? = nil var hasMore = true while hasMore { let resp = try await client.pull( entity: entity, cursor: cursor, excludeOwnWrites: true) for item in resp.items where !item.deleted { // Decrypt with the SAME recordId + modifiedDate used to encrypt; // item.lastModified is the server-stored (normalized) timestamp. let rec = try await enc.decrypt( Record.self, item.data.ciphertext, iv: item.data.iv, recordId: item.id, modifiedDate: item.lastModified) print("pulled from another device:", rec.id, rec.title) } cursor = resp.cursor hasMore = resp.hasMore } } } ``` Call `syncMyRecords(local: [...])` on each device. Device A's writes appear in device B's pull; neither ever sees the other's plaintext on the wire or in D1. ### Step 5 — Deletes Soft-delete through the kit so other devices see the tombstone: ```swift try await D1SyncClient.withClient(config: config) { client in try await client.delete( entity: "myrecords", id: id, lastModified: ISO8601DateFormatter.syncISO8601.string(from: Date())) } ``` Tombstones are purged after 30 days by the Worker's daily cron. ### Going further: full bidirectional sync with a local store The snippet above is the "direct cloud copy" layer (`D1SyncClient`). For a real app with a local store (SQLite / EventKit / AppleScript) use `SyncEngine` — a `public enum` of static functions that wraps `D1SyncClient` with last-write-wins, cursor state persistence, deletion detection, and conflict skipping. It is closure-driven: you supply `push`, `pull`, `applyUpsert`, `applyDelete`, `getId` closures bridging the engine to your local store. Reference implementations: `note` and `event` (below) — read their `Sources//Services/SyncService.swift` to see the wiring end-to-end. ### Security properties - AES-GCM-256, per-record. AAD is `"|"` — tampering with id or timestamp on the Worker breaks decryption. - The Worker never sees the key (local env only) and never sees plaintext (only `ciphertext` + `iv` blobs). - `API_TOKEN` is a bearer secret on the Worker; the encryption key is a separate local secret. Compromising the Worker does not decrypt past data. - `SyncConfig.apiURL` must be HTTPS (the kit rejects plain http). ## Documentation - [AppleSyncKit repository](https://github.com/FradSer/apple-sync-kit): Source, README, and usage for the Swift kit. - [Getting started](https://github.com/FradSer/apple-sync-kit#readme): Define your types, encrypt on device, and deploy the D1 Worker. ## Projects built with the kit - [note](https://github.com/FradSer/note): A CLI that syncs Apple Notes across machines — bodies encrypted, folders mirrored, built on the kit's snapshot push strategy. - [event](https://github.com/FradSer/event): Reminders and calendar events synced end-to-end encrypted over the same shared Worker, with macOS EventKit and a Linux SQLite mirror. ## Optional - [Project homepage](https://applesynckit.frad.me/): The AppleSyncKit landing page.