Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -394,7 +398,7 @@ function App() {
branch,
findingsCount,
);
void sendNotification({ title, body });
void notify(title, body);
})();
});

Expand Down Expand Up @@ -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);
}
},
);
Expand Down
53 changes: 53 additions & 0 deletions app/src/lib/notify.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> | 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<boolean> {
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<void> {
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);
}
}
Loading