8.0 KiB
Issue #714 — Native OS Notifications from Embedded Webviews
Branch: feat/714-native-os-notifications
Base: upstream/main
Upstream: tinyhumansai/openhuman
Origin (fork): oxoxDev/openhuman
Problem
Embedded webview apps (Slack, Discord, Gmail, WhatsApp) call window.Notification inside CEF but never produce native macOS/Windows toasts. The CEF runtime intercepts the web Notification API, but the intercept dropped on the floor — no bridge to tauri-plugin-notification, no click routing back to the originating account, no permission query/request pipeline.
Solution
Wire the tauri-cef notification intercept into tauri-plugin-notification, prefix each toast with the provider label (e.g. [Slack] New message from Alice), honour silent / icon / tag, and record a NotificationRoute keyed by {provider}:{account_id}:{tag_or_uuid} so a future platform click hook can emit notification:click and route focus back to the correct account. Also round-trip the OS notification permission via new invokes so the frontend sees the same "granted" | "denied" | "default" triple as the web API on both CEF and wry runtimes.
Commits (in order)
50b831ad feat(webview_accounts): native OS notifications from embedded webviews (#714)
Rust backend — the core of the feature.
-
app/src-tauri/src/webview_accounts/mod.rs(+141 / -3)NotificationRoutestruct:provider,account_id,tag,created_atnotification_routes: Mutex<HashMap<String, NotificationRoute>>onWebviewAccountStateclear_notification_routes(account_id)— purged on close / purgeforward_native_notification(app, provider, account_id, payload):- Prefixes title with
[Provider] - Respects
silent(records route, skips toast) - Passes
iconthrough to builder - Uses
tagas dedup key, falls back to monotonic timestamp
- Prefixes title with
tag_or_uuidhelper — tag is the web API's dedup key; timestamp fallback ensures untagged payloads route uniquelywebview_notification_permission_state/_request— maptauri::plugin::PermissionState(Granted | Denied | Prompt | PromptWithRationale) onto"granted" | "denied" | "default"permission_state_strhelper- Non-cef (wry) stubs return
"default"so frontend calls same invoke names on both runtimes - CEF registration in
setup:tauri_runtime_cef::notification::registerwith handler that callsforward_native_notification;unregisteron account close
-
app/src-tauri/src/lib.rs(+2)- Added
webview_notification_permission_stateandwebview_notification_permission_requestto the invoke handler list.
- Added
-
app/src-tauri/capabilities/default.json(+3)- Added
notification:allow-notify,notification:allow-request-permission,notification:allow-is-permission-grantedso the plugin can be invoked from the webview context.
- Added
97ef390f feat(accounts): wire notification permission + click bridge (#714)
Frontend — permission round-trip + dormant click listener.
app/src/services/webviewAccountService.ts(+59 / -1)ensureNotificationPermission(accountId)— invokeswebview_notification_permission_state, requests if"default", runs once per session on first account open. Desktop plugin auto-grants today, but shape matches web API so future platform prompts slot in without UI change.handleNotificationClick+listen('notification:click', …)— dispatchessetActiveAccountand invokesactivate_main_windowwhen the (currently dormant) platform click hook emits the event. Contract matches RustNotificationRouteshape so Rust emit side is a one-liner when UNUserNotificationCenter / notify-ruston_responseis wired.openWebviewAccountnow callsvoid ensureNotificationPermission(accountId)after the account opens.
e6f60180 chore: sync Cargo.lock to 0.52.26 after version bump
Cargo.lock+app/src-tauri/Cargo.lock(+2 / -2 each)- Picked up pending 0.52.26 version bump while building. No dependency graph change.
Quality Gates (all passed)
| Gate | Result | Time |
|---|---|---|
pnpm compile (tsc --noEmit) |
pass | 32.30s |
pnpm lint (eslint) |
pass | 63.65s |
pnpm rust:format:check |
pass | — |
cargo check --features cef --no-default-features |
pass | 22.21s |
cargo check --features wry --no-default-features |
pass | 6m 29s (cold) |
Skipped:
pnpm format:check— flags onlyapp/src/pages/Home.tsx(local build-tag pill#714,skip-worktreeflagged, per workflow Phase 3 Step 6). Confirmed viagit ls-files -v | grep '^S '→S app/src/pages/Home.tsx.cargo clippy— pre-existing errors insrc/slack_scanner/extract.rs(type_complexity) andsrc/lib.rs:212(unnecessary_map_or) unrelated to this feature. Verified withgit diff upstream/main -- app/src-tauri/src/lib.rsshows only the 2-line invoke handler addition.
Not yet done:
- Manual verification in built
.appbundle with real Slack/Discord/Gmail notifications. Requirespnpm macOS:build:debug(~10 min), install, open, trigger notifications, confirm provider-prefixed titles fire natively.
Key Files for Teammate Review
| File | Role |
|---|---|
app/src-tauri/src/webview_accounts/mod.rs |
Core Rust logic — intercept handler, route table, permission commands |
app/src-tauri/src/lib.rs |
Invoke handler registration |
app/src-tauri/capabilities/default.json |
Notification plugin capabilities |
app/src/services/webviewAccountService.ts |
Frontend permission round-trip + click bridge |
app/src-tauri/vendor/tauri-cef/crates/tauri-runtime-cef/src/notification.rs |
(vendored, unchanged) — source of the register/unregister/dispatch API used here |
Architecture Notes
Route keying
{provider}:{account_id}:{tag_or_uuid} — tag is the web Notifications API dedup key (second new Notification(title, { tag }) with same tag replaces the first). When absent, fall back to Instant::now() monotonic timestamp so every untagged payload routes uniquely. This matches browser semantics and prevents map collisions when two accounts of the same provider fire untagged notifications simultaneously.
Permission shape
tauri::plugin::PermissionState has 4 variants but the web API only has 3. Map:
Granted→"granted"Denied→"denied"Prompt,PromptWithRationale→"default"
Non-cef runtime stubs always return "default" — prevents invoke name mismatch between runtimes so frontend doesn't need a feature flag.
Dormant click listener
notification:click listener is registered frontend-side but Rust doesn't emit it yet. UNUserNotificationCenter (macOS) and notify-rust on_response (Linux/Windows) callbacks are the platform hooks that will emit once wired. The route table is already populated by the notification dispatch path so the emit side is a one-liner:
let route = state.notification_routes.lock().unwrap().get(&route_key).cloned();
if let Some(r) = route {
app.emit("notification:click", &r)?;
}
Next Steps for Teammate
- Manual verification — build
.app, test Slack/Discord/Gmail toasts, confirm title prefix, confirmsilent/icon/tagall honoured. - Platform click hooks — wire UNUserNotificationCenter delegate (macOS) and notify-rust
on_response(Linux/Windows) to emitnotification:clickwith the storedNotificationRoute. Route table already exists; emit is one line. - PR — template headings required:
## Summary,## Problem,## Solution,## Submission Checklist,## Impact,## Related.Closes #714.
Local State Caveats
- Home.tsx build-tag pill —
skip-worktreeflag set onapp/src/pages/Home.tsxwith inline#714pill (top-right, fixed). Per-clone, does NOT travel with branch. If teammate pulls this branch into their own clone, no pill appears locally. If they want one, Phase 3 Step 6 of.claude/rules/00-workflow.mdhas the snippet. - Cargo.lock — version bumped to 0.52.26 locally. Separate commit
e6f60180so diff review is clean.