Background
Follow-up to #547 — phases 1 and 2 landed (60d6f83, 2255b22) and fixed:
- localhost / server-event notification noise
- duplicate banners on OSC 9;2
- mute now silences OSC
What's still imperfect: clicking a Clawterm notification from the macOS Notification Center after the banner is dismissed does not reliably focus the originating tab.
Why it's broken
Today's click handler is src/notifications.ts sendWithClickSupport — it creates a Web Notification and wires webNotif.onclick. This works while the banner is on screen and the webview is alive. Once the banner moves to Notification Center and the webview throttles or backgrounds, the onclick doesn't fire when the user clicks the entry in NC.
The Tauri plugin's fallback path (onAction) is mobile-only on tauri-plugin-notification v2 — confirmed unfixed upstream in tauri-apps/plugins-workspace#2150. This has been the blocker since #94 → #113 → #174.
Plan — native UNUserNotificationCenter delegate
Per #547 Phase 3 / #174 Approach B. New Rust module owns the notification surface end-to-end:
- Deps — add
objc2, objc2-foundation, objc2-user-notifications to src-tauri/Cargo.toml. Drop tauri-plugin-notification (only notifyAgentAttention uses it; one call site).
src-tauri/src/notify_mac.rs (new):
init_notification_delegate(handle: AppHandle) — sets the UNUserNotificationCenter.current.delegate to a retained NSObject subclass at app startup.
- Delegate implements
userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler: — pulls tab_id from response.notification.request.content.userInfo, emits Tauri event notification-clicked with that payload.
#[tauri::command] send_notification { title, body, tab_id } — builds a UNMutableNotificationContent, sets userInfo["tab_id"], sets threadIdentifier = tab_id (NC groups same-tab banners), posts via add(request, withCompletionHandler:).
#[tauri::command] request_notification_permission — wraps requestAuthorization(options: [.alert, .sound]).
src-tauri/src/main.rs — register the new commands; call init_notification_delegate in .setup().
src-tauri/capabilities/default.json — drop notification:default, add the new command perms.
- Frontend (
src/notifications.ts):
- Replace
sendWithClickSupport with invoke("send_notification", { title, body, tabId }).
- Drop
@tauri-apps/plugin-notification import.
- Drop the Web Notification fallback (the native path becomes the single sink).
- Frontend (
src/terminal-manager.ts):
listen<string>("notification-clicked", (e) => { ... switchToTab(e.payload) }) — replaces today's onFocusTab plumbing (or keeps it, routed via this listener).
Edge cases to verify by hand
- App not running when user clicks NC entry → macOS launches app; the
notification-clicked event must fire after tabs are restored. Buffer the payload, or no-op if the tab ID isn't in the restored set (today's guard).
- Authorization denied → all calls become silent no-ops; UI should reflect that we can't notify (maybe a one-time toast).
- Multiple notifications for the same tab →
threadIdentifier = tab_id groups them in NC.
- macOS focus stealing on Sonoma+ →
getCurrentWindow().setFocus() is best-effort. Acceptable.
- Dev mode (
cargo tauri dev) — verify the bundle identifier the dev binary uses can request UN authorization. May need an entitlement or info plist key.
Acceptance
- After dismissing the banner, clicking the entry in Notification Center focuses Clawterm and switches to the originating tab.
- Click works after the app has been backgrounded for hours.
- Multiple notifications for the same tab collapse into one NC group.
- No regressions on the live-banner click (today's working case).
Files
| File |
Change |
src-tauri/Cargo.toml |
Add objc2 stack, drop tauri-plugin-notification |
src-tauri/src/notify_mac.rs |
New — delegate + send command |
src-tauri/src/main.rs |
Register commands + delegate setup |
src-tauri/capabilities/default.json |
Permission swap |
src-tauri/Info.plist (if needed) |
Auth strings |
src/notifications.ts |
Replace sendWithClickSupport with IPC |
src/terminal-manager.ts |
Listen for notification-clicked event |
Out of scope
Why this is a separate PR
Custom Rust obj-c2 code needs iterative testing in the running app to catch:
- Delegate not being retained (deallocated immediately on macOS)
- Authorization flow timing (must request before first send)
- Userinfo serialization round-trips
- Bundle identifier / entitlement quirks in dev vs release builds
Shipping #547 phases 1+2 separately means the user-visible bug fixes (duplicates, noise, mute) land immediately; this PR can iterate without holding those up.
Background
Follow-up to #547 — phases 1 and 2 landed (
60d6f83,2255b22) and fixed:What's still imperfect: clicking a Clawterm notification from the macOS Notification Center after the banner is dismissed does not reliably focus the originating tab.
Why it's broken
Today's click handler is
src/notifications.tssendWithClickSupport— it creates a WebNotificationand wireswebNotif.onclick. This works while the banner is on screen and the webview is alive. Once the banner moves to Notification Center and the webview throttles or backgrounds, theonclickdoesn't fire when the user clicks the entry in NC.The Tauri plugin's fallback path (
onAction) is mobile-only ontauri-plugin-notificationv2 — confirmed unfixed upstream in tauri-apps/plugins-workspace#2150. This has been the blocker since #94 → #113 → #174.Plan — native
UNUserNotificationCenterdelegatePer #547 Phase 3 / #174 Approach B. New Rust module owns the notification surface end-to-end:
objc2,objc2-foundation,objc2-user-notificationstosrc-tauri/Cargo.toml. Droptauri-plugin-notification(onlynotifyAgentAttentionuses it; one call site).src-tauri/src/notify_mac.rs(new):init_notification_delegate(handle: AppHandle)— sets theUNUserNotificationCenter.current.delegateto a retained NSObject subclass at app startup.userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:— pullstab_idfromresponse.notification.request.content.userInfo, emits Tauri eventnotification-clickedwith that payload.#[tauri::command] send_notification { title, body, tab_id }— builds aUNMutableNotificationContent, setsuserInfo["tab_id"], setsthreadIdentifier = tab_id(NC groups same-tab banners), posts viaadd(request, withCompletionHandler:).#[tauri::command] request_notification_permission— wrapsrequestAuthorization(options: [.alert, .sound]).src-tauri/src/main.rs— register the new commands; callinit_notification_delegatein.setup().src-tauri/capabilities/default.json— dropnotification:default, add the new command perms.src/notifications.ts):sendWithClickSupportwithinvoke("send_notification", { title, body, tabId }).@tauri-apps/plugin-notificationimport.src/terminal-manager.ts):listen<string>("notification-clicked", (e) => { ... switchToTab(e.payload) })— replaces today'sonFocusTabplumbing (or keeps it, routed via this listener).Edge cases to verify by hand
notification-clickedevent must fire after tabs are restored. Buffer the payload, or no-op if the tab ID isn't in the restored set (today's guard).threadIdentifier = tab_idgroups them in NC.getCurrentWindow().setFocus()is best-effort. Acceptable.cargo tauri dev) — verify the bundle identifier the dev binary uses can request UN authorization. May need an entitlement or info plist key.Acceptance
Files
src-tauri/Cargo.tomltauri-plugin-notificationsrc-tauri/src/notify_mac.rssrc-tauri/src/main.rssrc-tauri/capabilities/default.jsonsrc-tauri/Info.plist(if needed)src/notifications.tssendWithClickSupportwith IPCsrc/terminal-manager.tsnotification-clickedeventOut of scope
Why this is a separate PR
Custom Rust obj-c2 code needs iterative testing in the running app to catch:
Shipping #547 phases 1+2 separately means the user-visible bug fixes (duplicates, noise, mute) land immediately; this PR can iterate without holding those up.