[eric] notifications: a finished workflow's native notification called a preload bridge that was never added, so it never fired

This commit is contained in:
ciregenz
2026-07-31 23:36:05 -07:00
parent d756ad9897
commit dac60995e2
7 changed files with 204 additions and 61 deletions
+46
View File
@@ -83,6 +83,17 @@ function _squirrelUpdate(args) {
if (sq === '--squirrel-obsolete') { process.exit(0); }
})();
// Windows toast notifications are dropped on the floor unless our AppUserModelID
// matches the one Squirrel stamped on the Start Menu shortcut, and Squirrel's rule
// is com.squirrel.<nuspec id>.<exe name>. Derived, not hardcoded, so renaming the
// app can't silently kill notifications. Must run before the first Notification.
if (process.platform === 'win32' && app.isPackaged) {
try {
const nuspecId = require('./package.json').name;
app.setAppUserModelId(`com.squirrel.${nuspecId}.${path.basename(process.execPath, '.exe')}`);
} catch (_) {}
}
// NSIS->Squirrel migration cleanup. The first time this Squirrel build runs after
// an existing NSIS OpenSwarm was updated into it, silently uninstall that legacy
// NSIS copy so the user isn't left with two installs + two shortcuts. Found via
@@ -1121,6 +1132,8 @@ function markBackendReady() {
_backendReadyResolve();
try {
workflowsLifecycle.setBackend({ port: backendPort, token: authToken });
// Read lazily: mainWindow is replaced by recreateMainWindow, so a captured value goes stale.
workflowsLifecycle.setNotificationTarget(() => mainWindow);
workflowsLifecycle.startPolling();
} catch (_) {}
try { connectMainBridge(); } catch (_) {}
@@ -3231,6 +3244,39 @@ ipcMain.handle('open-external', (_event, url) => {
}
});
// The renderer's own Notification API only reaches Notification Center while a
// window exists and is not suspended, which is exactly the case a finished
// workflow is trying to survive. This hands it to the main process instead.
// Everything is clamped here: the payload crosses a trust boundary and the click
// handler can hand `deepLink` to the OS.
const NOTIFY_OUTCOMES = new Set(['open', 'ack', 'rerun', 'edit']);
function cleanNotifyText(value, max) {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '';
}
ipcMain.handle('workflow:notify', (_event, payload) => {
if (!payload || typeof payload !== 'object') return false;
const title = cleanNotifyText(payload.title, 120);
if (!title) return false;
// Only our own scheme: this string can reach shell.openExternal, and a file:// or
// an installed-app scheme there would be a renderer-triggered arbitrary launch.
const deepLink = typeof payload.deepLink === 'string' && payload.deepLink.startsWith('openswarm://')
? payload.deepLink.slice(0, 500)
: undefined;
const actions = (Array.isArray(payload.actions) ? payload.actions : [])
.filter((a) => a && NOTIFY_OUTCOMES.has(a.outcome) && cleanNotifyText(a.text, 30))
.slice(0, 3)
.map((a) => ({ text: cleanNotifyText(a.text, 30), outcome: a.outcome }));
const shown = workflowsLifecycle.showNativeNotification({
title,
body: cleanNotifyText(payload.body, 300),
deepLink,
runId: cleanNotifyText(payload.runId, 200) || undefined,
workflowId: cleanNotifyText(payload.workflowId, 200) || undefined,
actions,
});
return Boolean(shown);
});
// Applications launcher support. Names are bare .app basenames from the local scan; both
// handlers hard-validate the name and resolve strictly inside /Applications so a hostile
// renderer string can't traverse anywhere else.
+12
View File
@@ -76,6 +76,18 @@ contextBridge.exposeInMainWorld('openswarm', {
},
// Reveal a diagnostics folder in Finder/Explorer (path validated in main; diagnostics dir only).
revealBundle: (folderPath) => ipcRenderer.invoke('help:reveal-bundle', folderPath),
// Native OS notification for a finished workflow run, posted by the MAIN process
// so it survives a minimized/hidden/backgrounded renderer (the renderer's own
// Notification API does not). Resolves true once it is handed to the OS, which can
// still refuse it afterwards (main logs that). Fields are clamped in main;
// onNotificationAction carries the clicked outcome back.
notify: (payload) => ipcRenderer.invoke('workflow:notify', payload),
onNotificationAction: (cb) => {
const listener = (_event, payload) => cb(payload);
ipcRenderer.on('workflow:notification-action', listener);
return () => ipcRenderer.removeListener('workflow:notification-action', listener);
},
// True keyboard hold-to-talk needs the native key tap; renderers ask so Settings copy stays honest,
// and request triggers the macOS Accessibility prompt when the tap is blocked on permission.
setVoiceHotkey: (combo) => ipcRenderer.send('voice:set-hotkey', combo),
+36 -11
View File
@@ -126,12 +126,20 @@ function drainOnQuit(maxSeconds = 30) {
});
}
// Who receives the notification outcome. main.js injects the main window;
// BrowserWindow.getAllWindows()[0] is not it after a window recreate (the
// splash and browser popups get in front of it in creation order).
let notificationTarget = () => null;
function setNotificationTarget(fn) {
notificationTarget = typeof fn === 'function' ? fn : () => null;
}
// Native OS notification. Falls back silently when Notification isn't
// supported (some Linux setups, headless test envs). When `actions` is
// provided AND we're on macOS, attaches button actions so the user can
// ack/re-run/open without the app taking focus. Routes the chosen
// outcome back to the renderer via an IPC channel that the renderer's
// WebSocketManager already listens for.
// outcome back to the renderer over 'workflow:notification-action'.
function showNativeNotification({ title, body, deepLink, runId, workflowId, actions }) {
if (!Notification || !Notification.isSupported()) return null;
try {
@@ -141,23 +149,39 @@ function showNativeNotification({ title, body, deepLink, runId, workflowId, acti
: undefined;
if (platformActions && platformActions.length) opts.actions = platformActions;
const n = new Notification(opts);
const route = (outcome) => {
try {
const { BrowserWindow } = require('electron');
const wins = BrowserWindow.getAllWindows();
const wc = wins[0]?.webContents;
if (wc) wc.send('workflow:notification-action', { outcome, runId, workflowId, deepLink });
} catch (_) {}
const win = () => {
const w = notificationTarget();
return w && !w.isDestroyed() ? w : null;
};
const route = (outcome) => {
const w = win();
if (!w) return false;
try {
w.webContents.send('workflow:notification-action', { outcome, runId, workflowId, deepLink });
return true;
} catch (_) { return false; }
};
// The OS can refuse after show() returns (unauthorized app, notifications off).
// Silence here is how a dead notifier looks exactly like a working one, so say it out loud.
n.on('failed', (_event, error) => {
console.warn('[notify] the OS refused a workflow notification:', error);
});
n.on('action', (_event, idx) => {
const a = (actions || [])[idx];
if (a) route(a.outcome);
});
n.on('click', () => {
if (deepLink) {
const w = win();
if (w) {
try { if (!w.isVisible()) w.show(); } catch (_) {}
try { if (w.isMinimized()) w.restore(); } catch (_) {}
try { w.focus(); } catch (_) {}
}
// Only when there's no renderer to talk to does the deep link go through
// the OS, which re-launches us and lands on the openswarm:// handler.
if (!route('open') && deepLink) {
try { shell.openExternal(deepLink); } catch (_) {}
}
route('open');
});
n.show();
return n;
@@ -202,6 +226,7 @@ module.exports = {
getActive,
maybeVetoInstall,
drainOnQuit,
setNotificationTarget,
showNativeNotification,
getLoginItem,
setLoginItem,
@@ -30,7 +30,8 @@ import { hasModelConnected as selectHasModelConnected } from '@/app/components/O
import { shallowEqual } from 'react-redux';
import { fetchDashboards, createDashboard } from '@/shared/state/dashboardsSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addViewCard, selectFullscreenCardId, setTiledCard, clearTiledCard } from '@/shared/state/dashboardLayoutSlice';
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addViewCard, selectFullscreenCardId, setTiledCard, clearTiledCard, openWorkflowMonitor, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import { ackRun, runWorkflowNow } from '@/shared/state/workflowsSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { setInstalling } from '@/shared/state/updateSlice';
@@ -305,6 +306,21 @@ const AppShell: React.FC = () => {
});
}, []);
// A click or an action button on the OS notification a finished workflow posted. Every outcome lands on something real; an unwired button on a notification is worse than no button.
useEffect(() => {
const bridge = window.openswarm;
if (!bridge?.onNotificationAction) return;
return bridge.onNotificationAction(({ outcome, runId, workflowId }) => {
if (!workflowId) return;
switch (outcome) {
case 'open': dispatch(openWorkflowMonitor({ workflowId, runId })); break;
case 'ack': if (runId) dispatch(ackRun(runId)); break;
case 'rerun': dispatch(runWorkflowNow(workflowId)); break;
case 'edit': dispatch(openWorkflowsApp({ workflowId })); break;
}
});
}, [dispatch]);
// Zoom / find / tab-cycle from a focused browser GUEST (keydowns inside a webview can't reach this document, so main forwards them with the guest's id). Targets that exact browser; the host-focused counterparts live in the keydown below + useCanvasControls (zoom).
useEffect(() => {
const w = window as any;
+63
View File
@@ -59,3 +59,66 @@ export function notifyAgentCompletion(p: AgentCompletionPayload): void {
// Notification API can throw if sandboxed or headless; fail silently.
}
}
export type WorkflowNotificationOutcome = 'open' | 'ack' | 'rerun' | 'edit';
export interface WorkflowRunNotification {
workflowId: string;
workflowTitle: string;
runId?: string;
sessionId?: string;
status: string;
tierKind?: string;
fallback?: boolean;
}
const SUCCESS_TITLES = ['{name} is done', '{name} just wrapped up', 'Heads up: {name} finished', '{name} is ready'];
const FAILURE_TITLES = ['{name} hit a snag', "{name} couldn't finish", 'Something went sideways on {name}'];
const LATE_TITLES = ['{name} caught up late', '{name} ran late but made it'];
function workflowTitleFor(p: WorkflowRunNotification): string {
const name = p.workflowTitle || 'Workflow';
const pool = p.status === 'success' ? SUCCESS_TITLES
: p.status === 'failure' ? FAILURE_TITLES
: p.status === 'ran_late' ? LATE_TITLES
: null;
if (!pool) return `${name}: ${p.status}`;
// Seed by workflow id + current minute so two workflows pick different copy while one workflow stays stable across a few minutes.
const seed = Math.abs((p.workflowId.length + Math.floor(Date.now() / 60_000)) | 0);
return pool[seed % pool.length].replace('{name}', name);
}
function workflowBodyFor(p: WorkflowRunNotification): string {
if (p.tierKind && p.fallback) {
return `Would have ${p.tierKind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`;
}
const verb = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform) ? 'Tap' : 'Click';
if (p.status === 'success') return `${verb} to see what it did.`;
if (p.status === 'failure') return `${verb} to see what went wrong.`;
return `${verb} to open the run.`;
}
/** Native OS notification for a finished workflow run. Prefers the Electron main process, which reaches Notification Center even with the window hidden or the renderer backgrounded; the renderer's own Notification API is the browser-only fallback and it only fires when the tab is hidden. */
export function notifyWorkflowRun(p: WorkflowRunNotification): void {
const bridge = typeof window !== 'undefined' ? window.openswarm : undefined;
if (!bridge?.notify) {
notifyAgentCompletion({
sessionId: p.sessionId || p.workflowId,
sessionName: p.workflowTitle || 'Workflow',
status: p.status === 'success' ? 'completed' : 'error',
});
return;
}
bridge.notify({
title: workflowTitleFor(p),
body: workflowBodyFor(p),
deepLink: `openswarm://workflow/${p.workflowId}/run/${p.runId || ''}`,
runId: p.runId,
workflowId: p.workflowId,
actions: [
{ text: 'Looks good', outcome: 'ack' },
{ text: 'Re-run', outcome: 'rerun' },
{ text: 'Adjust', outcome: 'edit' },
],
}).catch(() => { /* the OS refused it; the run is still on the canvas */ });
}
+11 -49
View File
@@ -35,7 +35,7 @@ import { displaySessionName } from '../state/sessionDisplay';
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice';
import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils';
import { getAuthToken } from '../config';
import { notifyAgentCompletion } from '../notifications';
import { notifyAgentCompletion, notifyWorkflowRun } from '../notifications';
// Phase 0 boot instrumentation: one-shot flag so we report the first streamed agent token to Electron main exactly once per app launch. Module scope (not instance) because multiple WebSocketManagers exist (one per session WS).
let firstAgentResponseMarked = false;
@@ -763,55 +763,17 @@ class WebSocketManager {
break;
case 'workflow:notify':
try {
notifyAgentCompletion({
sessionId: data.session_id || data.workflow_id,
sessionName: data.workflow_title || 'Workflow',
status: data.status === 'success' ? 'completed' : 'error',
if (data.workflow_id) {
notifyWorkflowRun({
workflowId: data.workflow_id,
workflowTitle: data.workflow_title || 'Workflow',
runId: data.run_id,
sessionId: data.session_id,
status: data.status,
tierKind: data.tier_kind,
fallback: data.fallback,
});
} catch { /* notifications are best-effort */ }
try {
const w: any = (window as any).openswarm;
if (w?.notify) {
// Seed by workflow id + current minute so multiple workflows pick different copy while a single workflow stays stable within a few minutes.
const seed = ((data.workflow_id || '').length + Math.floor(Date.now() / 60000)) | 0;
const SUCCESS_TITLES = [
`${data.workflow_title || 'Workflow'} — done`,
`${data.workflow_title || 'Workflow'} just wrapped up`,
`Heads up: ${data.workflow_title || 'Workflow'} finished`,
`${data.workflow_title || 'Workflow'} is ready`,
];
const FAILURE_TITLES = [
`${data.workflow_title || 'Workflow'} hit a snag`,
`${data.workflow_title || 'Workflow'} couldn't finish`,
`Something went sideways on ${data.workflow_title || 'Workflow'}`,
];
const LATE_TITLES = [
`${data.workflow_title || 'Workflow'} caught up late`,
`${data.workflow_title || 'Workflow'} ran late but made it`,
];
const pool = data.status === 'success' ? SUCCESS_TITLES
: data.status === 'failure' ? FAILURE_TITLES
: data.status === 'ran_late' ? LATE_TITLES
: [`${data.workflow_title || 'Workflow'}${data.status}`];
const title = pool[Math.abs(seed) % pool.length];
const isMac = (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
const body = data.tier_kind && data.fallback
? `Would have ${data.tier_kind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`
: data.status === 'success'
? (isMac ? 'Tap to see what it did.' : 'Click to see what it did.')
: data.status === 'failure'
? (isMac ? 'Tap to see what went wrong.' : 'Click to see what went wrong.')
: (isMac ? 'Tap to open the run.' : 'Click to open the run.');
const deepLink = data.workflow_id ? `openswarm://workflow/${data.workflow_id}/run/${data.run_id || ''}` : undefined;
const actions = [
{ text: 'Looks good', outcome: 'ack' },
{ text: 'Re-run', outcome: 'rerun' },
{ text: 'Adjust', outcome: 'edit' },
];
w.notify({ title, body, deepLink, runId: data.run_id, workflowId: data.workflow_id, actions });
}
} catch { /* native notif optional */ }
}
break;
case 'dashboard:browser_card_keep':
+19
View File
@@ -40,6 +40,23 @@ declare global {
total: number;
}
// A finished-run notification handed to the OS by the Electron main process.
interface OpenSwarmNotifyRequest {
title: string;
body?: string;
deepLink?: string;
runId?: string;
workflowId?: string;
actions?: Array<{ text: string; outcome: 'open' | 'ack' | 'rerun' | 'edit' }>;
}
interface OpenSwarmNotifyAction {
outcome: 'open' | 'ack' | 'rerun' | 'edit';
runId?: string;
workflowId?: string;
deepLink?: string;
}
interface OpenSwarmAPI {
getBackendPort: () => number;
getWebviewPreloadPath: () => string;
@@ -74,6 +91,8 @@ declare global {
voiceRequestHoldPermission?: () => Promise<boolean>;
onAuthUrl?: (cb: (url: string) => void) => () => void;
onOauthClaim?: (cb: (url: string) => void) => () => void;
notify?: (payload: OpenSwarmNotifyRequest) => Promise<boolean>;
onNotificationAction?: (cb: (payload: OpenSwarmNotifyAction) => void) => () => void;
}
interface Window {