mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-03 03:38:43 +02:00
[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:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */ });
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
Vendored
+19
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user