# Architecture `@mac-sync` is three processes on three machines, connected by HTTPS and one Postgres database. This document describes the components and the invariants that hold across modules. ## System overview ```mermaid flowchart LR subgraph Mac["Mac (plum)"] app["MacSyncApp
menu-bar executable"] base["BaseSyncManager<Stats, SyncError>"] sqc["SendQueueClient<Transport>"] lws["LocalWebServer
localhost:8765"] app -->|owns 6 of| base app -->|owns 4 of| sqc app -->|owns 1| lws end subgraph Server["Server (DO backend droplet, 209.38.51.98:3201)"] hono["Hono + Bun"] pg[("PostgreSQL
icloud schema")] hono --> pg end subgraph Browser["Browser"] spa["React SPA (web/)"] end app <-->|"HTTPS /client/*
device-token auth"| hono spa <-->|"HTTPS /my/*
SSO auth"| hono spa -.->|"settings on localhost:8765"| lws ``` Sources: - Mac client entrypoint: `src/client/` (executable target `Package.swift:97-110`) - Server entrypoint: `src/server/src/app/server.ts:31-86` - Web SPA root: `web/src/App.tsx:49-71` - LocalWebServer routes: `@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift:42-110` - Deployment topology: `app.manifest.yaml:14-58` (plum = Mac client, DO backend droplet = server; homelan `black` is dead — uvlava rebuild) ## Module taxonomy Every module ships four (or three) Swift files in `@packages//Sources/Sync/`: - `Reader.swift` — talks to the local OS (chat.db, EventKit, Mail.app, Notes.app, PhotoKit) - `SyncManager.swift` — subclasses `BaseSyncManager`, drives the cycle - `APIClient.swift` — typed methods over `BaseAPIClient` (HTTPS to the server) - `Sender.swift` — only on bidirectional modules; applies pending items to the OS ```mermaid flowchart TD base["BaseSyncManager<Stats, SyncError>"] base --> im["IMessageSync
30s read, 30s send"] base --> ip["IPhotoSync
300s read, no send"] base --> imail["IMailSync
300s read, no send (*)"] base --> ical["ICalSync
300s read, 60s send"] base --> irem["IReminderSync
300s read, 60s send"] base --> inote["INoteSync
600s read, 60s send"] base --> icall["ICallsSync
120s read, read-only"] ``` Interval citations (each is the literal numeric arg to `super.init(...)` / `SendQueueClient(...)`): | Module | Read tick | Outbound tick | |------------|--------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------| | iMessage | `@packages/imessage/Sources/IMessageSync/SyncManager.swift:93` | `@packages/imessage/Sources/IMessageSync/SyncManager.swift:74` | | iPhoto | `@packages/iphoto/Sources/IPhotoSync/SyncManager.swift:99` | n/a | | iMail | `@packages/imail/Sources/IMailSync/SyncManager.swift:61` | n/a | | iCal | `@packages/ical/Sources/ICalSync/SyncManager.swift:80` | `@packages/ical/Sources/ICalSync/SyncManager.swift:61` | | iReminders | `@packages/ireminders/Sources/IReminderSync/SyncManager.swift:67` | `@packages/ireminders/Sources/IReminderSync/SyncManager.swift:57` | | iNotes | `@packages/inotes/Sources/INoteSync/SyncManager.swift:79` | `@packages/inotes/Sources/INoteSync/SyncManager.swift:60` | | iCalls | `@packages/icalls/Sources/ICallsSync/SyncManager.swift` (120s) | n/a (read-only) | (*) iMail bidirectional path is not wired; see [known limitations](./known-limitations.md). ## Shared abstractions Three files in `@packages/shared/Sources/MacSyncShared/Sync/` are the single source of truth for cross-module lifecycle: ### `BaseSyncManager` `open class BaseSyncManager: ObservableObject` (`@packages/shared/Sources/MacSyncShared/Sync/BaseSyncManager.swift:28-186`). Owns: - `@Published isSyncing, lastSyncCompletedAt, currentOperation, syncError, stats` (lines 32-36) - `private(set) var lastSync: Date?` watermark, persisted under `".lastSync"` in `UserDefaults` (lines 42, 179-185) - `private var syncTimer: Timer?` with `final startSync() / stopSync() / syncNow()` (lines 82-110) - An authorization gate: `gatedRunCycle` calls overridable `isAuthorized()` / `requestAuthorization()` / `onAuthorizationDenied()` (lines 112-121, 157-165) - Two extension points for Senders: `didStartSync()` / `willStopSync()` (lines 170-175) — `SendQueueClient`s start and stop here All lifecycle methods are `final`. Subclasses override only `performSync()` plus the authorization and start/stop hooks. The contract therefore cannot drift between modules. ### `SendQueueClient` `@MainActor public final class SendQueueClient` (`@packages/shared/Sources/MacSyncShared/Sync/SendQueueClient.swift:45-126`). A generic poll/apply/ack loop: 1. Every `interval` seconds, `drainOnce()` calls `transport.fetchPending()`. 2. For each item, the `apply: @MainActor @Sendable (Item) -> SendQueueApplyResult` closure runs the OS-side write (EventKit save, AppleScript, AppleScript+iMessage). 3. The result is reported via `transport.reportResult(id:status:error:)`. `Transport` is the only protocol; module senders adapt their typed `APIClient` calls (e.g. `getPendingCalendarSends()`, `reportCalendarSendResult(...)`) into the three protocol methods. The polling loop is therefore written exactly once. ### `SyncConnectionErrorHeuristic` A lowercase substring match over `error.localizedDescription` (`@packages/shared/Sources/MacSyncShared/Sync/SyncConnectionError.swift:11-20`): `"network" | "connection" | "timeout" | "unreachable" | "could not connect" | "offline"`. Every module's `SyncError` was duplicating this; now there is one helper. ### `AppleScriptEscape` `@packages/shared/Sources/MacSyncShared/Util/AppleScriptEscape.swift:13-19`: escapes `\`, `"`, `\n`, `\r` for interpolation into AppleScript string literals. Used by iMessage, iMail, and iNotes Senders. ## Send-queue contract Per-module Postgres tables with identical shape, built by the `sendQueueTableSql(tableName)` helper (`src/server/src/shared/sendQueue/SendQueueRepo.ts:146-168`): ``` id UUID PK DEFAULT gen_random_uuid() device_id UUID NOT NULL REFERENCES icloud.devices(id) ON DELETE CASCADE action TEXT NOT NULL payload JSONB NOT NULL status TEXT NOT NULL DEFAULT 'queued' -- 'queued' | 'sent' | 'failed' sent_at TIMESTAMPTZ failure_reason TEXT created_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ``` `createSendQueueRepo({ tableName, payloadSchema, allowedActions })` (`src/server/src/shared/sendQueue/SendQueueRepo.ts:56-139`) returns a typed `{ enqueue, listPending, markResult, count }` repo. The factory validates the `tableName` against `^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$` (line 54) because table names are concatenated into SQL. | Module | Table | Allowed actions | Admin enqueue route | Client pending route | Client ack route | `/my/*` write routes | |------------|--------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|--------------------------------------------|---------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | iMessage | `icloud.send_queue` (legacy) | bespoke (`entities/send-queue/schema.ts:5-23`) | `POST /admin/send-queue/enqueue` | `GET /client/imessage/send-queue/pending` | `POST /client/imessage/send-queue/:id/result` | none (sends initiated via `/admin/send-queue/enqueue`) | | iCal | `icloud.calendar_send_queue` | `create_event`, `update_event`, `delete_event` (`entities/calendarSendItem/types.ts:24`) | `POST /admin/calendar-send-queue/enqueue` | `GET /client/ical/send-queue/pending` | `POST /client/ical/send-queue/:id/result` | `POST /my/calendar/events`, `PUT /my/calendar/events/:id`, `DELETE /my/calendar/events/:id` (`surfaces/my/calendar.ts:80,90,102`) | | iReminders | `icloud.reminder_send_queue` | `create_reminder`, `update_reminder`, `delete_reminder` (`entities/reminderSendItem/types.ts:16-18`) | `POST /admin/reminder-send-queue/enqueue` | `GET /client/ireminders/send-queue/pending` | `POST /client/ireminders/send-queue/:id/result` | `POST /my/reminders/`, `PUT /my/reminders/:id`, `DELETE /my/reminders/:id` (`surfaces/my/reminders.ts:73,87,103`) | | iNotes | `icloud.note_send_queue` | `create_note`, `update_note`, `delete_note` (`entities/noteSendItem/types.ts:12`) | `POST /admin/note-send-queue/enqueue` | `GET /client/inotes/send-queue/pending` | `POST /client/inotes/send-queue/:id/result` | `POST /my/notes/`, `PUT /my/notes/:id`, `DELETE /my/notes/:id` (`surfaces/my/notes.ts:66,76,88`) | Routes verified against `src/server/src/surfaces/{client,my,admin}/*.ts` and wired up in `src/server/src/surfaces/{client,my,admin}/index.ts:11-19`. ## Cross-cutting invariants 1. **One sync manager per module.** Every module is a `final class` extending `BaseSyncManager`. Module-specific `@Published` state (e.g. `contactSyncInfo` on iMessage, `currentOperation` text on iCal) lives on the subclass. 2. **One send-queue contract.** All non-legacy modules go through `createSendQueueRepo`. The iMessage `icloud.send_queue` table predates the factory and stays on its own bespoke schema; the Mac client still polls it via the same generic `SendQueueClient` (`@packages/imessage/Sources/IMessageSync/SyncManager.swift:70-86`). 3. **Single AppleScript escape helper.** `AppleScriptEscape.quote(_)` is the one place strings get interpolated into AppleScript source. 4. **Watermarks are `lastSync` dates per module**, keyed by `persistenceKey` in `UserDefaults`. The base updates them at cycle end; modules read them at cycle start to scope incremental fetches. 5. **Authorization is opt-in.** `BaseSyncManager.requestAuthorization()` defaults to `true`. Modules that need permission (EventKit for iCal / iReminders, Automation for iMail / iNotes, Photos for iPhoto) override it. 6. **One blob-storage port (server).** Binary blobs go through the `ObjectStore` interface (`src/server/src/features/iphoto/storage/`), never a vendor path. The `S3Adapter` (`Bun.S3Client`) is **always SigV4-signed** and works against any S3-compatible store; the `LocalAdapter` is the dev on-disk default. Out-of-band consumers get short-lived **presigned** URLs, never bare object URLs. Selection is config (`STORAGE_BACKEND`); deployment topology is injected, never compiled in. See [iPhoto › Server-side blob storage](modules/iphoto.md#server-side-blob-storage). ## Database schema (server) Migrations are applied in order at boot (`src/server/src/app/server.ts:36-52`). Tables live in the `icloud` Postgres schema. - `devices`, `contacts`, `conversations`, `messages` — iMessage - `albums`, `photos` — iPhoto - `calendars`, `events` — iCal - `reminders` — iReminders - `notes` — iNotes - `calls` — iCalls (read-only) - `send_queue` (legacy iMessage), `calendar_send_queue`, `reminder_send_queue`, `note_send_queue` — outbound queues - `prospects` — unrelated prospector feature LocalWebServer (on the Mac, port 8765) also exposes `/api/calls` (direct recent calls) and includes `callHistoryDb` in `/api/diagnostics` for agent/tool use and the Dashboard "Native Subsystems" card. Mail is read-only via Mail.app's own storage (no first-class `mail` entity at the time of writing); ingestion goes through `features/imail/` (`surfaces/client/imail.ts:35-41`).