Skip to content

notifications: native UNUserNotificationCenter click handler so NC-after-dismissal click focuses the right tab #548

@Axelj00

Description

@Axelj00

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:

  1. Deps — add objc2, objc2-foundation, objc2-user-notifications to src-tauri/Cargo.toml. Drop tauri-plugin-notification (only notifyAgentAttention uses it; one call site).
  2. 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]).
  3. src-tauri/src/main.rs — register the new commands; call init_notification_delegate in .setup().
  4. src-tauri/capabilities/default.json — drop notification:default, add the new command perms.
  5. 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).
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent-uxAI agent experience and visibilityenhancementNew feature or requestuxUser experience and interaction quality

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions