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:
Claude
2026-08-19 17:51:07 +00:00
parent b6ed4aad9c
commit e607e8cea0
15 changed files with 1738 additions and 230 deletions
+1
View File
@@ -1 +1,2 @@
node_modules/
webui/test/fixtures/.fake-state/
+23 -10
View File
@@ -1,6 +1,6 @@
# Ubuntu Based Kiosk
**Current Version:** 2.16.0 (check script header for latest version)
**Current Version:** 2.17.0 (check script header for latest version)
**Built with Claude Sonnet 4.6 AI assistance**
**License:** GPL v3 - Keep derivatives open source
**Repository:** https://github.com/outis1one/ubuntu-based-kiosk/
@@ -1291,13 +1291,17 @@ terminal menu and the web UI, so they can't drift apart).
steps, so any code or hardware-config change picked up by the pull
actually takes effect. Also offers an on-demand Electron version
check. See "Recent Updates (v2.15.0)" below.
- `webui/` + `menus/addon_webui.sh`**Web UI** (Addons): a small
Node/Express app installed as a systemd service under `$KIOSK_USER`,
giving a browser-based editor for Sites & Page Timing, Display &
Interaction, and Password Protection & Lockout — no login of its own,
put it behind your own reverse proxy with Authelia forward-auth if
it needs to be reachable beyond a trusted LAN. See "Recent Updates
(v2.16.0)" below.
- `webui/` + `menus/addon_webui.sh`**Web UI** (Addons, installed by
default during provisioning): a small Node/Express app running as a
systemd service under `$KIOSK_USER` with zero ambient `sudo`, giving
a browser-based editor for Sites & Page Timing, Display & Interaction,
and Password Protection & Lockout, plus install/reconfigure for CUPS,
LMS/Squeezelite, and Asterisk Intercom, and Update — each reached
through a narrow, allow-listed root helper
(`webui_write_helper_script`) rather than any ambient privilege on
the service itself. No login of its own, put it behind your own
reverse proxy with Authelia forward-auth if it needs to be reachable
beyond a trusted LAN. See "Recent Updates (v2.17.0)" below.
- `install.sh` — entry point for the modular tool, now grouped **Core
Settings / Addons / Advanced** like the legacy menu. On a machine
with no kiosk installed yet, it provisions one first (see
@@ -1353,9 +1357,18 @@ full migration pass.
## Project Status & Future Plans
**Current Version:** 2.16.0
**Current Version:** 2.17.0
**Recent Updates (v2.16.0):**
**Recent Updates (v2.17.0):**
- **Web UI now installs by default** during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions.
- **Privilege model:** the web service itself still runs as `$KIOSK_USER` with zero ambient `sudo`. A new narrow, allow-listed root helper 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. Chosen over running the whole service as root after asking directly: since this repo has no login of its own by design, a request that reaches the web UI with no reverse proxy in front is effectively unauthenticated, so the allow-list bounds what that can actually do to five vetted actions, never a root shell.
- Long-running installs stream live output to the browser via Server-Sent Events, one action at a time — a second request while one is in flight gets a clear `409`, never silently queued or dropped.
- **Full visual redesign:** a sidebar shell (Sites/Display/Lockout/Addons/Update) replacing the single scrolling page of three cards, both light and dark themes via `prefers-color-scheme`, no external font/CDN dependency.
- A real bug was found and fixed by actually driving the redesigned UI in a headless browser, not just by reading the code: refreshing an addon's pill/button after a successful install used to rebuild the whole card, which raced (and usually lost to) the success status/log that same job had just written a moment earlier. Fixed to update the pill/buttons in place, leaving the completed job's log exactly as the user left it.
- Uninstall-via-web is deliberately still not offered, for any addon — flagged as needing its own double-confirmation design, not bundled into this pass. WiFi, Timezone, Power/Display/Quiet Hours, Diagnostics, Remote Access, Authelia, Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings, and the fleet/multi-kiosk dashboard all remain out of scope for the web UI too — each a named, sequenced follow-up, not an oversight.
**Previous (v2.16.0):**
- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions.
- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk.
+7
View File
@@ -33,6 +33,13 @@
: "${WIREGUARD_DIR:=/etc/wireguard}"
: "${WEBUI_DIR:=/opt/kiosk-webui}"
: "${WEBUI_ENV_DIR:=/etc/kiosk-webui}"
: "${SUDOERS_D_DIR:=/etc/sudoers.d}"
# The one root-owned script the Web UI's addon-install/Update actions are
# allowed to invoke via passwordless sudo (menus/addon_webui.sh writes it
# and the matching /etc/sudoers.d/kiosk-webui rule - see that file's
# header). Under $BIN_DIR since that's already the convention for every
# other addon's own scripts.
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
# The admin account actually running this tool (as opposed to $KIOSK_USER,
# the kiosk's own restricted account) - used where an addon needs to grant
+47 -10
View File
@@ -38,7 +38,8 @@
# which is more risk than this pass should take on.
#
# Depends on: lib/menu.sh, lib/config.sh, lib/electron.sh being sourced
# first, and every menus/*.sh this calls into for configuration.
# first, and every menus/*.sh this calls into for configuration
# (including menus/addon_webui.sh, for provision_configure_webui below).
################################################################################
PROVISION_FILES="$SCRIPT_DIR/provision/files"
@@ -54,7 +55,7 @@ provision_install_file() {
}
provision_install_packages() {
echo "[1/9] Installing packages..."
echo "[1/10] Installing packages..."
sudo apt update
sudo apt install -y \
xorg openbox lightdm unclutter screen curl git build-essential \
@@ -79,7 +80,7 @@ provision_install_packages() {
}
provision_create_kiosk_user() {
echo "[2/9] Creating kiosk user..."
echo "[2/10] Creating kiosk user..."
if ! id "$KIOSK_USER" &>/dev/null; then
sudo useradd -m -s /bin/bash -G audio,video,input,plugdev,netdev "$KIOSK_USER"
echo "$KIOSK_USER:kiosk" | sudo chpasswd
@@ -98,7 +99,7 @@ provision_create_kiosk_user() {
}
provision_install_nodejs() {
echo "[3/9] Installing Node.js..."
echo "[3/10] Installing Node.js..."
if ! command -v node &>/dev/null; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
@@ -107,7 +108,7 @@ provision_install_nodejs() {
}
provision_install_app() {
echo "[4/9] Installing kiosk app..."
echo "[4/10] Installing kiosk app..."
sudo cp "$KIOSK_APP_SRC"/*.js "$KIOSK_APP_SRC"/*.html "$KIOSK_APP_SRC/package.json" "$KIOSK_APP_SRC/start.sh" "$KIOSK_DIR/"
sudo chown "$KIOSK_USER:$KIOSK_USER" "$KIOSK_DIR"/*.js "$KIOSK_DIR"/*.html "$KIOSK_DIR/package.json" "$KIOSK_DIR/start.sh"
sudo chmod +x "$KIOSK_DIR/start.sh"
@@ -150,7 +151,7 @@ EOF
}
provision_configure_display() {
echo "[5/9] Configuring display (LightDM/Openbox/touch/video)..."
echo "[5/10] Configuring display (LightDM/Openbox/touch/video)..."
sudo -u "$KIOSK_USER" mkdir -p "$KIOSK_HOME/.config/openbox" "$KIOSK_HOME/.config/pulse"
sudo mkdir -p /etc/X11/xorg.conf.d
@@ -183,7 +184,7 @@ provision_configure_display() {
}
provision_configure_firewall() {
echo "[6/9] Configuring firewall..."
echo "[6/10] Configuring firewall..."
sudo ufw --force enable
sudo ufw default deny incoming
sudo ufw default allow outgoing
@@ -192,7 +193,7 @@ provision_configure_firewall() {
}
provision_configure_power_management() {
echo "[7/9] Configuring power management..."
echo "[7/10] Configuring power management..."
sudo mkdir -p /etc/polkit-1/localauthority/50-local.d
provision_install_file "etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla" /etc/polkit-1/localauthority/50-local.d/kiosk-power.pkla
@@ -226,11 +227,43 @@ provision_configure_power_management() {
fi
}
# Installed by default now, not opt-in - reuses menus/addon_webui.sh's
# own install helpers directly rather than duplicating them, with a
# fixed default port and no prompt (first-time install already has
# plenty). See that file's header for the privilege model (a narrow
# allow-listed root helper, not the service itself running as root) and
# why this exists at all: browser-based config editing, and - via that
# helper - install/reconfigure for CUPS/LMS/Squeezelite/Asterisk
# Intercom and Update, none of which are reimplemented here.
provision_configure_webui() {
echo "[8/10] Installing Web UI..."
if ! webui_install_app_files; then
log_warning "Web UI install failed - configure later: Addons -> Web UI"
return 0
fi
local port="8090"
webui_write_env_file "$port"
webui_write_unit_file
webui_write_helper_script
if ! webui_write_sudoers_file; then
log_warning "Web UI installed but its addon-install/Update helper could not be granted permission - configure later: Addons -> Web UI"
return 0
fi
if enable_and_start_units kiosk-webui; then
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
log_success "Web UI installed: http://$(get_ip_address):${port}"
else
log_warning "Web UI installed but failed to start - check: sudo journalctl -u kiosk-webui -n 50"
fi
}
# Configuration from here on is NOT reimplemented - it's the exact same
# Core Settings / Advanced menus this tool already uses to manage a
# kiosk after install, called directly instead of duplicated.
provision_configure_kiosk_settings() {
echo "[8/9] Configuring kiosk settings..."
echo "[9/10] Configuring kiosk settings..."
echo "Core Settings is next - sites, timezone, touch/navigation,"
echo "password protection, WiFi, and schedules. Skip and configure"
echo "later via ./install.sh if you'd rather do this after reboot."
@@ -256,7 +289,7 @@ provision_configure_kiosk_settings() {
}
provision_finish() {
echo "[9/9] Done."
echo "[10/10] Done."
echo
log_success "Core installation complete!"
echo
@@ -289,6 +322,9 @@ run_first_time_install() {
echo " - Hardware video acceleration"
echo " - Audio support (PipeWire)"
echo " - Time synchronization (NTP)"
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
echo " addon install/reconfigure and Update - no login of its own,"
echo " see Addons -> Web UI)"
echo
echo "OPTIONAL (configure after install, via Addons):"
echo " - Lyrion Music Server (LMS) / Squeezelite"
@@ -317,6 +353,7 @@ run_first_time_install() {
provision_configure_display
provision_configure_firewall
provision_configure_power_management
provision_configure_webui
provision_configure_kiosk_settings
provision_finish
return 0
+158 -14
View File
@@ -3,24 +3,48 @@
# menus/addon_webui.sh - "Web UI" addon: installs webui/ (a small Node/
# Express app) as a systemd service running as $KIOSK_USER, giving a
# browser-based editor for Sites & Page Timing, Display & Interaction,
# and Password Protection & Lockout - the three Core Settings menus that
# are pure config.json read/write with no privileged system mutation
# involved. The remaining Core Settings/Addons/Advanced menus (WiFi,
# Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other
# addon, everything in Advanced) stay terminal-only for now - a
# network-facing process shouldn't be handed sudo-level system mutation
# without a lot more thought than this first pass gives it.
# and Password Protection & Lockout - config.json read/write only, no
# sudo needed, since that file is already owned by $KIOSK_USER.
#
# Installed by default during first-time provisioning now
# (lib/provision.sh's provision_configure_webui, which calls the same
# install helpers this file defines) - this menu remains here for
# reconfiguring the port, restarting the service, or reinstalling it on
# a kiosk provisioned before this change.
#
# Also lets the web UI trigger a fixed, vetted set of privileged actions
# - install/reconfigure CUPS/LMS/Squeezelite/Asterisk Intercom, and
# Update - through a narrow, allow-listed root helper
# (webui_write_helper_script below), rather than by giving the service
# itself any elevated privilege. The helper is reachable only via a
# single-path passwordless sudo rule (webui_write_sudoers_file) and
# re-checks its own fixed action allow-list before dispatching anything,
# even though the sudoers rule alone already restricts which script can
# run - defense in depth. 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 (see webui/lib/actions.js and
# webui/test/*.test.js). No prompt/mutation refactor of any addon file
# was needed for this to work. Everything else (WiFi, Timezone, Power/
# Display/Quiet Hours, Complete Uninstall, Remote Access, Authelia,
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
# stays terminal-only for now.
#
# No login of its own, by design: this addon assumes it'll be put behind
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the
# same way other self-hosted apps get protected - Authelia integration
# is explicitly out of scope for this repo (Authelia runs elsewhere).
# Direct LAN access with no proxy in front has no authentication at all -
# treat it the same as SSH access to this kiosk.
# treat it the same as SSH access to this kiosk, which is also the
# access level the privileged helper effectively grants if reached
# without a proxy in front: bounded to its fixed action list, not a
# root shell, but real system mutation all the same.
#
# webui/'s own app-level logic (config.json schema/merge, API validation)
# lives and is tested entirely under webui/ - this file only wires it up
# as a system service and never touches config.json itself.
# webui/'s own app-level logic (config.json schema/merge, API
# validation, the action allow-list/stdin synthesis, the job/SSE
# system) lives and is tested entirely under webui/ - this file only
# wires it up as a system service plus the privileged helper, and never
# touches config.json itself.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
@@ -102,9 +126,122 @@ webui_write_env_file() {
PORT=${port}
BIND_ADDR=0.0.0.0
CONFIG_PATH=${CONFIG_PATH}
HELPER_PATH=${WEBUI_HELPER_PATH}
EOF
}
# Writes the one root-owned script the Web UI is ever allowed to reach
# via sudo (see the sudoers rule webui_write_sudoers_file writes right
# after this). Sources the exact same files install.sh does - full path
# list baked in at generation time from $SCRIPT_DIR, so the generated
# script has no runtime dependency on where it happens to be invoked
# from. The fixed ALLOWED_ACTIONS allow-list inside the script itself is
# the real gate (checked in addition to, not instead of, the sudoers
# rule only permitting this one script path) - a request that reaches
# this script can only ever trigger one of these exact, already-tested
# interactive `action_*` functions, driven the same way this project's
# own bash tests already drive them: real answers piped in on stdin, in
# the exact order the function's own prompts expect them. No prompt/
# mutation refactor of any addon file needed for this to work.
webui_write_helper_script() {
sudo mkdir -p "$(dirname "$WEBUI_HELPER_PATH")"
sudo tee "$WEBUI_HELPER_PATH" > /dev/null <<EOF
#!/bin/bash
set -euo pipefail
# lib/provision.sh reads \$SCRIPT_DIR directly at source time (no
# fallback default, unlike the lib/config.sh path vars) - must be a real
# exported variable here, not just used to interpolate the source paths
# below, or it's unbound under set -u.
export SCRIPT_DIR="$SCRIPT_DIR"
source "$SCRIPT_DIR/lib/menu.sh"
source "$SCRIPT_DIR/lib/config.sh"
source "$SCRIPT_DIR/lib/electron.sh"
source "$SCRIPT_DIR/menus/sites.sh"
source "$SCRIPT_DIR/menus/display.sh"
source "$SCRIPT_DIR/menus/timezone.sh"
source "$SCRIPT_DIR/menus/hidden_pin.sh"
source "$SCRIPT_DIR/menus/lockout.sh"
source "$SCRIPT_DIR/menus/wifi.sh"
source "$SCRIPT_DIR/menus/power_schedule.sh"
source "$SCRIPT_DIR/menus/diagnostics.sh"
source "$SCRIPT_DIR/menus/addon_cups.sh"
source "$SCRIPT_DIR/menus/addon_authelia.sh"
source "$SCRIPT_DIR/menus/addon_remote_access.sh"
source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
source "$SCRIPT_DIR/menus/addon_webui.sh"
source "$SCRIPT_DIR/menus/advanced_electron.sh"
source "$SCRIPT_DIR/menus/advanced_upgrade.sh"
source "$SCRIPT_DIR/menus/advanced_factory_reset.sh"
source "$SCRIPT_DIR/menus/advanced_virtual_consoles.sh"
source "$SCRIPT_DIR/menus/advanced_emergency_hotspot.sh"
source "$SCRIPT_DIR/menus/complete_uninstall.sh"
source "$SCRIPT_DIR/menus/clone_settings.sh"
source "$SCRIPT_DIR/lib/provision.sh"
# Read-only status wrappers, not addon logic of their own - just a
# single fixed-shape JSON line so the web UI can show "Install" vs.
# "Reconfigure" per addon in one round trip. None of the underlying
# checks (dpkg query, systemctl is-active/is-enabled, a file-existence
# test under \$KIOSK_HOME) need root, but this script is still only
# reachable via the same sudo-gated path as everything else here - one
# access path is simpler to reason about than two, and the extra sudo
# call for a read-only check is negligible.
status_all() {
echo "{\"cups\":\$(cups_is_installed && echo true || echo false),\"lms\":\$(lms_is_installed && echo true || echo false),\"squeezelite\":\$(squeezelite_is_installed && echo true || echo false),\"asterisk_intercom\":\$(baresip_is_installed && echo true || echo false)}"
}
ALLOWED_ACTIONS=(
action_install_cups
action_reconfigure_cups
action_install_lms
action_install_squeezelite
action_configure_asterisk_intercom
action_upgrade
status_all
)
action="\${1:-}"
allowed=false
for a in "\${ALLOWED_ACTIONS[@]}"; do
[[ "\$a" == "\$action" ]] && allowed=true && break
done
if ! \$allowed; then
echo "kiosk-webui-helper: action not permitted: \$action" >&2
exit 1
fi
"\$action"
EOF
sudo chown root:root "$WEBUI_HELPER_PATH"
sudo chmod 750 "$WEBUI_HELPER_PATH"
}
# Grants $KIOSK_USER passwordless sudo on exactly this one script path -
# no argument wildcarding at the sudoers level, since the script's own
# ALLOWED_ACTIONS check above is the real gate. Validated with
# `visudo -c -f` on a temp file before it's moved into place: a
# malformed sudoers snippet can break sudo system-wide, so this step is
# never skipped.
webui_write_sudoers_file() {
local tmp
tmp=$(mktemp)
echo "${KIOSK_USER} ALL=(root) NOPASSWD: ${WEBUI_HELPER_PATH}" > "$tmp"
if ! sudo visudo -c -f "$tmp" &>/dev/null; then
log_error "Generated sudoers rule failed validation - not installed"
rm -f "$tmp"
return 1
fi
sudo mkdir -p "$SUDOERS_D_DIR"
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS_D_DIR/kiosk-webui"
rm -f "$tmp"
}
webui_write_unit_file() {
sudo tee "$SYSTEMD_DIR/kiosk-webui.service" > /dev/null <<EOF
[Unit]
@@ -133,9 +270,10 @@ action_webui_install() {
echo
echo "This installs a small web app for editing Sites & Page Timing,"
echo "Display & Interaction, and Password Protection & Lockout from a"
echo "browser. It has no login of its own - put it behind your own"
echo "reverse proxy (e.g. Caddy + Authelia) if it needs to be reachable"
echo "beyond a trusted LAN."
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
echo "and Asterisk Intercom, and checking for updates. It has no login"
echo "of its own - put it behind your own reverse proxy (e.g. Caddy +"
echo "Authelia) if it needs to be reachable beyond a trusted LAN."
echo
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
@@ -150,6 +288,11 @@ action_webui_install() {
webui_write_env_file "$port"
webui_write_unit_file
webui_write_helper_script
if ! webui_write_sudoers_file; then
log_error "Web UI install failed - could not grant the addon-install/Update helper permission"
return 1
fi
if enable_and_start_units kiosk-webui; then
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
@@ -204,6 +347,7 @@ webui_do_uninstall() {
sudo systemctl disable kiosk-webui 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR/kiosk-webui.service"
sudo systemctl daemon-reload 2>/dev/null || true
sudo rm -f "$SUDOERS_D_DIR/kiosk-webui" "$WEBUI_HELPER_PATH"
sudo rm -rf "$WEBUI_DIR" "$WEBUI_ENV_DIR"
log_success "Web UI removed"
}
+55 -2
View File
@@ -1,8 +1,61 @@
#!/bin/bash
################################################################################
### Ubuntu Based Kiosk v2.16.0 ###
### Ubuntu Based Kiosk v2.17.0 ###
################################################################################
#
# RELEASE v2.17.0 - Web UI Now Installs by Default and Can Install/
# Reconfigure Addons + Check for Updates
# - Web UI (Addons -> Web UI) now installs by default during first-time
# provisioning (lib/provision.sh's provision_configure_webui), not
# opt-in - fixed port 8090, no prompt (matches every other core step).
# The Addons menu entry still works standalone for reconfiguring the
# port or reinstalling it on a kiosk provisioned before this change.
# - The web UI can now install/reconfigure CUPS Printing, LMS Server,
# Squeezelite Player, and Asterisk Intercom, and check for updates -
# the same four addons plus Update this project's user asked for by
# name. Every one of these is the exact same interactive action_*
# function the terminal menu already uses (action_install_cups,
# action_install_lms, action_install_squeezelite,
# action_configure_asterisk_intercom, action_upgrade) - no
# prompt/mutation refactor of any addon file, driven instead by
# piping the right answers on stdin, the same technique this
# project's own bash tests already use to drive these functions.
# - 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, reachable only
# via a single-path passwordless sudo rule generated and validated
# with `visudo -c -f` before being installed) is the only way the web
# UI ever gains privilege, and it re-checks its own fixed action
# allow-list before dispatching anything - a request that reaches it
# can only ever trigger one of five vetted actions, never a root
# shell. Chosen over running the whole service as root after asking
# directly: this repo has no login of its own by design (Authelia
# runs elsewhere), so a request that reaches it with no reverse proxy
# in front is effectively unauthenticated - the allow-list bounds
# what that can actually do.
# - Long-running installs stream live output to the browser via
# Server-Sent Events (webui/lib/jobs.js), with only one action
# running at a time (a second request while one is in flight gets a
# clear 409, not silently queued or dropped).
# - Full visual redesign: a sidebar shell (Sites/Display/Lockout/
# Addons/Update) replacing the single scrolling page of three cards,
# both light and dark themes via prefers-color-scheme, no external
# font/CDN dependency.
# - A real bug was found and fixed by actually driving the redesigned
# UI in a headless browser, not just by reading the code: refreshing
# an addon's pill/button after a successful install used to rebuild
# the whole card, which raced (and usually lost to) the success
# status/log that same job had just written a moment earlier. Fixed
# to update pill/buttons in place, leaving the completed job's log
# exactly as the user left it.
# - Uninstall-via-web is deliberately still not offered, for any addon -
# flagged as needing its own double-confirmation design, not bundled
# into this pass. WiFi, Timezone, Power/Display/Quiet Hours,
# Diagnostics, Remote Access, Authelia, Factory Reset, Virtual
# Consoles, Emergency Hotspot, Clone Settings, and the fleet/
# multi-kiosk dashboard all remain out of scope for the web UI too,
# each a named, sequenced follow-up rather than an oversight.
#
# RELEASE v2.16.0 - Web UI: Browser-Based Config Editor (install.sh ->
# Addons -> Web UI)
# - New: a small Node/Express app (webui/) installable via ./install.sh's
@@ -580,7 +633,7 @@ set -euo pipefail
### SECTION 1: CONSTANTS & GLOBALS
################################################################################
SCRIPT_VERSION="2.16.0"
SCRIPT_VERSION="2.17.0"
# Resolve the real path to this script file.
# When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor,
+171
View File
@@ -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 };
+97
View File
@@ -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 };
+371 -13
View File
@@ -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();
+193 -92
View File
@@ -7,100 +7,177 @@
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Kiosk Web UI</h1>
<p class="subtitle">Sites, display behavior, and lockout - saved directly to this kiosk's config.json.</p>
</header>
<main>
<section id="msg" class="banner" hidden></section>
<section class="card">
<h2>Sites &amp; Page Timing</h2>
<div id="sites-list"></div>
<div class="button-row">
<button type="button" id="add-site" class="secondary">Add a page</button>
<button type="button" id="save-sites">Save Sites &amp; Page Timing</button>
</div>
</section>
<section class="card">
<h2>Display &amp; Interaction</h2>
<form id="display-form">
<label>
Touch gesture mode
<select name="swipeMode">
<option value="dual">Dual-direction (recommended for touchscreens)</option>
<option value="standard">Standard</option>
</select>
</label>
<label>
Link navigation security
<select name="allowNavigation">
<option value="restricted">Restricted - only the loaded URL</option>
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
<option value="open">Open - any link</option>
</select>
</label>
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
<fieldset>
<legend>Home page</legend>
<label>
Home page
<select name="homeTabIndex" id="home-tab-select">
<option value="-1">Disabled</option>
</select>
</label>
<label>
Inactivity timeout (minutes)
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
</label>
</fieldset>
<button type="submit">Save Display &amp; Interaction</button>
</form>
</section>
<section class="card">
<h2>Password Protection &amp; Lockout</h2>
<form id="lockout-form">
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
<div id="lockout-fields">
<label>
<span id="password-label">Set lockout password</span>
<input type="password" name="newLockoutPassword" autocomplete="new-password">
</label>
<label>
Confirm password
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
</label>
<label>
Inactivity lockout timeout (minutes, 0 = boot/wake only)
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
</label>
<label class="checkbox">
<input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily
</label>
<label>
Daily lock time
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
</label>
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
<div class="shell">
<nav class="sidebar">
<div class="brand">
<div class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<div class="brand-text">
<strong>Kiosk Web UI</strong>
<span id="brand-sub">this kiosk</span>
</div>
</div>
<button type="submit">Save Password Protection &amp; Lockout</button>
</form>
</section>
</main>
<div class="nav">
<button class="nav-item active" data-page="sites">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
Sites &amp; Timing
</button>
<button class="nav-item" data-page="display">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M8 21h8M12 18v3"/></svg>
Display
</button>
<button class="nav-item" data-page="lockout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
Lockout
</button>
<button class="nav-item" data-page="addons">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
Addons
</button>
<button class="nav-item" data-page="update">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
Update
</button>
</div>
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div>
</nav>
<main class="main">
<div id="msg" class="banner" hidden></div>
<!-- Sites & Page Timing -->
<section class="page active" id="page-sites">
<div class="page-header">
<h1>Sites &amp; Page Timing</h1>
<p>The pages this kiosk rotates through, and how long each stays on screen.</p>
</div>
<div class="card">
<div id="sites-list"></div>
<div class="button-row">
<button type="button" id="add-site" class="secondary">Add a page</button>
<button type="button" id="save-sites">Save Sites &amp; Page Timing</button>
</div>
</div>
</section>
<!-- Display & Interaction -->
<section class="page" id="page-display">
<div class="page-header">
<h1>Display &amp; Interaction</h1>
<p>Touch gestures, link navigation, on-screen buttons, and the home page.</p>
</div>
<div class="card">
<form id="display-form">
<label>
Touch gesture mode
<select name="swipeMode">
<option value="dual">Dual-direction (recommended for touchscreens)</option>
<option value="standard">Standard</option>
</select>
</label>
<label>
Link navigation security
<select name="allowNavigation">
<option value="restricted">Restricted - only the loaded URL</option>
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
<option value="open">Open - any link</option>
</select>
</label>
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
<fieldset>
<legend>Home page</legend>
<label>
Home page
<select name="homeTabIndex" id="home-tab-select">
<option value="-1">Disabled</option>
</select>
</label>
<label>
Inactivity timeout (minutes)
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
</label>
</fieldset>
<div class="button-row"><button type="submit">Save Display &amp; Interaction</button></div>
</form>
</div>
</section>
<!-- Password Protection & Lockout -->
<section class="page" id="page-lockout">
<div class="page-header">
<h1>Password Protection &amp; Lockout</h1>
<p>Blank the screen after inactivity and require a password to unlock.</p>
</div>
<div class="card">
<form id="lockout-form">
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
<div id="lockout-fields">
<label>
<span id="password-label">Set lockout password</span>
<input type="password" name="newLockoutPassword" autocomplete="new-password">
</label>
<label>
Confirm password
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
</label>
<label>
Inactivity lockout timeout (minutes, 0 = boot/wake only)
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
</label>
<label class="checkbox"><input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily</label>
<label>
Daily lock time
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
</label>
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
</div>
<div class="button-row"><button type="submit">Save Password Protection &amp; Lockout</button></div>
</form>
</div>
</section>
<!-- Addons -->
<section class="page" id="page-addons">
<div class="page-header">
<h1>Addons</h1>
<p>Install and configure the same addons the terminal menu offers. Uninstall isn't available here yet.</p>
</div>
<div id="addons-list"></div>
</section>
<!-- Update -->
<section class="page" id="page-update">
<div class="page-header">
<h1>Update</h1>
<p>Pull the latest code from git and re-apply setup (packages, app files, hardware config).</p>
</div>
<div class="card">
<div class="button-row"><button type="button" id="run-upgrade">Check for and apply updates</button></div>
<div class="job-panel" id="job-panel-upgrade">
<div class="job-panel-header">
<span>Update</span>
<span class="job-status" id="job-status-upgrade"></span>
</div>
<pre class="job-log" id="job-log-upgrade"></pre>
</div>
</div>
</section>
</main>
</div>
<template id="site-row-template">
<div class="site-row card-inset">
@@ -134,6 +211,30 @@
</div>
</template>
<template id="addon-card-template">
<div class="card addon-card">
<div class="addon-card-top">
<div class="addon-title">
<div class="addon-icon"></div>
<div>
<div class="addon-name"></div>
<div class="addon-desc"></div>
</div>
</div>
<span class="pill unknown">checking&hellip;</span>
</div>
<div class="button-row addon-actions"></div>
<form class="addon-form"></form>
<div class="job-panel">
<div class="job-panel-header">
<span>Log</span>
<span class="job-status"></span>
</div>
<pre class="job-log"></pre>
</div>
</div>
</template>
<script src="app.js"></script>
</body>
</html>
+302 -89
View File
@@ -1,85 +1,222 @@
/* Design tokens - light by default, dark via prefers-color-scheme.
No external font/CDN dependency (self-hosted admin tool shouldn't
phone out to Google Fonts) - a well-tuned system-ui stack plus real
spacing/depth/motion is what actually reads as "modern", not the
typeface. */
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-inset: #1e222b;
--border: #2a2f3a;
--text: #e6e9ef;
--text-dim: #9aa3b2;
--accent: #4f8cff;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, Roboto, Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--bg: #f5f6f8;
--bg-elevated: #ffffff;
--bg-sunken: #eef0f3;
--border: #e2e5ea;
--border-strong: #cbd0d8;
--text: #14161a;
--text-dim: #5c6370;
--text-faint: #8b929e;
--accent: #3b6ff0;
--accent-hover: #2f5cd6;
--accent-text: #ffffff;
--danger: #e5566a;
--success: #3fb87f;
--error: #e5566a;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--accent-soft: #e8effe;
--success: #1c8a5b;
--success-soft: #e3f6ec;
--warning: #a9660a;
--warning-soft: #fdf1de;
--danger: #d13a3a;
--danger-soft: #fbe8e8;
--shadow-sm: 0 1px 2px rgba(20, 22, 26, 0.06);
--shadow-md: 0 4px 16px rgba(20, 22, 26, 0.08);
--radius: 10px;
--radius-lg: 14px;
--sidebar-w: 232px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101216;
--bg-elevated: #17191f;
--bg-sunken: #0c0d10;
--border: #262a33;
--border-strong: #363c48;
--text: #eceef2;
--text-dim: #9aa1ad;
--text-faint: #6b7280;
--accent: #5b8cff;
--accent-hover: #7ba0ff;
--accent-text: #0a0e18;
--accent-soft: #17233f;
--success: #3ecf8e;
--success-soft: #103527;
--warning: #e2a53f;
--warning-soft: #3a2c11;
--danger: #f0605f;
--danger-soft: #3a1616;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
}
}
* { box-sizing: border-box; }
::selection { background: var(--accent-soft); }
html, body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
line-height: 1.5;
font-family: var(--font);
font-size: 14.5px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
header {
padding: 2rem 1.5rem 1rem;
max-width: 720px;
margin: 0 auto;
a { color: var(--accent); }
/* ---------------------------------------------------------------- */
/* Shell: fixed sidebar + main content */
/* ---------------------------------------------------------------- */
.shell {
display: flex;
min-height: 100vh;
}
h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
.subtitle { margin: 0; color: var(--text-dim); font-size: 0.9rem; }
main {
max-width: 720px;
margin: 0 auto;
padding: 0 1.5rem 3rem;
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--bg-elevated);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 20px 12px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px 22px;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-text);
flex-shrink: 0;
}
.brand-mark svg { width: 17px; height: 17px; }
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.25;
min-width: 0;
}
.brand-text strong { font-size: 14px; }
.brand-text span { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 8px;
color: var(--text-dim);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
border: none;
background: transparent;
text-align: left;
width: 100%;
}
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; opacity: 0.85; }
.nav-item:hover { background: var(--bg-sunken); color: var(--text); }
.nav-item.active { background: var(--accent-soft); color: var(--accent); }
.nav-item.active svg { opacity: 1; }
.sidebar-footer {
margin-top: auto;
padding: 10px;
font-size: 11.5px;
color: var(--text-faint);
border-top: 1px solid var(--border);
padding-top: 14px;
}
.main {
flex: 1;
min-width: 0;
padding: 32px 40px 60px;
max-width: 880px;
}
.page { display: none; }
.page.active { display: block; }
.page-header { margin-bottom: 24px; }
.page-header h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; }
.page-header p { margin: 0; color: var(--text-dim); font-size: 13.5px; }
/* ---------------------------------------------------------------- */
/* Cards, forms, buttons */
/* ---------------------------------------------------------------- */
.card {
background: var(--panel);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 20px 22px;
margin-bottom: 18px;
}
.card h2 {
margin: 0 0 1rem;
font-size: 1.1rem;
}
.card h2 { margin: 0 0 14px; font-size: 14.5px; font-weight: 600; }
.card-inset {
background: var(--panel-inset);
background: var(--bg-sunken);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 10px;
}
form { display: flex; flex-direction: column; gap: 1rem; }
form { display: flex; flex-direction: column; gap: 14px; }
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.9rem;
gap: 5px;
font-size: 12.5px;
font-weight: 600;
color: var(--text-dim);
}
label.checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
gap: 8px;
font-weight: 500;
color: var(--text);
}
label.checkbox input { width: 16px; height: 16px; accent-color: var(--accent); }
label.checkbox input { width: 1.05rem; height: 1.05rem; }
.hint { color: var(--text-dim); font-size: 0.8rem; font-weight: normal; }
.hint { color: var(--text-faint); font-size: 11.5px; font-weight: normal; }
input[type="text"],
input[type="password"],
@@ -87,96 +224,172 @@ input[type="number"],
input[type="time"],
select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
padding: 0.5rem 0.6rem;
font-size: 0.95rem;
padding: 8px 10px;
font-size: 13.5px;
font-family: inherit;
transition: border-color 0.12s ease;
}
input:focus, select:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem 1rem;
border-radius: var(--radius);
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 14px;
margin: 0;
}
legend { padding: 0 0.4rem; color: var(--text-dim); font-size: 0.85rem; }
legend { padding: 0 6px; color: var(--text-dim); font-size: 12px; font-weight: 600; }
button {
border: none;
border-radius: 6px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
border-radius: 8px;
padding: 8px 15px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
background: var(--accent);
color: var(--accent-text);
align-self: flex-start;
transition: background-color 0.12s ease, transform 0.05s ease;
}
button:hover { background: var(--accent-hover); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.55; cursor: not-allowed; }
button.secondary {
background: transparent;
border: 1px solid var(--border);
border: 1px solid var(--border-strong);
color: var(--text);
}
button.secondary:hover { background: var(--bg-sunken); }
button.danger {
background: transparent;
border: 1px solid var(--danger);
color: var(--danger);
}
button.danger:hover { background: var(--danger-soft); }
button:hover { filter: brightness(1.1); }
.button-row { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
.button-row {
display: flex;
gap: 0.6rem;
margin-top: 0.75rem;
/* ---------------------------------------------------------------- */
/* Addon cards, status pills, job log panel */
/* ---------------------------------------------------------------- */
.addon-card { display: flex; flex-direction: column; gap: 12px; }
.addon-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.addon-title { display: flex; align-items: center; gap: 10px; }
.addon-icon {
width: 34px; height: 34px; border-radius: 9px;
background: var(--bg-sunken); display: flex; align-items: center; justify-content: center;
color: var(--text-dim); flex-shrink: 0;
}
.addon-icon svg { width: 18px; height: 18px; }
.addon-name { font-weight: 600; font-size: 14px; }
.addon-desc { color: var(--text-faint); font-size: 12px; margin-top: 1px; }
.pill {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 9px; border-radius: 999px;
font-size: 11px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
}
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; }
.pill.installed { background: var(--success-soft); color: var(--success); }
.pill.installed::before { background: var(--success); }
.pill.not-installed { background: var(--bg-sunken); color: var(--text-faint); }
.pill.not-installed::before { background: var(--text-faint); }
.pill.unknown { background: var(--warning-soft); color: var(--warning); }
.pill.unknown::before { background: var(--warning); }
.addon-form { display: none; }
.addon-form.open { display: flex; padding-top: 4px; border-top: 1px solid var(--border); margin-top: 4px; }
.job-panel {
display: none;
background: var(--bg-sunken);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.job-panel.open { display: block; }
.job-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--border);
font-size: 12.5px; font-weight: 600;
}
.job-status { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; }
.job-status.running { color: var(--accent); }
.job-status.success { color: var(--success); }
.job-status.failed { color: var(--danger); }
.spinner {
width: 12px; height: 12px; border-radius: 50%;
border: 2px solid var(--accent-soft); border-top-color: var(--accent);
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.job-log {
margin: 0; padding: 12px 14px;
font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
color: var(--text-dim);
max-height: 320px; overflow-y: auto;
white-space: pre-wrap; word-break: break-word;
}
/* ---------------------------------------------------------------- */
/* Site rows (Sites & Page Timing) */
/* ---------------------------------------------------------------- */
.site-row-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 560px) {
.site-row-grid { grid-template-columns: 1fr; }
gap: 12px;
}
@media (max-width: 640px) { .site-row-grid { grid-template-columns: 1fr; } }
.site-auth {
margin-top: 0.75rem;
margin-top: 10px;
border-top: 1px solid var(--border);
padding-top: 0.75rem;
padding-top: 10px;
}
.site-auth summary { cursor: pointer; color: var(--text-dim); font-size: 12px; font-weight: 600; }
.site-auth[open] summary { margin-bottom: 10px; }
.auth-state { color: var(--text-faint); font-weight: normal; }
.site-auth summary {
cursor: pointer;
color: var(--text-dim);
font-size: 0.85rem;
}
.site-auth[open] summary { margin-bottom: 0.6rem; }
.auth-state { color: var(--text-dim); }
.remove-site { margin-top: 0.75rem; }
/* ---------------------------------------------------------------- */
/* Toast banner */
/* ---------------------------------------------------------------- */
.banner {
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.9rem;
position: sticky;
top: 0.75rem;
z-index: 10;
border-radius: var(--radius);
padding: 11px 15px;
font-size: 13px;
font-weight: 500;
position: fixed;
top: 18px;
right: 18px;
max-width: 360px;
box-shadow: var(--shadow-md);
z-index: 50;
}
.banner.success { background: var(--success-soft); border: 1px solid var(--success); color: var(--success); }
.banner.error { background: var(--danger-soft); border: 1px solid var(--danger); color: var(--danger); }
.banner.success { background: rgba(63, 184, 127, 0.15); border: 1px solid var(--success); color: var(--success); }
.banner.error { background: rgba(229, 86, 106, 0.15); border: 1px solid var(--error); color: var(--error); }
@media (max-width: 800px) {
.shell { flex-direction: column; }
.sidebar { width: 100%; height: auto; position: static; flex-direction: row; align-items: center; padding: 12px; overflow-x: auto; }
.brand { padding: 0 10px 0 0; }
.nav { flex-direction: row; }
.sidebar-footer { display: none; }
.main { padding: 20px; }
}
+80
View File
@@ -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';
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Variant of fake-helper.sh that also implements status_all with real,
# minimally stateful JSON (tracked via marker files alongside itself),
# for browser/manual smoke testing of the Addons page's pills/buttons
# actually flipping after a real install - not just that a job reports
# success. jobs.test.js intentionally uses the plainer fake-helper.sh
# instead, to exercise the malformed-status-response error path.
STATE_DIR="$(dirname "$0")/.fake-state"
mkdir -p "$STATE_DIR"
if [[ "$1" == "status_all" ]]; then
state() { [[ -f "$STATE_DIR/$1" ]] && echo true || echo false; }
echo "{\"cups\":$(state cups),\"lms\":$(state lms),\"squeezelite\":$(state squeezelite),\"asterisk_intercom\":$(state asterisk_intercom)}"
exit 0
fi
echo "fake-helper: action=$1"
stdin_content=$(cat)
echo "fake-helper: stdin-bytes=${#stdin_content}"
sleep 0.3
case "$1" in
action_install_cups | action_reconfigure_cups) touch "$STATE_DIR/cups" ;;
action_install_lms) touch "$STATE_DIR/lms" ;;
action_install_squeezelite) touch "$STATE_DIR/squeezelite" ;;
action_configure_asterisk_intercom) touch "$STATE_DIR/asterisk_intercom" ;;
esac
echo "fake-helper: done"
exit "${FAKE_HELPER_EXIT_CODE:-0}"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# webui/test/fixtures/fake-helper.sh - stands in for the real, root-owned
# kiosk-webui-helper (menus/addon_webui.sh) in webui/test/jobs.test.js,
# so the job/SSE system can be tested without real root or a real addon
# install. Echoes what it received, sleeps briefly (long enough for the
# "another job is already running" test to reliably observe it), then
# exits with a controllable code.
echo "fake-helper: action=$1"
stdin_content=$(cat)
echo "fake-helper: stdin-bytes=${#stdin_content}"
sleep 0.2
echo "fake-helper: done"
exit "${FAKE_HELPER_EXIT_CODE:-0}"
+190
View File
@@ -0,0 +1,190 @@
'use strict';
// webui/test/jobs.test.js - integration test for the addon-install job
// system (/api/actions/*, /api/addons/status) against a fake helper
// script (test/fixtures/fake-helper.sh) instead of the real, root-owned
// kiosk-webui-helper - no real root, apt, or system mutation involved,
// matching this project's rule of never touching real system state in
// tests. Run with: node test/jobs.test.js
const fs = require('fs');
const os = require('os');
const path = require('path');
const assert = require('assert');
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-jobs-test-'));
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
process.env.HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-helper.sh');
process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo
const app = require('../server');
let failures = 0;
async function check(label, fn) {
try {
await fn();
console.log(`PASS: ${label}`);
} catch (e) {
failures++;
console.log(`FAIL: ${label} - ${e.message}`);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForDone(base, jobId, timeoutMs = 3000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await fetch(`${base}/api/actions/jobs/${jobId}`);
const body = await res.json();
if (body.status !== 'running') return body;
await sleep(20);
}
throw new Error('timed out waiting for job to finish');
}
async function main() {
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
const port = server.address().port;
const base = `http://127.0.0.1:${port}`;
await check('GET /api/actions lists the allow-listed actions with their fields', async () => {
const res = await fetch(`${base}/api/actions`);
assert.strictEqual(res.status, 200);
const body = await res.json();
const names = body.map((a) => a.name);
assert.ok(names.includes('install_cups'));
assert.ok(names.includes('configure_asterisk_intercom'));
const asterisk = body.find((a) => a.name === 'configure_asterisk_intercom');
assert.ok(asterisk.fields.includes('serverIp'));
});
await check('GET /api/addons/status returns the fake helper\'s status_all JSON', async () => {
// fake-helper.sh doesn't implement status_all specially - it just
// echoes/exits 0 with non-JSON text, so this exercises the
// malformed-response error path rather than a real status shape.
const res = await fetch(`${base}/api/addons/status`);
assert.strictEqual(res.status, 500);
});
let jobId;
await check('POST /api/actions/install_cups/run starts a job and returns its id', async () => {
const res = await fetch(`${base}/api/actions/install_cups/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.ok(body.jobId);
assert.strictEqual(body.status, 'running');
jobId = body.jobId;
});
await check('a second action while one is running is rejected with 409', async () => {
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 409);
});
await check('the job completes successfully and the log shows what the helper received', async () => {
const body = await waitForDone(base, jobId);
assert.strictEqual(body.status, 'success');
assert.strictEqual(body.exitCode, 0);
assert.ok(body.log.includes('fake-helper: action=action_install_cups'), body.log);
assert.ok(body.log.includes('fake-helper: stdin-bytes='), body.log);
assert.ok(body.log.includes('fake-helper: done'), body.log);
});
await check('after completion, a new action is accepted again (not stuck busy)', async () => {
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
await waitForDone(base, body.jobId);
});
await check('unknown action name is rejected with 400, no job created', async () => {
const res = await fetch(`${base}/api/actions/definitely_not_real/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.strictEqual(res.status, 400);
});
await check('missing required field is rejected with 400 before spawning anything', async () => {
const res = await fetch(`${base}/api/actions/configure_asterisk_intercom/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alreadyInstalled: false, extension: '201', password: 'x' }),
});
assert.strictEqual(res.status, 400);
const body = await res.json();
assert.ok(/serverIp/.test(body.error), body.error);
});
await check('a failing helper is reflected as status failed with the real exit code', async () => {
process.env.FAKE_HELPER_EXIT_CODE = '1';
// jobs.js reads process.env.HELPER_PATH/SUDO_CMD once at module
// load, but FAKE_HELPER_EXIT_CODE is read fresh by the spawned
// shell script every time, so no re-require needed here.
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { jobId: failId } = await res.json();
const body = await waitForDone(base, failId);
assert.strictEqual(body.status, 'failed');
assert.strictEqual(body.exitCode, 1);
delete process.env.FAKE_HELPER_EXIT_CODE;
});
await check('GET /api/actions/jobs/:id/stream (SSE) replays the log and sends a final done event', async () => {
const res = await fetch(`${base}/api/actions/install_lms/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alreadyInstalled: false }),
});
const { jobId: streamJobId } = await res.json();
const streamRes = await fetch(`${base}/api/actions/jobs/${streamJobId}/stream`);
assert.strictEqual(streamRes.status, 200);
assert.strictEqual(streamRes.headers.get('content-type'), 'text/event-stream');
const reader = streamRes.body.getReader();
const decoder = new TextDecoder();
let raw = '';
const deadline = Date.now() + 3000;
while (!raw.includes('event: done') && Date.now() < deadline) {
const { value, done } = await reader.read();
if (done) break;
raw += decoder.decode(value, { stream: true });
}
assert.ok(raw.includes('event: log'), raw);
assert.ok(raw.includes('fake-helper: action=action_install_lms'), raw);
assert.ok(raw.includes('event: done'), raw);
assert.ok(/data: \{"status":"success","exitCode":0\}/.test(raw), raw);
});
server.close();
fs.rmSync(scratchDir, { recursive: true, force: true });
if (failures > 0) {
console.log(`${failures} FAILURE(S)`);
process.exit(1);
}
console.log('ALL DONE');
}
main();