Magic Apps

Documentation

macmagic.app

Invitations awaiting a reply

InviteWatcher holds every invitation still awaiting a reply, and it is the only thing that counts them. CalendarService.publishPending() reads that set out for the Dock badge, the toolbar dropdown and the account switcher's dots, from the same array on the same call. No surface counts the event cache again: InviteWatcher drops an invitation the moment one is answered from a banner, where the cache still reports needsAction until the next read.

flowchart LR
  Poll["CalendarPoller"] --> Observe["InviteWatcher.observe()"]
  Observe --> Publish["publishPending()"]
  Publish --> Badge["updateDockBadge()<br/>every account"]
  Publish --> Menu["updateNativeToolbarInvites()<br/>active account"]
  Publish --> Dots["pushToolbarAccounts()<br/>per account"]
  Badge --> Dock["NSApp.dockTile.badgeLabel"]
  Menu --> Item["NSMenuToolbarItem"]
  Dots --> Switcher["AccountSegmentedControl"]

Outstanding invitations

An event is awaiting a reply while its myResponseStatus is RESPONSE_NEEDS_ACTION (0). Accepted, tentative and declined meetings do not count. Three further cases stay out:

inviteKey produces <sourceId>|invite|<seriesId>, and outstanding is one map across every source, holding every calendar an account subscribes to rather than the account's own alone. Reaching RESPONSE_NEEDS_ACTION means the user is a guest of the event, so the invitation owes a reply wherever its calendar lives.

The next occurrence stands for the series. observe keeps the earliest occurrence that has not yet finished, which dates every row by when the meeting next is. The poller expands a year ahead, so a weekly meeting yields about fifty-two occurrences per poll under one key, and taking whichever arrived last would date a standup next June.

observe takes the full window after every poll and returns only what arrived, which is the set the banner posts for. The first observation of a source seeds instead: its pending set is recorded silently, and only what appears afterwards counts as an arrival. That separation is notifications.md's Invite watcher.

Dock badge

publishPending() passes the count to updateDockBadge, which keeps none of its own.

export function formatRsvpBadge(count: number): string | null {
  if (count <= 0) return null;
  if (count > 99) return '99+';
  return String(count);
}

null clears the badge, so answering the last invitation leaves the tile bare. The label goes to addon.setDockBadge, which reaches dock_tile_set_badge in DockTileBridge.swift and assigns NSApp.dockTile.badgeLabel; updateDockBadge falls back to app.dock.setBadge when the native addon will not load.

The badge belongs to the app, so it counts every account. It is the number the user sees with the window closed, and it states how much is waiting for them rather than how much is waiting on whichever account happens to be open.

Toolbar dropdown

The dropdown sits in the window showing one account, so it lists that account alone; offering to answer an invitation that is not in the calendar behind the window invites a reply from the wrong identity. It is an NSMenuToolbarItem with the identifier invites, built in ToolbarBridge.swift.

The account name is not shown, because every row belongs to the account already named in the switcher.

publishPending() sends the whole list on every change, never a diff. It is a couple of dozen entries at most, and a menu rebuilt from the same array that produced the badge cannot drift apart from it.

publishPending() -> updateNativeToolbarInvites(json)
                 -> addon.updateInvites(json)
                 -> toolbar_update_invites  (dlsym)
                 -> ToolbarManager.updateInvites(json:)

updateInvites is looked up with dlsym but left out of the required-symbol check, so an older dylib still loads and has no dropdown.

A choice returns through the toolbar action callback as invite:yes, invite:maybe, invite:no, invite:read or invite:open, carrying the invitation key as the action's value. main.ts routes those to CalendarService.handleInviteAction. Mark All as Read carries no key and arrives as invitesReadAll. The three answers go to the same sendRsvp the notification banners use, which already holds a press made while an editor is open and falls back to opening the event when driving Google's controls fails; see actions.md's Answering an invitation.

Account switcher dots

Each segment of the account switcher carries the account's colour as a dot, filled when invitations have arrived on that account and not been dealt with, and a ring otherwise. newArrivalsByAccount() feeds it through pushToolbarAccounts() in main.ts, and ToolbarAccount.unreadCount carries the number into AppKit. Magical sets showUnreadCountsInSwitcher to false, so the dot carries the state alone without a count beside the account name.

The dot follows arrivals rather than the whole pile, whichever way dockBadgeMode is set. A dot fed by the backlog would be filled permanently on any account carrying invitations the user has decided to ignore. It clears the same two ways the arrivals do: answering, or marking read.

What the badge counts

dockBadgeMode in Settings → Notifications chooses between two readings of the same set. Both modes are Magimail's two under the same setting name; ../magimail/notifications.md's What the dock badge counts owns the shared reading. Magical also clears the second mode when the user marks invitations read, because it has a list to mark and Magimail has nothing equivalent.

Mode Counts Clears when
allPending (default) every invitation still awaiting a reply the last one is answered
newInvitations invitations that arrived while you were away answered, or marked read

newInvitations counts arrivals, which an invitation joins only when a banner fires for it, so the backlog Magical absorbs silently on a first look never enters. It applies to the Dock badge alone: the dropdown lists the whole pile either way, because it is a place the user goes to deal with invitations rather than one that calls them there.

Answering the invitation, here or from a banner or in Google, and marking it read from the dropdown are the two things that take the number down. Marking read reaches only the active account, because that is all the dropdown shows. Opening the dropdown does not mark anything read. arrivals is persisted beside the fired-alert record in alert-state.json, because the badge outlives the process: quit with two unanswered arrivals and the badge reads 2 on the next launch.

Answering an invitation

Three paths answer an invitation through Magical, and every one of them ends in deliverRsvp.

Answering in Google or on a phone never reaches Magical. observe no longer sees the invitation as pending at the next poll, so it leaves outstanding, and the key is forgotten so a genuine re-invitation is announced again.

deliverRsvp drives Google's own controls, then acts on the outcome. ok calls invites.resolve(key) and publishPending(), so the badge and the dropdown drop the invitation before the next poll. busy holds the intent until the editor closes and retries it on the held-action clock. failed, and an invitation with no resolvable eid, opens the event rather than dropping the response silently.

The watcher has three removal paths, and they differ in what they leave behind:

Every path that moves the number

publishPending() is the only caller of updateDockBadge, updateNativeToolbarInvites and the switcher refresh. Every path that can change the pile ends there:

Trigger What it does to the outstanding set
A poll completes observe() recomputes it for that source
An invitation is answered in Magical resolve(key) drops it, from a banner, the dropdown or the popover
A queued RSVP lands resolve(key) drops it once Google accepts the answer
An account is removed forget(sourceId) drops everything belonging to it
An invitation is marked read markRead(key), or acknowledge(sourceId) for the whole list
A meeting finishes The next poll drops it, once its last occurrence has passed
The active account changes Nothing; the dropdown re-filters, the badge holds

Two rows wait for a poll, which can be fifteen minutes away on battery: a meeting finishing, and an answer given outside Magical. Every other press reaches publishPending() as it lands, because the watcher drops the invitation the moment its surface reports it.