Magic Apps

Documentation

macmagic.app

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:

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.

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.