Magical calendar sync
Magical reads every signed-in account's schedule without OAuth. CalendarPoller posts to Google
Calendar's internal range endpoint with the partition's own session cookies, one loop per account
partition. The payload and its decoder are in calendar-wire-format.md,
and notifications.md consumes what this produces.
Why a session cookie poller
Magical already renders Google Calendar in a WebContentsView, so the user is signed in and this
transport costs no incremental authentication. Every other transport asks for a second sign-in or
fails its own way: a background WebContentsView costs 250MB–350MB of RAM and 800ms–1.5s per
navigation, the Calendar API v3 needs OAuth that many Workspace administrators block
(../shared/sessions-and-security.md's Why neither app holds an OAuth client),
and a private .ics feed is cached for 6 to 24 hours at Google's edge, too stale for an alert five
minutes before a meeting.
EventKit is kept as an opt-in secondary source, because it reaches a calendar's default notifications and the iCloud, Exchange and local calendars, none of which the range payloads carry. On the data an alert needs the two are close, since participation status and conference links are both decodable from the wire format.
One request covers the whole 365-day window in roughly 50KB under 150ms. Its failure mode is narrow
and loud: Google serialises Protocol Buffers with numeric tags that are stable within a message, so
pinned field indices hold, and the realistic breakages are a restructured response envelope or a
newly required request parameter, each surfacing as a 4xx, a thrown decode, or a range that suddenly
has no events. Plausible but wrong data, the one failure an alert engine cannot tolerate, comes only
from the decoder guessing, which
calendar-wire-format.md's Pinned indices
forbids.
Poller
CalendarPoller mirrors Magimail's atom-poller.ts: it polls Google Calendar's internal web
endpoint from the Electron main process, one loop per account partition.
flowchart TD
Partition["per-account partition (persist:magical_<id>)"] --> Fetch["session.fetch (Chromium network stack)"]
Fetch --> Endpoint["POST /calendar/u/<index>/sync.prefetcheventrange"]
Endpoint --> Decoder["calendar-decoder.ts"]
Decoder --> Cache["CalendarService cache"]
Cache --> Surfaces["menu bar and Dock"]
CalendarPoller keeps no events: it returns what it read and forgets it. They live in
CalendarService.cachedEvents, keyed by account. The poller only reads; it never writes to Google.
Zero-OAuth session mechanism
The protocol below was read from a live signed-in session (fixtures in
apps/magical/tests/fixtures/calendar-web-prefetcheventrange.json).
-
Endpoint:
POST https://calendar.google.com/calendar/u/<accountIndex>/sync.prefetcheventrange -
Authentication: the partition's standard Google cookies (
SSID,HSID,SID,SAPISID), with noAuthorization: SAPISIDHASHheader, no Google Cloud project and no consent screen.session.net.fetchlets Chromium attach the cookies from its own store, so nothing in the poller reads, copies or logs them. -
Headers:
Content-Type: application/x-www-form-urlencoded;charset=UTF-8 X-Is-Xhr-Request: 1 X-If-No-Redirect: 1 -
Request body (measured), sent as
application/x-www-form-urlencoded, withf.reqholding the JSON and three further parameters in the query string:f.req=[[ ["<calendarId>", "<calendarId>", …], // explicit; not implied by the account [null, null, 20352, 20386], // range, as two scalar epoch days [null, 3, "calendar.web_20260909.10_p0", …], // client build label, dated [null, 1, 1, 1, 1, null, 0, 0] ]] &cwuik=10&hl=en_GB&secid=<session token>- Calendars are named explicitly. The account's own calendar, secondary calendars, subscribed holiday calendars and imported calendars are each passed by id. A request naming no calendar returns nothing, so the poller first enumerates the account's calendar list.
- The range is two scalars at positions 2 and 3, not a nested
[start, end]pair. Epoch days, asMath.floor(Date.UTC(y, m, d) / 86400000). secidis a per-session token carried in the query string, stable across calls within a session and shared by both range endpoints.- The client build label is dated (
calendar.web_20260909.10_p0) and rotates with Google's releases.
[!NOTE] Only
f.reqis enforced. Replaying a captured request with each part removed (droppingsecid, sending a bogus one, nulling the build label, sending a two-year-stale label, droppingcwuikandhl, and all of these together) each returned the same full payload with200. Session cookies plusf.reqare sufficient, so the poller needs no page-scrape step and can fetch for an account whose view is suspended. Send the label anyway, from a constant, since a newly required request parameter is one realistic breakage; the CI canary notices when the snapshot stops holding. -
Session expiry: unlike page navigation, which redirects to
accounts.google.com, this XHR endpoint returns HTTP 401 with body:)]}' [["er",null,null,null,null,401,null,null,null,16]]CalendarPollerreads that asCalendarSessionExpiredError, which separates "signed out or session expired" from "signed in but no events scheduled".
Source contract
Nothing downstream imports the poller. The alert scheduler, the banner dispatcher and the deep-link
resolver depend only on the interface below, and CalendarPoller is one implementation of it:
export type SourceHealth = 'ok' | 'stale' | 'signed-out' | 'offline' | 'unreadable';
export interface CalendarEventSource {
/** Stable across restarts; scopes the cache and the already-notified set. */
readonly id: string; // 'google:account_personal'
readonly kind: 'google-session';
/** The account this source speaks for, for banner attribution. */
readonly accountId: string;
events(range: { start: Date; end: Date }): Promise<NormalizedCalendarEvent[]>;
/** Push where the source supports it; poll-driven here. */
onChanged(listener: () => void): () => void;
readonly lastSuccessfulReadAt: Date | null;
readonly health: SourceHealth;
}
The interface keeps the transport a swap rather than a rewrite.
health is never inferred from an empty result. "Signed in with nothing scheduled" and "cannot read
your calendar" look identical in the event list and must not look identical here; a poller with no
known calendar ids reports unreadable rather than ok.
offline and unreadable are separate states because they call for opposite messages. A thrown
fetch means the request never reached Google, so the calendar is untouched and the last good read
still describes it; the poller raises CalendarNetworkError and reports offline. Google answering
with something the decoder cannot use means the app is broken rather than the network, and reports
unreadable. Collapsing the two would tell a user on a train that Magical could not read their
calendar, and tell a user whose wire format has moved to check their wifi.
classifyFailure maps a thrown read to one of the three failure states. Anything it does not
recognise is unreadable, the safer error of the two: an outage wrongly called a broken decoder
still tells the user something is wrong, where a broken decoder wrongly called an outage sends them
after a fault no reconnection will fix.
Polling window and triggers
Window and paging
- Window:
CalendarServicepolls[today - 90 days, today + 365 days], computed bywindow()from the calendar day on every poll, which keeps it current for a menu bar app left running for weeks. There is no per-month cache and no prefetching; each poll fetches the whole window for every account, and the window never widens. - Paging: paging the agenda or the mini-month past either edge calls
ensureRangewith 60 days either side of the date paged to, and only the part outside the window is fetched; the poll keeps reading the baseline. Paging to March is a request to see March, not a standing order to re-read it every five minutes, so a month already visited is fetched again on return because nothing records which dates have been read. Rapid paging buffers the latest requested range in a trailing slot rather than queueing every intermediate month or dropping requests: once the in-flight read completes, it fetches the destination month directly. A range wider than the window is read as one piece beyond each edge, rather than as one span that re-reads the whole baseline in between. Clamping makes paging cheap: a back-tap two months out reads only the 30 days befored-90, about 1.3 MiB rather than the 5.3 MiB a full 121 days would cost. - Read authority: a read is authoritative over the range it covered and over nothing else.
Events overlapping that range are replaced wholesale by what came back, so a cancelled meeting
disappears; events outside it are kept, which is what lets a paged-to month survive the poll that
follows. The cache holds what has been fetched rather than what the window covers, and quitting
bounds it. Measured across
d-90..d+365on two live accounts, a poll is about 5 MiB and takes about 1.25 s; the 90 days of history cost 3.92 MiB, about 44 KiB a day, and the 275 days of future beyond it cost 2.59 MiB.
Triggers
- Background keep-alive: every 5 minutes on mains, 15 on battery. A calendar changes far less often than a mailbox, so both are longer than Magimail's equivalents.
- Window focus: re-polls when the user returns to the window, rate-limited to once a minute so switching between apps is not one request per switch.
- System wake: re-fetches immediately on
powerMonitor.on('resume'), and polling stops while suspended. - Backoff: a round in which every account failed doubles the interval, capped at an hour. One account succeeding resets it, so a signed-out second account cannot slow down the one that works.
- Timezone shift: the UTC offset is compared on each poll, and every armed alert is re-armed when it moves, because timers are delays from now and would otherwise point at the wrong wall-clock moment across a zone or DST boundary. Electron has no timezone-change event, and per-poll is frequent enough given the alert horizon.
Suspending an inactive account
Magical mirrors Magimail's AccountsManager suspension (inactiveAccountSuspendMinutes):
- Renderer release: when an account's
WebContentsViewhas been hidden forinactiveAccountSuspendMinutes(default 5 minutes, orSUSPEND_NEVER), Magical closes the view withwebContents.close()to reclaim its renderer. That is the only mechanism that releases all faulted-in Chromium renderer RAM back to macOS (../shared/performance-optimisation.md's What actually reclaims renderer memory). Measured on a three-account install, keeping every view live costs about 1.4GB of RAM; suspending the two inactive renderers brings that to about 790MB. - Polling continues: the session cookies and partition stay intact, and
CalendarPollerkeeps reading throughsession.fromPartition(partition).fetch, so the menu bar and Dock stay current while the view is unloaded. - State on resume: before closing,
webContents.getURL()is recorded insuspendedUrlswithout its query. Google Calendar updates the URL through the History API as the user changes dates or view modes, so on wakeloadURL()returns to that exact URL and restores the user's timeframe.