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:
@@ -17,8 +17,14 @@
|
||||
// config.json - so it never needs sudo.
|
||||
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const express = require('express');
|
||||
const { loadConfig, saveConfig } = require('./lib/config');
|
||||
const { ACTIONS } = require('./lib/actions');
|
||||
const { startJob, getJob } = require('./lib/jobs');
|
||||
|
||||
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 app = express();
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
@@ -130,6 +136,80 @@ app.put('/api/config', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Addons: install/reconfigure via the allow-listed root helper */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
app.get('/api/actions', (req, res) => {
|
||||
const list = Object.entries(ACTIONS).map(([name, a]) => ({ name, label: a.label, fields: a.fields }));
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
app.get('/api/addons/status', (req, res) => {
|
||||
const cmd = SUDO_CMD || HELPER_PATH;
|
||||
const args = SUDO_CMD ? [HELPER_PATH, 'status_all'] : ['status_all'];
|
||||
execFile(cmd, args, { timeout: 10_000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('status_all failed:', stderr || err.message);
|
||||
return res.status(500).json({ error: 'Could not read addon status' });
|
||||
}
|
||||
try {
|
||||
res.json(JSON.parse(stdout.trim()));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Malformed status response' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/actions/:name/run', (req, res) => {
|
||||
try {
|
||||
const job = startJob(req.params.name, req.body || {});
|
||||
res.json({ jobId: job.id, status: job.status, label: job.label });
|
||||
} catch (e) {
|
||||
res.status(e.status || 500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/actions/jobs/:jobId', (req, res) => {
|
||||
const job = getJob(req.params.jobId);
|
||||
if (!job) return res.status(404).json({ error: 'Unknown job' });
|
||||
res.json({ id: job.id, name: job.name, label: job.label, status: job.status, exitCode: job.exitCode, log: job.log.join('') });
|
||||
});
|
||||
|
||||
// Server-Sent Events: replays whatever's already logged, then streams
|
||||
// new lines as they arrive, then a final `done` event - works whether
|
||||
// the client connects before the job starts producing output or
|
||||
// reconnects partway through (e.g. after a page reload).
|
||||
app.get('/api/actions/jobs/:jobId/stream', (req, res) => {
|
||||
const job = getJob(req.params.jobId);
|
||||
if (!job) return res.status(404).end();
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
if (job.log.length) {
|
||||
res.write(`event: log\ndata: ${JSON.stringify(job.log.join(''))}\n\n`);
|
||||
}
|
||||
if (job.status !== 'running') {
|
||||
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const listener = (text) => {
|
||||
if (text === null) {
|
||||
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
|
||||
res.end();
|
||||
} else {
|
||||
res.write(`event: log\ndata: ${JSON.stringify(text)}\n\n`);
|
||||
}
|
||||
};
|
||||
job.listeners.add(listener);
|
||||
req.on('close', () => job.listeners.delete(listener));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 8090;
|
||||
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user