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
+27 -1
View File
@@ -4,11 +4,12 @@ version = 4
[[package]]
name = "OpenHuman"
version = "0.53.3"
version = "0.53.4"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"block2",
"cef",
"chrono",
"directories",
@@ -21,6 +22,8 @@ dependencies = [
"notify-rust",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-user-notifications",
"parking_lot",
"reqwest 0.12.28",
"rusqlite",
@@ -2825,6 +2828,16 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
@@ -2946,6 +2959,19 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"bitflags 2.11.0",
"block2",
"objc2",
"objc2-core-location",
"objc2-foundation",
]
[[package]]
name = "objc2-web-kit"
version = "0.3.2"
+3
View File
@@ -113,6 +113,9 @@ objc2-app-kit = "0.3.2"
mac-notification-sys = "0.6"
# iMessage scanner reads ~/Library/Messages/chat.db read-only on macOS.
rusqlite = { version = "0.37", features = ["bundled"] }
objc2-user-notifications = "0.3.2"
block2 = "0.6.2"
objc2-foundation = "0.3.2"
[target.'cfg(target_os = "linux")'.dependencies]
notify-rust = { version = "4", default-features = false, features = ["dbus"] }
+99 -1
View File
@@ -637,8 +637,13 @@ fn show_native_notification(
tag: Option<String>,
) -> Result<(), String> {
use tauri_plugin_notification::NotificationExt;
let permission_state = app
.notification()
.permission_state()
.map(|s| format!("{s:?}"))
.unwrap_or_else(|e| format!("err({e})"));
log::debug!(
"[notify] show_native_notification title_chars={} body_chars={} tag={:?}",
"[notify] show_native_notification title_chars={} body_chars={} tag={:?} permission={permission_state}",
title.len(),
body.len(),
tag
@@ -647,11 +652,102 @@ fn show_native_notification(
if !body.is_empty() {
builder = builder.body(&body);
}
#[cfg(target_os = "macos")]
{
builder = builder.sound("default");
}
builder
.show()
.map_err(|e| format!("notification show failed: {e}"))
}
#[cfg(target_os = "macos")]
fn macos_notification_permission_state_inner() -> Result<String, String> {
use std::ptr::NonNull;
use std::sync::mpsc;
use block2::RcBlock;
use objc2_user_notifications::{
UNAuthorizationStatus, UNNotificationSettings, UNUserNotificationCenter,
};
let center = UNUserNotificationCenter::currentNotificationCenter();
let (tx, rx) = mpsc::channel::<String>();
let completion = RcBlock::new(move |settings: NonNull<UNNotificationSettings>| {
let status = unsafe { settings.as_ref().authorizationStatus() };
let state = if status == UNAuthorizationStatus::Authorized {
"granted"
} else if status == UNAuthorizationStatus::Denied {
"denied"
} else if status == UNAuthorizationStatus::NotDetermined {
"not_determined"
} else if status == UNAuthorizationStatus::Provisional {
"provisional"
} else if status == UNAuthorizationStatus::Ephemeral {
"ephemeral"
} else {
"unknown"
};
let _ = tx.send(state.to_string());
});
center.getNotificationSettingsWithCompletionHandler(&completion);
rx.recv_timeout(std::time::Duration::from_secs(2))
.map_err(|_| "timed out waiting for macOS notification settings".to_string())
}
#[cfg(target_os = "macos")]
fn macos_notification_permission_request_inner() -> Result<String, String> {
use block2::RcBlock;
use objc2::runtime::Bool;
use objc2_foundation::NSError;
use objc2_user_notifications::{UNAuthorizationOptions, UNUserNotificationCenter};
use std::sync::mpsc;
let center = UNUserNotificationCenter::currentNotificationCenter();
let (tx, rx) = mpsc::channel::<bool>();
let options = UNAuthorizationOptions::Alert
| UNAuthorizationOptions::Badge
| UNAuthorizationOptions::Sound;
let completion = RcBlock::new(move |granted: Bool, _error: *mut NSError| {
let _ = tx.send(granted.as_bool());
});
center.requestAuthorizationWithOptions_completionHandler(options, &completion);
let granted = rx
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(|_| "timed out waiting for macOS permission prompt result".to_string())?;
if granted {
Ok("granted".to_string())
} else {
// If the user denies or notifications are disabled for the app,
// macOS reports `false` here.
Ok("denied".to_string())
}
}
#[tauri::command]
fn notification_permission_state() -> Result<String, String> {
#[cfg(target_os = "macos")]
{
return macos_notification_permission_state_inner();
}
#[cfg(not(target_os = "macos"))]
{
Ok("granted".to_string())
}
}
#[tauri::command]
fn notification_permission_request() -> Result<String, String> {
#[cfg(target_os = "macos")]
{
return macos_notification_permission_request_inner();
}
#[cfg(not(target_os = "macos"))]
{
Ok("granted".to_string())
}
}
fn show_main_window(app: &AppHandle<AppRuntime>) -> Result<(), String> {
let window = app
.get_webview_window("main")
@@ -1383,6 +1479,8 @@ pub fn run() {
gmail::gmail_trash,
gmail::gmail_add_label,
gmail::gmail_find_linkedin_profile_url,
notification_permission_state,
notification_permission_request,
activate_main_window,
show_native_notification
])
+54 -9
View File
@@ -10,10 +10,14 @@
*
* Mounted once at AppShell root.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import { showNativeNotification } from '../lib/nativeNotifications/tauriBridge';
import {
ensureNotificationPermission,
showNativeNotification,
} from '../lib/nativeNotifications/tauriBridge';
import { purgeWebviewAccount } from '../services/webviewAccountService';
import { addAccount, removeAccount } from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
@@ -181,21 +185,53 @@ function renderBody(path: string, close: () => void) {
// ── Notifications ────────────────────────────────────────────────────────
const NotificationsBody = ({ close }: { close: () => void }) => {
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [status, setStatus] = useState<'idle' | 'sending' | 'queued' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const TEST_NOTIFICATION_DELAY_MS = 3500;
const notificationTimeoutRef = useRef<number | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
return () => {
cancelledRef.current = true;
if (notificationTimeoutRef.current !== null) {
window.clearTimeout(notificationTimeoutRef.current);
notificationTimeoutRef.current = null;
}
};
}, []);
const handleAllow = async () => {
setStatus('sending');
setError(null);
try {
// First send triggers the OS permission prompt on macOS / Windows.
// Once granted the notification appears and subsequent calls
// succeed silently.
const granted = await ensureNotificationPermission();
if (coreIsTauri() && !granted) {
setStatus('error');
setError(
'Notification permission was denied. Please enable notifications for OpenHuman in System Settings Notifications.'
);
return;
}
// Delay the test ping so users can move focus away from OpenHuman.
// macOS commonly suppresses banner popouts while the app is foreground.
setStatus('queued');
await new Promise<void>(resolve => {
notificationTimeoutRef.current = window.setTimeout(() => {
notificationTimeoutRef.current = null;
resolve();
}, TEST_NOTIFICATION_DELAY_MS);
});
if (cancelledRef.current) {
return;
}
await showNativeNotification({
title: 'OpenHuman is good to go',
body: 'You will get pings here when something needs your attention.',
tag: 'welcome-notification-test',
});
if (cancelledRef.current) {
return;
}
setStatus('sent');
} catch (e) {
setStatus('error');
@@ -213,10 +249,19 @@ const NotificationsBody = ({ close }: { close: () => void }) => {
<button
type="button"
onClick={() => void handleAllow()}
disabled={status === 'sending'}
disabled={status === 'sending' || status === 'queued'}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60">
{status === 'sending' ? 'Asking your OS' : 'Send test notification'}
{status === 'sending'
? 'Asking your OS'
: status === 'queued'
? 'Sending test in 3.5s switch apps now'
: 'Send test notification'}
</button>
{status === 'queued' && (
<p className="text-xs text-stone-600">
Timer started. Switch to another app for a moment so the banner can pop out.
</p>
)}
{status === 'sent' && (
<p className="text-xs text-sage-700">
Sent. If you saw a pop-up in the corner, you're all set. If your OS asked for permission,
@@ -50,12 +50,21 @@ function scoreBadgeClass(score: number): string {
interface Props {
notification: IntegrationNotification;
onMarkRead: (id: string) => void;
onNavigate?: (id: string) => void;
onDismiss?: (id: string) => void;
}
const NotificationCard = ({ notification: n, onMarkRead, onDismiss }: Props) => {
const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }: Props) => {
const isUnread = n.status === 'unread';
const handleBodyClick = () => {
if (onNavigate) {
onNavigate(n.id);
} else if (isUnread) {
onMarkRead(n.id);
}
};
return (
<div
className={`w-full p-3 border-b border-stone-100 hover:bg-stone-50 transition-colors duration-150 ${
@@ -70,9 +79,7 @@ const NotificationCard = ({ notification: n, onMarkRead, onDismiss }: Props) =>
</div>
<button
onClick={() => {
if (isUnread) onMarkRead(n.id);
}}
onClick={handleBodyClick}
className="flex-1 min-w-0 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 rounded-sm">
{/* Header row: provider badge + timestamp */}
<div className="flex items-center gap-2 mb-1">
@@ -1,13 +1,17 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { resolveIntegrationRoute } from '../../lib/notificationRouter';
import {
dismissNotification,
fetchNotifications,
markNotificationActed,
markNotificationRead,
} from '../../services/notificationService';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
dismissIntegrationNotification,
markIntegrationActed,
markIntegrationRead,
setIntegrationError,
setIntegrationLoading,
@@ -21,6 +25,7 @@ import NotificationCard from './NotificationCard';
const NotificationCenter = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const {
integrationItems: items,
integrationLoading: loading,
@@ -73,6 +78,20 @@ const NotificationCenter = () => {
}
};
/** Navigate to the resolved route for the notification and mark it as acted. */
const handleNavigate = async (id: string) => {
const n = items.find(i => i.id === id);
if (!n) return;
const route = resolveIntegrationRoute(n);
dispatch(markIntegrationActed(id));
navigate(route);
try {
await markNotificationActed(id);
} catch {
// Optimistic update already applied; failure is non-critical.
}
};
const handleDismiss = async (id: string) => {
dispatch(dismissIntegrationNotification(id));
try {
@@ -191,6 +210,9 @@ const NotificationCenter = () => {
onMarkRead={id => {
void handleMarkRead(id);
}}
onNavigate={id => {
void handleNavigate(id);
}}
onDismiss={id => {
void handleDismiss(id);
}}
+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);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import type { NotificationItem } from '../store/notificationSlice';
import type { IntegrationNotification } from '../types/notifications';
import { resolveIntegrationRoute, resolveSystemRoute } from './notificationRouter';
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
const makeIntegration = (
overrides: Partial<IntegrationNotification> = {}
): IntegrationNotification => ({
id: 'i-1',
provider: 'slack',
title: 'Test',
body: 'Body',
raw_payload: {},
status: 'unread',
received_at: '2026-04-29T00:00:00Z',
...overrides,
});
const makeSystem = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 's-1',
category: 'messages',
title: 'Test',
body: 'Body',
timestamp: 1,
read: false,
...overrides,
});
// ─────────────────────────────────────────────────────────────────────────────
// resolveIntegrationRoute
// ─────────────────────────────────────────────────────────────────────────────
describe('resolveIntegrationRoute', () => {
it('returns explicit deep_link when present', () => {
const n = makeIntegration({ deep_link: '/chat?account=abc' });
expect(resolveIntegrationRoute(n)).toBe('/chat?account=abc');
});
it.each(['gmail', 'slack', 'whatsapp', 'telegram', 'discord', 'linkedin'])(
'routes %s provider to /chat',
provider => {
expect(resolveIntegrationRoute(makeIntegration({ provider }))).toBe('/chat');
}
);
it('falls back to /notifications for unknown providers', () => {
expect(resolveIntegrationRoute(makeIntegration({ provider: 'unknown-app' }))).toBe(
'/notifications'
);
});
it('prefers deep_link over provider default', () => {
const n = makeIntegration({ provider: 'slack', deep_link: '/skills' });
expect(resolveIntegrationRoute(n)).toBe('/skills');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// resolveSystemRoute
// ─────────────────────────────────────────────────────────────────────────────
describe('resolveSystemRoute', () => {
it('returns explicit deepLink when present', () => {
const item = makeSystem({ deepLink: '/skills' });
expect(resolveSystemRoute(item)).toBe('/skills');
});
it('routes messages category to /chat', () => {
expect(resolveSystemRoute(makeSystem({ category: 'messages' }))).toBe('/chat');
});
it('routes agents category to /chat', () => {
expect(resolveSystemRoute(makeSystem({ category: 'agents' }))).toBe('/chat');
});
it('routes skills category to /skills', () => {
expect(resolveSystemRoute(makeSystem({ category: 'skills' }))).toBe('/skills');
});
it('routes system category to /home', () => {
expect(resolveSystemRoute(makeSystem({ category: 'system' }))).toBe('/home');
});
it('prefers deepLink over category default', () => {
const item = makeSystem({ category: 'messages', deepLink: '/notifications' });
expect(resolveSystemRoute(item)).toBe('/notifications');
});
});
+98
View File
@@ -0,0 +1,98 @@
import debug from 'debug';
import type { NotificationItem } from '../store/notificationSlice';
import type { IntegrationNotification } from '../types/notifications';
const log = debug('notifications:router');
// ─────────────────────────────────────────────────────────────────────────────
// Known in-app hash routes
// ─────────────────────────────────────────────────────────────────────────────
const ROUTES = {
chat: '/chat',
skills: '/skills',
home: '/home',
notifications: '/notifications',
} as const;
/**
* Providers whose notifications belong in the unified chat / accounts view.
* Add new provider slugs here as integrations are added.
*/
const MESSAGE_PROVIDERS = new Set([
'gmail',
'slack',
'whatsapp',
'telegram',
'discord',
'linkedin',
]);
// ─────────────────────────────────────────────────────────────────────────────
// Route resolvers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Resolve a hash-router path for an integration (provider) notification.
*
* Priority:
* 1. Explicit `deep_link` set by the core triage pipeline.
* 2. Provider default — message providers → /chat.
* 3. `/notifications` fallback.
*/
export function resolveIntegrationRoute(n: IntegrationNotification): string {
if (n.deep_link) {
log('[notification-router] integration id=%s explicit deep_link=%s', n.id, n.deep_link);
return n.deep_link;
}
if (MESSAGE_PROVIDERS.has(n.provider)) {
log('[notification-router] integration id=%s provider=%s → /chat', n.id, n.provider);
return ROUTES.chat;
}
log(
'[notification-router] integration id=%s provider=%s → /notifications (fallback)',
n.id,
n.provider
);
return ROUTES.notifications;
}
/**
* Resolve a hash-router path for a system-event (`NotificationItem`) notification.
*
* Priority:
* 1. Explicit `deepLink` stored on the item.
* 2. Category default: messages/agents → /chat; skills → /skills; system → /home.
* 3. `/notifications` fallback.
*/
export function resolveSystemRoute(item: NotificationItem): string {
if (item.deepLink) {
log('[notification-router] system id=%s explicit deepLink=%s', item.id, item.deepLink);
return item.deepLink;
}
switch (item.category) {
case 'messages':
log('[notification-router] system id=%s category=messages → /chat', item.id);
return ROUTES.chat;
case 'agents':
log('[notification-router] system id=%s category=agents → /chat', item.id);
return ROUTES.chat;
case 'skills':
log('[notification-router] system id=%s category=skills → /skills', item.id);
return ROUTES.skills;
case 'system':
log('[notification-router] system id=%s category=system → /home', item.id);
return ROUTES.home;
default:
log(
'[notification-router] system id=%s category=%s → /notifications (fallback)',
item.id,
item.category
);
return ROUTES.notifications;
}
}
+9 -4
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import NotificationCenter from '../components/notifications/NotificationCenter';
import { resolveSystemRoute } from '../lib/notificationRouter';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
clearAll,
@@ -38,18 +39,22 @@ const Notifications = () => {
const handleClick = (item: NotificationItem) => {
if (!item.read) dispatch(markRead({ id: item.id }));
if (item.deepLink) navigate(item.deepLink);
navigate(resolveSystemRoute(item));
};
return (
<div className="p-4 pt-6 space-y-4">
{/* Integration notifications — from connected accounts, scored by local AI */}
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden min-h-[200px]">
<div
data-testid="integration-notifications-section"
className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden min-h-[200px]">
<NotificationCenter />
</div>
{/* Core-bridge notifications — system events */}
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div
data-testid="system-events-section"
className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
<div>
<h1 className="text-lg font-semibold text-stone-900">System Events</h1>
@@ -80,7 +85,7 @@ const Notifications = () => {
) : (
<ul className="divide-y divide-stone-100">
{items.map(item => (
<li key={item.id}>
<li key={item.id} data-testid="notification-item">
<button
type="button"
onClick={() => handleClick(item)}
+60 -9
View File
@@ -117,6 +117,12 @@ class SocketService {
private token: string | null = null;
private mcpTransport: SocketIOMCPTransportImpl | null = null;
private pendingListeners: Array<{ event: string; callback: (...args: unknown[]) => void }> = [];
// Maps original caller callbacks → wrapped callbacks so off() can locate the
// exact function references that were registered with socket.io, scoped by event.
private listenerMap = new Map<
string,
Map<(...args: unknown[]) => void, Set<(...args: unknown[]) => void>>
>();
/**
* Connect to the socket server with authentication.
@@ -268,15 +274,17 @@ class SocketService {
* Disconnect from the socket server
*/
disconnect(): void {
const uid = getSocketUserId();
if (this.socket) {
const uid = getSocketUserId();
socketLog('Disconnecting', { userId: uid });
this.socket.disconnect();
this.socket = null;
this.token = null;
this.mcpTransport = null;
store.dispatch(resetForUser({ userId: uid }));
}
this.token = null;
this.mcpTransport = null;
this.listenerMap.clear();
this.pendingListeners = [];
}
/**
@@ -320,6 +328,13 @@ class SocketService {
socketLog('Received event', { event, argsCount: args.length, hasData: args.length > 0 });
callback(...args);
};
// Track original→wrapped per event so the same callback can be used for
// multiple events without collisions.
const byEvent = this.listenerMap.get(event) ?? new Map();
const wrappedSet = byEvent.get(callback) ?? new Set();
wrappedSet.add(wrappedCallback);
byEvent.set(callback, wrappedSet);
this.listenerMap.set(event, byEvent);
if (this.socket) {
this.socket.on(event, wrappedCallback);
} else {
@@ -332,12 +347,31 @@ class SocketService {
* Remove an event listener
*/
off(event: string, callback?: (...args: unknown[]) => void): void {
if (this.socket) {
if (callback) {
this.socket.off(event, callback);
} else {
this.socket.off(event);
if (callback) {
const byEvent = this.listenerMap.get(event);
const wrappedSet = byEvent?.get(callback);
const wrappedCallbacks =
wrappedSet && wrappedSet.size > 0 ? Array.from(wrappedSet) : [callback];
const hadWrapped = !!wrappedSet && wrappedSet.size > 0;
byEvent?.delete(callback);
if (byEvent && byEvent.size === 0) {
this.listenerMap.delete(event);
}
socketLog('Removing listener', { event, hadWrappedVersion: hadWrapped });
for (const wrapped of wrappedCallbacks) {
if (this.socket) {
this.socket.off(event, wrapped);
}
// Also remove from the pending queue in case the socket isn't up yet.
this.pendingListeners = this.pendingListeners.filter(
p => !(p.event === event && p.callback === wrapped)
);
}
} else {
socketLog('Removing all listeners for event', { event });
this.socket?.off(event);
this.pendingListeners = this.pendingListeners.filter(p => p.event !== event);
this.listenerMap.delete(event);
}
}
@@ -351,8 +385,25 @@ class SocketService {
argsCount: args.length,
hasData: args.length > 0,
});
callback(...args);
try {
callback(...args);
} finally {
const byEvent = this.listenerMap.get(event);
const wrappedSet = byEvent?.get(callback);
wrappedSet?.delete(wrappedCallback);
if (wrappedSet && wrappedSet.size === 0) {
byEvent?.delete(callback);
}
if (byEvent && byEvent.size === 0) {
this.listenerMap.delete(event);
}
}
};
const byEvent = this.listenerMap.get(event) ?? new Map();
const wrappedSet = byEvent.get(callback) ?? new Set();
wrappedSet.add(wrappedCallback);
byEvent.set(callback, wrappedSet);
this.listenerMap.set(event, byEvent);
if (this.socket) {
this.socket.once(event, wrappedCallback);
} else {
@@ -27,6 +27,13 @@ describe('notificationSlice', () => {
expect(s2.items.map(i => i.id)).toEqual(['b', 'a']);
});
it('notificationReceived dedupes by id', () => {
let s = reducer(undefined, notificationReceived(makeItem({ id: 'dup', title: 'first' })));
s = reducer(s, notificationReceived(makeItem({ id: 'dup', title: 'updated' })));
expect(s.items).toHaveLength(1);
expect(s.items[0].title).toBe('updated');
});
it('drops item when its category preference is off', () => {
let s = reducer(undefined, setPreference({ category: 'messages', enabled: false }));
s = reducer(s, notificationReceived(makeItem({ id: 'a', category: 'messages' })));
@@ -4,6 +4,7 @@ import type { IntegrationNotification } from '../../types/notifications';
import notificationReducer, {
addIntegrationNotification,
dismissIntegrationNotification,
markIntegrationActed,
markIntegrationRead,
setIntegrationNotifications,
} from '../notificationSlice';
@@ -80,6 +81,41 @@ describe('notificationSlice — integration notifications', () => {
});
});
describe('markIntegrationActed', () => {
it('sets status to acted and decrements unread count for an unread notification', () => {
const n = makeNotification({ id: 'n-1', status: 'unread' });
const loaded = notificationReducer(
initialState,
setIntegrationNotifications({ items: [n], unread_count: 1 })
);
const state = notificationReducer(loaded, markIntegrationActed('n-1'));
expect(state.integrationItems[0].status).toBe('acted');
expect(state.integrationUnreadCount).toBe(0);
});
it('sets status to acted without changing unread count for a read notification', () => {
const n = makeNotification({ id: 'n-1', status: 'read' });
const loaded = notificationReducer(
initialState,
setIntegrationNotifications({ items: [n], unread_count: 0 })
);
const state = notificationReducer(loaded, markIntegrationActed('n-1'));
expect(state.integrationItems[0].status).toBe('acted');
expect(state.integrationUnreadCount).toBe(0);
});
it('is a no-op for unknown id', () => {
const n = makeNotification({ id: 'n-1', status: 'unread' });
const loaded = notificationReducer(
initialState,
setIntegrationNotifications({ items: [n], unread_count: 1 })
);
const state = notificationReducer(loaded, markIntegrationActed('does-not-exist'));
expect(state.integrationUnreadCount).toBe(1);
expect(state.integrationItems[0].status).toBe('unread');
});
});
describe('addIntegrationNotification', () => {
it('prepends a new unread notification and increments integrationUnreadCount', () => {
const n1 = makeNotification({ id: 'n-1' });
+18
View File
@@ -51,6 +51,13 @@ const notificationSlice = createSlice({
notificationReceived(state, action: PayloadAction<NotificationItem>) {
const item = action.payload;
if (!state.preferences[item.category]) return;
const existingIndex = state.items.findIndex(i => i.id === item.id);
if (existingIndex >= 0) {
// Replace existing entry in place to avoid duplicate rows when
// socket reconnects or upstream replays the same event id.
state.items[existingIndex] = item;
return;
}
state.items.unshift(item);
if (state.items.length > MAX_ITEMS) {
state.items.length = MAX_ITEMS;
@@ -95,6 +102,16 @@ const notificationSlice = createSlice({
state.integrationUnreadCount = Math.max(0, state.integrationUnreadCount - 1);
}
},
markIntegrationActed(state, action: PayloadAction<string>) {
const n = state.integrationItems.find(i => i.id === action.payload);
if (n) {
const wasUnread = n.status === 'unread';
n.status = 'acted';
if (wasUnread) {
state.integrationUnreadCount = Math.max(0, state.integrationUnreadCount - 1);
}
}
},
dismissIntegrationNotification(state, action: PayloadAction<string>) {
const n = state.integrationItems.find(i => i.id === action.payload);
if (n) {
@@ -133,6 +150,7 @@ export const {
setIntegrationError,
setIntegrationNotifications,
markIntegrationRead,
markIntegrationActed,
dismissIntegrationNotification,
addIntegrationNotification,
} = notificationSlice.actions;
+2
View File
@@ -21,6 +21,8 @@ export interface IntegrationNotification {
received_at: string;
/** ISO 8601 timestamp — undefined until triage completes */
scored_at?: string;
/** Optional in-app hash route (e.g. "/chat") set by the core triage pipeline. */
deep_link?: string;
}
export interface NotificationSettings {
+5
View File
@@ -111,6 +111,7 @@ const HASH_TO_SIDEBAR_LABEL = {
'/skills': 'Skills',
'/home': 'Home',
'/conversations': 'Conversations',
'/notifications': 'Alerts',
'/settings': 'Settings',
'/intelligence': 'Intelligence',
};
@@ -277,6 +278,10 @@ export async function navigateToConversations() {
await navigateViaHash('/conversations');
}
export async function navigateToNotifications() {
await navigateViaHash('/notifications');
}
// ---------------------------------------------------------------------------
// Onboarding walkthrough
// Current flow: Welcome → Local AI → Screen & Accessibility → Tools → Skills (5 steps, indices 04).
+264
View File
@@ -0,0 +1,264 @@
// @ts-nocheck
import { browser, expect } from '@wdio/globals';
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
import { callOpenhumanRpc } from '../helpers/core-rpc';
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
import {
dumpAccessibilityTree,
waitForText,
waitForWebView,
waitForWindowVisible,
} from '../helpers/element-helpers';
import { supportsExecuteScript } from '../helpers/platform';
import { completeOnboardingIfVisible, navigateViaHash } from '../helpers/shared-flows';
import { startMockServer, stopMockServer } from '../mock-server';
function stepLog(message: string, context?: unknown): void {
const stamp = new Date().toISOString();
if (context === undefined) {
console.log(`[NotificationsE2E][${stamp}] ${message}`);
return;
}
console.log(`[NotificationsE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2));
}
function getUnreadCount(stats: Record<string, unknown>): number {
const candidates = ['unread_count', 'unread', 'total_unread'];
for (const key of candidates) {
const value = stats[key];
if (typeof value === 'number') return value;
}
return 0;
}
async function waitForNotificationsSections(timeout = 10_000): Promise<void> {
await browser.waitUntil(
async () =>
(await browser.execute(() => {
const integration = document.querySelector(
'[data-testid="integration-notifications-section"]'
);
const system = document.querySelector('[data-testid="system-events-section"]');
return integration !== null && system !== null;
})) === true,
{ timeout, timeoutMsg: 'Notifications sections did not render in time' }
);
}
/**
* Poll the core ping/about RPC until it responds or the deadline expires.
* Fails fast if the sidecar is not reachable within the timeout.
*/
async function waitForCoreSidecar(timeout = 30_000): Promise<void> {
let lastErr: unknown;
await browser.waitUntil(
async () => {
const result = await callOpenhumanRpc('openhuman.about_info', {});
if (result.ok) {
stepLog('core sidecar ready', { result: result.result });
return true;
}
lastErr = result.error;
return false;
},
{
timeout,
interval: 1_000,
timeoutMsg: `Core sidecar not ready after ${timeout}ms: ${String(lastErr)}`,
}
);
}
describe('Notifications', () => {
before(async () => {
await startMockServer();
await waitForApp();
await triggerAuthDeepLinkBypass('e2e-notifications-user');
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await waitForAppReady(15_000);
await completeOnboardingIfVisible('[NotificationsE2E]');
// Fail fast if core sidecar is not up.
await waitForCoreSidecar(30_000);
});
after(async () => {
await stopMockServer();
});
it('notification_ingest creates a new notification via core RPC', async () => {
const result = await callOpenhumanRpc('openhuman.notification_ingest', {
id: 'e2e-notif-001',
category: 'system',
title: 'E2E Test Notification',
body: 'Created by the notifications E2E spec',
timestamp_ms: Date.now(),
});
stepLog('notification_ingest result', { ok: result.ok, result: result.result });
expect(result.ok).toBe(true);
const payload = result.result?.result ?? {};
expect(payload.skipped).not.toBe(true);
expect(payload.id).toBe('e2e-notif-001');
});
it('notification_list returns the ingested notification', async () => {
const result = await callOpenhumanRpc('openhuman.notification_list', { limit: 20 });
stepLog('notification_list result', { ok: result.ok, result: result.result });
expect(result.ok).toBe(true);
const items: unknown[] =
result.result?.result?.notifications ?? result.result?.result?.items ?? [];
const found = items.some(
(n: unknown) =>
typeof n === 'object' &&
n !== null &&
(n as Record<string, unknown>)['id'] === 'e2e-notif-001'
);
expect(found).toBe(true);
});
it('notification_mark_read transitions notification status', async () => {
const before = await callOpenhumanRpc('openhuman.notification_stats', {});
expect(before.ok).toBe(true);
const beforeStats = before.result?.result ?? {};
const initialUnread = getUnreadCount(beforeStats);
const result = await callOpenhumanRpc('openhuman.notification_mark_read', {
id: 'e2e-notif-001',
});
stepLog('notification_mark_read result', { ok: result.ok, result: result.result });
expect(result.ok).toBe(true);
const after = await callOpenhumanRpc('openhuman.notification_stats', {});
expect(after.ok).toBe(true);
const afterStats = after.result?.result ?? {};
const finalUnread = getUnreadCount(afterStats);
if (initialUnread > 0) {
expect(finalUnread).toBeLessThan(initialUnread);
} else {
expect(finalUnread).toBe(0);
}
});
it('notification_stats returns aggregate statistics', async () => {
const result = await callOpenhumanRpc('openhuman.notification_stats', {});
stepLog('notification_stats result', { ok: result.ok, result: result.result });
expect(result.ok).toBe(true);
const stats = result.result?.result ?? {};
// Stats must have at least a numeric total or unread count.
const hasNumericField = Object.values(stats).some(v => typeof v === 'number');
expect(hasNumericField).toBe(true);
});
it('Notifications page renders integration notifications', async () => {
if (!supportsExecuteScript()) {
stepLog('skipping UI test — supportsExecuteScript() is false (Appium Mac2)');
return;
}
await navigateViaHash('/notifications');
await waitForNotificationsSections(10_000);
const currentHash = await browser.execute(() => window.location.hash);
stepLog('Notifications route hash', { currentHash });
expect(String(currentHash)).toContain('/notifications');
// The integration notifications section wraps NotificationCenter.
const sectionVisible = await browser.execute(() => {
const el = document.querySelector('[data-testid="integration-notifications-section"]');
return el !== null;
});
if (!sectionVisible) {
const tree = await dumpAccessibilityTree();
stepLog('integration-notifications-section not found', { tree: tree.slice(0, 4000) });
}
expect(sectionVisible).toBe(true);
await waitForText('E2E Test Notification', 8_000);
await waitForText('Created by the notifications E2E spec', 8_000);
});
it('Notifications page shows System Events section', async () => {
if (!supportsExecuteScript()) {
stepLog('skipping UI test — supportsExecuteScript() is false (Appium Mac2)');
return;
}
await navigateViaHash('/notifications');
await waitForNotificationsSections(10_000);
const sectionVisible = await browser.execute(() => {
const el = document.querySelector('[data-testid="system-events-section"]');
return el !== null;
});
if (!sectionVisible) {
const tree = await dumpAccessibilityTree();
stepLog('system-events-section not found', { tree: tree.slice(0, 4000) });
}
expect(sectionVisible).toBe(true);
// The heading text should also be present.
await waitForText('System Events', 8_000);
await waitForText('All caught up', 8_000);
});
it('native notification permission command returns a valid state', async () => {
if (!supportsExecuteScript()) {
stepLog('skipping tauri command test — supportsExecuteScript() is false (Appium Mac2)');
return;
}
// E2E command-wiring validation intentionally exercises the low-level
// invoke bridge from the webview context.
const state = await browser.execute(async () => {
const invoker = (window as unknown as { __TAURI_INTERNALS__?: { invoke?: Function } })
.__TAURI_INTERNALS__?.invoke;
if (typeof invoker !== 'function') {
throw new Error('window.__TAURI_INTERNALS__.invoke is not available');
}
return await invoker('notification_permission_state');
});
stepLog('notification_permission_state result', { state });
const allowedStates = [
'granted',
'denied',
'not_determined',
'provisional',
'ephemeral',
'unknown',
];
expect(allowedStates.includes(String(state))).toBe(true);
});
it('native notification plugin command is callable from webview', async () => {
if (!supportsExecuteScript()) {
stepLog('skipping tauri command test — supportsExecuteScript() is false (Appium Mac2)');
return;
}
// E2E command-wiring validation intentionally exercises the low-level
// invoke bridge from the webview context.
const result = await browser.execute(async () => {
const invoker = (window as unknown as { __TAURI_INTERNALS__?: { invoke?: Function } })
.__TAURI_INTERNALS__?.invoke;
if (typeof invoker !== 'function') {
throw new Error('window.__TAURI_INTERNALS__.invoke is not available');
}
await invoker('plugin:notification|notify', {
options: {
title: 'OpenHuman E2E notification',
body: 'Verifies the plugin command is wired and callable.',
},
});
return 'ok';
});
stepLog('plugin:notification|notify execute result', { result });
expect(result).toBe('ok');
});
});
+23
View File
@@ -174,6 +174,29 @@ Deep links require a `.app` bundle. Use `pnpm tauri build --debug --bundles app`
The first Docker build compiles Rust + tauri-driver from source. Subsequent runs use cached layers. Cargo registry and git sources are cached via Docker volumes.
## Spec: Notifications
**File**: `app/test/e2e/specs/notifications.spec.ts`
Tests notification RPC methods via the live core sidecar and the Notifications UI page:
- `notification_ingest` — creates a new notification via core RPC
- `notification_list` — verifies the ingested notification is returned
- `notification_mark_read` — marks a notification as read
- `notification_stats` — checks aggregate statistics shape
- UI: Notifications page renders the integration notifications section (`[data-testid="integration-notifications-section"]`)
- UI: Notifications page shows the System Events section (`[data-testid="system-events-section"]`)
**Run**:
```bash
bash app/scripts/e2e-run-spec.sh test/e2e/specs/notifications.spec.ts notifications
```
**Platform note**: RPC tests (`notification_ingest`, `notification_list`, `notification_mark_read`, `notification_stats`) run on both Linux (tauri-driver) and macOS (Appium Mac2). UI assertions (Notifications page sections) require Linux / tauri-driver because `browser.execute()` is unavailable on Mac2 — those tests auto-skip when `supportsExecuteScript()` returns `false`.
---
## Agent-observable artifact flow
For a canonical, inspectable run that drops screenshots, page-source dumps, and mock request logs on disk: