Web UI now installs by default during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in, and can install/ reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates - the addons and Update action asked for by name. Privilege model: the web service itself still runs as $KIOSK_USER with zero ambient sudo. A new narrow, allow-listed root helper (menus/addon_webui.sh's webui_write_helper_script) is the only way it ever gains privilege, reachable only via a single-path passwordless sudo rule generated and validated with `visudo -c -f` before being installed, and it re-checks its own fixed action allow-list before dispatching anything. Each allow-listed action is the exact same interactive action_* function the terminal menu already uses, driven by piping the right answers on stdin - the same technique this project's own bash tests already use, so no prompt/mutation refactor of any addon file was needed. webui/lib/actions.js's stdin sequences were cross-validated against the real bash functions (not just read), which caught two real bugs (Squeezelite and Asterisk Intercom both silently lost their "decline reconfigure" path). Long-running installs stream live output via Server-Sent Events (webui/lib/jobs.js), one action at a time. Full visual redesign: a sidebar shell (Sites/Display/Lockout/Addons/ Update) replacing the single scrolling page, light+dark themes via prefers-color-scheme, no external font/CDN dependency. Actually driving the redesigned UI in a headless browser (not just reading the code) caught a real bug: refreshing an addon's pill/button after a successful install used to rebuild the whole card, racing (and usually losing to) the success status/log that job had just written. Fixed to update pill/buttons in place. Uninstall-via-web is deliberately still not offered, for any addon. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
98 lines
3.1 KiB
JavaScript
98 lines
3.1 KiB
JavaScript
'use strict';
|
|
|
|
// webui/lib/jobs.js - runs one privileged action at a time via the
|
|
// allow-listed root helper (menus/addon_webui.sh's kiosk-webui-helper),
|
|
// and keeps an in-memory log so /api/actions/:name/run's caller and any
|
|
// number of SSE stream reconnects all see the same output. No database -
|
|
// this tool manages one kiosk, a Map is plenty.
|
|
//
|
|
// HELPER_PATH and SUDO_CMD are both overridable via environment (see
|
|
// webui/test/jobs.test.js): tests point HELPER_PATH at a small fake
|
|
// script and clear SUDO_CMD, so the real test suite never needs actual
|
|
// root or a real addon install - the same principle as every stubbed
|
|
// bash test in this project, just on the Node side.
|
|
|
|
const { spawn } = require('child_process');
|
|
const { randomUUID } = require('crypto');
|
|
const { getAction } = require('./actions');
|
|
|
|
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
|
|
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
|
|
|
const jobs = new Map();
|
|
let activeJobId = null;
|
|
|
|
function startJob(actionName, fields) {
|
|
const action = getAction(actionName);
|
|
if (!action) {
|
|
const err = new Error(`Unknown action: ${actionName}`);
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
if (activeJobId) {
|
|
const err = new Error('Another action is already running - wait for it to finish first');
|
|
err.status = 409;
|
|
throw err;
|
|
}
|
|
|
|
// buildStdin() validates its own required fields and throws a plain
|
|
// Error with a human-readable message on bad input - treated as a
|
|
// 400 here, before anything is spawned.
|
|
let stdin;
|
|
try {
|
|
stdin = action.buildStdin(fields || {});
|
|
} catch (e) {
|
|
e.status = 400;
|
|
throw e;
|
|
}
|
|
|
|
const jobId = randomUUID();
|
|
const job = {
|
|
id: jobId,
|
|
name: actionName,
|
|
label: action.label,
|
|
status: 'running',
|
|
log: [],
|
|
exitCode: null,
|
|
listeners: new Set(),
|
|
};
|
|
jobs.set(jobId, job);
|
|
activeJobId = jobId;
|
|
|
|
const child = SUDO_CMD
|
|
? spawn(SUDO_CMD, [HELPER_PATH, action.helperAction])
|
|
: spawn(HELPER_PATH, [action.helperAction]);
|
|
|
|
const appendLine = (chunk) => {
|
|
const text = chunk.toString();
|
|
job.log.push(text);
|
|
for (const listener of job.listeners) listener(text);
|
|
};
|
|
child.stdout.on('data', appendLine);
|
|
child.stderr.on('data', appendLine);
|
|
|
|
const finish = (status, exitCode) => {
|
|
if (job.status !== 'running') return; // 'error' and 'close' can both fire
|
|
job.status = status;
|
|
job.exitCode = exitCode;
|
|
for (const listener of job.listeners) listener(null);
|
|
if (activeJobId === jobId) activeJobId = null;
|
|
};
|
|
child.on('close', (code) => finish(code === 0 ? 'success' : 'failed', code));
|
|
child.on('error', (err) => {
|
|
job.log.push(`\n[error] ${err.message}\n`);
|
|
finish('failed', null);
|
|
});
|
|
|
|
child.stdin.write(stdin);
|
|
child.stdin.end();
|
|
|
|
return job;
|
|
}
|
|
|
|
function getJob(jobId) {
|
|
return jobs.get(jobId);
|
|
}
|
|
|
|
module.exports = { startJob, getJob };
|