Acting on an event, an invitation or a task
Magical acts on Google Calendar by driving Calendar's own controls in the account's live view, and it
renders no action UI of its own. Joining a call, answering an invitation, revealing an event, ticking
a task off, quick-adding an event and raising the window each start on a surface the helper draws and
end in one method in the main process. The invitation pile is rsvp.md, resolving a join
URL and opening it is conference-links.md, and a tick's journey through
Google Tasks is google-tasks.md.
Action set
| Action | What it does | Lands in |
|---|---|---|
| Join a conference link | Opens the meeting in its desktop client or the browser | CalendarService.joinCall |
| Answer an invitation | Accepts, tentatively accepts or declines | CalendarService.deliverRsvp |
| Reveal an event | Routes its account's view to the event and opens its panel | AccountsManager.revealEvent |
| Delete an event | Deletes it through Calendar's own controls | CalendarService.deleteEvent |
| Complete or reopen a task | Ticks a task off, or back open, through the button in its own panel | CalendarService.setTaskCompleted |
| Quick add | Opens the Quick Add HUD | MenuBarManager's tray.openQuickAdd |
| Open | Raises the main window, creating it if the app has none | MenuBarManager's tray.openApp |
How an action travels
Three routes reach the main process. The tray RPC carries every press made on a surface the helper
draws; a notification action and a magical:// link arrive from outside the helper.
flowchart LR Surfaces["Popover, agenda card,<br/>status item menu"] -->|"tray.joinCall, tray.rsvp, …"| Manager["MenuBarManager.handleEvent"] Banner["Notification banner"] -->|alert.action| Service["CalendarService"] Link["magical:// link"] -->|openDeepLink| Service Manager --> Service
Tray RPC
The status item, its popover and its right-click menu are all drawn in the Swift helper, so every
press they make is a newline-delimited JSON-RPC message over the helper's stdin/stdout. The same
channel carries the whole tray state the other way.
| RPC method | Direction | Payload | Description |
|---|---|---|---|
tray.create |
Main → Helper | None | Puts the status item in the menu bar. |
tray.destroy |
Main → Helper | None | Takes it out again when the user hides it. |
tray.update |
Main → Helper | { accounts, activeAccountId?, calendars, events, isCalendarCollapsed, hiddenCalendars, isOffline, lastSyncedAt?, menuBarDisplayMode, showMenuBarAccountColor, showCompletedTasks } |
The whole tray state, pushed on every poll and on any setting it draws from. |
tray.openApp |
Helper → Main | None | Raises the main window, creating it if the app has none. |
tray.search |
Helper → Main | { query, accountId? } |
Runs a search in the chosen account and raises the main window; see menu-bar.md's Search field. |
tray.showAccount |
Helper → Main | { accountId } |
Switches to an account and raises the window. |
tray.openQuickAdd |
Helper → Main | { accountId? } |
Opens the Quick Add HUD. |
tray.openSettings |
Helper → Main | None | Asks for the settings window, which comes back as settings.show; see menu-bar.md's Header. |
tray.quit |
Helper → Main | None | Quits Magical. |
tray.joinCall |
Helper → Main | { url } |
Opens a conference link in the Zoom or Teams desktop client; see conference-links.md's Opening in the browser instead. |
tray.focusEvent |
Helper → Main | { accountId, eventId } |
Reveals the event in its account's view; see Revealing an event. |
tray.rsvp |
Helper → Main | { accountId, eventId, response } |
Answers an invitation. response is yes, no or maybe. |
tray.deleteEvent |
Helper → Main | { accountId, eventId } |
Deletes the event through Calendar's own controls. |
tray.setTaskCompleted |
Helper → Main | { accountId, eventId, completed } |
Ticks a task off, or back open, through the button in its own panel; see google-tasks.md. |
tray.ensureRange |
Helper → Main | { targetDateMs } |
Fetches the part of ±60 days around a scrolled-to date the poll misses, once. |
tray.setCalendarCollapsed |
Helper → Main | { isCollapsed } |
Persists whether the mini-month grid is open. |
tray.setShowCompletedTasks |
Helper → Main | { showCompletedTasks } |
Persists whether completed tasks are visible. |
tray.setHiddenCalendars |
Helper → Main | { hiddenCalendars } |
Persists which calendars the filter has hidden. |
tray.popoverOpened |
Helper → Main | None | Re-polls if the cache is stale, so opening the popover never shows yesterday. |
tray.refresh |
Helper → Main | None | Polls straight away. |
MenuBarManager.handleEvent dispatches each method and returns false for one it does not
recognise, so a helper new enough to ask for something this build does not know cannot fail silently.
Notification banner
The helper posts every banner, and what the user presses comes back as an alert.action event.
action is one of open, join, snooze, rsvp:yes, rsvp:maybe or rsvp:no, and the event
carries the alert's key, the accountId, the eventId, the event's deepLink and, when there is
one, its conferenceUrl.
CalendarService.onAlertAction routes each one to the same method the tray RPC reaches:
joincallsjoinCallwith theconferenceUrl.snoozere-arms the alert from the fired record, throughAlertScheduler.snooze(key, 5).rsvp:yes,rsvp:maybeandrsvp:noanswer throughsendRsvp, which is keyed by the invitation.open, and anything unrecognised, callsopenDeepLinkwith the event'sdeepLink, which is a reveal.
The categories and the actions on them are in notifications.md.
magical:// links
A link reaches the same methods a banner and a popover row use, so a Focus Filter, a script or a
Shortcut can act on a named event without one. Clicking an event in the popover is the other producer:
it builds the event's link and hands it to CalendarService.openDeepLink.
event-links.ts builds two forms.
| Form | Shape |
|---|---|
| Precise | magical://event?account=<id>&eid=<eid>&at=<iso> |
| Fallback | magical://date?account=<id>&at=<iso> |
Both carry at, because an eid names an event but not where it sits, and revealing one means
routing to its day before the chip exists to click. The eid is read out of the event's webUrl
with a regex rather than through searchParams, which decodes + as a space; a silently mangled
eid opens the wrong thing instead of failing. An occurrence of a recurring task carries no webUrl,
and extractEid prefers the chipEid the grid names it by.
action names what to do with the event, and defaults to reveal. An event link also takes yes,
no, maybe and delete, the buttons a meeting banner carries; a date link takes only reveal,
because a day is somewhere to look rather than something to answer. parseEventLink refuses an action
this build does not know rather than treating it as reveal.
Answering and deleting bring openDeepLink to actOnLinkedEvent, which finds the cached event by
eid and runs it through answerRsvp or deleteEvent. An event outside the polled window, or one
whose source has not been read, has nothing cached to keep in step, so the view is driven directly
instead. A link whose eid cannot be resolved still opens Magical on the right day for the right
account, through resolveToCalendarUrl.
app.setAsDefaultProtocolClient is registered before whenReady, so a magical:// link that
launches the app is delivered rather than dropped.
Video calls
A right-click or control-click on the status item opens a native NSMenu in the helper, not the
popover. The first item is a bold Join “…” naming the next meeting when nextEvent(within: nil)
has a conference link, with its countdown and times beneath it as a disabled item. Then Open
Calendar, New Event, Refresh Calendars and Show Completed Tasks, and after a separator
Preferences… and Quit Magical. Each sends the same message the popover's own control does:
tray.joinCall, tray.openApp, tray.openQuickAdd, tray.refresh, tray.setShowCompletedTasks,
tray.openSettings and tray.quit.
The menu is handed to the NSStatusItem and shown with button.performClick(nil), rather than popped
up at a point, which is what gets the button's own highlight and the menu bar's placement.
performClick blocks until tracking ends, so clearing item.menu on the line after restores the
left-click action.
Every join goes through CalendarService.joinCall, so alwaysJoinInBrowser is honoured wherever the
press came from; conference-links.md owns the resolution and the native
scheme.
Answering an invitation
Answering drives Google's own controls in the account's view, the way Magimail's
performThreadAction drives Gmail's. CalendarService.deliverRsvp resolves the invitation to an
eid and AccountsManager.performRsvp does the driving.
Entry points
The surfaces know different things. A banner and the toolbar dropdown hold the invitation's key and
come through sendRsvp; the menu bar popover knows only an account and an event and comes through
answerRsvp, which derives the key from the cached event. The key is derived rather than looked up in
the pile, so an invitation the poller has never reported is still answered. Both entry points land in
deliverRsvp.
Outcomes
performRsvp returns one of three outcomes.
ok: the watcher resolves the key and the Dock badge drops straight away, rather than waiting for the next poll.busy: the user is mid-edit, and answering would navigate out from under them. The intent is queued and retried everyHELD_RETRY_MS(5 seconds), expiring afterHELD_EXPIRY_MS(15 minutes).failed: the event opens in front of the user rather than the response being dropped silently.
An invitation with no resolvable eid cannot be addressed at all, so it opens the event instead of
pretending the answer was sent.
Holding an action until the editor closes
Answering and deleting both refuse to run while the user is on the editor route, under the discard alert, or in a dialog holding a field they can type into, and both are then queued on one clock. Revealing is not held, because it asks to look at something now and arriving late is worse than not arriving.
An event's own detail popup does not count as mid-edit. It is a [role="dialog"] like the
quick-create overlay, and reveal opens one deliberately. What separates the two is whether the dialog
holds an editable field, measured on a live account: the detail popup for an event and for a task
hold none, quick-create holds nine. data-chips-dialog is not the discriminator it appears to be,
because Calendar puts it on quick-create too. The full editor is caught by the route instead.
Waking a sleeping account
If the target account was suspended, performRsvp wakes it by navigating its view to resumeUrl (or
the Calendar login page) and awaiting Calendar readiness, which it takes as a rendered chip or grid.
After the response is dispatched, the view is scheduled for suspension again if it was not active. If
the event chip is already rendered in the current view (hasEventChip), performRsvp skips routing
and clicks the chip in place.
Routing to the event
routeTo pushes state and waits until the DOM is still. It calls history.pushState and dispatches a
popstate event, which Calendar's router answers without a page load. Returning as soon as the state
is pushed loses clicks: Calendar keeps the outgoing view's nodes for about 270 ms, so a chip found in
that window belongs to the render being torn down, and clicking it does nothing. routeTo waits with a
MutationObserver until nothing has changed for 250 ms. A route that never renders is reported as
failed rather than clicked into.
The path is /calendar/u/<n>/r/<mode>/<y>/<m>/<d>, and the mode is reused from what the view already
shows, so a link moves the user in time rather than out of their view. pathForDate reads the mode
from the current path, or from data-viewkey when the path names none, which is how an account woken
by a link resumes (/calendar/u/<n>/r). Year and schedule are the exception and fall back to a day:
both render no data-eventid chips at all.
Measured against a live Calendar, a route redraws in bursts ending at 262 ms (week to day), 254 ms (day to day) and 488 ms (month to day), with the longest gap inside one transition at 190 ms; an idle page mutates not at all.
Driving Calendar's controls
Synthetic clicks without feedback risk racing route restoration while network requests are in flight.
Shared driving primitives live in @magimail/core through DOM_DRIVE_PREAMBLE: resolveOrdered,
waitUntil and clickAndConfirm. clickRsvpControl finds the response button, clicks it with a
trusted input event, and confirms through waitUntil: the button reflects selection
(aria-label*="selected", aria-checked="true"), live regions differ from their pre-click state, or
the detail panel closes after being open. Delete and complete routes follow the same pattern.
The chip is clicked as an element, not a point. clickEventChip scrolls the chip into view, checks
that elementFromPoint returns the chip, focuses the view, and sends mouse events through
sendInputEvent. Calendar acts on trusted input only, so button.click() is ignored. A point outside
the viewport is refused rather than clicked, because a grid that scrolls puts chips at coordinates no
click could reach. [data-eventid] is a wrapper in day view and carries role="button" itself in
week view, so the button is looked for in both positions. A task's chip is prefixed ttb_ or named
tasks_<taskId>, and a working location's is composed from _WL_ and the entry's times and ids;
chipSelector matches every form.
Both confirmation checks are compared against what was true before the click. Calendar parks the text
Loading... in its first [role="status"] and leaves it there, so asking whether a status region
holds text answers yes on an idle page; and asking whether no dialog is open answers yes wherever no
panel was open to begin with.
Serialising actions per account
withAccountActionLock queues background actions (performRsvp, deleteEvent, completeTask) per
account id, so concurrent responses or deep links on the same account run in order rather than
interleaving view attachment and route restoration. Route restoration only restores the previous path
if the view still displays the path it routed to, so a user who navigated away in the meantime keeps
their new location.
Revealing an event
A reveal puts the event in front of the user inside its account's view. tray.focusEvent builds the
event's magical:// link and hands it to CalendarService.openDeepLink, which routes to
AccountsManager.revealEvent. The account is switched and the window raised before revealing, because
a background view is not laid out at the size its own rects describe. revealEvent preserves the view
mode, routes the document with pushState and clicks the event's chip, so the detail panel opens over
the view the user already works in. /r/eventedit/<eid> is never used for a reveal.
How far it got is reported rather than reduced to success or failure. revealed means the panel
opened; routed means the view is on the event's day but the panel did not open; busy means an
editor is open. An event with no at names the event but not where it sits; the cache is asked, and
asking it is what keeps such a link working rather than dropping the user on today.
Open, by contrast, raises the main window and nothing else: tray.openApp shows and focuses the
window, creating it if the app has none. Revealing from the tray raises the window too:
tray.focusEvent hands the link over and then calls app.focus({ steal: true }) with show() and
focus(), because those two alone order windows within Magical and do not take focus from the app
that has it.
Reconciling the event cache
A triage action changes the app's own copies before the next poll arrives, and the surfaces that count or draw from them have to agree at once.
- An answer drops the invitation, not the event. On success
deliverRsvpcallsinvites.resolveandpublishPending, so the Dock badge and the dropdown reflect the answer straight away. The event cache still reportsneedsActionuntil the next read; seersvp.md. - A tick is written into the event, then corrected.
setTaskCompletedsetsisCompletedandcompletedAton the cached event, persistsevent-cache.jsonand pushes an update, without removing the event the way a delete does. The Tasks poller corrects the write on the next sync that names the task;COMPLETION_GRACE_MS(30 seconds) keeps a sync that disagrees from overruling the write before Google has recorded it. Seegoogle-tasks.md. - A delete takes the event out at once. The cached list is rewritten as the press is made, so the popover redraws without it rather than one poll later.
- A held action runs once the editor closes. When an account is mid-edit,
deliverRsvp,deleteEventandsetTaskCompletedreportbusyand the intent is queued, retried everyHELD_RETRY_MSand expiring afterHELD_EXPIRY_MS. Giving up on a held delete polls, which puts the event back, because taking it out of the popover was the press's immediate effect.