190 lines
7.9 KiB
Markdown
190 lines
7.9 KiB
Markdown
# Data flow
|
|
|
|
Three diagrams trace bytes through the system: inbound (Mac to server),
|
|
outbound (web to Mac via send queue), and bootstrap (cold start).
|
|
|
|
## Inbound: Mac to server
|
|
|
|
How a new iMessage, photo, calendar event etc. reaches the database. iCal is
|
|
shown; every other module follows the same shape.
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
autonumber
|
|
participant Timer as Timer (interval s)
|
|
participant SM as ICalSync.SyncManager
|
|
participant Reader as CalendarReader
|
|
participant API as ICalSync.APIClient
|
|
participant Hono as Hono /client/ical/*
|
|
participant PG as PostgreSQL icloud
|
|
|
|
Timer->>SM: fire (BaseSyncManager.syncNow)
|
|
SM->>SM: gatedRunCycle -> isAuthorized?
|
|
SM->>Reader: fetchCalendars(), fetchEvents(since: lastSync)
|
|
Reader-->>SM: [SyncCalendarPayload], [SyncEventPayload]
|
|
SM->>API: syncCalendars(payloads)
|
|
API->>Hono: POST /client/ical/calendars (device token)
|
|
Hono->>PG: INSERT ... ON CONFLICT UPDATE
|
|
PG-->>Hono: row count
|
|
Hono-->>API: { synced: N }
|
|
SM->>API: syncEvents(payloads)
|
|
API->>Hono: POST /client/ical/events
|
|
Hono->>PG: upsert + tsvector update via trigger
|
|
Hono-->>API: { synced: N }
|
|
SM->>SM: setLastSync(now), lastSyncCompletedAt = now
|
|
```
|
|
|
|
Sources:
|
|
|
|
- `BaseSyncManager.runCycle` updates the watermark at the end of every cycle:
|
|
`@packages/shared/Sources/MacSyncShared/Sync/BaseSyncManager.swift:123-131`.
|
|
- Calendar reader is invoked from
|
|
`@packages/ical/Sources/ICalSync/SyncManager.swift:116-` (the
|
|
`performSync()` override; see Module Reader for the Reader contract).
|
|
- API methods: `@packages/ical/Sources/ICalSync/APIClient.swift:106-130`.
|
|
- Server route registration:
|
|
`src/server/src/surfaces/client/ical.ts:59-69`.
|
|
|
|
Other modules' inbound endpoints:
|
|
|
|
- `POST /client/imessage/sync` (`surfaces/client/imessage.ts:71`)
|
|
- `POST /client/imessage/contacts` (`surfaces/client/imessage.ts:77`)
|
|
- `POST /client/iphoto/sync` (`surfaces/client/iphoto.ts:63`)
|
|
- `POST /client/iphoto/albums` (`surfaces/client/iphoto.ts:69`)
|
|
- `POST /client/iphoto/upload/:localIdentifier` (`surfaces/client/iphoto.ts:75`) — binary upload
|
|
- `POST /client/imail/sync` (`surfaces/client/imail.ts:40`)
|
|
- `POST /client/ireminders/sync` (`surfaces/client/ireminders.ts:32`)
|
|
- `POST /client/inotes/sync` (`surfaces/client/inotes.ts:29`)
|
|
|
|
## Outbound: web to Mac via SendQueueClient
|
|
|
|
How a user-initiated write in the React SPA reaches Calendar.app /
|
|
Reminders.app / Notes.app. iCal shown; reminders and notes are structurally
|
|
identical.
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
autonumber
|
|
participant SPA as React SPA
|
|
participant My as Hono /my/calendar/*
|
|
participant Send as features/ical/sendService
|
|
participant Admin as Hono /admin/calendar-send-queue
|
|
participant Repo as createSendQueueRepo<br/>(icloud.calendar_send_queue)
|
|
participant PG as PostgreSQL
|
|
participant Client as Hono /client/ical/send-queue
|
|
participant SQC as Mac SendQueueClient
|
|
participant Sender as CalendarSender (EventKit)
|
|
participant EK as EKEventStore
|
|
|
|
SPA->>My: POST /my/calendar/events { ... }
|
|
My->>Send: enqueueEvent(action, payload, deviceId)
|
|
Send->>Repo: enqueue(...)
|
|
Repo->>PG: INSERT INTO icloud.calendar_send_queue (status='queued')
|
|
PG-->>Repo: { id }
|
|
Repo-->>SPA: 200 { id }
|
|
|
|
Note over SQC: Timer fires every 60s
|
|
SQC->>Client: GET /client/ical/send-queue/pending (device token)
|
|
Client->>Repo: listPending(deviceId)
|
|
Repo->>PG: SELECT ... WHERE status='queued' ORDER BY created_at
|
|
Repo-->>Client: rows
|
|
Client-->>SQC: [PendingItem]
|
|
loop each item
|
|
SQC->>Sender: apply(item)
|
|
Sender->>EK: save(_:span:) / remove(_:span:)
|
|
EK-->>Sender: result
|
|
Sender-->>SQC: .sent | .failed(reason)
|
|
SQC->>Client: POST /client/ical/send-queue/:id/result
|
|
Client->>Repo: markResult({ id, status, error? })
|
|
Repo->>PG: UPDATE status='sent'|'failed', sent_at=now()
|
|
end
|
|
```
|
|
|
|
Sources:
|
|
|
|
- `/my/calendar/events` write routes: `src/server/src/surfaces/my/calendar.ts:80, 90, 102`.
|
|
- `sendService.enqueueEvent` and the repo wiring:
|
|
`src/server/src/features/ical/sendService.ts:16-19` (factory call).
|
|
- `SendQueueRepo` SQL: `src/server/src/shared/sendQueue/SendQueueRepo.ts:69-127`.
|
|
- Mac-side polling: `SendQueueClient.drainOnce()`
|
|
(`@packages/shared/Sources/MacSyncShared/Sync/SendQueueClient.swift:93-125`).
|
|
- Calendar transport adapter: `@packages/ical/Sources/ICalSync/SyncManager.swift:56-65`.
|
|
- Sender: `@packages/ical/Sources/ICalSync/Sender.swift` (`CalendarSender.apply`).
|
|
|
|
The iMessage outbound flow is the same loop but talks to the legacy
|
|
`icloud.send_queue` table via `IMessageSendTransport`
|
|
(`@packages/imessage/Sources/IMessageSync/SendQueueAdapter.swift`) and applies
|
|
via AppleScript instead of EventKit
|
|
(`@packages/imessage/Sources/IMessageSync/Sender.swift`).
|
|
|
|
## Bootstrap: cold start
|
|
|
|
What happens when `MacSyncApp` launches for the first time on a new Mac.
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
autonumber
|
|
participant App as MacSyncApp
|
|
participant Cfg as ConfigFile (~/.config/com.lilith.mac-sync/config.json)
|
|
participant Dev as DeviceRegistration<br/>(MacSyncShared)
|
|
participant Hono as Hono /client/devices/*
|
|
participant PG as icloud.devices
|
|
participant Mgrs as SyncManager.shared (x6)
|
|
participant LWS as LocalWebServer :8765
|
|
participant Browser as Browser
|
|
|
|
App->>Cfg: load() — read serverURL, deviceName, etc.
|
|
Cfg-->>App: config
|
|
App->>Dev: register(deviceName, model, osVersion)
|
|
Dev->>Hono: POST /client/devices/register (no auth)
|
|
Hono->>PG: INSERT device, return token
|
|
Hono-->>Dev: { deviceId, token }
|
|
Dev->>Dev: persist token to Keychain
|
|
loop poll until ready
|
|
App->>Dev: status check
|
|
Dev->>Hono: GET /client/devices/:id/status (no auth)
|
|
Hono-->>Dev: { ready }
|
|
end
|
|
App->>Mgrs: startSync() on each of the six
|
|
Mgrs->>Mgrs: gatedRunCycle -> isAuthorized? -> performSync
|
|
Mgrs->>Mgrs: setLastSync(now); persist to UserDefaults
|
|
Mgrs->>Mgrs: didStartSync -> sendQueueClient.start() (iMessage/iCal/iRem/iNotes)
|
|
App->>LWS: start() on port 8765
|
|
Browser->>LWS: GET / (SPA)
|
|
Browser->>LWS: GET /api/settings
|
|
LWS-->>Browser: { serverURL, deviceName, ... }
|
|
```
|
|
|
|
Sources:
|
|
|
|
- `LocalWebServer` route registration:
|
|
`@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift:42-110`.
|
|
Settings endpoints live on lines 43-57; static SPA fallback on 94-109.
|
|
- Device registration is exempt from the device-token middleware:
|
|
`src/server/src/app/server.ts:69-75`.
|
|
- The base manager loads watermarks in `init` from `UserDefaults`:
|
|
`@packages/shared/Sources/MacSyncShared/Sync/BaseSyncManager.swift:67-69`.
|
|
- `didStartSync()` is the hook each bidirectional manager overrides to start
|
|
its `SendQueueClient`. See iMessage
|
|
(`@packages/imessage/Sources/IMessageSync/SyncManager.swift:170-172`), iCal
|
|
(`@packages/ical/Sources/ICalSync/SyncManager.swift:67-69`), iReminders, and
|
|
iNotes (`@packages/inotes/Sources/INoteSync/SyncManager.swift:66-72`).
|
|
- The web SPA is served from the Mac binary's `webapp/` resource directory in
|
|
production builds, or `<repo>/web/dist/` in dev
|
|
(`LocalWebServer.swift:137-153`).
|
|
|
|
## Watermark, time, and idempotency notes
|
|
|
|
- `lastSync` is the **start time of the previous cycle**, not the timestamp of
|
|
the latest record. Modules apply tolerance (iMail uses ±1 min, see
|
|
`@packages/imail/Sources/IMailSync/SyncManager.swift:42-43`) to catch late
|
|
arrivals.
|
|
- Inbound upserts use module-specific external IDs (iMessage `guid`, iMail
|
|
`Message-ID`, iCal `eventIdentifier`, iNotes `noteIdentifier`) so retrying a
|
|
cycle is safe.
|
|
- Outbound send-queue items are idempotent on the server side
|
|
(`status='queued'` -> `'sent'|'failed'` is a one-shot transition); the Mac
|
|
client is responsible for not double-applying inside a single drain. The
|
|
`isProcessing` flag in `SendQueueClient`
|
|
(`@packages/shared/Sources/MacSyncShared/Sync/SendQueueClient.swift:54, 93-96`)
|
|
guarantees only one drain runs at a time.
|