NotificationService — desktop notifications with action buttons
Runs in: both worker and view. Notification action callbacks must register from the worker — they may fire while the view is Dormant.
Permission required: notifications:send
An extension calls send() with optional action buttons. When the user
clicks a button on the OS notification, the host looks the action up in
a Rust-side registry and invokes the extension's declared command
through the same dispatch path a search-result click or asyar://
deep link would take — the extension receives the fire via its normal
executeCommand() handler, no extra listener wiring required.
Interface
interface NotificationAction {
id: string; // action-local id (unique within the notification)
title: string; // button label shown in the OS notification
commandId: string; // extension's command to fire (must be in manifest.json)
args?: Record<string, unknown>; // JSON-serialisable args forwarded to the command
}
interface NotificationOptions {
title: string;
body?: string;
icon?: string;
actions?: NotificationAction[];
}
interface INotificationService {
checkPermission(): Promise<boolean>;
requestPermission(): Promise<boolean>;
send(options: NotificationOptions): Promise<string>; // returns notification id
dismiss(notificationId: string): Promise<void>; // drops pending actions + closes OS banner
}
Full example — "Coffee ending in 1 minute"
import type { INotificationService } from 'asyar-sdk';
const notifications = context.getService<INotificationService>('notifications');
const notificationId = await notifications.send({
title: 'Coffee ending in 1 minute',
body: 'Extend or stop before the timer runs out.',
actions: [
{ id: 'extend', title: 'Extend 30m', commandId: 'coffee.extend', args: { minutes: 30 } },
{ id: 'stop', title: 'Stop now', commandId: 'coffee.stop' },
],
});
// Later, if the countdown reaches zero before the user reacts:
await notifications.dismiss(notificationId);
manifest.json must declare both commands:
{
"commands": [
{ "id": "coffee.extend", "name": "Extend coffee", "mode": "background" },
{ "id": "coffee.stop", "name": "Stop coffee", "mode": "background" }
]
}
When the user clicks Extend 30m, the extension's executeCommand('coffee.extend', { minutes: 30 }) fires. Asyar's window does not open — command dispatch is detached from view presentation.
Dispatch flow
- Extension calls
notifications.send({ actions: [...] }). - SDK proxy validates each action locally (non-empty
id/title/commandId, args JSON-serialisable) and forwards the whole payload to the host over postMessage. - Host's
NotificationService.send()hits the Rustsend_notificationcommand, which:- validates the action list on the Rust side (
id/title/commandIdnon-empty, args deserialisable), - inserts
(notificationId, actionId) → (extensionId, commandId, argsJson)into theNotificationActionRegistry, - calls the platform backend to show the notification (see Platform matrix).
- validates the action list on the Rust side (
- User clicks a button. The platform backend translates the click to
(notificationId, actionId)and calls the registered click sink. - The click sink resolves the entry through
dispatch::resolve_click, emitsasyar:notification-actionwith{ notificationId, actionId, extensionId, commandId, argsJson }, and removes the whole notification's entries (OS-level actions are one-shot). NotificationActionBridge(running in the launcher window) receives the Tauri event, validates the extension is still installed + enabled and the command is registered, and callscommandService.executeCommand(objectId, args).- Extension's
executeCommand(commandId, args)runs.
Platform matrix
| OS | How it renders | Reliable action count | Notes |
|---|---|---|---|
| macOS | mac-notification-sys → NSUserNotification |
1 primary + dropdown for 2+ | One action renders as a single button; multiple actions appear under an "Actions" dropdown menu. A "Close" button is always present. |
| Linux (GNOME, KDE, dunst) | notify-rust → xdg freedesktop |
2–4 typically | Depends on the notification daemon. KDE Plasma surfaces all actions inline; GNOME collapses them. |
| Windows | tauri-plugin-notification → toast |
0 | Action buttons are dropped with a warning; the notification still fires with title + body. |
When a platform can't render actions, the notification still delivers — the buttons are silently stripped from the UI and a warning is logged. Extensions should not crash or behave differently based on action support.
Lifecycle
Pending-action entries are purged when any of the following happens:
- User clicks an action — the whole notification's entries are dropped after dispatch (one-shot semantics).
notifications.dismiss(notificationId)— explicit cleanup.- Extension uninstall —
NotificationActionRegistry::remove_all_for_extensionis called fromextensions::lifecycle::uninstall, so stale buttons stop firing into nothing. Disable is not a purge trigger (matchesPowerRegistry/AppEventsHub); theNotificationActionBridgeinstead drops clicks for disabled extensions at dispatch time, so re-enabling the extension keeps pending actions functional. - TTL expiry — a background tokio task spawned during
setup_apprunspurge_expiredevery hour, dropping entries older thanDEFAULT_TTL(24 h). This protects against the OS silently closing a notification without telling us.
If a user clicks a button on a notification whose extension has since been disabled or uninstalled, the click is logged and dropped — the extension is not auto-enabled. If the command was removed but the extension is still installed, the bridge logs a warning and swallows the click.
Error surfaces
| Condition | Where | Surfaced as |
|---|---|---|
NotificationAction.id empty |
SDK proxy | rejected before IPC with "NotificationAction requires a non-empty id" |
NotificationAction.title empty |
SDK proxy | rejected with "NotificationAction \"<id>\" requires a non-empty title" |
NotificationAction.commandId empty |
SDK proxy + Rust validator | rejected with "...requires a non-empty commandId" |
args not JSON-serialisable |
SDK proxy | rejected with "args are not JSON-serialisable" |
Extension missing notifications:send permission |
Permission gate | IPC response error: "Permission denied: notifications:send is required..." |
commandId unknown at click time |
NotificationActionBridge |
logged + swallowed |
| Extension disabled at click time | NotificationActionBridge |
logged + swallowed |
Wire format
| TS call | IPC type string | Permission |
|---|---|---|
send({ title, actions }) |
asyar:api:notifications:send |
notifications:send |
dismiss(id) |
asyar:api:notifications:dismiss |
notifications:send |
checkPermission() |
asyar:api:notifications:checkPermission |
— (core) |
requestPermission() |
asyar:api:notifications:requestPermission |
— (core) |
The Rust side emits asyar:notification-action on action click; this is an internal launcher event — extensions never subscribe to it directly.