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:
+371
-13
@@ -1,10 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
||||
// user-controlled value (site URL/name/username) is set via .value or
|
||||
// .textContent, never innerHTML, so nothing typed into a site name or
|
||||
// URL can execute as markup - the one new attack surface a browser-based
|
||||
// config UI has that the terminal menus never did.
|
||||
// user-controlled value (site URL/name/username, addon form fields) is
|
||||
// set via .value or .textContent, never innerHTML, so nothing typed
|
||||
// into a form can execute as markup - the one new attack surface a
|
||||
// browser-based config UI has that the terminal menus never did.
|
||||
|
||||
const msgEl = document.getElementById('msg');
|
||||
let currentConfig = null;
|
||||
@@ -17,6 +17,25 @@ function showMessage(text, isError) {
|
||||
showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Sidebar navigation */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.nav-item').forEach((b) => b.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(`page-${btn.dataset.page}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('brand-sub').textContent = location.host || 'this kiosk';
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Config API (Sites / Display / Lockout) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
async function apiGet() {
|
||||
const res = await fetch('/api/config');
|
||||
if (!res.ok) throw new Error('Failed to load configuration');
|
||||
@@ -95,7 +114,6 @@ function collectTabs() {
|
||||
tab.username = row.querySelector('.site-auth-username').value;
|
||||
const newPass = row.querySelector('.site-auth-password').value;
|
||||
if (newPass) tab.password = newPass;
|
||||
// else: omit `password` entirely - server keeps the existing one.
|
||||
} else {
|
||||
tab.username = '';
|
||||
tab.password = '';
|
||||
@@ -104,10 +122,6 @@ function collectTabs() {
|
||||
});
|
||||
}
|
||||
|
||||
// A "Save" per row would be simpler individually, but sites.sh's own
|
||||
// save_config always rewrites the whole tabs array too - this mirrors
|
||||
// that, saving all sites (and the home-page selection they feed into)
|
||||
// together whenever anything in the Sites section changes.
|
||||
async function saveSites() {
|
||||
const tabs = collectTabs();
|
||||
if (tabs.some((t) => !t.url)) {
|
||||
@@ -227,10 +241,6 @@ lockoutForm.addEventListener('submit', async (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Populate forms from a config snapshot */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
function populateAll(config) {
|
||||
displayForm.elements.swipeMode.value = config.swipeMode;
|
||||
displayForm.elements.allowNavigation.value = config.allowNavigation;
|
||||
@@ -251,6 +261,352 @@ function populateAll(config) {
|
||||
refreshLockoutFieldVisibility();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Shared: run a privileged action and stream its log via SSE */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
// jobPanelEls = { panel, status, log }. Returns a promise resolving to
|
||||
// {status, exitCode} once the job finishes (or rejects on a request-level
|
||||
// error before a job even started, e.g. validation).
|
||||
async function runAction(actionName, fields, jobPanelEls) {
|
||||
const res = await fetch(`/api/actions/${actionName}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields || {}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || 'Could not start action');
|
||||
|
||||
jobPanelEls.panel.classList.add('open');
|
||||
jobPanelEls.log.textContent = '';
|
||||
setJobStatus(jobPanelEls.status, 'running');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const source = new EventSource(`/api/actions/jobs/${body.jobId}/stream`);
|
||||
source.addEventListener('log', (ev) => {
|
||||
jobPanelEls.log.textContent += JSON.parse(ev.data);
|
||||
jobPanelEls.log.scrollTop = jobPanelEls.log.scrollHeight;
|
||||
});
|
||||
source.addEventListener('done', (ev) => {
|
||||
const result = JSON.parse(ev.data);
|
||||
setJobStatus(jobPanelEls.status, result.status);
|
||||
source.close();
|
||||
resolve(result);
|
||||
});
|
||||
source.onerror = () => {
|
||||
source.close();
|
||||
reject(new Error('Lost connection to the log stream'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setJobStatus(el, status) {
|
||||
el.className = `job-status ${status}`;
|
||||
if (status === 'running') {
|
||||
el.innerHTML = '';
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner';
|
||||
el.appendChild(spinner);
|
||||
el.appendChild(document.createTextNode('Running'));
|
||||
} else {
|
||||
el.textContent = status === 'success' ? 'Success' : 'Failed';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Addons */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const addonsList = document.getElementById('addons-list');
|
||||
const addonCardTemplate = document.getElementById('addon-card-template');
|
||||
|
||||
const ICONS = {
|
||||
printer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V3h12v6M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7"/></svg>',
|
||||
music: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>',
|
||||
speaker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><circle cx="12" cy="14" r="4"/><circle cx="12" cy="6" r="1"/></svg>',
|
||||
phone: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .3 2 .6 2.9a2 2 0 0 1-.5 2.1L8 9.9a16 16 0 0 0 6 6l1.2-1.2a2 2 0 0 1 2.1-.5c.9.3 1.9.5 2.9.6a2 2 0 0 1 1.8 2Z"/></svg>',
|
||||
};
|
||||
|
||||
function pillHtml(state) {
|
||||
if (state === true) return '<span class="pill installed">Installed</span>';
|
||||
if (state === false) return '<span class="pill not-installed">Not installed</span>';
|
||||
return '<span class="pill unknown">Unknown</span>';
|
||||
}
|
||||
|
||||
let addonStatus = {};
|
||||
|
||||
async function loadAddonStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/addons/status');
|
||||
if (res.ok) addonStatus = await res.json();
|
||||
} catch (e) {
|
||||
// leave addonStatus as-is (pills show "Unknown"); not fatal to the page
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by addon, so a single card can be refreshed in place after its
|
||||
// own job finishes (see refreshAddonCard) without touching the other
|
||||
// three, and - critically - without recreating the job-panel/log the
|
||||
// user is currently looking at. An earlier version called the full
|
||||
// renderAddons() rebuild after every successful job "to update the
|
||||
// pill"; that raced (and usually lost to) the same success/log state it
|
||||
// had just written a moment earlier, since rebuilding the whole list
|
||||
// replaces the job-panel node with a fresh empty one. Caught by an
|
||||
// actual headless-browser run, not just reading the code - the log
|
||||
// looked fine reading it, but watching it in Chromium showed the
|
||||
// "Success" state flash and vanish.
|
||||
const ADDON_RECIPES = {};
|
||||
|
||||
function buildAddonCard(recipe) {
|
||||
ADDON_RECIPES[recipe.key] = recipe;
|
||||
const node = addonCardTemplate.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.addon = recipe.key;
|
||||
fillAddonCard(node, recipe);
|
||||
return node;
|
||||
}
|
||||
|
||||
// (Re)fills everything in a card EXCEPT the job-panel/log, which is
|
||||
// left exactly as it is - so refreshing a card's install-state after a
|
||||
// job finishes doesn't erase the result the user just watched stream in.
|
||||
function fillAddonCard(node, { key, title, desc, icon, buildForm }) {
|
||||
node.querySelector('.addon-icon').innerHTML = ICONS[icon];
|
||||
node.querySelector('.addon-name').textContent = title;
|
||||
node.querySelector('.addon-desc').textContent = desc;
|
||||
node.querySelector('.pill').outerHTML = pillHtml(addonStatus[key]);
|
||||
|
||||
const actionsEl = node.querySelector('.addon-actions');
|
||||
const formEl = node.querySelector('.addon-form');
|
||||
actionsEl.textContent = '';
|
||||
formEl.textContent = '';
|
||||
formEl.className = 'addon-form';
|
||||
|
||||
const jobPanelEls = {
|
||||
panel: node.querySelector('.job-panel'),
|
||||
status: node.querySelector('.job-status'),
|
||||
log: node.querySelector('.job-log'),
|
||||
};
|
||||
buildForm({ node, actionsEl, formEl, jobPanelEls, installed: addonStatus[key] === true });
|
||||
}
|
||||
|
||||
// Called after one addon's own job finishes - refreshes just that
|
||||
// card's pill/buttons/form (e.g. "Install" -> "Reconfigure") in place.
|
||||
async function refreshAddonCard(key) {
|
||||
await loadAddonStatus();
|
||||
const node = addonsList.querySelector(`[data-addon="${key}"]`);
|
||||
if (node) fillAddonCard(node, ADDON_RECIPES[key]);
|
||||
}
|
||||
|
||||
function addSubmitAction(formEl, jobPanelEls, actionName, collectFields, onDone) {
|
||||
formEl.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = formEl.querySelector('button[type=submit]');
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const result = await runAction(actionName, collectFields(), jobPanelEls);
|
||||
if (result.status === 'success') {
|
||||
showMessage('Done');
|
||||
if (onDone) await onDone();
|
||||
} else {
|
||||
showMessage('Action failed - see the log below', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAddons() {
|
||||
addonsList.textContent = '';
|
||||
|
||||
// CUPS: no fields at all - the button itself is the whole form.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'cups', title: 'CUPS Printing', desc: 'Network printer sharing', icon: 'printer',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = installed ? 'Reconfigure for network access' : 'Install CUPS Printing';
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = installed ? 'reconfigure_cups' : 'install_cups';
|
||||
const result = await runAction(action, {}, jobPanelEls);
|
||||
if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('cups'); }
|
||||
else showMessage('Action failed - see the log below', true);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
},
|
||||
}));
|
||||
|
||||
// LMS Server: fresh install has no fields; once installed, an
|
||||
// optional port-reconfigure field.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'lms', title: 'LMS Server', desc: 'Lyrion / Logitech Media Server', icon: 'music',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
if (!installed) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Install LMS Server';
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await runAction('install_lms', { alreadyInstalled: false }, jobPanelEls);
|
||||
if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('lms'); }
|
||||
else showMessage('Action failed - see the log below', true);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
return;
|
||||
}
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'secondary';
|
||||
toggleBtn.textContent = 'Reconfigure port';
|
||||
toggleBtn.addEventListener('click', () => formEl.classList.toggle('open'));
|
||||
actionsEl.appendChild(toggleBtn);
|
||||
|
||||
const portLabel = document.createElement('label');
|
||||
portLabel.innerHTML = 'New HTTP port';
|
||||
const portInput = document.createElement('input');
|
||||
portInput.type = 'number'; portInput.min = '1'; portInput.max = '65535'; portInput.value = '9000';
|
||||
portLabel.appendChild(portInput);
|
||||
formEl.appendChild(portLabel);
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = 'Apply new port';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'install_lms', () => ({
|
||||
alreadyInstalled: true, reconfigurePort: true, newPort: parseInt(portInput.value, 10),
|
||||
}), () => refreshAddonCard('lms'));
|
||||
},
|
||||
}));
|
||||
|
||||
// Squeezelite: player name + LMS server, both for install and
|
||||
// reconfigure - the form is the same either way.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'squeezelite', title: 'Squeezelite Player', desc: 'Turns this kiosk into an LMS-connected speaker', icon: 'speaker',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
formEl.classList.add('open');
|
||||
|
||||
const nameLabel = document.createElement('label');
|
||||
nameLabel.textContent = 'Player name';
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text'; nameInput.value = 'Kiosk'; nameInput.placeholder = 'Kiosk';
|
||||
nameLabel.appendChild(nameInput);
|
||||
formEl.appendChild(nameLabel);
|
||||
|
||||
const serverLabel = document.createElement('label');
|
||||
serverLabel.innerHTML = 'LMS server <span class="hint">(IP:PORT, blank for auto-discovery)</span>';
|
||||
const serverInput = document.createElement('input');
|
||||
serverInput.type = 'text'; serverInput.placeholder = '192.168.1.100:3483';
|
||||
serverLabel.appendChild(serverInput);
|
||||
formEl.appendChild(serverLabel);
|
||||
|
||||
const rebootHint = document.createElement('p');
|
||||
rebootHint.className = 'hint';
|
||||
rebootHint.textContent = 'A reboot is required after install/reconfigure before Squeezelite starts.';
|
||||
formEl.appendChild(rebootHint);
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = installed ? 'Reconfigure Squeezelite' : 'Install Squeezelite';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'install_squeezelite', () => ({
|
||||
alreadyInstalled: installed, reconfigure: true,
|
||||
playerName: nameInput.value.trim(), lmsServer: serverInput.value.trim(),
|
||||
}), () => refreshAddonCard('squeezelite'));
|
||||
},
|
||||
}));
|
||||
|
||||
// Asterisk Intercom: server/extension/password/options, always shown.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'asterisk_intercom', title: 'Asterisk Intercom', desc: 'SIP extension client (Baresip)', icon: 'phone',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
formEl.classList.add('open');
|
||||
|
||||
const mk = (label, type, opts) => {
|
||||
const l = document.createElement('label');
|
||||
l.textContent = label;
|
||||
const i = document.createElement('input');
|
||||
i.type = type;
|
||||
Object.assign(i, opts || {});
|
||||
l.appendChild(i);
|
||||
formEl.appendChild(l);
|
||||
return i;
|
||||
};
|
||||
const ip = mk('Server IP or hostname', 'text', { placeholder: '10.0.0.5' });
|
||||
const port = mk('Server port', 'number', { placeholder: '5060', min: '1', max: '65535' });
|
||||
const ext = mk('Extension number', 'text', { placeholder: '201' });
|
||||
const pass = mk('SIP password', 'password', { autocomplete: 'new-password' });
|
||||
|
||||
const autoAnswerLabel = document.createElement('label');
|
||||
autoAnswerLabel.className = 'checkbox';
|
||||
const autoAnswer = document.createElement('input');
|
||||
autoAnswer.type = 'checkbox';
|
||||
autoAnswerLabel.appendChild(autoAnswer);
|
||||
autoAnswerLabel.appendChild(document.createTextNode('Auto-answer incoming calls (intercom mode)'));
|
||||
formEl.appendChild(autoAnswerLabel);
|
||||
|
||||
const tlsLabel = document.createElement('label');
|
||||
tlsLabel.className = 'checkbox';
|
||||
const useTls = document.createElement('input');
|
||||
useTls.type = 'checkbox';
|
||||
tlsLabel.appendChild(useTls);
|
||||
tlsLabel.appendChild(document.createTextNode('Use TLS encryption'));
|
||||
formEl.appendChild(tlsLabel);
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = installed ? 'Reconfigure Asterisk Intercom' : 'Connect to Asterisk server';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'configure_asterisk_intercom', () => ({
|
||||
alreadyInstalled: installed, reconfigure: true,
|
||||
serverIp: ip.value.trim(), serverPort: port.value ? parseInt(port.value, 10) : undefined,
|
||||
extension: ext.value.trim(), password: pass.value,
|
||||
autoAnswer: autoAnswer.checked, useTls: useTls.checked,
|
||||
}), () => refreshAddonCard('asterisk_intercom'));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Update */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
document.getElementById('run-upgrade').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await runAction('upgrade', {}, {
|
||||
panel: document.getElementById('job-panel-upgrade'),
|
||||
status: document.getElementById('job-status-upgrade'),
|
||||
log: document.getElementById('job-log-upgrade'),
|
||||
});
|
||||
showMessage(result.status === 'success' ? 'Update finished' : 'Update failed - see the log below', result.status !== 'success');
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Init */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
currentConfig = await apiGet();
|
||||
@@ -259,6 +615,8 @@ async function init() {
|
||||
} catch (e) {
|
||||
showMessage(e.message, true);
|
||||
}
|
||||
await loadAddonStatus();
|
||||
renderAddons();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user