Menu bar status item and popover
The status item and popover run in the Swift helper. The popover's rows can open a thread or act on
it; the actions themselves, and how they reach Gmail, are in actions.md.
Process boundary and IPC
The menu bar status item and popover live in the Swift helper process (MagimailHelper), not in
Electron:
flowchart TD
subgraph Electron["Electron Main Process"]
Poller["Atom feed Poller (ses.fetch)"]
Store["Account Store (accounts.json)"]
Supervisor["Helper Supervisor (helper-process.ts)"]
end
subgraph IPC["Bidirectional Stdio Stream"]
JSON_IN["JSON-RPC Requests & Events (stdin)"]
JSON_OUT["JSON-RPC Notifications (stdout)"]
end
subgraph Swift["MagimailHelper (Native Swift Process)"]
Controller["MenuBarController (NSStatusItem)"]
Popover["NSPopover (AppKit / SwiftUI)"]
Model["TrayModel (Persistent Feed Cache)"]
end
Poller --> Supervisor
Store --> Supervisor
Supervisor -->|tray.update| JSON_IN
JSON_IN --> Controller
Controller --> Model
Model --> Popover
Popover -->|tray.showThread / tray.threadAction / tray.compose / tray.search| JSON_OUT
JSON_OUT --> Supervisor
Supervisor -->|Focus & Navigate| Electron
Helper and main process talk in newline-delimited JSON-RPC over stdin and stdout:
| Event / Method | Direction | Payload | Action Performed |
|---|---|---|---|
tray.update |
Electron → Helper | { accounts: Account[], threads: Thread[], totalUnread: int } |
Updates TrayModel, refreshes status item title/unread dot, updates popover view. |
tray.showThread |
Helper → Electron | { threadId: string, accountId: string } |
Switches main window to target account partition and navigates directly to thread. |
tray.threadAction |
Helper → Electron | { threadId: string, accountId: string, action: string } |
Archives, trashes, marks read or marks unread that one thread, leaving the window alone. |
tray.compose |
Helper → Electron | { accountId?: string } |
Opens compose window in specified account (or shows account picker if unspecified). |
tray.search |
Helper → Electron | { query: string, accountId?: string } |
Switches to the named account, then loads the search in its Gmail view. |
tray.openPrefs |
Helper (Internal) | None | Instantiates native SettingsWindowController directly in Swift. |
tray.popoverOpened |
Helper → Electron | None | Asks every poller for a hint poll, rate-limited inside the poller. |
tray.refresh |
Helper → Electron | None | Polls every account now, then pushes tray.messages for each so the popover redraws. |
Status item mechanics
The status item's ownership, the template-image glyph rule and the glyph grid are in
../shared/menu-bar.md. Magimail's own detail is that the unread count goes
in the status item title as tabular numbers (e.g. (3)), so there is no custom badge bitmap; the
glyph's state or the account dot says whether there is unread mail.
Popover layout and sizing
The single fixed width and the content-driven height are the shared popover rules in
../shared/menu-bar.md. Magimail's width is TrayPopoverMetrics.width,
340pt, and its feed stops growing at maxListHeight, 320pt, then scrolls.
Account filter menu
A menu in the header narrows the feed to one account, sitting beside compose and the gear. It appears once there are two accounts to choose between.
The menu lists "All Accounts" with the total unread count, then one item per account carrying its colour dot and its own count. The dot is filled when mail is waiting and a ring when it is not, the same mark the main window's toolbar switcher uses. An account whose session has expired shows an orange warning triangle instead, and sheds its count: the feed behind it has stopped, so the number is one nobody should read.
Each item is a Toggle, which AppKit draws as a tick beside the account showing. Selecting an
account sets the filter to it, so the ticks behave as a radio group and "All Accounts" is the way
back. The selection also decides which account the header's compose button targets; see
quick-compose.md.
Which account a search runs in
A Gmail search runs inside one account's session, so the popover has to name one. The placeholder
says which, and where the results appear: "Search Personal — opens in app". tray.search carries
that account, and the main process switches to it before loading the search URL, so the window lands
on the results rather than on another account's inbox.
The filter names the account whenever it is set. With the filter on "All Accounts" and more than one
account configured, the accounts hang off the field's own magnifier icon as an NSSearchField
searchMenuTemplate: each one by name, with its colour dot and a tick on the account a query would
go to. AppKit draws the chevron and owns the hit target, so the field keeps the popover's full width.
The dot is the same colour as the accent pill on that account's rows, so the menu says where a query
goes without spending a word on it. The tick follows the account the placeholder names rather than
whatever was last clicked, so it sits on the first account before anything has been picked;
TraySearchField rebuilds the menu on every update, which is what keeps the two in step.
The menu carries none of AppKit's recents tags, NSSearchFieldRecentsMenuItemTag and its siblings. A
search field reads those tags to manage a recent-search list and rewrites the items carrying them;
untagged items it passes through as written, which is why plain NSMenuItems behave as an ordinary
menu here.
Getting a newer list
The Atom poller runs on its configured interval (see atom-feed.md), so what the
popover shows on opening is as old as the last tick. Two things shorten that.
Opening the popover sends tray.popoverOpened, and every poller takes it as a hint. Hints are
rate-limited inside AtomFeedPoller.requestImmediatePoll() rather than obeyed, so opening the
popover repeatedly is not a way to hammer Gmail.
The gear menu's Refresh Inbox, on ⌘R, is the way to force one. It calls pollNow() on every
account, waits for them all, and then pushes tray.messages per account. That push is what makes the
refresh visible: messages are otherwise pulled, with the helper asking through tray.getMessages as
the popover opens, so a poll answered after that would sit in the main process until the popover was
closed and reopened. TrayModel.isRefreshing disables the item while a poll is out, and any state
arriving from Electron clears it, whether that is a changed unread count or a fresh message list.
The popover stays open throughout, because the point is watching the list it is already showing get
newer.
Feed aging and TrayModel state
Atom feed dropout reconciliation
A thread that drops out of the feed stays in the popover, muted, rather than disappearing. Gmail's
authenticated Atom feed (atom-feed.md) only returns unread messages, so reading an
email (on another device or in the main window) drops that thread from subsequent poll payloads.
Replacing the popover's state on every poll would empty the list the moment the inbox was cleared,
leaving the popover blank.
TrayModel keeps its own copy of the threads instead:
flowchart TD
Poll["New Atom feed Payload"] --> Diff{"Compare with Local Cache"}
Diff -->|Present in Feed| MarkUnread["Update Metadata & Mark Unread (Semibold)"]
Diff -->|Missing from Feed| AgeRead["Retain in Cache & Transition to Read (Muted)"]
MarkUnread --> Cache["Persistent Local Cache (Sorted by Timestamp)"]
AgeRead --> Cache
Cache --> Popover["Render Inset Message List"]
- A message missing from a newer poll moves to a muted 'read' state rather than being deleted.
- Inbox zero still shows recent context rather than collapsing into an empty placeholder.
- Read items are pruned by count alone, not by age.
TrayModel.retentionLimitkeeps eight threads per account, and each poll carries five, so an account holds its five unread and the three most recent that have aged out.
Category symbols mapping
When Gmail categories are present, threads display an unweighted SF Symbol beside the timestamp:
| Category | Gmail Tab | SF Symbol |
|---|---|---|
| Primary | Inbox Tray | tray |
| Social | Two People | person.2 |
| Promotions | Price Tag | tag |
| Updates | Info Circle | info.circle |
| Forums | Speech Bubbles | bubble.left.and.bubble.right |
The mapping is duplicated on purpose, in MailCategory.swift (helper) and WidgetCache.swift
(widget extension): helper and extension run in separate sandboxed bundles, with no shared framework
to hold one copy. Primary draws its own symbol (tray) rather than being left blank, so "Primary"
cannot be read as "Not Yet Classified".