Files
openswarm/electron/workflowsLifecycle.js

234 lines
7.7 KiB
JavaScript

// Lifecycle helpers that keep scheduled workflows surviving real-world
// app states (machine sleep, window closed, auto-update). All exports are
// safe to call before the backend is up; failed fetches return null and
// callers degrade to "no active runs known."
const { app, powerSaveBlocker, Notification, shell } = require('electron');
const http = require('http');
let backendPortRef = null;
let authTokenRef = null;
let blockerId = null;
let updaterVetoPending = false;
let pollTimer = null;
let lastActiveCount = 0;
let onActiveChange = () => {};
function setBackend({ port, token }) {
backendPortRef = port;
authTokenRef = token;
}
function setActiveChangeListener(cb) {
onActiveChange = cb || (() => {});
}
// Cheap GET to the localhost backend. Resolves null on any error.
function fetchJson(pathStr) {
return new Promise((resolve) => {
if (!backendPortRef) return resolve(null);
const req = http.request({
hostname: '127.0.0.1',
port: backendPortRef,
path: pathStr,
method: 'GET',
headers: authTokenRef ? { Authorization: `Bearer ${authTokenRef}` } : {},
timeout: 1500,
}, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch { resolve(null); }
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.end();
});
}
async function getActive() {
// Must hit the /api prefix; the bare path 401s and would leave the
// powerSaveBlocker + updater-veto blind to in-flight runs.
const res = await fetchJson('/api/workflows/active');
if (!res || !Array.isArray(res.active)) return [];
return res.active;
}
// powerSaveBlocker holds the system awake while at least one workflow is
// active. Released as soon as the active list goes empty so we don't pin
// the user's laptop on idle.
function ensureBlocker(active) {
if (active && blockerId == null) {
try { blockerId = powerSaveBlocker.start('prevent-app-suspension'); } catch (_) {}
} else if (!active && blockerId != null) {
try { powerSaveBlocker.stop(blockerId); } catch (_) {}
blockerId = null;
}
}
function startPolling() {
if (pollTimer) return;
// 5s cadence is the sweet spot: fast enough to release the
// powerSaveBlocker promptly after a fire, slow enough that the localhost
// request is invisible in CPU traces.
pollTimer = setInterval(async () => {
const active = await getActive();
const count = active.length;
ensureBlocker(count > 0);
if (count !== lastActiveCount) {
lastActiveCount = count;
try { onActiveChange(active); } catch (_) {}
}
// If the updater queued an install while a run was in flight, fire it
// the moment the active list drains.
if (updaterVetoPending && count === 0) {
updaterVetoPending = false;
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.quitAndInstall(false, true);
} catch (_) {}
}
}, 5000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// Updater veto: if a workflow is running and the user clicks "Install
// update," queue it instead of quitAndInstall'ing on top of an active
// run. Returns true if vetoed (caller should display a "queued" banner),
// false otherwise.
async function maybeVetoInstall() {
const active = await getActive();
if (active.length === 0) return false;
updaterVetoPending = true;
return true;
}
// Drain on quit: give in-flight runs up to QUIT_DRAIN_S to finish before
// killing the backend. The user-facing tradeoff is a slow quit when busy
// vs. losing the run; we lean toward "wait" because the run already
// committed real cost.
function drainOnQuit(maxSeconds = 30) {
return new Promise((resolve) => {
const deadline = Date.now() + maxSeconds * 1000;
const tick = async () => {
const active = await getActive();
if (active.length === 0 || Date.now() > deadline) return resolve();
setTimeout(tick, 500);
};
tick();
});
}
// 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 over 'workflow:notification-action'.
function showNativeNotification({ title, body, deepLink, runId, workflowId, actions }) {
if (!Notification || !Notification.isSupported()) return null;
try {
const opts = { title: title || 'OpenSwarm', body: body || '', silent: false };
const platformActions = Array.isArray(actions) && process.platform === 'darwin'
? actions.map((a) => ({ type: 'button', text: a.text }))
: undefined;
if (platformActions && platformActions.length) opts.actions = platformActions;
const n = new Notification(opts);
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', () => {
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 (_) {}
}
});
n.show();
return n;
} catch (_) {
return null;
}
}
// Launch-at-login wrappers. macOS + Windows both honor this; Linux is a
// no-op in Electron's API.
function getLoginItem() {
try {
const { openAtLogin } = app.getLoginItemSettings();
return Boolean(openAtLogin);
} catch (_) { return false; }
}
function setLoginItem(value) {
try {
// openAsHidden is macOS-only; on Windows the equivalent is passing
// a --hidden arg and having main.js suppress the initial window
// when the arg is present. Linux uses a .desktop file in
// ~/.config/autostart/ which Electron writes for us via this same
// call (no extra plumbing needed).
const opts = {
openAtLogin: Boolean(value),
openAsHidden: true,
};
if (process.platform === 'win32') {
opts.args = ['--hidden'];
}
app.setLoginItemSettings(opts);
return Boolean(value);
} catch (_) { return false; }
}
module.exports = {
setBackend,
setActiveChangeListener,
startPolling,
stopPolling,
getActive,
maybeVetoInstall,
drainOnQuit,
setNotificationTarget,
showNativeNotification,
getLoginItem,
setLoginItem,
};