Web UI: install/reconfigure addons, default-on install, visual redesign (v2.17.0)
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
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user