Native notifications
Both apps post banners through the Swift helper rather than Electron, because the helper is what
gives a notification its bundle identity and its action buttons. What each app announces, and when,
is its own: Magimail's arrival and watermark logic is in
../magimail/notifications.md, Magical's alert scheduler and invite
watcher in ../magical/notifications.md.
Helper posts the banner
MagicalHelper/MagimailHelper owns UNUserNotificationCenter and posts every banner. Dispatch is
a sendToHelper call over the newline-delimited JSON-RPC in
process-model-and-ipc.md. When the helper is absent, as it is under
pnpm dev, dispatch silently does nothing.
Electron's own Notification does support macOS action buttons, so the often-repeated claim that the
buttons would have to be dropped is wrong. What Electron does not expose is content.filterCriteria,
the per-account hook a macOS Focus Filter matches on; staying in Swift keeps that capability without a
second process, since the helper already owns the menu bar and the settings window.
Registration happens at launch
The main process sends notify.setup immediately after spawning the helper, which registers the
action categories and requests authorization. Left to the first dispatch, macOS would know nothing
about the app until a notification arrived: there would be no row in System Settings to grant
beforehand, and the first notification would necessarily be missed.
Bundle identity
UNUserNotificationCenter aborts without a bundle identity, so the helper must run from
Contents/MacOS inside the app and carry the app's signing identifier rather than one derived from
its filename. A helper elsewhere in the bundle, or signed under its own identifier, either aborts or
fails requestAuthorization with UNErrorDomain Code=1, and the app never appears in System
Settings, Notifications. Under pnpm dev the helper runs outside any bundle, so check banners on the
installed build.
The signing rules and signIgnore are in building-and-signing.md; the
identity the widget extension needs, and the quit that follows from sharing an identity, are in
process-model-and-ipc.md; the Focus filter's identity requirement is in
focus-filters.md.
Category and action contract
Every banner belongs to a registered UNNotificationCategory, and the actions are declared on the
category rather than per notification.
- Registering the categories is what supplies the actions. An action that is not on the category cannot be attached.
- macOS decides how many actions it shows inline and collapses the rest. Switching the app to
Alerts in System Settings leaves the actions there, behind an Options dropdown;
UNNotificationActionhas no say in the matter. Registering fewer is the only thing that changes it, and the number at which macOS stops collapsing is unverified. - An action that cannot apply to a given notification is not registered on that category, not disabled on it, because macOS cannot hide an action per notification. Magical therefore has two meeting categories rather than one carrying a disabled Join button.
.foregroundis a deliberate omission for an action that leaves the app. An action that hands a URL to another application does not ask to bring this one forward on the way.
Content and formatting
macOS lays a banner out as title, then subtitle, then body, and the order is not the order the fields are named in. A subject therefore has to be the subtitle to appear above a snippet; assigning the snippet to the subtitle renders it above the subject and in bold, which reads as the subject being a preview of itself.
Magimail's mapping is: sender name as title, subject as subtitle, snippet as body; when the snippet is empty the subject moves into the body rather than being printed twice.
Magical's mapping is: an alert uses the meeting name, then In 5 minutes · 14:30, then the conference
provider or the location. An invitation uses the event name, then Invitation from <organiser>, then
when it is.
Sound belongs to the category level
The loud level sets content.sound and the silent level does not. willPresent honours the same
level, so a silent category stays silent when the app is frontmost; a global switch on top would only
raise the question of which wins. macOS System Settings remains the final say.