fix(notifications): request OS permission at startup and fix socketService listener leak (#998)

This commit is contained in:
Mega Mind
2026-04-28 18:56:44 -07:00
committed by GitHub
parent dcbcf7cc4e
commit 23a18c9c3f
21 changed files with 893 additions and 39 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
export { startNativeNotificationsService, stopNativeNotificationsService } from './service';
export { showNativeNotification } from './tauriBridge';
export { ensureNotificationPermission, showNativeNotification } from './tauriBridge';
+25 -3
View File
@@ -7,7 +7,7 @@ import {
type NotificationItem,
notificationReceived,
} from '../../store/notificationSlice';
import { showNativeNotification } from './tauriBridge';
import { ensureNotificationPermission, showNativeNotification } from './tauriBridge';
const log = debug('native-notifications');
@@ -52,18 +52,27 @@ function dispatchAndMaybeBanner(
timestampOverride?: number
): void {
const prefs = store.getState().notifications.preferences;
log(
'[dispatch] category=%s id=%s enabled=%s focused=%s',
category,
item.id,
prefs[category],
windowIsFocused()
);
if (!prefs[category]) {
log('category %s disabled, skipping', category);
return;
}
const timestamp = timestampOverride && timestampOverride > 0 ? timestampOverride : Date.now();
const full: NotificationItem = { ...item, category, timestamp, read: false };
log('[dispatch] enqueue id=%s title=%s', full.id, full.title);
store.dispatch(notificationReceived(full));
// Only fire OS-level banner when the user isn't already looking at the
// window — otherwise the in-app center is enough and a native toast is
// redundant noise.
if (!windowIsFocused()) {
void showNativeNotification({ title: full.title, body: full.body, tag: full.id });
log('[dispatch] window unfocused, firing native banner id=%s', full.id);
void showNativeNotification({ title: full.title, body: full.body });
}
}
@@ -82,8 +91,15 @@ export function startNativeNotificationsService(): void {
if (started) return;
started = true;
// Request OS notification permission early so native banners can fire.
// Fire-and-forget — permission state is logged for diagnostics.
void ensureNotificationPermission().then(granted => {
log('notification permission ensured: granted=%s', granted);
});
chatDoneListener = (...args: unknown[]) => {
const p = (args[0] ?? {}) as ChatDonePayload;
log('[socket] chat_done');
dispatchAndMaybeBanner('agents', {
id: `chat_done:${p.thread_id ?? 'unknown'}:${p.request_id ?? Date.now()}`,
title: 'Agent reply ready',
@@ -94,6 +110,7 @@ export function startNativeNotificationsService(): void {
chatErrorListener = (...args: unknown[]) => {
const p = (args[0] ?? {}) as ChatErrorPayload;
log('[socket] chat_error');
dispatchAndMaybeBanner('system', {
id: `chat_error:${p.thread_id ?? 'unknown'}:${p.request_id ?? Date.now()}`,
title: 'Agent error',
@@ -107,7 +124,11 @@ export function startNativeNotificationsService(): void {
// bus. See src/openhuman/notifications/bus.rs.
coreNotificationListener = (...args: unknown[]) => {
const p = (args[0] ?? {}) as CoreNotificationPayload;
if (!p.id || !p.title) return;
log('[socket] core_notification id=%s category=%s', p.id, p.category);
if (!p.id || !p.title) {
log('[socket] core_notification missing id/title dropped');
return;
}
const serverTs = p.timestamp_ms && p.timestamp_ms > 0 ? p.timestamp_ms : Date.now();
dispatchAndMaybeBanner(
p.category,
@@ -123,6 +144,7 @@ export function startNativeNotificationsService(): void {
disconnectListener = (...args: unknown[]) => {
const reason = typeof args[0] === 'string' ? args[0] : 'unknown';
log('[socket] disconnect reason=%s', reason);
dispatchAndMaybeBanner('system', {
id: `socket_disconnect:${Date.now()}`,
title: 'Connection lost',
+35 -6
View File
@@ -7,7 +7,38 @@ const errLog = debug('native-notifications:bridge:error');
export interface ShowNativeNotificationArgs {
title: string;
body: string;
tag?: string;
}
/**
* Request OS notification permission if not already granted.
* Returns true if permission is (or was just) granted, false otherwise.
* No-op (returns false) when running outside Tauri.
*/
export async function ensureNotificationPermission(): Promise<boolean> {
if (!isTauri()) {
log('not running in tauri, skipping permission request');
return false;
}
try {
const grantedRaw = await invoke<boolean | null>('plugin:notification|is_permission_granted');
const granted = grantedRaw === true;
log('notification permission check (plugin): granted=%s raw=%o', granted, grantedRaw);
if (granted) return true;
const requestResult = await invoke<string>('plugin:notification|request_permission');
const requestState = String(requestResult ?? 'unknown').toLowerCase();
const nowGranted = requestState === 'granted' || requestState === 'provisional';
log('notification permission request result=%s granted=%s', requestState, nowGranted);
if (nowGranted) return true;
// Re-check once after request because some platforms may not return
// a definitive granted state from request_permission directly.
const pluginGranted = await invoke<boolean | null>('plugin:notification|is_permission_granted');
return pluginGranted === true;
} catch (err) {
errLog('ensureNotificationPermission failed: %O', err);
return false;
}
}
/**
@@ -20,12 +51,10 @@ export async function showNativeNotification(args: ShowNativeNotificationArgs):
return;
}
try {
await invoke('show_native_notification', {
title: args.title,
body: args.body,
tag: args.tag ?? null,
await invoke('plugin:notification|notify', {
options: { title: args.title, body: args.body, sound: 'default' },
});
} catch (err) {
errLog('show_native_notification failed: %O', err);
errLog('plugin:notification|notify failed: %O', err);
}
}