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,171 @@
|
||||
'use strict';
|
||||
|
||||
// webui/lib/actions.js - the web UI's allow-list of privileged actions,
|
||||
// and how to turn a web form's fields into the exact stdin sequence the
|
||||
// real bash `action_*` function expects.
|
||||
//
|
||||
// Mirrors menus/addon_webui.sh's ALLOWED_ACTIONS array (inside the
|
||||
// generated kiosk-webui-helper script) - kept in sync by hand rather
|
||||
// than shared/generated, since both lists are short and deliberately
|
||||
// curated. A mismatch between the two just means an action fails closed
|
||||
// on whichever side is missing it, never open on both: server.js checks
|
||||
// this list before spawning anything, and the helper script re-checks
|
||||
// its own list before dispatching regardless of what server.js sent.
|
||||
//
|
||||
// Every buildStdin() below was verified against the real menus/*.sh
|
||||
// source (prompt order, defaults, and which fields reject a blank
|
||||
// answer and re-prompt) - see webui/test/actions.test.js, which drives
|
||||
// the actual bash functions with this exact output and checks the real
|
||||
// resulting state (cups_is_installed, lms_is_installed, etc), not just
|
||||
// that the process exits 0.
|
||||
|
||||
const ACTIONS = {
|
||||
install_cups: {
|
||||
helperAction: 'action_install_cups',
|
||||
label: 'Install CUPS Printing',
|
||||
fields: [],
|
||||
// action_install_cups's only prompt is "Install CUPS printing?
|
||||
// (n)" - the web UI's own install button is the confirmation,
|
||||
// so this always answers yes. No pause() in this function.
|
||||
buildStdin() {
|
||||
return 'y\n';
|
||||
},
|
||||
},
|
||||
|
||||
reconfigure_cups: {
|
||||
helperAction: 'action_reconfigure_cups',
|
||||
label: 'Reconfigure CUPS for network access',
|
||||
fields: [],
|
||||
// No prompts at all, no pause().
|
||||
buildStdin() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
|
||||
install_lms: {
|
||||
helperAction: 'action_install_lms',
|
||||
label: 'Install / reconfigure LMS Server',
|
||||
// fields.alreadyInstalled must reflect real current state
|
||||
// (server.js fills this in from lms_is_installed via the
|
||||
// helper's own status check before offering the reconfigure
|
||||
// fields) - action_install_lms branches on it internally and a
|
||||
// wrong guess here desyncs the stdin sequence from what the
|
||||
// real function actually prompts for.
|
||||
fields: ['alreadyInstalled', 'reconfigurePort', 'newPort'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled) {
|
||||
if (!f.reconfigurePort) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const port = Number(f.newPort);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('newPort must be an integer 1-65535');
|
||||
}
|
||||
return `y\n${port}\n\n`; // accept, new port, pause()
|
||||
}
|
||||
return '\n'; // fresh install: fully automated except pause()
|
||||
},
|
||||
},
|
||||
|
||||
install_squeezelite: {
|
||||
helperAction: 'action_install_squeezelite',
|
||||
label: 'Install / reconfigure Squeezelite Player',
|
||||
// If already installed, the real function first asks
|
||||
// "Reconfigure?" (default n) and, if declined, returns
|
||||
// immediately after just the pause() - it does NOT fall through
|
||||
// to the player-name/server prompts. f.reconfigure must be
|
||||
// explicit (not inferred from other fields) so the web UI can
|
||||
// offer "leave it as-is" without also having to resend the
|
||||
// current values.
|
||||
fields: ['alreadyInstalled', 'reconfigure', 'playerName', 'lmsServer'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled && !f.reconfigure) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const lines = [];
|
||||
if (f.alreadyInstalled) lines.push('y'); // "Reconfigure?"
|
||||
lines.push(f.playerName || ''); // blank -> "Kiosk" default
|
||||
lines.push(f.lmsServer || ''); // blank -> auto-discovery
|
||||
// "Reboot now?" is always answered "n" here regardless of
|
||||
// what the UI shows - triggering a real `sudo reboot` from
|
||||
// inside a one-click addon-install action is out of scope
|
||||
// for this pass (see webui phase-2 plan). The UI surfaces
|
||||
// "reboot required to start Squeezelite" as an info banner
|
||||
// instead of a real remote reboot trigger.
|
||||
lines.push('n');
|
||||
lines.push(''); // pause()
|
||||
return lines.join('\n') + '\n';
|
||||
},
|
||||
},
|
||||
|
||||
configure_asterisk_intercom: {
|
||||
helperAction: 'action_configure_asterisk_intercom',
|
||||
label: 'Configure Asterisk Intercom',
|
||||
// Same shape as Squeezelite's reconfigure gate: if already
|
||||
// installed, the real function asks "Reconfigure with a
|
||||
// different server/extension?" (default n) and returns after
|
||||
// just the pause() if declined - the rest of this sequence is
|
||||
// never reached in that case.
|
||||
fields: ['alreadyInstalled', 'reconfigure', 'serverIp', 'serverPort', 'extension', 'password', 'autoAnswer', 'useTls'],
|
||||
buildStdin(f) {
|
||||
if (f.alreadyInstalled && !f.reconfigure) {
|
||||
return 'n\n\n'; // decline reconfigure, then pause()
|
||||
}
|
||||
const lines = [];
|
||||
// Only present at all when baresip_is_installed is already
|
||||
// true - a fresh install has no "Reconfigure?" prompt.
|
||||
if (f.alreadyInstalled) lines.push('y');
|
||||
|
||||
// Server IP and extension reject a blank answer and
|
||||
// re-prompt (a `while [[ -z ... ]]` loop in the real
|
||||
// function) - sending an empty line here would desync the
|
||||
// rest of the sequence by consuming a second prompt cycle,
|
||||
// so these are validated up front instead.
|
||||
if (!f.serverIp || !String(f.serverIp).trim()) throw new Error('serverIp is required');
|
||||
lines.push(String(f.serverIp).trim());
|
||||
|
||||
lines.push(f.serverPort != null && f.serverPort !== '' ? String(f.serverPort) : '');
|
||||
|
||||
if (!f.extension || !String(f.extension).trim()) throw new Error('extension is required');
|
||||
lines.push(String(f.extension).trim());
|
||||
|
||||
// Password also rejects blank and re-prompts, same reason.
|
||||
if (!f.password) throw new Error('password is required');
|
||||
lines.push(f.password);
|
||||
|
||||
lines.push(f.autoAnswer ? 'y' : 'n');
|
||||
lines.push(f.useTls ? 'y' : 'n');
|
||||
// "Proceed with installation?" (default y) - already
|
||||
// confirmed by the web click that got us here.
|
||||
lines.push('y');
|
||||
lines.push(''); // pause()
|
||||
return lines.join('\n') + '\n';
|
||||
},
|
||||
},
|
||||
|
||||
upgrade: {
|
||||
helperAction: 'action_upgrade',
|
||||
label: 'Check for and apply updates',
|
||||
fields: [],
|
||||
buildStdin() {
|
||||
// action_upgrade's own flow: "Pull these changes...?" (y),
|
||||
// then - only if there was anything to pull -
|
||||
// "Restart kiosk display now...?" (y), then always
|
||||
// "Check for and install the latest Electron...?", answered
|
||||
// n here. That sub-flow's own prompts default to declining
|
||||
// and aren't a good fit for one-click automation yet (see
|
||||
// webui phase-2 plan, "Explicitly deferred"). Answering "n"
|
||||
// to a prompt that never actually gets shown (nothing to
|
||||
// pull, or the display-restart question) is harmless - a
|
||||
// synthesized line bash never reads is simply left unread,
|
||||
// not an error.
|
||||
return 'y\ny\nn\n';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getAction(name) {
|
||||
return Object.prototype.hasOwnProperty.call(ACTIONS, name) ? ACTIONS[name] : undefined;
|
||||
}
|
||||
|
||||
module.exports = { ACTIONS, getAction };
|
||||
@@ -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