From c87a10a6786cb68488e8acc3b73717d941590a5d Mon Sep 17 00:00:00 2001 From: Alejandro Perez Date: Thu, 2 Jul 2026 23:56:21 +0100 Subject: [PATCH] app(fe): request OS notification permission before sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS silently drops notifications from apps that never requested authorization — cockpit never did, so every send (completion, permission requests, and the Rust poller's, same bundle) vanished. One shared notify() helper requests permission on first use (single in-flight prompt), logs suppression, and both send sites route through it. Co-Authored-By: Claude Fable 5 --- app/src/App.tsx | 13 ++++++----- app/src/lib/notify.ts | 53 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 app/src/lib/notify.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 5b63165..75089c4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { sendNotification } from "@tauri-apps/plugin-notification"; +import { ensureNotificationPermission, notify } from "@/lib/notify"; import { useAppStore } from "./store"; import type { ViewState, RestackProgress, PendingPermission } from "./store"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; @@ -342,6 +342,10 @@ function App() { useKeyboardShortcuts(shortcuts); useEffect(() => { + // Request OS notification permission up front: without a grant, every + // send (frontend AND the Rust-side poller, same app bundle) silently + // no-ops on macOS. One prompt per install; cached afterwards. + void ensureNotificationPermission(); void fetchReviews(); void fetchConfig(); void fetchAuthoredPrs(); @@ -394,7 +398,7 @@ function App() { branch, findingsCount, ); - void sendNotification({ title, body }); + void notify(title, body); })(); }); @@ -435,10 +439,7 @@ function App() { applyPermissionRequest(permission); if (!notifiedPermissionIds.current.has(permission.id)) { notifiedPermissionIds.current.add(permission.id); - void sendNotification({ - title: "Agent waiting for permission", - body: permission.summary, - }); + void notify("Agent waiting for permission", permission.summary); } }, ); diff --git a/app/src/lib/notify.ts b/app/src/lib/notify.ts new file mode 100644 index 0000000..472e205 --- /dev/null +++ b/app/src/lib/notify.ts @@ -0,0 +1,53 @@ +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from "@tauri-apps/plugin-notification"; + +/** + * Cached permission state. `null` until the first check resolves so + * concurrent early callers share one in-flight request instead of + * racing multiple permission prompts. + */ +let grantedPromise: Promise | null = null; + +/** + * Check — and on first use, request — macOS/OS notification permission. + * + * Without an explicit grant the plugin's `sendNotification` silently + * no-ops on macOS, which made every cockpit notification vanish. The + * request only prompts the user once per app install; afterwards this + * resolves from the cached OS state. + */ +export function ensureNotificationPermission(): Promise { + grantedPromise ??= (async () => { + try { + if (await isPermissionGranted()) return true; + const outcome = await requestPermission(); + return outcome === "granted"; + } catch (e: unknown) { + console.error("notification permission check failed", e); + return false; + } + })(); + return grantedPromise; +} + +/** + * Send an OS notification, requesting permission on first use. + * + * Best-effort per Invariant 1: a denied grant or plugin failure logs and + * returns — notifications are a courtesy, never load-bearing. + */ +export async function notify(title: string, body: string): Promise { + const granted = await ensureNotificationPermission(); + if (!granted) { + console.warn("notification suppressed (permission not granted):", title); + return; + } + try { + sendNotification({ title, body }); + } catch (e: unknown) { + console.error("sendNotification failed", e); + } +}