diff --git a/package-lock.json b/package-lock.json index f358af49c..9d8094f45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.9.0", @@ -1716,6 +1717,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", diff --git a/package.json b/package.json index 261bd6496..9ec23d3ef 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.9.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 350844423..63c7cfa81 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -572,6 +572,7 @@ dependencies = [ "git2", "ignore", "libc", + "mac-notification-sys", "portable-pty", "reqwest", "serde", @@ -580,6 +581,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-updater", @@ -2339,6 +2341,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "mach2" version = "0.4.3" @@ -2552,6 +2566,20 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3150,7 +3178,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3311,6 +3339,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4551,6 +4588,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4716,6 +4772,18 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.17", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.24.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 690b5addf..c7b5afb44 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["macos-private-api"] } tauri-plugin-opener = "2" tauri-plugin-process = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["fs", "net", "io-util", "process", "rt", "sync", "time"] } @@ -40,3 +41,6 @@ chrono = { version = "0.4", features = ["clock"] } [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" + +[target."cfg(target_os = \"macos\")".dependencies] +mac-notification-sys = "0.6" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 543413aad..ef3ca44d1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "dialog:default", "process:default", "updater:default", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "notification:default" ] } diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 67076dc84..ff0f26af8 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1,10 +1,12 @@ #[path = "../backend/mod.rs"] +#[allow(dead_code)] mod backend; #[path = "../codex_config.rs"] mod codex_config; #[path = "../storage.rs"] mod storage; #[path = "../types.rs"] +#[allow(dead_code)] mod types; use serde_json::{json, Map, Value}; @@ -35,6 +37,7 @@ struct DaemonEventSink { tx: broadcast::Sender, } +#[allow(dead_code)] #[derive(Clone)] enum DaemonEvent { AppServer(AppServerEvent), @@ -1060,6 +1063,7 @@ fn parse_optional_u32(value: &Value, key: &str) -> Option { } } +#[allow(dead_code)] fn parse_optional_bool(value: &Value, key: &str) -> Option { match value { Value::Object(map) => map.get(key).and_then(|value| value.as_bool()), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7495007e4..a65d31041 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod event_sink; mod git; mod git_utils; mod local_usage; +mod notifications; mod prompts; mod settings; mod state; @@ -216,6 +217,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![ settings::get_app_settings, settings::update_app_settings, @@ -266,6 +268,7 @@ pub fn run() { prompts::prompts_move, prompts::prompts_workspace_dir, prompts::prompts_global_dir, + notifications::send_native_notification, terminal::terminal_open, terminal::terminal_write, terminal::terminal_resize, diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs new file mode 100644 index 000000000..423339593 --- /dev/null +++ b/src-tauri/src/notifications.rs @@ -0,0 +1,104 @@ +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; +use tauri::{AppHandle, Emitter, Manager}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeNotificationPayload { + pub title: String, + pub body: Option, + pub workspace_id: String, + pub thread_id: Option, + pub kind: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct NotificationClickEvent { + workspace_id: String, + thread_id: Option, + kind: String, +} + +#[cfg(target_os = "macos")] +static WAITING_FOR_CLICK: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "macos")] +fn emit_notification_click(app: &AppHandle, payload: NotificationClickEvent) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + let _ = app.emit("notification-clicked", payload); +} + +#[cfg(target_os = "macos")] +fn resolve_bundle_id(app: &AppHandle) -> String { + if tauri::is_dev() { + "com.apple.Terminal".to_string() + } else { + app.config().identifier.clone() + } +} + +#[cfg(target_os = "macos")] +#[tauri::command] +pub async fn send_native_notification( + app: AppHandle, + payload: NativeNotificationPayload, +) -> Result { + if WAITING_FOR_CLICK + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return Ok(false); + } + let app_handle = app.clone(); + + tauri::async_runtime::spawn_blocking(move || { + let title = payload.title; + let body = payload.body.unwrap_or_default(); + let workspace_id = payload.workspace_id; + let thread_id = payload.thread_id; + let kind = payload.kind; + + let mut notification = mac_notification_sys::Notification::new(); + notification.title(&title); + notification.message(&body); + notification.wait_for_click(true); + + let bundle_id = resolve_bundle_id(&app_handle); + let _ = mac_notification_sys::set_application(&bundle_id); + + match notification.send() { + Ok(response) => match response { + mac_notification_sys::NotificationResponse::Click + | mac_notification_sys::NotificationResponse::ActionButton(_) + | mac_notification_sys::NotificationResponse::Reply(_) => { + emit_notification_click( + &app_handle, + NotificationClickEvent { + workspace_id, + thread_id, + kind, + }, + ); + } + _ => {} + }, + Err(_) => {} + } + WAITING_FOR_CLICK.store(false, Ordering::SeqCst); + }); + + Ok(true) +} + +#[cfg(not(target_os = "macos"))] +#[tauri::command] +pub async fn send_native_notification( + _app: AppHandle, + _payload: NativeNotificationPayload, +) -> Result { + Ok(false) +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index ce33c3f13..83bcffef3 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -265,6 +265,11 @@ pub(crate) struct AppSettings { rename = "notificationSoundsEnabled" )] pub(crate) notification_sounds_enabled: bool, + #[serde( + default = "default_notification_push_enabled", + rename = "notificationPushEnabled" + )] + pub(crate) notification_push_enabled: bool, #[serde( default = "default_experimental_collab_enabled", rename = "experimentalCollabEnabled" @@ -339,6 +344,10 @@ fn default_notification_sounds_enabled() -> bool { true } +fn default_notification_push_enabled() -> bool { + true +} + fn default_experimental_collab_enabled() -> bool { false } @@ -382,6 +391,7 @@ impl Default for AppSettings { last_composer_reasoning_effort: None, ui_scale: 1.0, notification_sounds_enabled: true, + notification_push_enabled: true, experimental_collab_enabled: false, experimental_steer_enabled: false, experimental_unified_exec_enabled: false, @@ -422,6 +432,7 @@ mod tests { assert!(settings.last_composer_reasoning_effort.is_none()); assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); assert!(settings.notification_sounds_enabled); + assert!(settings.notification_push_enabled); assert!(!settings.experimental_steer_enabled); assert!(!settings.dictation_enabled); assert_eq!(settings.dictation_model_id, "base"); diff --git a/src/App.tsx b/src/App.tsx index f27e476bb..1be387c48 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -81,17 +81,20 @@ import { useWorkspaceSelection } from "./features/workspaces/hooks/useWorkspaceS import { useLocalUsage } from "./features/home/hooks/useLocalUsage"; import { useNewAgentShortcut } from "./features/app/hooks/useNewAgentShortcut"; import { useAgentSoundNotifications } from "./features/notifications/hooks/useAgentSoundNotifications"; +import { useAgentPushNotifications } from "./features/notifications/hooks/useAgentPushNotifications"; import { useWindowFocusState } from "./features/layout/hooks/useWindowFocusState"; import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { usePanelVisibility } from "./features/layout/hooks/usePanelVisibility"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; import { playNotificationSound } from "./utils/notificationSounds"; +import { listen } from "@tauri-apps/api/event"; import { pickWorkspacePath, } from "./services/tauri"; import type { AccessMode, GitHubPullRequest, + NotificationClickPayload, QueuedMessage, WorkspaceInfo, } from "./types"; @@ -640,6 +643,14 @@ function MainApp() { onMessageActivity: queueGitStatusRefresh }); + useAgentPushNotifications({ + enabled: appSettings.notificationPushEnabled, + isWindowFocused, + workspaces, + threadsByWorkspace, + onDebug: addDebugEntry, + }); + const { handleCopyThread } = useCopyThread({ activeItems, onDebug: addDebugEntry, @@ -681,6 +692,47 @@ function MainApp() { setCenterMode, setSelectedDiffPath, }); + + useEffect(() => { + let unlisten: (() => void) | null = null; + let canceled = false; + + listen("notification-clicked", (event) => { + const { workspaceId, threadId } = event.payload ?? {}; + if (!workspaceId) { + return; + } + selectWorkspace(workspaceId); + if (threadId) { + setActiveThreadId(threadId, workspaceId); + } + }) + .then((handler) => { + if (canceled) { + try { + handler(); + } catch { + // Ignore unlisten errors when already removed. + } + } else { + unlisten = handler; + } + }) + .catch(() => { + // Ignore notification listener errors (unsupported platform). + }); + + return () => { + canceled = true; + if (unlisten) { + try { + unlisten(); + } catch { + // Ignore unlisten errors when already removed. + } + } + }; + }, [selectWorkspace, setActiveThreadId]); const { worktreePrompt, openPrompt: openWorktreePrompt, diff --git a/src/features/notifications/hooks/useAgentPushNotifications.ts b/src/features/notifications/hooks/useAgentPushNotifications.ts new file mode 100644 index 000000000..bd18431c0 --- /dev/null +++ b/src/features/notifications/hooks/useAgentPushNotifications.ts @@ -0,0 +1,190 @@ +import { useCallback, useMemo, useRef } from "react"; +import type { + ApprovalRequest, + DebugEntry, + ThreadSummary, + WorkspaceInfo, +} from "../../../types"; +import { sendLocalNotification } from "../../../utils/pushNotifications"; +import { useAppServerEvents } from "../../app/hooks/useAppServerEvents"; + +const COMPLETION_DEDUP_MS = 1500; + +type PushNotificationOptions = { + enabled: boolean; + isWindowFocused: boolean; + workspaces: WorkspaceInfo[]; + threadsByWorkspace: Record; + onDebug?: (entry: DebugEntry) => void; +}; + +function buildThreadKey(workspaceId: string, threadId: string) { + return `${workspaceId}:${threadId}`; +} + +function buildApprovalKey(workspaceId: string, requestId: number) { + return `${workspaceId}:${requestId}`; +} + +function formatApprovalMethod(method: string) { + const trimmed = method.replace(/^codex\/requestApproval\/?/, ""); + return trimmed || method; +} + +export function useAgentPushNotifications({ + enabled, + isWindowFocused, + workspaces, + threadsByWorkspace, + onDebug, +}: PushNotificationOptions) { + const lastNotifiedAtByThread = useRef(new Map()); + const notifiedApprovalKeys = useRef(new Set()); + + const workspaceLabels = useMemo( + () => new Map(workspaces.map((workspace) => [workspace.id, workspace.name])), + [workspaces], + ); + + const threadLabelsByWorkspace = useMemo(() => { + const map = new Map>(); + Object.entries(threadsByWorkspace).forEach(([workspaceId, threads]) => { + const threadMap = new Map(); + threads.forEach((thread) => { + threadMap.set(thread.id, thread.name); + }); + map.set(workspaceId, threadMap); + }); + return map; + }, [threadsByWorkspace]); + + const shouldNotifyCompletion = useCallback( + (threadKey: string) => { + if (!enabled) { + return false; + } + if (isWindowFocused) { + return false; + } + const lastNotifiedAt = lastNotifiedAtByThread.current.get(threadKey); + if (lastNotifiedAt && Date.now() - lastNotifiedAt < COMPLETION_DEDUP_MS) { + return false; + } + lastNotifiedAtByThread.current.set(threadKey, Date.now()); + return true; + }, + [enabled, isWindowFocused], + ); + + const shouldNotifyApproval = useCallback( + (approvalKey: string) => { + if (!enabled) { + return false; + } + if (isWindowFocused) { + return false; + } + if (notifiedApprovalKeys.current.has(approvalKey)) { + return false; + } + notifiedApprovalKeys.current.add(approvalKey); + return true; + }, + [enabled, isWindowFocused], + ); + + const notifyAgentCompleted = useCallback( + async (workspaceId: string, threadId: string) => { + const threadKey = buildThreadKey(workspaceId, threadId); + if (!shouldNotifyCompletion(threadKey)) { + return; + } + const workspaceName = workspaceLabels.get(workspaceId); + const threadName = threadLabelsByWorkspace.get(workspaceId)?.get(threadId); + const title = threadName ? `Agent finished: ${threadName}` : "Agent finished"; + const body = workspaceName ? `Workspace: ${workspaceName}` : undefined; + await sendLocalNotification( + { title, body }, + onDebug, + { + title, + body, + workspaceId, + threadId, + kind: "completion", + }, + ); + }, + [ + onDebug, + shouldNotifyCompletion, + threadLabelsByWorkspace, + workspaceLabels, + ], + ); + + const notifyApprovalRequest = useCallback( + async (request: ApprovalRequest) => { + const approvalKey = buildApprovalKey(request.workspace_id, request.request_id); + if (!shouldNotifyApproval(approvalKey)) { + return; + } + const workspaceName = workspaceLabels.get(request.workspace_id); + const methodLabel = formatApprovalMethod(request.method); + const bodyParts = []; + if (workspaceName) { + bodyParts.push(workspaceName); + } + if (methodLabel) { + bodyParts.push(methodLabel); + } + const body = bodyParts.length ? bodyParts.join(" - ") : undefined; + const params = request.params ?? {}; + const inferredThreadId = + typeof params.thread_id === "string" + ? params.thread_id + : typeof params.threadId === "string" + ? params.threadId + : null; + await sendLocalNotification( + { title: "Approval needed", body }, + onDebug, + { + title: "Approval needed", + body, + workspaceId: request.workspace_id, + threadId: inferredThreadId, + kind: "approval", + }, + ); + }, + [onDebug, shouldNotifyApproval, workspaceLabels], + ); + + const handleTurnCompleted = useCallback( + (workspaceId: string, threadId: string) => { + void notifyAgentCompleted(workspaceId, threadId); + }, + [notifyAgentCompleted], + ); + + const handleAgentMessageCompleted = useCallback( + (event: { workspaceId: string; threadId: string }) => { + void notifyAgentCompleted(event.workspaceId, event.threadId); + }, + [notifyAgentCompleted], + ); + + const handlers = useMemo( + () => ({ + onTurnCompleted: handleTurnCompleted, + onAgentMessageCompleted: handleAgentMessageCompleted, + onApprovalRequest: (request: ApprovalRequest) => { + void notifyApprovalRequest(request); + }, + }), + [handleAgentMessageCompleted, handleTurnCompleted, notifyApprovalRequest], + ); + + useAppServerEvents(handlers); +} diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index b6b44aa6e..6419a872c 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -708,7 +708,28 @@ export function SettingsView({
Sounds
- Control notification audio alerts. + Control notification alerts. +
+
+
+
Push notifications
+
+ Show a local notification when an agent finishes or needs approval. +
+
+
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 85a48882c..bd5e4183a 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -16,6 +16,7 @@ const defaultSettings: AppSettings = { lastComposerReasoningEffort: null, uiScale: UI_SCALE_DEFAULT, notificationSoundsEnabled: true, + notificationPushEnabled: true, experimentalCollabEnabled: false, experimentalSteerEnabled: false, experimentalUnifiedExecEnabled: false, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 26774a574..7837ddb11 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -6,6 +6,7 @@ import type { DictationModelStatus, DictationSessionState, LocalUsageSnapshot, + NativeNotificationPayload, WorkspaceInfo, WorkspaceSettings, } from "../types"; @@ -92,6 +93,12 @@ export async function openWorkspaceIn(path: string, app: string): Promise return invoke("open_workspace_in", { path, app }); } +export async function sendNativeNotification( + payload: NativeNotificationPayload, +): Promise { + return invoke("send_native_notification", { payload }); +} + export async function connectWorkspace(id: string): Promise { return invoke("connect_workspace", { id }); } diff --git a/src/types.ts b/src/types.ts index 3ad56871c..f96d72441 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,7 @@ export type AppSettings = { lastComposerReasoningEffort: string | null; uiScale: number; notificationSoundsEnabled: boolean; + notificationPushEnabled: boolean; experimentalCollabEnabled: boolean; experimentalSteerEnabled: boolean; experimentalUnifiedExecEnabled: boolean; @@ -113,6 +114,17 @@ export type ApprovalRequest = { params: Record; }; +export type NotificationClickPayload = { + workspaceId: string; + threadId?: string | null; + kind: "completion" | "approval"; +}; + +export type NativeNotificationPayload = NotificationClickPayload & { + title: string; + body?: string; +}; + export type GitFileStatus = { path: string; status: string; diff --git a/src/utils/pushNotifications.ts b/src/utils/pushNotifications.ts new file mode 100644 index 000000000..45f08741a --- /dev/null +++ b/src/utils/pushNotifications.ts @@ -0,0 +1,82 @@ +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from "@tauri-apps/plugin-notification"; +import type { DebugEntry } from "../types"; +import type { NativeNotificationPayload } from "../types"; +import { sendNativeNotification } from "../services/tauri"; + +type DebugLogger = (entry: DebugEntry) => void; + +export type PushNotificationPayload = { + title: string; + body?: string; +}; + +let permissionPromise: Promise | null = null; +let permissionGranted: boolean | null = null; + +async function ensureNotificationPermission(onDebug?: DebugLogger) { + if (permissionGranted !== null) { + return permissionGranted; + } + + if (!permissionPromise) { + permissionPromise = (async () => { + try { + let granted = await isPermissionGranted(); + if (!granted) { + const permission = await requestPermission(); + granted = permission === "granted"; + } + permissionGranted = granted; + return granted; + } catch (error) { + onDebug?.({ + id: `${Date.now()}-notification-permission-error`, + timestamp: Date.now(), + source: "error", + label: "notification/permission error", + payload: error instanceof Error ? error.message : String(error), + }); + permissionGranted = false; + return false; + } finally { + permissionPromise = null; + } + })(); + } + + return permissionPromise; +} + +export async function sendLocalNotification( + payload: PushNotificationPayload, + onDebug?: DebugLogger, + target?: NativeNotificationPayload, +) { + const allowed = await ensureNotificationPermission(onDebug); + if (!allowed) { + return false; + } + try { + if (target) { + const handled = await sendNativeNotification(target); + if (handled) { + return true; + } + } + sendNotification(payload); + return true; + } catch (error) { + onDebug?.({ + id: `${Date.now()}-notification-send-error`, + timestamp: Date.now(), + source: "error", + label: "notification/send error", + payload: error instanceof Error ? error.message : String(error), + }); + return false; + } +}