Originally created by: kristofferR
The macOS half of [#86] (Ref [#86]), counterpart to Linux [#167] (Ref [#167]). Today macos/notifications.rs registers no UNNotificationCategory and no actions — clicks are the only interaction.
Design
UNNotificationAction + UNNotificationCategory to objc2-user-notifications features (UNTextInputNotificationAction lives in the former; UNTextInputNotificationResponse is already enabled via UNNotificationResponse). Add NSSet to objc2-foundation features (setNotificationCategories: takes NSSet).setup_macos_notifications, right after setDelegate: (valid regardless of auth state): one category carrier.message containing a UNTextInputNotificationAction ("reply", title "Reply", button "Send", placeholder "Message…"). "Mark as read" is deferred to the same post-soak stretch as [#167] — no native mark-read exists and it needs fragile page automation.deliver_notification_macos grows a small opts struct (thread_id: Option<String>, reply_eligible: bool; sync alerts pass none/false). Eligibility computed on the trusted side in show_message_notification: !hide_notification_preview && validated_thread_path(...).is_some(). Eligible → setCategoryIdentifier("carrier.message"); otherwise no category at all (redacted/route-less banners simply have no reply field). Also stash the validated path in userInfo["path"] next to "id" — delivered notifications persist in Notification Center across app restarts while the in-memory route map does not; re-validate on readback.NotifyDelegate::didReceiveNotificationResponse: branch on actionIdentifier (default / dismiss / "reply"). Reply → downcast to UNTextInputNotificationResponse, take userText, trim + cap ~2000 chars (empty → treat as plain click), call new on_notification_reply(app, id, path, text), and call the completion handler immediately — the ack wait must never block the delegate callback. Default click gains the userInfo["path"] fallback so post-restart clicks still route.on_notification_reply (top-level notifications.rs, platform-neutral — [#167] reuses it): resolve route (map, else userInfo path); serialize replies via a global REPLY_IN_FLIGHT mutex (the page helper drives SPA navigation; two concurrent automations would race the composer); register in a pending-ack map; eval window.__carrierQuickReply?.(path, text, id) (JSON-escaping load-bearing) without show_main — background send is the feature; wait ≤20 s for the carrier:reply-result ack ({id, ok}, new listen_any in lib.rs). Failure/timeout → show_main + __carrierOpenThread(path) (helper leaves the text in the composer per the [#167] contract) + one fixed-string "Reply not sent — opening the conversation" notification (no category, no thread id).inject/src/messenger/features/quick-reply.ts + pure logic in inject/src/messenger/lib/ is specified in [#167] and platform-neutral — whichever issue lands first ships it; the other reuses it.Edge cases: auth denied → categories register fine, nothing breaks; concurrent replies serialized, acks routed by id; pre-restart banners reply via userInfo path; logged-out page / composer drift fail closed to the fallback with text preserved; hide_notification_preview gates per delivery on the trusted side.
Testing. Unit (runs on Linux CI): reply-text sanitation, eligibility matrix, pending-ack map (timeout, unknown id, eviction), payload parsing. Manual on the Mac: reply with window hidden to tray (stays hidden, message arrives); kill + relaunch and reply to an old banner; failure path; hide-preview on → no reply field; two threads replied quickly → both route correctly. A debug --features mcp build drives the page flow via tauri-mcp.
Part of [#179].
Tickets: #167
Tickets: #179
Tickets: #181
Tickets: #230
Tickets: #86
Originally posted by: kristofferR
Shipped in [#230], released in v1.9.0.
The
carrier.messagecategory registers aUNTextInputNotificationAction, and replies route through the sharedon_notification_replywithout raising the window. Eligibility is decided on the trusted side, so redacted or route-less banners get no category and no reply field. Delivered notifications carry the validated path inuserInfo, re-validated on readback, so a banner still replies correctly after a restart. A reply that cannot be sent opens the conversation with the text preserved as an unsent draft rather than being dropped."Mark as read" stays deferred as the design specified.
Related
Tickets:
#230Ticket changed by: kristofferR